Programming DeFi: Uniswap V2. Part 3
Introduction
Another month, another blog post! 🙈
So far, our UniswapV2 implementation had the most crucial part done–the pair contract. We haven’t yet implemented protocol fees (the fee Uniswap takes from each liquidity deposit) but we’ll do this a little bit later since this is not a critical part of the exchange.
Today, we’ll move forward and implement the factory contract, which serves as a registry of all deployed pair contracts. And we’ll also start implementing high level contracts, which make the exchange user friendlier and easier to user.
Let’s go!
You can find full source code of this part here: source code, part 3.
Factory contract
The factory contract is a registry of all deployed pair contracts. This contract is necessary because we don’t want to have pairs of identical tokens so liquidity is not split into multiple identical pairs. The contract also simplifies pair contracts deployment: instead of deploying the pair contract manually, one can simply call a method in the factory contract.
There’s only one factory contract deployed by the Uniswap team, and the contract serves as the official registry of Uniswap pairs. This is also useful in terms of pairs discovery: one can query the contract to find a pair by token addresses. Also, the history of contract’s events can be scanned to find all deployed pairs. Of course, nothing stops us from deploying our pair manually and not registering it with the factory contract.
Let’s get to the code.
contract ZuniswapV2Factory {
error IdenticalAddresses();
error PairExists();
error ZeroAddress();
event PairCreated(
address indexed token0,
address indexed token1,
address pair,
uint256
);
mapping(address => mapping(address => address)) public pairs;
address[] public allPairs;
...
The factory contract is minimal and plain: it only emits PairCreated
event when a pair is created and it stores a list
and a mapping of all created pairs.
Creating pairs is tricky though:
function createPair(address tokenA, address tokenB)
public
returns (address pair)
{
if (tokenA == tokenB) revert IdenticalAddresses();
(address token0, address token1) = tokenA < tokenB
? (tokenA, tokenB)
: (tokenB, tokenA);
if (token0 == address(0)) revert ZeroAddress();
if (pairs[token0][token1] != address(0)) revert PairExists();
bytes memory bytecode = type(ZuniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
IZuniswapV2Pair(pair).initialize(token0, token1);
pairs[token0][token1] = pair;
pairs[token1][token0] = pair;
allPairs.push(pair);
emit PairCreated(token0, token1, pair, allPairs.length);
}
First, we don’t allow pairs with identical tokens. Notice that we don’t check if the token contracts actually exist–we don’t care because it’s up to user to provide valid ERC20 token addresses.
Next, we sort token addresses–this is important to avoid duplicates (the pair contract allows swaps in both directions). Also, pair token addresses are used to generate pair address–we’ll talk about this next.
Next comes the main part of the function: deployment of a pair. And this part requires more attention.
Contracts deployment via CREATE2 opcode
In Ethereum, contracts can deploy contracts. One can call a function of a deployed contract, and this function will deploy another contract–this makes deployment of, let’s call them “template”, contracts much easier. You don’t need to compile and deploy a contract from you computer, you can do this via an existing contract.
In EVM, there are two opcodes that deploy contracts:
- CREATE, which was in EVM from the very beginning. This opcode creates a new account
(Ethereum address) and deploys contract code at this address. The new address is calculated based on the deployer
contract’s nonce–this is identically to how contract address is determined when you deploy contract manually. Nonce
is the counter of address’ successful transactions: when you send a transaction, you increase your nonce. This
dependence on nonce when generating new account address makes
CREATE
non-deterministic: the address depends on on the nonce of the deployer contract, which you cannot control. You do can know it, but by the time you deploy your contract, the nonce can be different. - CREATE2, which was added in EIP-1014. This
opcode acts exactly like
CREATE
but it allows to generate new contract’s address deterministically.CREATE2
doesn’t use external state (like other contract’s nonce) to generate a contract address and lets us fully control how the address is generated. You don’t need to knownonce
, you only need to know deployed contract bytecode (which is static) and salt (which is a sequence of bytes chosen by you).
Let’s return to the code:
...
bytes memory bytecode = type(ZuniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
...
In the first line, we get the creation bytecode of ZuniswapV2Pair
contract. Creation bytecode is actual smart contract
bytecode. It includes:
- Constructor logic. This part is responsible for smart contract initialization and deployment. It’s not stored on the blockchain.
- Runtime bytecode, which is actual business logic of contract. It’s this bytecode that’s stored on the Ethereum blockchain.
We want to use full bytecode here.
Next line creates salt
, a sequence of bytes that’s used to generate new contract’s address deterministically. We’re
hashing pair’s token addresses to create the salt–this means that every unique pair of tokens will produce
a unique salt, and every pair will have unique salt and address.
And the final line is where we’re calling create2
to:
- Create a new address deterministically using
bytecode
+salt
. - Deploy a new
ZuniswapV2Pair
contract. - Get that pair’s address.
This StackOverflow answer does the great job of explaining CREATE2 parameters.
The rest of createPair
should be clear:
-
After a pair is deployed, we need to initialize it, which simply means to set its tokens:
// ZuniswapV2Pair.sol function initialize(address token0_, address token1_) public { if (token0 != address(0) || token1 != address(0)) revert AlreadyInitialized(); token0 = token0_; token1 = token1_; }
-
Then, the new pair is stored in the
pairs
mapping andallPairs
array. -
And finally, we can emit
PairCreated
event.
Moving on!
Router contract
We’re now ready to open a new bigger chapter of this series: we’re starting working on the Router
contract.
The Router
contract is a high-level contract that serves as the entrypoint for most user applications. This contract
makes it easier to create pairs, add and remove liquidity, calculate prices for all possible swap variations and perform
actual swaps. Router
works with all pairs deployed via the Factory contract, it’s a universal contract.
It’s also a big contract and we probably won’t implement all of its functions because most of them are variants of swapping.
In parallel to Router
, we’ll be programming the Library
contract, which implements all basic and core functions,
most of which are swap amounts calculations.
Let’s look at Router’s constructor: router can deploy pairs thus it needs to know the address of the Factory contract.
contract ZuniswapV2Router {
error InsufficientAAmount();
error InsufficientBAmount();
error SafeTransferFailed();
IZuniswapV2Factory factory;
constructor(address factoryAddress) {
factory = IZuniswapV2Factory(factoryAddress);
}
...
Today, we’ll implement only liquidity management, and next time we’ll finish the contract.
Let’s start with addLiquidity
:
function addLiquidity(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin,
address to
)
public
returns (
uint256 amountA,
uint256 amountB,
uint256 liquidity
)
...
When compared to the mint
function from the pair contract, this function has quite many parameters!
tokenA
andtokenB
are used to find (or create) the pair we want to add liquidity to.amountADesired
andamountBDesired
are the amounts we want to deposit into the pair. These are upper bounds.amountAMin
andamountBMin
are the minimal amounts we want to deposit. Remember that thePair
contract always issues smaller amount of LP tokens when we deposit unbalanced liquidity? (We discussed this in Part1). So, themin
parameters allow us to control how much liquidity we’re ready to lose.to
address is the address that receives LP-tokens.
...
if (factory.pairs(tokenA, tokenB) == address(0)) {
factory.createPair(tokenA, tokenB);
}
...
Here’s where you start seeing the high abstraction nature of the Router
contract: if there’s no pair contract for
the specified ERC20 tokens, it’ll be created by the Router
contract. factory.pairs
method is the pairs
mapping:
Solidity made the helper method with two parameters since the mapping is nested.
...
(amountA, amountB) = _calculateLiquidity(
tokenA,
tokenB,
amountADesired,
amountBDesired,
amountAMin,
amountBMin
);
...
In the next step, we’re calculating the amounts that will be deposited. We’ll return to this function a little bit later.
...
address pairAddress = ZuniswapV2Library.pairFor(
address(factory),
tokenA,
tokenB
);
_safeTransferFrom(tokenA, msg.sender, pairAddress, amountA);
_safeTransferFrom(tokenB, msg.sender, pairAddress, amountB);
liquidity = IZuniswapV2Pair(pairAddress).mint(to);
...
After we’ve calculated liquidity amounts, we can finally transfer tokens from the user and mint LP-tokens in exchange.
Most of these lines should be already familiar to you, except the pairFor
function–we’ll implement it right after
implementing _calculateLiquidity
. Also, notice that this contract doesn’t expect user to transfer tokens manually–it
transfers them from user’s balance using the ERC20 transferFrom
function.
function _calculateLiquidity(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin
) internal returns (uint256 amountA, uint256 amountB) {
(uint256 reserveA, uint256 reserveB) = ZuniswapV2Library.getReserves(
address(factory),
tokenA,
tokenB
);
...
In this function, we want to find the liquidity amounts that will satisfy our desired and minimal amounts. Since there’s a delay between when we choose liquidity amounts in UI and when our transaction gets processed, actual reserves ratio might change, which will result in us losing some LP-tokens (as a punishment for depositing unbalanced liquidity). By selecting desired and minimal amounts, we can minimize this loss.
Refer to Part1 to learn about how unbalanced liquidity affects issued LP-tokens.
First step in the function is to get pool reserves by using the library contract–we’ll implement this soon. Knowing pair reserves, we can calculate optimal liquidity amounts
...
if (reserveA == 0 && reserveB == 0) {
(amountA, amountB) = (amountADesired, amountBDesired);
...
If reserves are empty then this is a new pair, which means our liquidity will define the reserves ratio, which means we won’t get punished by providing unbalanced liquidity. Thus, we’re allowed to deposit full desired amounts.
...
} else {
uint256 amountBOptimal = ZuniswapV2Library.quote(
amountADesired,
reserveA,
reserveB
);
if (amountBOptimal <= amountBDesired) {
if (amountBOptimal <= amountBMin) revert InsufficientBAmount();
(amountA, amountB) = (amountADesired, amountBOptimal);
...
Otherwise, we need to find optimal amounts, and we begin with finding optimal tokenB
amount. quote
is another
function from the library contract: by taking input amount and pair reserves, it calculates output amount, which is
tokenA
price nominated in tokenB
multiplied by input amount.
quote
is not how swap price is calculated! We’ll discuss prices calculation in details in next part.
If amountBOptimal
is less or equal to our desired amount AND if it’s higher than our minimal amount, then it’s used.
This difference between desired and minimal amounts is what protects us from slippage.
However, if amountBOptimal
is greater than our desired amount, it cannot be used and we need to find a different, optimal, amount A.
...
} else {
uint256 amountAOptimal = ZuniswapV2Library.quote(
amountBDesired,
reserveB,
reserveA
);
assert(amountAOptimal <= amountADesired);
if (amountAOptimal <= amountAMin) revert InsufficientAAmount();
(amountA, amountB) = (amountAOptimal, amountBDesired);
}
Using identical logic we’re finding amountAOptimal
: it also must be within our minimal-desired range.
If this logic is not clear for you, feel free experimenting with tests! Luckily, Foundry and Forge make writing Solidity tests so much easier!
Let’s put aside the Router contract and switch to the library.
Library contract
The Library contract is a library (no pun intended 😬). Library, in Solidity, is a stateless contract (i.e. it doesn’t have mutable state) that implements a set of functions that can be used by other contracts–this is the main purpose of a library. Unlike contracts, libraries don’t have state: their functions are executed in caller’s state via DELEGATECALL. But, like contracts, libraries must be deployed to be used. Luckily, Forge makes our life easier since it supports automatic libraries linking (we don’t need to deploy libraries in our tests).
Let’s implement the library!
library ZuniswapV2Library {
error InsufficientAmount();
error InsufficientLiquidity();
function getReserves(
address factoryAddress,
address tokenA,
address tokenB
) public returns (uint256 reserveA, uint256 reserveB) {
(address token0, address token1) = _sortTokens(tokenA, tokenB);
(uint256 reserve0, uint256 reserve1, ) = IZuniswapV2Pair(
pairFor(factoryAddress, token0, token1)
).getReserves();
(reserveA, reserveB) = tokenA == token0
? (reserve0, reserve1)
: (reserve1, reserve0);
}
...
This is a high-level function, it can get reserves of any pair (don’t confuse it with the one from the pair contract–that one returns reserves of the specific pair).
First step in the function is token addresses sorting–we always want to do this when we want to find pair address by
token addresses. And this is what we do in the next step: having factory address and sorted token addresses, we’re able
to obtain the pair address–we’ll look at the pairFor
function next.
Notice that the reserves are sorted back before being returned: we want to return them in the same order as token addresses were specified!
Now, let’s look at the pairFor
function:
function pairFor(
address factoryAddress,
address tokenA,
address tokenB
) internal pure returns (address pairAddress) {
The function is used to find pair address by factory and token addresses. The straightforward way of doing that is by fetching pair address from the factory contract, like:
ZuniswapV2Factory(factoryAddress).pairs(address(token0), address(token1))
But this would make an external call, which makes the function a little more expensive.
Uniswap uses are more advanced approach, and this is where we get a benefit from the deterministic address generation
of CREATE2
opcode.
(address token0, address token1) = sortTokens(tokenA, tokenB);
pairAddress = address(
uint160(
uint256(
keccak256(
abi.encodePacked(
hex"ff",
factoryAddress,
keccak256(abi.encodePacked(token0, token1)),
keccak256(type(ZuniswapV2Pair).creationCode)
)
)
)
)
);
This piece of code generates an address in the same way CREATE2
does.
- First step is to sort token addresses. Remember the
createPair
function? We used sorted token addresses as salt. - Next, we build a sequence of bytes that includes:
0xff
– this first byte helps to avoid collisions withCREATE
opcode. (More details are in EIP-1014.)factoryAddress
– factory that was used to deploy the pair.- salt – token addressees sorted and hashed.
- hash of pair contract bytecode – we hash
creationCode
to get this value.
- Then, this sequence of bytes gets hashed (
keccak256
) and converted toaddress
(bytes
->uint256
->uint160
->address
).
This whole process is defined in EIP-1014 and implemented in the CREATE2
opcode. What we’re doing here is reimplementing address generation in Solidity!
Finally, we’ve reached the quote
function.
function quote(
uint256 amountIn,
uint256 reserveIn,
uint256 reserveOut
) public pure returns (uint256 amountOut) {
if (amountIn == 0) revert InsufficientAmount();
if (reserveIn == 0 || reserveOut == 0) revert InsufficientLiquidity();
return (amountIn * reserveOut) / reserveIn;
}
As we discussed earlier, this function calculates output amount based on input amount and pair reserves. This allows to find how much of token B we would get in exchange for a specific amount of token A. This function is only used in liquidity calculation. In swapping, a formula based on the constant product formula is used.
That’s it for today!
Links
- evm.codes – an interactive reference to EVM opcodes.
- EIP-1014 – CREATE2 opcode specification.
- UniswapV2 Whitepaper – worth reading and re-reading.