DeFi programming: Uniswap. Part 2

Вика Егорова
10 min readSep 3, 2021

Introduction

This is the second part of a series of articles about programming DeFi smart contracts. In the previous part, we first came into contact with Uniswap, its basic mechanics and began to create a smart contract of the Exchange, which can accept liquidity from users, calculate withdrawal amounts and perform exchanges.

Today we are going to finish the implementation of Uniswap V1. While it won’t be a complete copy of the Uniswap V1, it will have all the basic features.

This part is filled with new code, so let’s go directly to it. To see the full project code click here.

Increased liquidity

In the previous part, we talked about the fact that our implementation is not perfect. That was the reason, and today we will finalize this feature.addLiquidity

So far, the function looks like this:

function addLiquidity(uint256 _tokenAmount) public payable {
IERC20 token = IERC20(tokenAddress);
token.transferFrom(msg.sender, address(this), _tokenAmount);
}

What’s the problem with it? The function allows you to add an arbitrary amount of liquidity at any time.

As you remember, the exchange rate is calculated as the ratio of reserves:

Where Px and Py are the eth and token prices; x and y — eth and token reserves.

We also learned that the token exchange changes reserves in a non-linear way, which affects prices, and that arbitrageurs make a profit by balancing prices in such a way that they coincide with prices on large centralized exchanges.

The problem with our implementation is that our code allows us to significantly change prices at any given time. Or, in other words, the current implementation of the function does not ensure compliance with the ratio of current reserves and new liquidity. This is a problem because it allows us to manipulate prices, and we want prices on decentralized exchanges to be as close as possible to prices on centralized exchanges. And we want our exchange smart contracts to act as price oracles.

Thus, we must ensure that the added liquidity is placed in the same proportional ratio of the eth token that has already been established in the pool. At the same time, we want liquidity to arrive in an arbitrary proportion of the eth-token at the stage of creating the Exchange, when the initial reserves are empty, i.e. when the pool has not yet been initialized. This is important, since it is at this point that the price of the eth-token is set for the first time.

AddLiquidity will now have two branches:

  1. If it is a new Exchange (without liquidity, the pool is empty), allow you to start an arbitrary amount of liquidity.
  2. Otherwise, observe the established proportion of reserves when there is liquidity.

The first branch remains unchanged:

if (getReserve() == 0) {
IERC20 token = IERC20(tokenAddress);
token.transferFrom(msg.sender, address(this), _tokenAmount);

The second branch is the new code:

} else {
uint256 ethReserve = address(this).balance - msg.value;
uint256 tokenReserve = getReserve();
uint256 tokenAmount = (msg.value * tokenReserve) / ethReserve;
require(_tokenAmount >= tokenAmount, "insufficient token amount");
IERC20 token = IERC20(tokenAddress);
token.transferFrom(msg.sender, address(this), tokenAmount);
}

Единственное отличие заключается в том, что мы депонируем не все токены, предоставленные пользователем, а только сумму, рассчитанную на основе текущего соотношения резервов. Чтобы получить сумму, мы умножаем соотношение (tokenReserve / ethReserve) на количество депонированных eth. Если пользователь вложил меньше этой суммы, будет выдана ошибка.

Это позволит сохранить цену при добавлении ликвидности в пул.

LP-токены

Мы ещё не обсуждали эту концепцию, но она является важной частью дизайна Uniswap.

We need to have a way to reward liquidity providers for their contributions. If they do not have motivation, then they will not provide liquidity, because no one will invest their eth \tokens in a third-party smart contract just like that. Moreover, the remuneration of liquidity providers should not be paid by us (developers), because for this we (developers) would have to look for investments or issue our inflationary token.

The only good solution is to charge a small commission from each token exchange and distribute the accumulated commission between liquidity providers. It also seems quite reasonable: users (traders) pay for services (liquidity) provided by other people.

For the reward to be fair, we must reward liquidity providers in proportion to their contribution, i.e. the amount of liquidity they provide. If someone has provided 50% of the pool’s liquidity, they should receive 50% of the accumulated funds. That makes sense, right?

Now this task seems quite difficult. However, there is an elegant solution: liquidity provider tokens or LP tokens.

LP tokens are essentially ERC20 tokens automatically issued and transferred to liquidity providers in exchange for their liquidity contribution. Essentially, LP tokens are stocks:

  1. You receive LP tokens in exchange for your liquidity that you have provided to the pool.
  2. The number of LP tokens you receive is proportional to the share of your liquidity in the pool reserves.
  3. Commissions are distributed in proportion to the number of LP tokens you own.
  4. LP tokens can be exchanged back for liquidity and receive accumulated commissions.

Okay, how will we calculate the number of LP tokens issued depending on the amount of liquidity provided? This is not so obvious because there are some requirements that we have to meet:

  1. Each issued share must always be correct. If someone after me adds or removes liquidity from the pool, my share should remain corresponding to my contribution in the total amount of liquidity.
  2. Write operations (such as saving new data or updating existing data in a smart contract) in Ethereum are very expensive. Therefore, we want to reduce the maintenance costs of LP tokens (i.e. we do not want to run functions that regularly recalculate and update the proportional ratio of shares).

Imagine that we issue a lot of tokens (say 1 billion) and distribute them among all liquidity providers. If we always distribute all the tokens (the first liquidity provider receives 1 billion, the second — its share, etc.), then we are forced to recalculate subsequently the issued shares, which is expensive. If we initially distribute only a part of the tokens, we risk falling into the supply limit, which will eventually force us to redistribute the existing shares.

The only good solution is the complete absence of limits on reserves and the issuance of new tokens when adding new liquidity. This allows us to infinitely increase LP tokens, and if we use the right formula, all issued LP tokens will remain in the correct ratio to the total amount of liquidity (will increase proportionally) when liquidity is added or removed. Fortunately, inflation does not reduce the value of LP tokens because they are always backed by some amount of liquidity that does not depend on the number of LP tokens issued.

Now the last detail in this puzzle: how to calculate the number of LP tokens that need to be issued when depositing liquidity?

The Exchange’s smart contract stores eth and token reserves. But how will we calculate the number of LP tokens? Based on the overall reserve? Or only one of them (eth, token)? Uniswap V1 calculates the number of LP tokens in proportion to the eth reserve, but Uniswap V2 can only exchange between tokens (not between eth and token), so it’s unclear which calculation to choose. Let’s stick with what Uniswap V1 does, and later we’ll see how to solve this problem when there are two ERC20 tokens.

This equation shows how the number of new LP tokens is calculated depending on the number of nested eths:

Each liquidity provider mints LP tokens in proportion to the share of eth placed in the total eth reserve. It’s not easy, try to substitute different numbers into this equation and see how the total sum changes. For example, what would be and , if someone deposits a certain amount of eth in ? Do the shares issued before remain correct (the correct ratio to the updated liquidity amount)?amountMintedtotalAmountetherReserve

Let’s move on to the code.

Before modifying, we need to make our Exchange smart contract an ERC20 contract and change its constructor:addLiquidity

contract Exchange is ERC20 {
address public tokenAddress;
constructor(address _token) ERC20("Zuniswap-V1", "ZUNI-V1") {
require(_token != address(0), "invalid token address");
tokenAddress = _token;
}
}

Our LP tokens will have a name and symbol. Feel free to take this code and improve it.

Now update: when adding initial liquidity, the number of issued LP tokens is equal to the number of eth deposited.addLiquidity

function addLiquidity (uint256 _tokenAmount)
public
payable
returns (uint256)
{
if (getReserve() == 0) {
...
uint256 liquidity = address(this).balance;
_mint(msg.sender, liquidity);
return liquidity;

Additional liquidity issues LP-tokens in proportion to the number of invested eth:

} else {
...
uint256 liquidity = (totalSupply() * msg.value) / ethReserve;
_mint(msg.sender, liquidity);
return liquidity;
}
}

Just a few lines and we now have LP tokens!

Fees-levies

Now we are ready to collect commissions on exchanges made in the pool. Before that, we need to answer a few questions:

  1. Do we want to take commissions in eth or tokens? Do we want to pay remuneration to liquidity providers in eth or tokens?
  2. How do I collect a small flat fee from each exchange?
  3. How to distribute the accumulated commissions between liquidity providers in proportion to their contribution?

Again, this may seem like a daunting task, but we already have everything to solve it.

Let’s think about the last two questions. We can enter an additional payment, which is sent along with the exchange transaction. Such payments are accumulated in a fund from which any liquidity provider can withdraw an amount proportional to its share. This sounds like a sensible idea, and surprisingly, it’s almost been implemented:

  1. Traders are already sending eth/tokens to the Exchange’s smart contract. Instead of asking for a commission, we can simply subtract it from the eth/tokens that are sent to the smart contract.
  2. We already have a fund — these are the reserves of the Exchange! Reserves can be used for accumulated fees. This also means that reserves will grow over time,so the formula for a constant ratio of traded pairs is not so constant! However, this does not invalidate it: the commission is small compared to reserves, and there is no way to manipulate it to try to significantly change the reserves.
  3. And now we have the answer to the first question: commissions are paid in the currency of the asset being traded. Liquidity providers receive a balanced amount of eth and tokens plus a share of accumulated commissions proportional to the share of their LP tokens.

That’s it! Let’s move on to the code.

Uniswap charges a 0.03% commission on each exchange. We’ll take 1%, just to make it easier to see the difference in the tests. Adding a commission to a smart contract is as simple as adding a pair of multipliers to the function:getAmount

function getAmount(
uint256 inputAmount,
uint256 inputReserve,
uint256 outputReserve
) private pure returns (uint256) {
require(inputReserve > 0 && outputReserve > 0, "invalid reserves");
uint256 inputAmountWithFee = inputAmount * 99;
uint256 numerator = inputAmountWithFee * outputReserve;
uint256 denominator = (inputReserve * 100) + inputAmountWithFee;
return numerator / denominator;
}

Since Solidity does not support floating-point division, we have to resort to a trick: the numerator and denominator are multiplied by the power of 10, and the collection is subtracted from the multiplier in the numerator. Usually we calculate it this way:

At Solidity, we have to do it this way:

But it’s still the same thing.

Removal of liquidity

Finally, the last function on our list: .removeLiquidity

To remove liquidity, we can again use LP tokens: we do not need to remember the amounts deposited by each liquidity provider, and we can calculate the amount of remote liquidity based on the share of LP tokens.

function removeLiquidity(uint256 _amount)
public
returns (uint256, uint256)
{
require(_amount > 0, "invalid amount");
uint256 ethAmount = (address(this).balance * _amount) / totalSupply();
uint256 tokenAmount = (getReserve() * _amount) / totalSupply();
_burn(msg.sender, _amount);
payable(msg.sender).transfer(ethAmount);
IERC20(tokenAddress).transfer(msg.sender, tokenAmount);
return (ethAmount, tokenAmount);
}

When liquidity is withdrawn, it is returned in both eth and tokens, and their number, of course, is balanced. It is this point that leads to inconstant losses: theratio of reserves changes over time following changes in their prices in Russian rubles. When liquidity is removed, the balance sheet may be different from what it was when liquidity was deposited. This means that you will receive different amounts of eth and tokens, and their total price may be lower than if you just kept them in your wallet.

To calculate the amounts, we multiply the reserves by the share of LP-tokens:

Note that LP tokens are burned every time liquidity is withdrawn. LP tokens are secured only by deposited liquidity.

LP rewards and non-permanent losses

Let’s write a test that reproduces the full cycle of adding liquidity, exchanging tokens, accumulating commissions and removing liquidity:

  1. First, the liquidity provider deposits 100 eth and 200 tokens. Thus, 1 token is equal to 0.5 eth, and 1 eth is equal to 2 tokens.
    exchange.addLiquidity(toWei(200), { value: toWei(100) });
  2. The user exchanges 10 eth and expects to receive at least 18 tokens. In fact, he received 18.0164 tokens. This includes slippage (the traded amounts are relatively large) and a commission of 1%.
    exchange.connect(user).ethToTokenSwap(toWei(18), { value: toWei(10) });
  3. The liquidity provider then deletes its liquidity:
    exchange.removeLiquidity(toWei(100));
  4. The liquidity provider received 109.9 eth (including transaction fees) and 181.9836 tokens. As you can see, these figures are different from those that were entered: we received 10 eth, which the user traded, but in exchange we had to give 18.0164 tokens. However, this amount includes a 1% commission that the user paid to us. Since the liquidity provider provided all the liquidity, it received all the commissions.

Conclusion

I hope LP tokens are no longer a mystery to you.

However, we are not finished yet: the Exchange’s Smart Contract is complete, but we also need to implement the Factory Smart Contract, which serves as a registry of Exchanges and a bridge connecting several Exchanges and making it possible to exchange tokens for tokens. We will implement it in the next part!

Series of articles

  1. DeFi programming: Uniswap. Part 1

2. DeFi programming: Uniswap. Part 2

3. DeFi programming: Uniswap. Part 3

--

--

Вика Егорова

indicator system for working according to the Volume Spread method