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!
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.
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.
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:
PoolManager
is unlocked via unlockPoolManager
hands execution back to msg.sendermsg.sender
interacts with Uniswap V4 (e.g., performing swaps/adding/removing liquidity)PoolManager
is locked againIn 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:
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.SETTLE_ALL
: Settles the debt from the caller to the pool.TAKE_ALL
: Collects the owed output tokens from the pool and sends them to the caller.Now, let’s explore each action.
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.
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:
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
.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:
WBTC
.USDC
.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.
SETTLE_ALL
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.
PoolManager
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.
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.
V4Router
Returning to DeltaResolver::_settle
, execution now enters poolManager.settle().
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.
TAKE_ALL
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
.
PoolManager
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:
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.
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!
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.