DeFi programming: Uniswap.

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

--

Introduction

The best way to learn something is to teach others. The second best way to learn something is to do it yourself. I decided to combine these two methods and teach myself and you how to program DeFi services on Ethereum (and any other blockchains based on EVM — Ethereum Virtual Machine).

We’ll focus on how these services work, try to understand the economic mechanics that make them what they are (and all DeFi are based on economic mechanics). We will figure out, disassemble, study and create the basic mechanisms of DeFi.

However, we will only work on smart contracts: creating a front-end for smart contracts is also a big and interesting task, but it is beyond the scope of this article.

Let’s start our journey with Uniswap. The full source code can be found here.

Different versions of Uniswap

As of June 2021, three versions of Uniswap have been launched.

The first version (V1) was launched in November 2018 and allowed exchange only between eth and tokens. And it was also possible to exchange tokens for tokens.

The second version (V2) was launched in March 2020 and represented an improvement on V1, allowing for direct exchange between any ERC20 tokens, as well as an associated exchange between any pairs.

The third version (V3) was launched in May 2021 and significantly improved capital efficiency, allowing liquidity providers to withdraw most of their liquidity from pools while receiving the same rewards (or squeezing capital in smaller price ranges and earning up to 4,000x profits).

In this series, we will analyze each of the protocol versions and try to build simplified copies of each of them.

This article focuses on Uniswap V1 to keep the chronological order and better understand what improvements have been made from version to version.

What is Uniswap?

Uniswap is a decentralized cryptocurrency exchange (DEX) that aims to be an alternative to centralized exchanges. It runs on the Ethereum blockchain and is fully automated: there are no administrators, managers or users with privileged access.

At the lower-level level, it is a protocol that allows you to create pools, or pairs of tokens, and fill them with liquidity so that users can exchange (trade) tokens using this liquidity. This algorithm is called an automated market maker or automated liquidity provider.

Let’s learn more about market makers.

Market makers (market makers, market movements) are organizations that provide liquidity (flooding with trading assets) in classic markets. Liquidity is what makes trades possible: if you want to sell something but no one buys it, there will be no deal. Some trading pairs have high liquidity (e.g. BTC-USDT) and some have low or no liquidity (e.g. some fraudulent or questionable altcoins).

DEX must have sufficient liquidity to function and serve as an alternative to centralized exchanges. One way to get this liquidity is for developers to invest their own money (or their investors’ money) in DEX and become a market maker. However, this is a difficult solution to implement, as it will take a lot of money to provide sufficient liquidity for all pairs, given that DEX allows you to exchange any tokens. Moreover, it will make DEX centralized: being the only market makers, developers will have more power in their hands.

The best solution is to let everyone become a market maker,and that’s what makes Uniswap an automated market maker:any user can deposit their funds into a trading pair (and benefit from it).

Another important role of Uniswap is the price oracle. Price oracles are services that get token prices from centralized exchanges and provide them to smart contracts — such prices are usually difficult to manipulate as volumes on centralized exchanges are often very large. However, without such large volumes, Uniswap can still serve as a price oracle.

Uniswap acts as a secondary market, attracting arbitrageurs who profit from the price difference between Uniswap and centralized exchanges. Due to this, prices in Uniswap pools are as close as possible to prices on larger exchanges. And this would not have been possible without proper pricing and reserve balancing functions.

Constant ratio of traded pairs

You’ve probably heard that definition before, let’s see what it means.

Automatic market maker is a general term that encompasses the various algorithms of decentralized market makers. The most popular of them (and those that gave rise to this term) are associated with prediction markets — markets that allow you to make a profit on predictions. Uniswap and other intra-stray exchanges are a logical extension of these algorithms.

Uniswap is based on the formula of the constant ratio of traded pairs:

Where x is the eth reserve, y is the token reserve (or vice versa), and k is the constant. Uniswap requires that k remain unchanged no matter how many reserves x or yexist. When you exchange eth for tokens, you put your eth in a smart contract and get a certain number of tokens in return. Uniswap ensures that after each trade k remains unchanged (in fact it is not, later we will see why).

This formula is also responsible for calculating prices.

Development of smart contracts

To really understand how Uniswap works, we’ll create a copy of it. We will write smart contracts on Solidity and use HardHat as a development environment. HardHat is a really good tool that greatly simplifies the development, testing and deployment of smart contracts.

Setting up a project

First, create an empty directory (I named my zuniswap), go to it via cd and install HardHat:

$ mkdir zuniswap && cd $_$ yarn add -D hardhat

We will also need a smart contract to create tokens, let’s take advantage of the ERC20 smart contracts provided by OpenZeppelin.

$ yarn add -D @openzeppelin/contracts

Initialize the HardHat project and delete everything from the contract, script, and test folders.

$ yarn hardhat
...следуйте инструкциям...

Final touch: we will be using the latest version of Solidity, at the time of writing this is the . Open yours and update the Solidity version at the bottom of it.0.8.4hardhat.config.js

Token contract

Uniswap V1 supports exchange only between eth and tokens. Therefore, we need a smart contract of tokens and for this we will take the ERC20 standard. Let’s write it!

// contracts/Token.sol
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";contract Token is ERC20 {
constructor(
string memory _name,
string memory _symbol,
uint256 _initialSupply
) ERC20(_name, _symbol) {
_mint(msg.sender, _initialSupply);
}
}

That’s all we need: we extend the ERC20 smart contract provided by OpenZeppelin and define our own constructor that allows us to specify the token name (), symbol () and initial number of tokens (). The constructor also creates tokens in the amount specified in and sends them to the address of the token creator._name_symbolinitialSupplyinitialSupply

Now the fun begins!

Exchange Smart Contract

Uniswap V1 has only two smart contracts: Factory and Exchange.

Factory is a smart registry contract that allows you to create Exchanges ( Exchange), tracks all deployed Exchanges, allows you to find the address of the Exchange by token address and vice versa. At the same time, it is immediately worth noting that each exchanged pair (eth-token) is deployed as a separate smart contract of the Exchange and this smart contract allows you to exchange eth for the specified token and back. Thus, the Exchange smart contract determines the logic of the exchange on the Exchange for one specific token.

We’ll create an Exchange smart contract and leave Factory for another article.

Let’s create a new blank smart contract:

// contracts/Exchange.sol
pragma solidity ^0.8.0;
contract Exchange {}

Since each Exchange allows you to exchange only one token (or otherwise — each token requires its own separate Exchange), we need to specify which token will be exchanged:

contract Exchange  {
address public tokenAddress;
constructor(address _token) {
require(_token != address(0), “Неправильный адрес токена”);
tokenAddress = _token;
}
}

The token address is a variable stored in the state of the smart contract, which makes it available from any other function of the smart contract. If you make it, users and developers will be able to read it and find out which token this Exchange is associated with. In the constructor, we check that the correct token address (not the zero address) is specified and store it in a state variable.public

Liquidity assurance

As we have already found out, liquidity makes trading on tokens possible. Thus, we need a way to add liquidity to the Exchange’s smart contract:

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";contract Exchange {
...
function addLiquidity(uint256 _tokenAmount) public payable {
IERC20 token = IERC20(tokenAddress);
token.transferFrom(msg.sender, address(this), _tokenAmount);
}
}

By default, smart contracts cannot receive eth, which can be fixed using a modifier that allows you to get eth in the function: any eth sent along with calling the function marked as , are added to the balance of the smart contract.payablepayable

Token escrow is a very different matter: since token balances are stored on token smart contracts, we must use the function (as defined by the ERC20 standard) to transfer tokens from the address of the sender of the transaction to the smart contract. In addition, the sender of the transaction will have to call a function on the token’s smart contract to allow our Exchange smart contract to receive its tokens.transferFromapprove

This implementation is not complete. I deliberately made it so that I focused more on pricing features. We will fill this gap in a later article.addLiquidity

Let’s also add a helper function that shows the balance of tokens on the Exchange:

function getReserve() public view returns (uint256) {
return IERC20(tokenAddress).balanceOf(address(this));
}

And now we can test addLiquidity to make sure everything is correct:

describe("addLiquidity", async () => {
it("добавляет ликвидность", async () => {
await token.approve(exchange.address, toWei(200));
await exchange.addLiquidity(toWei(200), { value: toWei(100) });
expect(await getBalance(exchange.address)).to.equal(toWei(100));
expect(await exchange.getReserve()).to.equal(toWei(200));
});
});

First, we allow the Exchange’s smart contract to use 200 tokens by calling the . Then we call to deposit 200 tokens (to get them, the exchange’s smart contract calls) and 100 eth, which are sent along with the function call. Then we make sure that the exchange actually got them.approveaddLiquiditytransferFrom

For brevity, I’ve omitted a lot of boilerplate code in the tests. Please check the full source code if something is unclear.

Pricing function

Now let’s think about how we will calculate stock prices.

It may be tempting to think that price is simply an inventory ratio, for example:

And this is logical: the Exchange’s smart contracts do not interact with centralized exchanges or any other external price oracles, so they cannot know the correct price. In fact, the Exchange’s smart contract is itself a price oracle. All they know is eth and token stocks, and that’s the only information they have to calculate prices.

Let’s stick to this idea and build a pricing function:

function getPrice(uint256 inputReserve, uint256 outputReserve)  
public pure returns (uint256) {
require(inputReserve > 0 && outputReserve > 0, "invalid reserves");
return inputReserve / outputReserve;
}

And let’s check this out:

describe("getPrice", async () => {  it("возвращает правильные цены", async () => {
await token.approve(exchange.address, toWei(2000));
await exchange.addLiquidity(toWei(2000), { value: toWei(1000) });
const tokenReserve = await exchange.getReserve();
const etherReserve = await getBalance(exchange.address);
// ETH за токен
expect(
(await exchange.getPrice(etherReserve, tokenReserve)).toString()
).to.eq("0.5");
// токен за ETH
expect(await exchange.getPrice(tokenReserve, etherReserve)).to.eq(2);
});
});

We have contributed 2000 tokens and 1000 eth and expect the price of the token to be 0.5 eth and the price of eth to 2 tokens. However, the test failed: it says that we get 0 eth in exchange for our tokens. Why is that?

The reason is that Solidity supports integer division rounded to integer. The price of 0.5 is rounded up to 0! Let’s fix this by increasing accuracy:

function getPrice(uint256 inputReserve, uint256 outputReserve)
public
pure
returns (uint256)
{
...
return (inputReserve * 1000) / outputReserve;
}

After updating the test, it will pass:

// ETH за токен
expect(await exchange.getPrice(etherReserve, tokenReserve)).to.eq(500);
// токен за ETH
expect(await exchange.getPrice(tokenReserve, etherReserve)).to.eq(2000);

Thus, now 1 token is equal to 0.5 eth, and 1 eth is equal to 2 tokens.

Everything looks right, but what happens if we exchange 2,000 tokens for eth? We will get 1000 eth, and that’s all we have under a smart contract! The exchange will be emptied!

Apparently, something is wrong with the pricing function: it allows you to empty the Exchange, and this we do not want.

The reason for this is that the pricing function belongs to the constant sum formula , which defineskkk as the constant sum of xxx and yyy. The function of this formula is a straight line:

Graph of the constant sum function

It crosses the x and yaxes, which means that it allows 0 in either of them! We definitely don’t want that.

Correct pricing function

Recall that Uniswap is a market maker of a constant ratio of traded pairs, which means that it is based on the formula of a constant ratio of traded pairs:

Does this formula give a better pricing function? Let’s see.

The formula states that kkk remains constant no matter what the reserves are (xxx and yyy). Each transaction increases the reserve of eth or token and reduces the reserve of the token or eth — let’s write this logic in the formula:

Where Δx is the number of eth or tokens that we exchange for Δy is the number of tokens or eth that we receive in exchange. With this formula, we can now find Δy:

This looks interesting: the function now takes into account the amount entered. Let’s try to program it, but keep in mind that now we are dealing with amounts, not prices.

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

It’s a low-level feature, so let it be private. Let’s make two high-level wrapper functions to simplify calculations:

function getTokenAmount(uint256 _ethSold) public view returns (uint256) {
require(_ethSold > 0, "ethSold слишком мал");
uint256 tokenReserve = getReserve();
return getAmount(_ethSold, address(this).balance, tokenReserve);
}
function getEthAmount(uint256 _tokenSold) public view returns (uint256) {
require(_tokenSold > 0, "tokenSold слишком мал");
uint256 tokenReserve = getReserve();
return getAmount(_tokenSold, tokenReserve, address(this).balance);
}

And let’s check them:

describe("getTokenAmount", async () => {
it("возвращает правильную сумму токена", async () => {
... addLiquidity ...
let tokensOut = await exchange.getTokenAmount(toWei(1));
expect(fromWei(tokensOut)).to.equal("1.998001998001998001");
});
});
describe("getEthAmount", async () => {
it("возвращает правильную сумму эт", async () => {
... addLiquidity ...
let ethOut = await exchange.getEthAmount(toWei(2));
expect(fromWei(ethOut)).to.equal("0.999000999000999");
});
});

So, now we get 1,998 tokens for 1 eth and 0,999 eth for 2 tokens. These amounts are very close to those obtained using the previous pricing function. However, they are a little smaller. Why is that?

The formula for the constant ratio of traded pairs,on which we based our price calculations, is actually hyperbole:

Hyperbole never crosses xxx or yyy, so none of the reserves are ever equal to 0. This makes the reserves endless!

And there is another interesting consequence: the pricing function causes slippage of the price. The greater the number of tokens traded in relation to reserves, the lower the price will be.

That’s what we saw in the tests: we got a little less than we expected. This may seem like a disadvantage of a constant ratio of traded pairs (since every trade has a slippage), however, it is the same mechanism that protects pools from emptying. This is also consistent with the law of supply and demand: the higher the demand (the larger the volume of products you want to get) in relation to the supply (reserves), the higher the price (the less you get).

Let’s improve our tests to see how slippage affects prices:

describe("getTokenAmount", async () => {
it("возвращает правильную сумму токена", async () => {
... addLiquidity ...
let tokensOut = await exchange.getTokenAmount(toWei(1));
expect(fromWei(tokensOut)).to.equal("1.998001998001998001");
tokensOut = await exchange.getTokenAmount(toWei(100));
expect(fromWei(tokensOut)).to.equal("181.818181818181818181");
tokensOut = await exchange.getTokenAmount(toWei(1000));
expect(fromWei(tokensOut)).to.equal("1000.0");
});
});
describe("getEthAmount", async () => {
it("возвращает правильное количество eth", async () => {
... addLiquidity ...
let ethOut = await exchange.getEthAmount(toWei(2));
expect(fromWei(ethOut)).to.equal("0.999000999000999");
ethOut = await exchange.getEthAmount(toWei(100));
expect(fromWei(ethOut)).to.equal("47.619047619047619047");
ethOut = await exchange.getEthAmount(toWei(2000));
expect(fromWei(ethOut)).to.equal("500.0");
});
});

Как вы видите, когда мы пытаемся опустошить пул, мы получаем только половину того, что ожидали.

One last thing to note is that our original pricing function, based on the ratio of reserves, was not flawed. In fact, it is true when the number of tokens we trade is very small compared to reserves. But to create an AMM, we need something more complex.

Exchange function

Now we are ready to implement the exchange.

function ethToTokenSwap(uint256 _minTokens) public payable {
uint256 tokenReserve = getReserve();
uint256 tokensBought = getAmount(
msg.value,
address(this).balance - msg.value,
tokenReserve
);
require(tokensBought >= _minTokens, "недостаточное количество вывода");
IERC20(tokenAddress).transfer(msg.sender, tokensBought);
}

Exchanging eth for tokens means sending a certain amount of eth (stored in a variable) to the smart contract function and receiving tokens in return. Note that we need to subtract the smart contract from the balance, since by the time the functions are called, the eth sent have already been added to its balance.msg.valuemsg.value

Another important variable here is the minimum number of tokens that the user wants to receive in exchange for their eth. This amount is calculated in the user interface and always includes slippage tolerance; the user agrees to receive at least as much, but not less. This is a very important mechanism that protects users from dummy bots trying to intercept their transactions and change the pool balance to their advantage._minTokens

Finally, the last part of the code for today:

function tokenToEthSwap(uint256 _tokensSold, uint256 _minEth) public {
uint256 tokenReserve = getReserve();
uint256 ethBought = getAmount(
_tokensSold,
tokenReserve,
address(this).balance
);
require(ethBought >= _minEth, "недостаточное количество продукции");
IERC20(tokenAddress).transferFrom(
msg.sender,
address(this),
_tokensSold
);
payable(msg.sender).transfer(ethBought);
}

The function transfers tokens in quantity from the user’s balance to the Brizhi balance and in exchange sends the user eth in the amount specified in ._tokensSoldethBought

Conclusion

That’s it for today! We’re not done yet, but we’ve done a lot. Our exchange’s smart contract can accept liquidity from users, calculate prices in a way that protects against devastation, and allows users to exchange eth for tokens and back. That’s a lot, but some important parts are still missing:

  1. Adding new liquidity can cause significant price changes.

2. Liquidity providers are not rewarded; all exchanges are free.

3. There is no way to remove liquidity.

4. There is no possibility to exchange ERC20 tokens.

5. The factory is still not implemented.

We’ll do that in the next part.

  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