DeFi programming: Uniswap V2 (1)
What is Uniswap?
Simply put, Uniswap is a decentralized exchange (DEX) that aims to be an alternative to centralized exchanges. It runs on the Ethereum blockchain and is fully automated: no administrators or users with privileged access.
At a deeper level, it is an algorithm that allows the creation of trading pools or token trading pairs, filling them with liquidity, and allowing users to use this liquidity to exchange tokens. Such algorithms are called automated market makers or automated liquidity providers.
So what is a market maker?
A market maker is an entity that provides liquidity (trading assets) to the market. The essence of trading is actually liquidity: if you want to sell something and no one buys it, no trading will take place. Some trading pairs are highly liquid (eg BTC-USDT), but some are illiquid or not at all (eg some altcoins).
DEX
(Decentralized Exchange) There must be a lot of liquidity to function, and it is possible to replace the traditional centralized exchange. One way to get liquidity is for DEX developers to put their own money (or their investors’ money) into it and become market makers. However, this is not a realistic solution, since considering that DEXs allow exchanges between any token, they require a large amount of capital to provide sufficient liquidity for all pairs. Also, this would make DEX
centralized: as the sole market maker, the developers would have a lot of power in their hands, which goes against the idea of decentralization, so it definitely won't work.
A better solution would be to allow anyone to become a market maker, which is why Uniswap
becomes an automated market maker: any user can deposit their funds on a trading pair (and benefit from it).
Uniswap
plays another important role as a price oracle. A price oracle is a service that takes the price of a token from a centralized exchange and feeds it to a smart contract — such a price is often difficult to manipulate because the trading volume on a centralized exchange is usually very high. However, Uniswap can still function as a price oracle, despite not having as much trading volume.
Uniswap
acts as a secondary market, attracting arbitrageurs who profit from the price difference between Uniswap
and CEX
, which makes Uniswap
price on the fund pool is as close as possible to the price of the big exchange.
Constant Product Market Maker
Uniswap
core is the constant product function:
where X is the ETH reserve, Y is the token reserve (or vice versa), and K is a constant. Uniswap
requires K to remain the same, no matter how much X or Y is in reserve. When you trade Ethereum for tokens, you deposit your ether into a contract and get a certain amount of tokens in return. Uniswap
sure that K remains the same after each transaction (this is not true, we will see why later), this formula is also responsible for the pricing calculation, and later we will see the specific implementation, so Uniswap
The implementation principle of Uniswap
has been described, and then we will implement a Uniswap V2
.
Toolset
In this tutorial series, I will use Foundry for contract development and testing, Foundry
is a modern Ethereum toolkit written in Rust
, compared to Hardhat
Faster and more importantly allows us to use Solidity
to write test code, which is more friendly and convenient for a backend development.
We'll also use solmate , instead of OpenZeppelin
for ERC20
, as the latter is a bit bloated and opinionated. A specific reason for not using OpenZeppelin
in this project to implement ERC20
is that it does not allow the transfer of tokens to zero addresses. Solmate, in turn, is a series of gas-optimized contracts that are not that restrictive.
It's also worth noting that many things have changed since 2020 Uniswap V2
launched. For example, the SafeMath library has been deprecated since the release of Solidity
0.8, which introduced native overflow checking. So it can be said that we are building a modern version of Uniswap.
Uniswap V2 Architecture
The core architectural idea of Uniswap V2 is a liquidity pool: liquidity providers can pledge their liquidity in contracts; collateralized liquidity allows anyone else to trade in a decentralized manner. Similar to Uniswap V1, traders pay a small fee, which is accumulated in the contract and then shared by all liquidity providers.
The core contract of Uniswap V2 is UniswapV2Pair. The main purpose of this contract is to accept the user's tokens and exchange them using the accumulated token reserve. That's why it's a pooled contract. Each UniswapV2Pair contract can only pool a pair of tokens and only allow swaps between those two tokens - that's why it's called a "Pair".
The codebase of the Uniswap V2 contract is divided into two repositories:
The core repository stores these contracts:
- UniswapV2ERC20 - Extended ERC20 implementation for LP tokens. It also implements the off-chain approval EIP-2612 uses to support token transfers.
- UniswapV2Factory - This is a factory contract that creates Pair contracts and acts as a registry for them, generating pair addresses in the
create2
way - we'll learn more about how this works later. - UniswapV2Pair - The main contract responsible for the core logic.
The peripheral repository contains several contracts that make Uniswap easier to use. These include UniswapV2Router, which is the main entry point for the Uniswap UI and other decentralized applications working on top of Uniswap.
Another important contract in the peripheral repository is UniswapV2Library, a collection of helper functions that implement important computations. We will implement these two contracts.
Well, let's get started!
Liquidity Pool
Without liquidity, there can be no transactions. So, the first feature we need to implement is a liquidity pool. How does it work?
Liquidity pools are simply contracts that store the liquidity of tokens and allow the execution of swaps that use this liquidity. So a "liquidity pool" means sending tokens to a smart contract and storing them there for a period of time.
As you may already know, each contract has its own storage space, and so do ERC20 tokens, each of which has a link address and balance mapping
. And our pool will have its own balance in the ERC20 contract. Is this enough to make the pool liquid? As it turns out, no.
The main reason is that relying only on ERC20 balances will enable price manipulation: imagine someone sending a large amount of tokens to a pool, in a profitable exchange, and cashing out at the end. To avoid this, we need to keep track of the pool reserves on our side, and we also need to control when they are updated.
We will use the reserve0 and reserve1 variables to keep track of the reserves in the pool.
contract ZuniswapV2Pair is ERC20, Math {
...
uint256 private reserve0;
uint256 private reserve1;
...
}
I have omitted a lot of code for brevity. Check out the full code on the GitHub repo .
Uniswap V2 implements a method of increasing liquidity in the peripheral contract UniswapV2Router, but the underlying liquidity actually still exists in the matching contract: liquidity management is simply regarded as LP-tokens management. When you add liquidity to a pair, contracts mint LP-tokens; when you remove liquidity, LP-tokens are burnt, core contracts are lower-level contracts that only perform core operations.
The following is the underlying function of depositing liquidity:
function mint() public {
uint256 balance0 = IERC20(token0).balanceOf(address(this));
uint256 balance1 = IERC20(token1).balanceOf(address(this));
uint256 amount0 = balance0 - reserve0; // 尚未被计算的新存入的金额
uint256 amount1 = balance0 - reserve1; // 尚未被计算的新存入的金额
uint256 liquidity;
if (totalSupply == 0) {
liquidity = ???
_mint(address(0), MINIMUM_LIQUIDITY);
} else {
liquidity = ???
}
if (liquidity <= 0) revert InsufficientLiquidityMinted();
_mint(msg.sender, liquidity);
_update(balance0, balance1);
emit Mint(msg.sender, amount0, amount1);
}
First, we need to calculate the amount of new deposits that have not been calculated (kept in the reserve), there is also a write clear in the comments, and then, we calculate the number of LP tokens that must be issued as a reward for providing liquidity. Then, we issue tokens and update the reserve (the function _update simply saves the balance into the reserve variable), and the whole liquidity provision method is done. For the initial LP amount, Uniswap V2 ended up using the geometric mean of the deposited amount, now, let's calculate the LP tokens issued when there is already some liquidity.
The main requirements here are:
- proportional to the amount deposited.
- Proportional to the total issuance of LP-tokens.
The white paper gives such a formula:
The number of new LP tokens, proportional to the number of tokens deposited, is minted. However, in V2, there are two base tokens - which one should we use in the formula?
We can choose one of them, but there is an interesting problem: the closer the ratio of the deposited amount is to the ratio of the reserve, the smaller the difference. Therefore, if the proportion of the deposited amount is different, the LP amount will also be different, and one of them will be larger than the other. If we choose the larger one, then we incentivize price changes by providing liquidity, which leads to price manipulation. If we choose the smaller one, we will penalize depositing unbalanced liquidity (liquidity providers will get less LP-tokens). Obviously, it is more beneficial to choose a smaller number. This is what Uniswap is doing. In fact, you will find that there is no calculation of how much B Token you need to pledge A Token to balance the liquidity. This matter is actually placed on the periphery However, the underlying contract is simple enough that anyone can directly call the pair contract itself to provide liquidity without going through the routing contract.
For example, suppose user A provides liquidity at 100:100, and his current LP-Token is 100. At this time, B provides liquidity at 200:100. If it is calculated at 200, it is found that B's LP-Token is 200, but it's unfair to A and would cause the price to fluctuate too much. If it is calculated according to 100, then B can actually get 100 liquidity by providing 100:100, but it pays 100 more A Tokens, resulting in the loss of part of the tokens when the final user withdraws the liquidity, which is considered a right User B provides a penalty for liquidity imbalance, so the final code is as follows:
if (totalSupply == 0) {
liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
_mint(address(0), MINIMUM_LIQUIDITY);
} else {
liquidity = Math.min(
(amount0 * totalSupply) / _reserve0,
(amount1 * totalSupply) / _reserve1
);
}
When totalSupply == 0, we subtract MINIMUM_LIQUIDITY (which is a constant of 1000) when providing initial liquidity. This prevents a liquidity pool's token share (1e-18) from becoming too expensive, which would turn away small liquidity providers. Simply subtracting 1000 from the initial liquidity makes a liquidity share 1000 times cheaper. Here's an article analyzing the problem. Uniswap V2 Design Myths
Writing tests in Solidity
As I said above, I'll be using Foundry to test our smart contracts -- this will allow us to quickly build our tests and do not have any business with JavaScript.
First we initialize the test contract:
contract ZuniswapV2PairTest is DSTest {
ERC20Mintable token0;
ERC20Mintable token1;
ZuniswapV2Pair pair;
function setUp() public {
token0 = new ERC20Mintable("Token A", "TKNA");
token1 = new ERC20Mintable("Token B", "TKNB");
pair = new ZuniswapV2Pair(address(token0), address(token1));
token0.mint(10 ether);
token1.mint(10 ether);
}
// Any function starting with "test" is a test case.
}
Let's add a test for providing initial liquidity:
function testMintBootstrap() public {
token0.transfer(address(pair), 1 ether);
token1.transfer(address(pair), 1 ether);
pair.mint();
assertEq(pair.balanceOf(address(this)), 1 ether - 1000);
assertReserves(1 ether, 1 ether);
assertEq(pair.totalSupply(), 1 ether);
}
1 ether token0 and 1 ether token1 are added to the test pool. As a result, 1 ether LP token is issued and we get 1 ether -1000 (minus minimum liquidity). The pool's reserves and total supply are changed accordingly.
What happens when balanced liquidity is given to a pool that already has some liquidity? let's see.
function testMintWhenTheresLiquidity() public {
token0.transfer(address(pair), 1 ether);
token1.transfer(address(pair), 1 ether);
pair.mint(); // + 1 LP
token0.transfer(address(pair), 2 ether);
token1.transfer(address(pair), 2 ether);
pair.mint(); // + 2 LP
assertEq(pair.balanceOf(address(this)), 3 ether - 1000);
assertEq(pair.totalSupply(), 3 ether);
assertReserves(3 ether, 3 ether);
}
Everything here looks correct. Let's see what happens when unbalanced liquidity is provided:
function testMintUnbalanced() public {
token0.transfer(address(pair), 1 ether);
token1.transfer(address(pair), 1 ether);
pair.mint(); // + 1 LP
assertEq(pair.balanceOf(address(this)), 1 ether - 1000);
assertReserves(1 ether, 1 ether);
token0.transfer(address(pair), 2 ether);
token1.transfer(address(pair), 1 ether);
pair.mint(); // + 1 LP
assertEq(pair.balanceOf(address(this)), 2 ether - 1000);
assertReserves(3 ether, 2 ether);
}
Here's what we're talking about: even if users provide more liquidity in token0 than in token1, they still only get 1 LP-token. Now let's turn to liquidity removal.
remove liquidity
Elimination of liquidity is the opposite of supply. Likewise, burning is the opposite of casting. Removing liquidity from the pool means burning LP tokens in exchange for a corresponding amount of base tokens. The formula for calculating the number of tokens returned to provide liquidity is as follows:
Simply put: the number of tokens returned is proportional to the number of LP tokens held and the total supply of LP tokens. The bigger your share of LP tokens, the bigger the reserve share you get after burning.
The function is implemented as follows:
function burn() public {
uint256 balance0 = IERC20(token0).balanceOf(address(this));
uint256 balance1 = IERC20(token1).balanceOf(address(this));
uint256 liquidity = balanceOf[msg.sender];
uint256 amount0 = (liquidity * balance0) / totalSupply;
uint256 amount1 = (liquidity * balance1) / totalSupply;
if (amount0 <= 0 || amount1 <= 0) revert InsufficientLiquidityBurned();
_burn(msg.sender, liquidity);
_safeTransfer(token0, msg.sender, amount0);
_safeTransfer(token1, msg.sender, amount1);
balance0 = IERC20(token0).balanceOf(address(this));
balance1 = IERC20(token1).balanceOf(address(this));
_update(balance0, balance1);
emit Burn(msg.sender, amount0, amount1);
}
It can be seen that uniswap does not support the removal of some liquidity. Of course, the above code actually has some problems, and we will solve these problems later. Next, we continue to improve the contract testing part.
function testBurn() public {
token0.transfer(address(pair), 1 ether);
token1.transfer(address(pair), 1 ether);
pair.mint();
pair.burn();
assertEq(pair.balanceOf(address(this)), 0);
assertReserves(1000, 1000);
assertEq(pair.totalSupply(), 1000);
assertEq(token0.balanceOf(address(this)), 10 ether - 1000);
assertEq(token1.balanceOf(address(this)), 10 ether - 1000);
}
We see that with the exception of the minimum liquidity sent to zero addresses, the pool returns to an uninitialized state.
Now, let's see what happens when we burn after providing unbalanced liquidity:
function testBurnUnbalanced() public {
token0.transfer(address(pair), 1 ether);
token1.transfer(address(pair), 1 ether);
pair.mint();
token0.transfer(address(pair), 2 ether);
token1.transfer(address(pair), 1 ether);
pair.mint(); // + 1 LP
pair.burn();
assertEq(pair.balanceOf(address(this)), 0);
assertReserves(1500, 1000);
assertEq(pair.totalSupply(), 1000);
assertEq(token0.balanceOf(address(this)), 10 ether - 1500);
assertEq(token1.balanceOf(address(this)), 10 ether - 1000);
}
What we see here is that we have lost 500 wei of token0! This is the penalty for price manipulation we talked about above. But this amount is ridiculously small and doesn't seem to matter at all. This is because our current user (the test contract) is the only liquidity provider. What if we provide unbalanced liquidity to a pool initialized by another user? let's see.
function testBurnUnbalancedDifferentUsers() public {
testUser.provideLiquidity(
address(pair),
address(token0),
address(token1),
1 ether,
1 ether
);
assertEq(pair.balanceOf(address(this)), 0);
assertEq(pair.balanceOf(address(testUser)), 1 ether - 1000);
assertEq(pair.totalSupply(), 1 ether);
token0.transfer(address(pair), 2 ether);
token1.transfer(address(pair), 1 ether);
pair.mint(); // + 1 LP
assertEq(pair.balanceOf(address(this)), 1);
pair.burn();
assertEq(pair.balanceOf(address(this)), 0);
assertReserves(1.5 ether, 1 ether);
assertEq(pair.totalSupply(), 1 ether);
assertEq(token0.balanceOf(address(this)), 10 ether - 0.5 ether);
assertEq(token1.balanceOf(address(this)), 10 ether);
}
We now lose 0.5 ether, which is 1/4 of what we deposited. Now that's a huge amount! So who ended up getting this 0.5 ether: paired or test user? How about writing a test function?
in conclusion
This concludes today's article, if you have any questions, please leave me a message.
For follow-up updates, please pay attention to the public number
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。