uniswap — Deep Dive into V3’s Source Code

Trapdoor-Tech
8 min readJun 19, 2021

--

Understanding uniswap V3 technical white paper helps understand the source code. Uniswap V3 logic is a little bit complicated, but the implementation is rather clear. I strongly recommend you to understand the sniswap V3 technical white paper before looking at the source code:

https://starli.medium.com/uniswap-deep-dive-into-v3-technical-white-paper-2fe2b5c90d2

Links to uniswap V3 Smart Contract source code are listed below:

https://github.com/Uniswap/uniswap-v3-core

https://github.com/Uniswap/uniswap-v3-periphery

Structure Overview

Similar to V2 code logic, the functionality can break down into two parts: core and periphery. The relationship between two parts shown below:

Periohery functions can also break down into two parts: Position management and swap router management. NonfungiblePositionManager is in charge of creation of transaction pool and addtion/removal of liquidity. SwapRouter is swap router management. UniswapV3Factory is the unified interface of the transaction pool UniswapV3Pool. UniswapV3Pool is set up and managed by UniswapV3PoolDeployer. UniswapV3Pool is the core logic, and it manages Tick and Position, implements liuquidity management,and the swap functionality in a transaction pool. In each Pool, Position becomes an ERC721 Token. That is to say, each Position has its independent ERC721 Token ID。

Create Transaction Pool

NonfungiblePositionManager takes charge of creatin of transaction pool and the addition/deletion of liquidity. First let us take a look at some definitions of global variables:

/// @dev IDs of pools assigned by this contract
mapping(address => uint80) private _poolIds;
/// @dev Pool keys by pool ID, to save on SSTOREs for position data
mapping(uint80 => PoolAddress.PoolKey) private _poolIdToPoolKey;
/// @dev The token ID position data
mapping(uint256 => Position) private _positions;
/// @dev The ID of the next token that will be minted. Skips 0
uint176 private _nextId = 1;

/// @dev The ID of the next pool that is used for the first time. Skips 0
uint80 private _nextPoolId = 1;

Each Pool has a unique index, the index starts from 1(_nextPoolId). _poolIds records all the corresponding relationship between transaction pool addresses and indexes. Each transaction key information is represented by PoolKey (defined in libraries/PoolAddress.sol):

struct PoolKey {
address token0;
address token1;
uint24 fee;
}

Every transaction pool is uniquely marked by two Tokens and the only transaction fee. _poolIdToPoolKey recrods the corresponding relationship between transaction pool index and PoolKey.

Position from all transaction pools are summed up and managed, with a unique global index _nextId, which starts from 1. Each Position is generated by the assigned address and its range:

function compute(
address owner,
int24 tickLower,
int24 tickUpper
) internal pure returns (bytes32) {
return keccak256(abi.encodePacked(owner, tickLower, tickUpper));
}

Let us take a look at the constructor of NonfungiblePositionManager:

constructor(
address _factory,
address _WETH9,
address _tokenDescriptor_
) ERC721Permit('Uniswap V3 Positions NFT-V1', 'UNI-V3-POS', '1') PeripheryImmutableState(_factory, _WETH9) {
_tokenDescriptor = _tokenDescriptor_;
}

_factory is the address of UniswapV3Factory in the core function. _WETH9 is the address of the ETH Smart contract. _tokenDescriptor is the address of the interface for ERC721 property information.

Through createAndInitializePoolIfNecessary function we can create a transaction pool:

function createAndInitializePoolIfNecessary(
address tokenA,
address tokenB,
uint24 fee,
uint160 sqrtPriceX96
) external payable override returns (address pool) {

The logic here is straight forward: check if the corresponding transaction pool exists by using UniswapV3Factory. If not, create the transaction pool; if yes, but not initialized yet, then we initialize the transaction pool. Here let us take a closer look at these two functions: createPool and the initialize function for each transaction pool.

  • createPool

The core logic is to use deploy function from UniswapV3PoolDeployer to create UniswapV3Pool smart contract and configure the two tokens, transaction fee, and tickSpacing info:

pool = deploy(address(this), token0, token1, fee, tickSpacing);

Then looking at deploy function, we create UniswapV3Pool smart contract. Pay attention to the address setup for each transaction pool — it is the result of token0/token1/fee encoding. That is to say, each transaction pool has its unique address, and it is consistent with PoolKey information. Going with this approach, we can reverse engineer the transaction pool address from PoolKey information.

function deploy(
address factory,
address token0,
address token1,
uint24 fee,
int24 tickSpacing
) internal returns (address pool) {
parameters = Parameters({factory: factory, token0: token0, token1: token1, fee: fee, tickSpacing: tickSpacing});
pool = address(new UniswapV3Pool{salt: keccak256(abi.encode(token0, token1, fee))}());
delete parameters;
}
  • initialize

Each transaction pool initializes the parameters and states by using initialize function. All transaction pool parameters and states are recorded by a data structure Slot0:

struct Slot0 {
// the current price
uint160 sqrtPriceX96;
// the current tick
int24 tick;
// the most-recently updated index of the observations array
uint16 observationIndex;
// the current maximum number of observations that are being stored
uint16 observationCardinality;
// the next maximum number of observations to store, triggered in observations.write
uint16 observationCardinalityNext;
// the current protocol fee as a percentage of the swap fee taken on withdrawal
// represented as an integer denominator (1/x)%
uint8 feeProtocol;
// whether the pool is locked
bool unlocked;
}
/// @inheritdoc IUniswapV3PoolState
Slot0 public override slot0;

Note that the transaction prize is initialized during initialization. This way all liquidity additioni logics are unified.

Add Liquidity

mint function in NonfungiblePositionManager increases initial value for liquidity. increaseLiquidity function implements the liquidity addition. The logic of these two functions are basically the same, by using addLiquidity function. mint needs creating an extra ERC721 token.

addLiquidity is documented at LiquidityManagement.sol:

struct AddLiquidityParams {
address token0;
address token1;
uint24 fee;
address recipient;
int24 tickLower;
int24 tickUpper;
uint128 amount;
uint256 amount0Max;
uint256 amount1Max;
}
/// @notice Add liquidity to an initialized pool
function addLiquidity(AddLiquidityParams memory params)
internal
returns (
uint256 amount0,
uint256 amount1,
IUniswapV3Pool pool
)

First we calculate the corresponding transaction pool address through the transaction pool core information.

PoolAddress.PoolKey memory poolKey =
PoolAddress.PoolKey({token0: params.token0, token1: params.token1, fee: params.fee});
pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey));

Mint function implements the core logic for adding liquidity. mint function includes these two sub-functions: _modifyPosition and _updatePosition.

  • _updatePosition

For the ease of our calculation, the update of liquidity state is revealed by the Tick liquidityNet at the position boundary:

function _updatePosition(                                                                       
address owner,
int24 tickLower,
int24 tickUpper,
int128 liquidityDelta,
int24 tick
) private returns (Position.Info storage position) {

_updatePosition mainly updates the corresponding boundary Tick info for Position:

flippedLower = ticks.update(
tickLower,
tick,
liquidityDelta,
_feeGrowthGlobal0X128,
_feeGrowthGlobal1X128,
false,
maxLiquidityPerTick
);
flippedUpper = ticks.update(
tickUpper,
tick,
liquidityDelta,
_feeGrowthGlobal0X128,
_feeGrowthGlobal1X128,
true,
maxLiquidityPerTick
);
  • _modifyPosition

Besides updating Tick information, _modifyPosition needs to calculate the corresponding money value of liquidity based on current price. Current price is saved in _slot0.tick. The overall logic as follows:

if (_slot0.tick < params.tickLower) {
...
} else if (_slot0.tick < params.tickUpper) {
...
liquidity = LiquidityMath.addDelta(liquidityBefore, params.liquidityDelta);
} else {
...
}

For specific formula please refer to the formula 6.29 and 6.30 in the technical whitepapaer. Note that, while adding liquidity, current liquidity requires an update if the added liquidity included current price. As shown in above code snippet ,the liquidity update. In each transaction pool the liquidity saves current price corresponded lump sum of total liquidity.

Mint function of in the transaction pool only implements the two Token value calculation corresponded to liquidity under current price. The token transaction is implemented by uniswapV3MintCallback.

Remove liquidity

Removing liquidity logic is similar to adding logic. It uses the burn function in the transaction pool. burn function is using _modifyPosition function to implement the adjustment of liquidity. _modifyPosition implements the adjustment to both positive and negative liquidity.

After removing liquidity, the reserved to be withdrawn cash value is saved temporarily in tokensOwed0 and tokensOwed1 variables for each liquidity:

position.tokensOwed0 +=
uint128(amount0) +
uint128(
FullMath.mulDiv(
feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128,
position.liquidity,
FixedPoint128.Q128
)
);
position.tokensOwed1 +=
uint128(amount1) +
uint128(
FullMath.mulDiv(
feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128,
position.liquidity,
FixedPoint128.Q128
)
);

If one certain liquidity is 0, and all transaction fee has been collected, then we can remove the ERC721 Token corresponds to the liquidity through burn function in NonfungiblePositionManager.

Swap Process

Swap logic is documented in SwapRouter.sol. It implements multiple connected paths swap logic. There are two different functions:

  • exactInputSingle/exactInput
  • exactOutputSingle/exactOutput

exactInputSingle and exactOutputSingle is the swap function in single transaction pool. One function is to dedicate input value from swap in exchange for certain amount of output, while the other function is to dedicate output value for swap, and reserve deduct how much input value it requires.

No matter it is exactInputSingle or exactOutputSingle, the final function used is the swap function from transaction pool:

function swap(
address recipient,
bool zeroForOne,
int256 amountSpecified,
uint160 sqrtPriceLimitX96,
bytes calldata data
) external override noDelegateCall returns (int256 amount0, int256 amount1) {

recipient is the “from address” which initiates the swap. zeroForOne means that, whether Token0 is converted to Token1, and amountSpecified is the amount to be converted, sqrtPriceLimitX96 is the upper limit of the price.

exactInput differentiates from exactOutput through the positivity/negativity of the input value:

bool exactInput = amountSpecified > 0;

The main body of the function is made up by a while loop. That is to say, swap process can break down into several small steps, and adjust the current Tick until all transactions are satisfied:

while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
  • Calculate next possible Tick and update the price:
(step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(
state.tick,
tickSpacing,
zeroForOne
);
step.sqrtPriceNextX96 = TickMath.getSqrtRatioAtTick(step.tickNext);
  • Calculate swap Token0/Token1 and its transaction fee
(state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = SwapMath.computeSwapStep(
state.sqrtPriceX96,
(zeroForOne ? step.sqrtPriceNextX96 < sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96)
? sqrtPriceLimitX96
: step.sqrtPriceNextX96,
state.liquidity,
state.amountSpecifiedRemaining,
fee
);

Token0/Token1 value fluctuates within a price range. We can obtain such range through getAmount0Delta/getAmount1Delta function (SqrtPriceMath.sol) , which is the formula stated in 6.14/6.16.

Calculate the fee

  • if (cache.feeProtocol > 0) { uint256 delta = step.feeAmount / cache.feeProtocol; step.feeAmount -= delta; state.protocolFee += uint128(delta); }

if (state.liquidity > 0) state.feeGrowthGlobalX128 += FullMath.mulDiv(step.feeAmount, FixedPoint128.Q128, state.liquidity);

  • Update Tick information
int128 liquidityNet =
ticks.cross(
step.tickNext,
(zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128),
(zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128)
);

After completing swap, we use the IUniswapV3SwapCallback interface to implement two token transactions through Swap function:

if (zeroForOne) {
if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));
uint256 balance0Before = balance0();
IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA');
} else {
if (amount0 < 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0));
uint256 balance1Before = balance1();
IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
require(balance1Before.add(uint256(amount1)) <= balance1(), 'IIA');
}

Multiple paths swap (exactInput/exactOutput is based on exactInputSingle/exactOutputSingle.

Transaction Fee Withdrawal

NonfungiblePositionManager provides collect function to withdraw transaction fee. Each Position records the feeGrowthInside in certain time period under the condition that the liquidity does not change. Each Position updates the growth rate when it gets updated. If the liquidity is not updated, the burn function is used to update the growth rate during transaction fee withdrawal, and we can calculate the required transaction fee:

pool.burn(position.tickLower, position.tickUpper, 0);

Then through the collect function from transaction pool, we conclude the collection of transaction fee.

(amount0, amount1) = pool.collect(recipient, position.tickLower, position.tickUpper, amount0Max, amount1Max);

Summary:

uniswap V3 core is that it provides liquidity within certain range. Compared to V2, the complexity has imcreased quite a bit. The entire code breaks down to two parts: core logic and supporting functionalities. Core logic can break down to two parts: transaction pool and Position management, and Swap function logic. Each Position in the transaction pool is designed and implemented as ERC721 Token. Swap core logic can be implmented based on Tick and Position management.

--

--

Trapdoor-Tech
Trapdoor-Tech

Written by Trapdoor-Tech

Trapdoor-Tech tries to connect the world with zero-knowledge proof technologies. zk-SNARK/STARK solution and proving acceleration are our first small steps :)

No responses yet