Swap Path
Let’s imagine that we have only these pools: WETH/USDC, USDC/USDT, and WBTC/USDT. If we want to swap WETH for WBTC, we’ll need to make multiple swaps (WETH→USDC→USDT→WBTC) since there’s no WETH/WBTC pool. We can do this manually or we can improve our contracts to handle such chained, or multi-pool, swaps. Of course, we’ll do the latter!
When doing multi-pool swaps, we send the output of the previous swap to the input of the next one. For example:
- in the WETH/USDC pool, we’re selling WETH and buying USDC;
- in the USDC/USDT pool, we’re selling USDC from the previous swap and buying USDT;
- in the WBTC/USDT pool, we’re selling USDT from the previous pool and buying WBTC.
We can turn this series into a path:
WETH/USDC,USDC/USDT,WBTC/USDT
And iterate over such a path in our contracts to perform multiple swaps in one transaction. However, recall from the previous chapter that we don’t need to know pool addresses and, instead, we can derive them from pool parameters. Thus, the above path can be turned into a series of tokens:
WETH, USDC, USDT, WBTC
Recall that tick spacing is another parameter (besides tokens) that identifies a pool. Thus, the above path becomes:
WETH, 60, USDC, 10, USDT, 60, WBTC
Where 60 and 10 are tick spacings. We’re using 60 in volatile pairs (e.g. ETH/USDC, WBTC/USDT) and 10 in stablecoin pairs (USDC/USDT).
Now, having such a path, we can iterate over it to build pool parameters for each of the pools:
WETH, 60, USDC
;USDC, 10, USDT
;USDT, 60, WBTC
.
Knowing these parameters, we can derive pool addresses using PoolAddress.computeAddress
, which we implemented in the previous chapter.
We also can use this concept when doing swaps within one pool: the path would simply contain the parameters of one pool. And, thus, we can use swap paths in all swaps, universally.
Let’s build a library to work with swap paths.
Path Library
In code, a swap path is a sequence of bytes. In Solidity, a path can be built like this:
bytes.concat(
bytes20(address(weth)),
bytes3(uint24(60)),
bytes20(address(usdc)),
bytes3(uint24(10)),
bytes20(address(usdt)),
bytes3(uint24(60)),
bytes20(address(wbtc))
);
It looks like this:
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 # weth address
00003c # 60
A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 # usdc address
00000a # 10
dAC17F958D2ee523a2206206994597C13D831ec7 # usdt address
00003c # 60
2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 # wbtc address
These are the functions that we’ll need to implement:
- calculating the number of pools in a path;
- figuring out if a path has multiple pools;
- extracting first pool parameters from a path;
- proceeding to the next pair in a path;
- and decoding first pool parameters.
Calculating the Number of Pools in a Path
Let’s begin with calculating the number of pools in a path:
// src/lib/Path.sol
library Path {
/// @dev The length the bytes encoded address
uint256 private constant ADDR_SIZE = 20;
/// @dev The length the bytes encoded tick spacing
uint256 private constant TICKSPACING_SIZE = 3;
/// @dev The offset of a single token address + tick spacing
uint256 private constant NEXT_OFFSET = ADDR_SIZE + TICKSPACING_SIZE;
/// @dev The offset of an encoded pool key (tokenIn + tick spacing + tokenOut)
uint256 private constant POP_OFFSET = NEXT_OFFSET + ADDR_SIZE;
/// @dev The minimum length of a path that contains 2 or more pools;
uint256 private constant MULTIPLE_POOLS_MIN_LENGTH =
POP_OFFSET + NEXT_OFFSET;
...
We first define a few constants:
ADDR_SIZE
is the size of an address, 20 bytes;TICKSPACING_SIZE
is the size of a tick spacing, 3 bytes (uint24
);NEXT_OFFSET
is the offset of a next token address–to get it, we skip an address and a tick spacing;POP_OFFSET
is the offset of a pool key (token address + tick spacing + token address);MULTIPLE_POOLS_MIN_LENGTH
is the minimum length of a path that contains 2 or more pools (one set of pool parameters + tick spacing + token address).
To count the number of pools in a path, we subtract the size of an address (first or last token in a path) and divide the remaining part by NEXT_OFFSET
(address + tick spacing):
function numPools(bytes memory path) internal pure returns (uint256) {
return (path.length - ADDR_SIZE) / NEXT_OFFSET;
}
Figuring Out if a Path Has Multiple Pools
To check if there are multiple pools in a path, we need to compare the length of a path with MULTIPLE_POOLS_MIN_LENGTH
:
function hasMultiplePools(bytes memory path) internal pure returns (bool) {
return path.length >= MULTIPLE_POOLS_MIN_LENGTH;
}
Extracting First Pool Parameters From a Path
To implement other functions, we’ll need a helper library because Solidity doesn’t have native bytes manipulation functions. Specifically, we’ll need a function to extract a sub-array from an array of bytes, and a couple of functions to convert bytes to address
and uint24
.
Luckily, there’s a great open-source library called solidity-bytes-utils. To use the library, we need to extend the bytes
type in the Path
library:
library Path {
using BytesLib for bytes;
...
}
We can implement getFirstPool
now:
function getFirstPool(bytes memory path)
internal
pure
returns (bytes memory)
{
return path.slice(0, POP_OFFSET);
}
The function simply returns the first “token address + tick spacing + token address” segment encoded as bytes.
Proceeding to a Next Pair in a Path
We’ll use the next function when iterating over a path and throwing away processed pools. Notice that we’re removing “token address + tick spacing”, not full pool parameters, because we need the other token address to calculate the next pool address.
function skipToken(bytes memory path) internal pure returns (bytes memory) {
return path.slice(NEXT_OFFSET, path.length - NEXT_OFFSET);
}
Decoding First Pool Parameters
And, finally, we need to decode the parameters of the first pool in a path:
function decodeFirstPool(bytes memory path)
internal
pure
returns (
address tokenIn,
address tokenOut,
uint24 tickSpacing
)
{
tokenIn = path.toAddress(0);
tickSpacing = path.toUint24(ADDR_SIZE);
tokenOut = path.toAddress(NEXT_OFFSET);
}
Unfortunately, BytesLib
doesn’t implement toUint24
function but we can implement it ourselves! BytesLib
has multiple toUintXX
functions, so we can take one of them and convert it to a uint24
one:
library BytesLibExt {
function toUint24(bytes memory _bytes, uint256 _start)
internal
pure
returns (uint24)
{
require(_bytes.length >= _start + 3, "toUint24_outOfBounds");
uint24 tempUint;
assembly {
tempUint := mload(add(add(_bytes, 0x3), _start))
}
return tempUint;
}
}
We’re doing this in a new library contract, which we can then use in our Path library alongside BytesLib
:
library Path {
using BytesLib for bytes;
using BytesLibExt for bytes;
...
}