Back to blogs
Written by
immeas
Published on
March 25, 2025

Uniswap V4 Swap: Deep Dive Into Execution and Accounting

Explore Uniswap V4's swap mechanics with an in-depth look at flash accounting, transient storage, and execution flow through detailed code examples and analysis.

Table of Contents

Introduction

I've been exploring Uniswap V4 to better understand its swap execution. To deepen my knowledge, I analyzed the code that drives a swap, focusing on the new “flash accounting” model introduced in V4: How debts are created, settled, and tracked using transient storage and when value is actually moved. If you're curious about the inner workings of Uniswap V4 swaps, especially from an accounting perspective, this guide is for you. Let’s dive in!

0. Swap setup

To demo this, we'll use an ERC-20 to ERC-20 swap, specifically WBTC to USDC (pool) on a local fork of Ethereum mainnet:

Here's the foundry test:

function testSwapWBTCForUSDC() public {
    uint128 amountIn = 1e7;
    uint128 minAmountOut = 0;
    deal(WBTC_ADDRESS, address(this), amountIn);

    PoolKey memory wbtc_usdc_key = PoolKey({
        currency0: Currency.wrap(WBTC_ADDRESS),
        currency1: Currency.wrap(USDC_ADDRESS),
        fee: 3000,
        tickSpacing: 60,
        hooks: IHooks(address(0))
    });

    WBTC.approve(PERMIT2_ADDRESS, amountIn);
    PERMIT2.approve(WBTC_ADDRESS, UNIVERSAL_ROUTER_ADDRESS, amountIn, uint48(block.timestamp));

    bytes memory actions = abi.encodePacked(
        uint8(Actions.SWAP_EXACT_IN_SINGLE),
        uint8(Actions.SETTLE_ALL),
        uint8(Actions.TAKE_ALL)
    );

    bytes[] memory params = new bytes[](3);
    params[0] = abi.encode( // SWAP_EXACT_IN_SINGLE
        IV4Router.ExactInputSingleParams({
            poolKey: wbtc_usdc_key,
            zeroForOne: true,
            amountIn: amountIn,
            amountOutMinimum: minAmountOut,
            hookData: bytes("")
        })
    );
    params[1] = abi.encode(wbtc_usdc_key.currency0, amountIn); // SETTLE_ALL
    params[2] = abi.encode(wbtc_usdc_key.currency1, minAmountOut); // TAKE_ALL

    bytes[] memory inputs = new bytes[](1);
    inputs[0] = abi.encode(actions, params);

    bytes memory commands = abi.encodePacked(uint8(Commands.V4_SWAP));
    UNIVERSAL_ROUTER.execute(commands, inputs, block.timestamp);

    assertGt(USDC.balanceOf(address(this)), minAmountOut);
}

The full test suite, including swaps with native ETH, is available here. You’ll also find a diagram of the complete flow to act as a handy reference as you read.

1. UniversalRouter + Dispatcher

The swap execution starts with the following lines from our test:

bytes memory commands = abi.encodePacked(uint8(Commands.V4_SWAP));
UNIVERSAL_ROUTER.execute(commands, inputs, block.timestamp);

UniversalRouter::execute is the first stop:

/// @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; commandIndex++) {
        bytes1 command = commands[commandIndex];

        bytes calldata input = inputs[commandIndex];

        (success, output) = dispatch(command, input); // <--- pass to dispatch

        if (!success && successRequired(command)) {
            revert ExecutionFailed({commandIndex: commandIndex, message: output});
        }
    }
}

Since Commands.V4_SWAP is the only command in our test, the loop runs just once.

Next, execution moves to the command dispatch function, Dispatcher::dispatch:

/// @notice Decodes and executes the given command with the given inputs
/// @param commandType The command type to execute
/// @param inputs The inputs to execute the command with
/// @dev 2 masks are used to enable use of a nested-if statement in execution for efficiency reasons
/// @return success True on success of the command, false on failure
/// @return output The outputs or error messages, if any, from the command
function dispatch(bytes1 commandType, bytes calldata inputs) internal returns (bool success, bytes memory output) {
    uint256 command = uint8(commandType & Commands.COMMAND_TYPE_MASK);

    success = true;

    // 0x00 <= command < 0x21
    if (command < Commands.EXECUTE_SUB_PLAN) {
        // 0x00 <= command < 0x10
        if (command < Commands.V4_SWAP) {
            ...
        } else {
            // 0x10 <= command < 0x21
            if (command == Commands.V4_SWAP) {
                // pass the calldata provided to V4SwapRouter._executeActions (defined in BaseActionsRouter)
                _executeActions(inputs);
                // This contract MUST be approved to spend the token since its going to be doing the call on the position manager
            } else if {
                ...
        }
    } else {
        ...
    }
}

These functions are there to route the call between different Uniswap versions. As we’re using V4, we’ll be entering the Uniswap V4 periphery.

2. BaseActionsRouter

The execution flow reaches BaseActionsRouter::_executeActions, where the first key novelties of Uniswap V4 appear:

/// @notice internal function that triggers the execution of a set of actions on v4
/// @dev inheriting contracts should call this function to trigger execution
function _executeActions(bytes calldata unlockData) internal {
    poolManager.unlock(unlockData);
}

This function marks the first direct interaction with the actual pool, introducing a major change in Uniswap V4: the PoolManager. As a singleton contract, PoolManager holds all Uniswap V4 pools, requiring an unlock before any interaction can take place. PoolManager::unlock is arguably its most important function because it ensures proper accounting, we'll explore this topic later.

Let's break down PoolManager::unlock:

/// @inheritdoc IPoolManager
function unlock(bytes calldata data) external override returns (bytes memory result) {
    if (Lock.isUnlocked()) AlreadyUnlocked.selector.revertWith();

    Lock.unlock();

    // the caller does everything in this callback, including paying what they owe via calls to settle
    result = IUnlockCallback(msg.sender).unlockCallback(data);

    // ... verifying important accounting
}

Key lines to focus on:

Lock.unlock();

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

First, the contract state is set to unlocked, and then execution returns to msg.sender (UniversalRouter). This introduces a crucial change in the execution flow in Uniswap V4:

  1. PoolManager is unlocked via unlock
  2. PoolManager hands execution back to msg.sender
  3. msg.sender interacts with Uniswap V4 (e.g., performing swaps/adding/removing liquidity)
  4. Accounting is verified for correctness
  5. PoolManager is locked again

In our case, msg.sender is BaseActionsRouter, which is part of UniversalRouter

Execution of the swap continues in SafeCallback::unlockCallback:

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

This function simply forwards the call to SafeCallback::_unlockCallback, which is overridden by BaseActionsRouter::_unlockCallback.

/// @notice function that is called by the PoolManager through the SafeCallback.unlockCallback
/// @param data Abi encoding of (bytes actions, bytes[] params)
/// where params[i] is the encoded parameters for actions[i]
function _unlockCallback(bytes calldata data) internal override returns (bytes memory) {
    // abi.decode(data, (bytes, bytes[]));
    (bytes calldata actions, bytes[] calldata params) = data.decodeActionsRouterParams();
    _executeActionsWithoutUnlock(actions, params);
    return "";
}

At this point, PoolManager is unlocked, and execution continues in BaseActionsRouter::_executeActionsWithoutUnlock:

function _executeActionsWithoutUnlock(bytes calldata actions, bytes[] calldata params) internal {
    uint256 numActions = actions.length;
    if (numActions != params.length) revert InputLengthMismatch();

    for (uint256 actionIndex = 0; actionIndex < numActions; actionIndex++) {
        uint256 action = uint8(actions[actionIndex]);

        _handleAction(action, params[actionIndex]);
    }
}

This function loops through and executes each action in sequence.

Now is a good time to revisit the actions we passed in the foundry test for our swap:

bytes memory actions = abi.encodePacked(
    uint8(Actions.SWAP_EXACT_IN_SINGLE),
    uint8(Actions.SETTLE_ALL),
    uint8(Actions.TAKE_ALL)
);

And the accounting is:

  1. SWAP_EXACT_IN_SINGLE: Creates a debt of the input token (WBTC) from the caller to the pool and a debt of the output token (USDC) from the pool to the caller.
  2. SETTLE_ALL: Settles the debt from the caller to the pool.
  3. TAKE_ALL: Collects the owed output tokens from the pool and sends them to the caller.

Now, let’s explore each action.

3. SWAP_EXACT_IN_SINGLE

3.1 V4Router

V4Router::_handleAction  routes different actions. In our case, we’re interested in handling Actions.SWAP_EXACT_IN_SINGLE:

function _handleAction(uint256 action, bytes calldata params) internal override {
    // swap actions and payment actions in different blocks for gas efficiency
    if (action < Actions.SETTLE) {
            ...
        } else if (action == Actions.SWAP_EXACT_IN_SINGLE) {
            IV4Router.ExactInputSingleParams calldata swapParams = params.decodeSwapExactInSingleParams();
            _swapExactInputSingle(swapParams);
            return;
        } else if {
            ...
    } else {
        ...
    }
    revert UnsupportedAction(action);
}

Here, swapParams are decoded and passed to V4Router::_swapExactInputSingle:

function _swapExactInputSingle(IV4Router.ExactInputSingleParams calldata params) private {
    uint128 amountIn = params.amountIn;
    if (amountIn == ActionConstants.OPEN_DELTA) {
        amountIn =
            _getFullCredit(params.zeroForOne ? params.poolKey.currency0 : params.poolKey.currency1).toUint128();
    }
    uint128 amountOut =
        _swap(params.poolKey, params.zeroForOne, -int256(uint256(amountIn)), params.hookData).toUint128();
    if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived(params.amountOutMinimum, amountOut);
}

The first condition, if (amountIn == ActionConstants.OPEN_DELTA), checks if the swap is using an existing "tab" in PoolManager. Since we’re starting from 0, we can ignore it, it’s only applicable if you’re doing intermediate swaps and outside the scope of this test.

Execution then moves to V4Router::_swap:

function _swap(PoolKey memory poolKey, bool zeroForOne, int256 amountSpecified, bytes calldata hookData)
    private
    returns (int128 reciprocalAmount)
{
    // for protection of exactOut swaps, sqrtPriceLimit is not exposed as a feature in this contract
    unchecked {
        BalanceDelta delta = poolManager.swap(
            poolKey,
            IPoolManager.SwapParams(
                zeroForOne, amountSpecified, zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1
            ),
            hookData
        );

        reciprocalAmount = (zeroForOne == amountSpecified < 0) ? delta.amount1() : delta.amount0();
    }
}

This function performs the actual swap by calling PoolManager.

3.2 PoolManager

The swap itself happens in PoolManager::swap:

/// @inheritdoc IPoolManager
function swap(PoolKey memory key, IPoolManager.SwapParams memory params, bytes calldata hookData)
    external
    onlyWhenUnlocked
    noDelegateCall
    returns (BalanceDelta swapDelta)
{
    if (params.amountSpecified == 0) SwapAmountCannotBeZero.selector.revertWith();
    PoolId id = key.toId();
    Pool.State storage pool = _getPool(id);
    pool.checkPoolInitialized();

    BeforeSwapDelta beforeSwapDelta;
    {
        int256 amountToSwap;
        uint24 lpFeeOverride;
        (amountToSwap, beforeSwapDelta, lpFeeOverride) = key.hooks.beforeSwap(key, params, hookData);

        // execute swap, account protocol fees, and emit swap event
        // _swap is needed to avoid stack too deep error
        swapDelta = _swap(
            // ... swap
        );
    }

    BalanceDelta hookDelta;
    (swapDelta, hookDelta) = key.hooks.afterSwap(key, params, swapDelta, hookData, beforeSwapDelta);

    // if the hook doesn't have the flag to be able to return deltas, hookDelta will always be 0
    if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) _accountPoolBalanceDelta(key, hookDelta, address(key.hooks));

    _accountPoolBalanceDelta(key, swapDelta, msg.sender);
}

This function:

  1. Retrieves the pool state.
  2. Executes any pre-swap hooks, not applicable in our case since our pool has no hooks.
  3. Performs the swap.
  4. Applies post-swap hooks, again, not applicable in our case.
  5. Records balance changes.

The swap results in a BalanceDelta (compact data type that represents two int128 values packed into a single int256),  which records the debts between UniversalRouter and the PoolManager.

After the swap completes, this balance delta is stored:

_accountPoolBalanceDelta(key, swapDelta, msg.sender);


PoolManager::_accountPoolBalanceDelta
records the balance delta using transient storage, a new Ethereum Virtual Machine (EVM) feature that persists only for the duration of the transaction:

/// @notice Accounts the deltas of 2 currencies to a target address
function _accountPoolBalanceDelta(PoolKey memory key, BalanceDelta delta, address target) internal {
    _accountDelta(key.currency0, delta.amount0(), target);
    _accountDelta(key.currency1, delta.amount1(), target);
}

PoolManager::_accountDelta does two key things:

/// @notice Adds a balance delta in a currency for a target address
function _accountDelta(Currency currency, int128 delta, address target) internal {
    if (delta == 0) return;

    (int256 previous, int256 next) = currency.applyDelta(target, delta);

    if (next == 0) {
        NonzeroDeltaCount.decrement();
    } else if (previous == 0) {
        NonzeroDeltaCount.increment();
    }
}

  • currency.applyDelta(target, delta) records the outstanding balance.
  • NonzeroDeltaCount tracks the number of outstanding debts.

This is crucial because it determines if we can exit PoolManager::unlock, for that, NonzeroDeltaCount must be 0.

After the swap:

  • UniversalRouter (and by extension, us) owes WBTC to the PoolManager.
  • PoolManager owes USDC to UniversalRouter.
  • NonzeroDeltaCount increases to 2 (one debt in each direction).

Once stored, swapDelta is returned. 

In our test, the value was: -3402823669209384634633746074317682105591720676. Since BalanceDelta packs both values together, let's decode it:

function testSplitInt() public {
    BalanceDelta delta = BalanceDelta.wrap(-3402823669209384634633746074317682105591720676);
    console.logInt(delta.amount0()); // -10_000_000
    console.logInt(delta.amount1()); // 8_968_279_324
}

This confirms:

  • The PoolManager is owed 10_000_000 (-1e7) WBTC.
  • The PoolManager owes 8_968_279_324 (~9_000e6) USDC.

3.3 V4Router

The final step in  V4Router::_swapExactInputSingle is a slippage check:

if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived(params.amountOutMinimum, amountOut);

We set params.amountOutMinimum to 0 in our test, so this check passes. However, you should never do this in a real swap because it leaves your swap open for slippage abuse. 

With that, SWAP_EXACT_IN_SINGLE is fully executed! 

The next step is clearing debts so we can exit PoolManager::unlock and complete the swap.

4. SETTLE_ALL


4.1 V4Router

The next action is Actions.SETTLE_ALL, which is routed through V4SwapRouter::_handleAction:

if (action == Actions.SETTLE_ALL) {
    (Currency currency, uint256 maxAmount) = params.decodeCurrencyAndUint256();
    uint256 amount = _getFullDebt(currency);
    if (amount > maxAmount) revert V4TooMuchRequested(maxAmount, amount);
    _settle(currency, msgSender(), amount);
    return;
}

In our test, the parameters we sent were:

params[1] = abi.encode(WBTC_USDC_KEY.currency0, amountIn);

Here:

  • currency0 (i.e., currency) is WBTC.
  • amountIn (i.e., maxAmount) is 1e7, representing the WBTC amount we owe PoolManager.

DeltaResolver::_getFullDebt queries PoolManager to determine the actual debt:

function _getFullDebt(Currency currency) internal view returns (uint256 amount) {
    int256 _amount = poolManager.currencyDelta(address(this), currency);
    // If the amount is positive, it should be taken not settled.
    if (_amount > 0) revert DeltaNotNegative(currency);
    // Casting is safe due to limits on the total supply of a pool
    amount = uint256(-_amount);
}

This function calls PoolManager::exttload, which returns our debt value.

Looking at the transaction trace from Forge (-vvvv), we see:

├─ [859] POOL_MANAGER::exttload(0xcc542c39d285d4bff2e6d92da545b4deeab7b8d383577645f35f8576aa18a8a8) [staticcall]
│   └─ ← [Return] 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffff676980

This result (0xffff...676980) corresponds to -1e7, confirming that we owe 10_000_000 WBTC.

Execution then moves to DeltaResolver::_settle:

function _settle(Currency currency, address payer, uint256 amount) internal {
    if (amount == 0) return;

    poolManager.sync(currency);
    if (currency.isAddressZero()) {
        poolManager.settle{value: amount}();
    } else {
        _pay(currency, payer, amount);
        poolManager.settle();
    }
}

The first step is calling poolManager.sync(currency)

Before settling the debt, we must sync transient storage in PoolManager to ensure it correctly tracks the incoming balance.

4.2 PoolManager

PoolManager::sync:

function sync(Currency currency) external {
    // address(0) is used for the native currency
    if (currency.isAddressZero()) {
        // The reserves balance is not used for native settling, so we only need to reset the currency.
        CurrencyReserves.resetCurrency();
    } else {
        uint256 balance = currency.balanceOfSelf();
        CurrencyReserves.syncCurrencyAndReserves(currency, balance);
    }
}

CurrencyReserves::syncCurrencyAndReserves writes the current WTBC balance to transient storage:

function syncCurrencyAndReserves(Currency currency, uint256 value) internal {
    assembly ("memory-safe") {
        tstore(CURRENCY_SLOT, and(currency, 0xffffffffffffffffffffffffffffffffffffffff))
        tstore(RESERVES_OF_SLOT, value)
    }
}

This ensures PoolManger correctly tracks the WBTC balance before settling.

4.3 V4SwapRouter

Since we are not settling with native ETH, execution proceeds to V4SwapRouter::_pay. This is part of the UniversalRouter repo, specifically V4SwapRouter::_pay:

function _pay(Currency token, address payer, uint256 amount) internal override {
    payOrPermit2Transfer(Currency.unwrap(token), payer, address(poolManager), amount);
}

This leads to Permit2Payments::payOrPermit2Transfer:

function payOrPermit2Transfer(address token, address payer, address recipient, uint256 amount) internal {
    if (payer == address(this)) pay(token, recipient, amount);
    else permit2TransferFrom(token, payer, recipient, amount.toUint160());
}

Since payer is not address(this), execution continues in Permit2Payments::permit2TransferFrom:

function permit2TransferFrom(address token, address from, address to, uint160 amount) internal {
    PERMIT2.transferFrom(from, to, amount, token);
}

This calls PERMIT2, which finally executes the WBTC transfer:

├─ [9162] PERMIT2::transferFrom(UniV4Swap: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], POOL_MANAGER: [0x000000000004444c5dc75cB358380D2e3dE08A90], 10000000 [1e7], WBTC: [0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599])
│   ├─ [7770] WBTC::transferFrom(UniV4Swap: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], POOL_MANAGER: [0x000000000004444c5dc75cB358380D2e3dE08A90], 10000000 [1e7])
│   │   ├─ emit Transfer(from: UniV4Swap: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], to: POOL_MANAGER: [0x000000000004444c5dc75cB358380D2e3dE08A90], value: 10000000 [1e7])
│   │   └─ ← [Return] true
│   └─ ← [Return]

Since UniversalRouter executes the transfer, users must approve UniversalRouter to transfer WBTC via Permit2. This was done in our test:

PERMIT2.approve(WBTC_ADDRESS, UNIVERSAL_ROUTER_ADDRESS, amountIn, uint48(block.timestamp));

We’ve transferred the tokens for our debt to the pool. Now it’s time to settle the accounting. 

4.4 V4Router

Returning to DeltaResolver::_settle, execution now enters poolManager.settle().

4.5 PoolManager

PoolManager::settle and PoolManager::_settle handle the final settlement:

function settle() external payable onlyWhenUnlocked returns (uint256) {
    return _settle(msg.sender);
}

...

// if settling native, integrators should still call `sync` first to avoid DoS attack vectors
function _settle(address recipient) internal returns (uint256 paid) {
    Currency currency = CurrencyReserves.getSyncedCurrency();

    // if not previously synced, or the syncedCurrency slot has been reset, expects native currency to be settled
    if (currency.isAddressZero()) {
        paid = msg.value;
    } else {
        if (msg.value > 0) NonzeroNativeValue.selector.revertWith();
        // Reserves are guaranteed to be set because currency and reserves are always set together
        uint256 reservesBefore = CurrencyReserves.getSyncedReserves();
        uint256 reservesNow = currency.balanceOfSelf();
        paid = reservesNow - reservesBefore;
        CurrencyReserves.resetCurrency();
    }

    _accountDelta(currency, paid.toInt128(), recipient);
}

Since we’re not using ETH, the function calculates the settled amount and updates transient storage. This is where the earlier sync call becomes essential. It ensures that CurrencyReserves.getSyncedCurrency() returns WBTC and that CurrencyReserves.getSyncedReserves() reflects the balance before the transfer.

As a result, reservesNow - reservesBefore correctly reflects the amount paid, which is stored in paid. Then, CurrencyReserves.resetCurrency() clears the transient storage, preventing any lingering currency data for future operations.

Finally, _accountDelta(currency, paid.toInt128(), recipient) cancels out our debt:

function _accountDelta(Currency currency, int128 delta, address target) internal {
    if (delta == 0) return;

    (int256 previous, int256 next) = currency.applyDelta(target, delta);

    if (next == 0) {
        NonzeroDeltaCount.decrement();
    } else if (previous == 0) {
        NonzeroDeltaCount.increment();
    }
}

At this point, our WBTC debt is cleared. Next, we claim the USDC that PoolManager owes us.

Note: If we had not called sync, the CurrencyReserves.getSyncedCurrency() function would have returned address(0). Even if we had transferred ETH instead of WBTC, the debt would not have been cleared. This would have caused execution to revert later in PoolManager::unlock due to outstanding debt, leaving the swap incomplete.

5. TAKE_ALL


5.1 V4Router

Once again, execution begins in V4Router::_handleAction:

} else if (action == Actions.TAKE_ALL) {
    (Currency currency, uint256 minAmount) = params.decodeCurrencyAndUint256();
    uint256 amount = _getFullCredit(currency);
    if (amount < minAmount) revert V4TooLittleReceived(minAmount, amount);
    _take(currency, msgSender(), amount);
    return;
}

To refresh, the parameters we passed are:

params[2] = abi.encode(WBTC_USDC_KEY.currency1, minAmountOut);

Here currency1 is USDC.

The function then calls DeltaResolver::_getFullCredit, which functions similarly to _getFullDebt in the SETTLE_ALL flow:

function _getFullCredit(Currency currency) internal view returns (uint256 amount) {
    int256 _amount = poolManager.currencyDelta(address(this), currency);
    // If the amount is negative, it should be settled not taken.
    if (_amount < 0) revert DeltaNotPositive(currency);
    amount = uint256(_amount);
}

Looking at the Forge test trace, we see the extload call to PoolManager returning:

├─ [859] POOL_MANAGER::exttload(0x7a546babd112f483b54774c6cda4e5032ea25f89ff1fdd03827ba7f5c9a6386d) [staticcall]
│   └─ ← [Return] 0x00000000000000000000000000000000000000000000000000000002168d151c

This value (0x000000...2168d151c) is 8_968_279_324 (~9,000e6), which matches our expected output in USDC.

Since we set minAmount to 0, the slippage check is bypassed (again, not advisable for mainnet swaps).

Execution proceeds to DeltaResolver::_take:

function _take(Currency currency, address recipient, uint256 amount) internal {
    if (amount == 0) return;
    poolManager.take(currency, recipient, amount);
}

With that, execution moves to PoolManager.

5.2 PoolManager

PoolManager::take:

function take(Currency currency, address to, uint256 amount) external onlyWhenUnlocked {
    unchecked {
        // negation must be safe as amount is not negative
        _accountDelta(currency, -(amount.toInt128()), msg.sender);
        currency.transfer(to, amount);
    }
}

This function:

  1. Subtracts the amount from transient storage, reducing the recorded debt.
  2. Transfers the USDC amount (~9,000e6) to to, which is our test contract (msgSender() from V4Router::_handleAction).

Since we are fully settling the balance, next = 0, which reduces NonzeroDeltaCount by 1, bringing it to 0.

At this point, all debts are cleared.

6. Exit PoolManager::unlock

With all actions completed, execution returns to BaseActionsRouter::_executeActionsWithoutUnlock and then back to the main callback in PoolManager::unlock.

The final two lines of PoolManager::unlock execute:

 if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
    Lock.lock();

Since NonzeroDeltaCount = 0, the check passes, and the PoolManager is locked again.

Swap complete! 

Summary

Congrats on making it to the end! I hope this walkthrough provided clarity on how swaps execute under the hood in Uniswap V4.

For the full test suite, including native ETH swaps, check out this GitHub repo. If you've followed along, understanding how native tokens are handled should be a breeze.

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.