Multi-pool Swaps #
We’re now proceeding to the core of this milestone–implementing multi-pool swaps in our contracts. We won’t touch Pool contract in this milestone because it’s a core contract that should implement only core features. Multi-pool swaps is a utility feature, and we’ll implement it in Manager and Quoter contracts.
Updating Manager Contract #
Single-pool and Multi-pool Swaps #
In our current implementation, swap
function in Manager contract supports only single-pool swaps and takes pool address
in parameters:
function swap(
address poolAddress_,
bool zeroForOne,
uint256 amountSpecified,
uint160 sqrtPriceLimitX96,
bytes calldata data
) public returns (int256, int256) { ... }
We’re going to split it into two functions: single-pool swap and multi-pool swap. These functions will have different set of parameters:
struct SwapSingleParams {
address tokenIn;
address tokenOut;
uint24 tickSpacing;
uint256 amountIn;
uint160 sqrtPriceLimitX96;
}
struct SwapParams {
bytes path;
address recipient;
uint256 amountIn;
uint256 minAmountOut;
}
SwapSingleParams
takes pool parameters, input amount, and a limiting price–this is pretty much identical to what we had before. Notice, thatdata
is no longer required.SwapParams
takes path, output amount recipient, input amount, and minimal output amount. The latter parameter replacessqrtPriceLimitX96
because, when doing multi-pool swaps, we cannot use the slippage protection from Pool contract (which uses a limiting price). We need to implement another slippage protection, which checks the final output amount and compares it withminAmountOut
: the slippage protection fails when the final output amount is smaller thanminAmountOut
.
Core Swapping Logic #
Let’s implement an internal _swap
function that will be called by both single- and multi-pool swap functions. It’ll
prepare parameters and call Pool.swap
.
function _swap(
uint256 amountIn,
address recipient,
uint160 sqrtPriceLimitX96,
SwapCallbackData memory data
) internal returns (uint256 amountOut) {
...
SwapCallbackData
is a new data structure that contains data we pass between swap functions and uniswapV3SwapCallback
:
struct SwapCallbackData {
bytes path;
address payer;
}
path
is a swap path and payer
is the address that provides input tokens in swaps–we’ll have different payers during
multi-pool swaps.
First thing we do in _swap
, is extracting pool parameters using Path
library:
// function _swap(...) {
(address tokenIn, address tokenOut, uint24 tickSpacing) = data
.path
.decodeFirstPool();
Then we identify swap direction:
bool zeroForOne = tokenIn < tokenOut;
Then we make the actual swap:
// function _swap(...) {
(int256 amount0, int256 amount1) = getPool(
tokenIn,
tokenOut,
tickSpacing
).swap(
recipient,
zeroForOne,
amountIn,
sqrtPriceLimitX96 == 0
? (
zeroForOne
? TickMath.MIN_SQRT_RATIO + 1
: TickMath.MAX_SQRT_RATIO - 1
)
: sqrtPriceLimitX96,
abi.encode(data)
);
This piece is identical to what we had before but this time we’re calling getPool
to find the pool. getPool
is
a function that sorts tokens and calls PoolAddress.computeAddress
:
function getPool(
address token0,
address token1,
uint24 tickSpacing
) internal view returns (IUniswapV3Pool pool) {
(token0, token1) = token0 < token1
? (token0, token1)
: (token1, token0);
pool = IUniswapV3Pool(
PoolAddress.computeAddress(factory, token0, token1, tickSpacing)
);
}
After making a swap, we need to figure out which of the amounts is the output one:
// function _swap(...) {
amountOut = uint256(-(zeroForOne ? amount1 : amount0));
And that’s it. Let’s now look at how single-pool swap works.
Single-pool Swapping #
swapSingle
acts simply as a wrapper of _swap
:
function swapSingle(SwapSingleParams calldata params)
public
returns (uint256 amountOut)
{
amountOut = _swap(
params.amountIn,
msg.sender,
params.sqrtPriceLimitX96,
SwapCallbackData({
path: abi.encodePacked(
params.tokenIn,
params.tickSpacing,
params.tokenOut
),
payer: msg.sender
})
);
}
Notice that we’re building a one-pool path here: single-pool swap is a multi-pool swap with one pool 🙂.
Multi-pool Swapping #
Multi-pool swapping is only slightly more difficult than single-pool swapping. Let’s look at it:
function swap(SwapParams memory params) public returns (uint256 amountOut) {
address payer = msg.sender;
bool hasMultiplePools;
...
First swap is paid by user because it’s user who provides input tokens.
Then, we start iterating over pools in the path:
...
while (true) {
hasMultiplePools = params.path.hasMultiplePools();
params.amountIn = _swap(
params.amountIn,
hasMultiplePools ? address(this) : params.recipient,
0,
SwapCallbackData({
path: params.path.getFirstPool(),
payer: payer
})
);
...
In each iteration, we’re calling _swap
with these parameters:
params.amountIn
tracks input amounts. During the first swap it’s the amount provided by user. During next swaps its the amounts returned from previous swaps.hasMultiplePools ? address(this) : params.recipient
–if there are multiple pools in the path, recipient is the manager contract, it’ll store tokens between swaps. If there’s only one pool (last one) in the path, recipient is the one specified in the parameters (usually the same user that initiates the swap).sqrtPriceLimitX96
is set to 0 to disable slippage protection in the Pool contract.- Last parameter is what we pass to
uniswapV3SwapCallback
–we’ll look at it shortly.
After making one swap, we need to proceed to next pool in a path or return:
...
if (hasMultiplePools) {
payer = address(this);
params.path = params.path.skipToken();
} else {
amountOut = params.amountIn;
break;
}
}
This is where we’re changing payer and removing a processed pool from the path.
Finally, the new slippage protection:
if (amountOut < params.minAmountOut)
revert TooLittleReceived(amountOut);
Swap Callback #
Let’s look at the updated swap callback:
function uniswapV3SwapCallback(
int256 amount0,
int256 amount1,
bytes calldata data_
) public {
SwapCallbackData memory data = abi.decode(data_, (SwapCallbackData));
(address tokenIn, address tokenOut, ) = data.path.decodeFirstPool();
bool zeroForOne = tokenIn < tokenOut;
int256 amount = zeroForOne ? amount0 : amount1;
if (data.payer == address(this)) {
IERC20(tokenIn).transfer(msg.sender, uint256(amount));
} else {
IERC20(tokenIn).transferFrom(
data.payer,
msg.sender,
uint256(amount)
);
}
}
The callback expects encoded SwapCallbackData
with path and payer address. It extracts pool tokens from the path,
figures out swap direction (zeroForOne
), and the amount the contract needs to transfer out. Then, it acts differently
depending on payer address:
- If payer is the current contract (this is so when making consecutive swaps), it transfers tokens to the next pool (the one that called this callback) from current contract’s balance.
- If payer is a different address (the user that initiated the swap), it transfers tokens from user’s balance.
Updating Quoter Contract #
Quoter is another contract that needs to be updated because we want to use it to also find output amounts in multi-pool swaps.
Similarly to Manager, we’ll have two variants of quote
function: single-pool and multi-pool one. Let’s look at the
former first.
Single-pool Quoting #
We need to make only a couple of changes in our current quote
implementation:
- rename it to
quoteSingle
; - extract parameters into a struct (this is mostly a cosmetic change);
- instead of a pool address, take two token addresses and a tick spacing in the parameters.
// src/UniswapV3Quoter.sol
struct QuoteSingleParams {
address tokenIn;
address tokenOut;
uint24 tickSpacing;
uint256 amountIn;
uint160 sqrtPriceLimitX96;
}
function quoteSingle(QuoteSingleParams memory params)
public
returns (
uint256 amountOut,
uint160 sqrtPriceX96After,
int24 tickAfter
)
{
...
And the only change we have in the body of the function is usage of getPool
to find the pool address:
...
IUniswapV3Pool pool = getPool(
params.tokenIn,
params.tokenOut,
params.tickSpacing
);
bool zeroForOne = params.tokenIn < params.tokenOut;
...
Multi-pool Quoting #
Multi-pool quoting implementation is similar to the multi-pool swapping one, but it uses fewer parameters.
function quote(bytes memory path, uint256 amountIn)
public
returns (
uint256 amountOut,
uint160[] memory sqrtPriceX96AfterList,
int24[] memory tickAfterList
)
{
sqrtPriceX96AfterList = new uint160[](path.numPools());
tickAfterList = new int24[](path.numPools());
...
As parameters, we only need input amount and swap path. The function returns similar values as quoteSingle
, but “price
after” and “tick after” are collected after each swap, thus we need to returns arrays.
uint256 i = 0;
while (true) {
(address tokenIn, address tokenOut, uint24 tickSpacing) = path
.decodeFirstPool();
(
uint256 amountOut_,
uint160 sqrtPriceX96After,
int24 tickAfter
) = quoteSingle(
QuoteSingleParams({
tokenIn: tokenIn,
tokenOut: tokenOut,
tickSpacing: tickSpacing,
amountIn: amountIn,
sqrtPriceLimitX96: 0
})
);
sqrtPriceX96AfterList[i] = sqrtPriceX96After;
tickAfterList[i] = tickAfter;
amountIn = amountOut_;
i++;
if (path.hasMultiplePools()) {
path = path.skipToken();
} else {
amountOut = amountIn;
break;
}
}
The logic of the loop is identical to the one in the updated swap
function:
- get current pool’s parameters;
- call
quoteSingle
on current pool; - save returned values;
- repeat if there’re more pools in the path, or return otherwise.