How to create your own Uniswap

In this tutorial we’re going to build a very basic decentralized exchange (DEX) like Uniswap or PancakeSwap.

Our project will consist of 2 smart contracts: Exchange.sol and ExchangePool.sol.

Full source code can be found here: https://github.com/ryzhak/dex-demo

Exchange contract has the following features:

  • Exchange owner can create a new pool of a pair of ERC20 tokens
  • Any user can add liquidity (stake a pair of ERC20 tokens) to the pool. When a user adds liquidity to the pool he receives LP (liquidity provider) tokens which can be later used to remove liquidity (unstake a pair of ERC20 tokens) or for liquidity mining (not implemented in this tutorial). Normally the more liquidity a user adds in a single pool the more fee a user gets when somebody makes a swap/trade in the pool (again swap fees are not implemented in this tutorial).
  • Any user can remove liquidity (unstake a pair of ERC20 tokens) from the pool to get his ERC20 tokens back. When a user removes liquidity his LP tokens are burned.
  • Any user can make a swap/trade/sell/buy in the pool.

Notice that this project is not production ready as the following features are not implemented:

  • Swap trading fees
  • Slippage protection
  • Many validation steps are missed
  • Reentrancy protection
  • ETH => ERC20 and ERC20 => ETH swaps are not supported. Exchange pool consists of 2 ERC20 tokens. But ETH is not ERC20 compliant. That is why if we were to implement such a feature we would have to convert ETH to WETH inside the contract and operate with WETH because it is ERC20 compliant. So when a user sells ETH then ETH is converted to WETH inside the smart contract and sent to the pool. When a user buys ETH then WETH is converted to ETH in the smart contract and sent to the user.

AMM

Centralized exchanges use order book to match sellers and buyers. So if a user wants to buy ETH but nobody sells it then order will not be fulfilled. On the contrary, with a decentralized exchange user will always fulfill the order. Uniswap and other decentralized exchanges use different automated market maker models (AMM). Uniswap uses constant product k = a * b formula where a is the amount of the 1st token in the liquidity pool and b is the amount of the 2nd token in the liquidity pool. K is a constant that means total assets liquidity in the pool has to remain the same. So when a user sells/swaps B token to buy A token then A price goes up as there becomes less A in the pool and B price goes down as there becomes more B in the pool.

Example:

  1. User1 adds 10 CAT tokens and 100 DOG tokens to the liquidity pool.
  2. User1 gets 10 * 100 = 1000 LP (liquidity providers) tokens.
  3. User2 wants to sell 1 CAT token to buy as many DOG tokens as possible.
  4. Amount of CAT tokens after the swap: 11
  5. K (constant product) should always remain the same so the amount of DOG tokens after the swap: K / 11 = 1000 / 11 = ~90.91

Amount of DOG tokens that user2 will get for selling 1 CAT token: total amount of DOG token in the pool before the swap – amount of DOG token in the pool after the swap = 100 – 90.91 = 9.09

Init project

Requirements:

  • Truffle
  • Solidity
  • NodeJS
  • Ganache

Create a new project folder and run truffle init to initialize an empty truffle project. Next install openzeppelin contracts via npm install @openzeppelin/contracts –save. Then create an empty ExchangePool contract via truffle create contract ExchangePool. And finally create an empty Exchange contract via truffle create contract Exchange.

Creating a pool contract

Add the following code to the ExchangePool.sol file:

// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
 
import '@openzeppelin/contracts/access/Ownable.sol';
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
 
/**
* @title DEX pool contract
*/
contract ExchangePool is ERC20, Ownable {
   // ERC20 token addresses in the pool (sorted: tokenAddress0 < tokenAddress1)
   address public tokenAddress0;
   address public tokenAddress1;
 
   /**
    * @notice Contract constructor
    * @param _tokenAddress0 1st ERC20 token address in the pool
    * @param _tokenAddress1 2nd ERC20 token address in the pool
    */
   constructor(address _tokenAddress0, address _tokenAddress1) ERC20('POOL-TOKEN', 'POOL-LP') {
         tokenAddress0 = _tokenAddress0;
         tokenAddress1 = _tokenAddress1;
   }
 
   //======================
   // Owner methods.
   // Owner is an exchange.
   //======================
 
   /**
    * @notice Approves owner (normally the exchange contract) to spend tokens in the pool
    * @param _tokenAddress ERC20 token address in the pool
    * @param _tokenAmount ERC20 token amount to approve
    */
   function approvePoolTokenAmount(
       address _tokenAddress,
       uint256 _tokenAmount
   ) public onlyOwner {
       require(tokenAddress0 == _tokenAddress || tokenAddress1 == _tokenAddress, 'NOT_POOL_TOKEN');
       ERC20(_tokenAddress).approve(owner(), _tokenAmount);
   }
 
   /**
    * @notice Burns LP tokens
    * @param _account account address to burn LP tokens from
    * @param _amount amount of tokens to burn
    */
   function burn(address _account, uint256 _amount) public onlyOwner {
       _burn(_account, _amount);
   }
 
   /**
    * @notice Mints LP tokens
    * @param _account address where to mint LP tokens
    * @param _amount amount of LP tokens to mint
    */
   function mint(address _account, uint256 _amount) public onlyOwner {
       _mint(_account, _amount);
   }
}

Exchange contract will have a list of all available pools (ExchangePool contract). Only Exchange contract can create new pools so Exchange will always be the owner of the ExchangePool contract.

ExchangePool is ERC20 token itself because it maintains LP (liquidity provider) tokens of users who added liquidity to the pool.

ExchangePool contract has 2 ERC20 token addresses which define the pool. Notice that token addresses in the pool are always sorted so tokenAddress0 < tokenAddress1.

Owner method approvePoolTokenAmount() approves the owner (exchange) to spend tokens from the pool’s address.

Owner method mint() creates new LP (liquidity provider) tokens when a user adds liquidity to the pool.

Owner method burn() deletes LP tokens when a user removes liquidity from the pool.

Creating an exchange contract

Add the following code to the Exchange.sol file:

// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
 
import '@openzeppelin/contracts/access/Ownable.sol';
import '@openzeppelin/contracts/token/ERC20/IERC20.sol';
import './ExchangePool.sol';
 
/**
* @title demo DEX contract
*/
contract Exchange is Ownable {
   // all available liquidity pools for token pairs
   mapping(address => mapping(address => address)) public pools;
 
   //================
   // Public methods
   //================
 
   /**
    * @notice Returns a pool address by ERC20 token addresses in the pool (addresses can be in any order)
    * @param _tokenAddress0 1st ERC20 token address in the pool
    * @param _tokenAddress1 2nd ERC20 token address in the pool
    */
   function getPoolAddress(address _tokenAddress0, address _tokenAddress1) public view returns (address) {
       // sort addresses and return a pool address
       (address sortedTokenAddress0, address sortedTokenAddress1) = sortAddresses(_tokenAddress0, _tokenAddress1);
       return pools[sortedTokenAddress0][sortedTokenAddress1];
   }
 
   /**
    * @notice Sorts 2 addresses (addresses in the pool are always sorted)
    * @param _tokenAddress0 1st ERC20 token address in the pool
    * @param _tokenAddress1 2nd ERC20 token address in the pool
    */
   function sortAddresses(address _tokenAddress0, address _tokenAddress1) public pure returns (address, address) {
       return _tokenAddress0 < _tokenAddress1 ? (_tokenAddress0, _tokenAddress1) : (_tokenAddress1, _tokenAddress0);
   }
 
   /**
    * @notice Adds liquidity (ERC20 tokens) to the pool
    * @param _tokenAddress0 1st ERC20 token address
    * @param _tokenAddress1 2nd ERC20 token address
    * @param _amountToken0 1st ERC20 token amount
    * @param _amountToken1 2nd ERC20 token amount
    */
   function addLiquidity(
       address _tokenAddress0,
       address _tokenAddress1,
       uint256 _amountToken0,
       uint256 _amountToken1
   ) external {
       // get a pool contract
       ExchangePool pool = ExchangePool(getPoolAddress(_tokenAddress0, _tokenAddress1));
       // check that pool exists
       require(address(pool) != address(0), 'POOL_DOES_NOT_EXIST');
       // check that user has enough tokens
       require(IERC20(_tokenAddress0).balanceOf(msg.sender) >= _amountToken0, 'NOT_ENOUGH_BALANCE');
       require(IERC20(_tokenAddress1).balanceOf(msg.sender) >= _amountToken1, 'NOT_ENOUGH_BALANCE');
 
       // transfer tokens to the pool (user should approve exchange contract to transfer tokens)
       IERC20(_tokenAddress0).transferFrom(msg.sender, address(pool), _amountToken0);
       IERC20(_tokenAddress1).transferFrom(msg.sender, address(pool), _amountToken1);
 
       // mint LP tokens to the user
       pool.mint(msg.sender, _amountToken0 * _amountToken1);
   }
 
   /**
    * @notice Removes liquidity from the pool.
    * Burns user's LP tokens and transfers his ERC20 tokens back.
    * @param _tokenAddress0 1st ERC20 token address in the pool
    * @param _tokenAddress1 2nd ERC20 token address in the pool
    * @param _lpTokensAmount amount of LP (liquidity provider) tokens to burn
    */
   function removeLiquidity(
       address _tokenAddress0,
       address _tokenAddress1,
       uint256 _lpTokensAmount
   ) external {
       // get a pool contract
       ExchangePool pool = ExchangePool(getPoolAddress(_tokenAddress0, _tokenAddress1));
       // check that pool exists
       require(address(pool) != address(0), 'POOL_DOES_NOT_EXIST');
       // check that user has enough LP tokens
       require(IERC20(address(pool)).balanceOf(msg.sender) >= _lpTokensAmount, 'NOT_ENOUGH_LP_BALANCE');
 
       // burn LP tokens
       pool.burn(msg.sender, _lpTokensAmount);
 
       // get token amounts to transfer
       uint256 totalShares = (IERC20(pool.tokenAddress0()).balanceOf(address(pool)) * IERC20(pool.tokenAddress1()).balanceOf(address(pool)));
       uint256 tokenAmount0 = _lpTokensAmount * IERC20(pool.tokenAddress0()).balanceOf(address(pool)) / totalShares;
       uint256 tokenAmount1 = _lpTokensAmount * IERC20(pool.tokenAddress1()).balanceOf(address(pool)) / totalShares;
 
       // approve exchange to transfer tokens from the pool address
       pool.approvePoolTokenAmount(pool.tokenAddress0(), tokenAmount0);
       pool.approvePoolTokenAmount(pool.tokenAddress1(), tokenAmount1);
 
       // transfer tokens to the user
       IERC20(pool.tokenAddress0()).transferFrom(address(pool), msg.sender, tokenAmount0);
       IERC20(pool.tokenAddress1()).transferFrom(address(pool), msg.sender, tokenAmount1);
   }
 
   /**
    * @notice Sells a given amount of input token for output token
    * @param _tokenAddressIn address of the ERC20 token that user wants to sell
    * @param _tokenAmountIn amoint of ERC20 token that user wants to sell
    * @param _tokenAddressOut address of the output ERC20 token which user wants to buy
    */
   function swap(
       address _tokenAddressIn,
       uint256 _tokenAmountIn,
       address _tokenAddressOut
   ) external {
       // get a pool contract
       ExchangePool pool = ExchangePool(getPoolAddress(_tokenAddressIn, _tokenAddressOut));
       // check that pool exists
       require(address(pool) != address(0), 'POOL_DOES_NOT_EXIST');
       // check that user has enough tokens to sell
       require(IERC20(_tokenAddressIn).balanceOf(msg.sender) >= _tokenAmountIn, 'NOT_ENOUGH_BALANCE');
 
       // calculate the amount of out token that user should get for selling input token
       uint k = IERC20(pool.tokenAddress0()).balanceOf(address(pool)) * IERC20(pool.tokenAddress1()).balanceOf(address(pool));
       uint256 tokenAmountInAfter = _tokenAmountIn + IERC20(_tokenAddressIn).balanceOf(address(pool));
       uint256 tokenAmountOutAfter = k / tokenAmountInAfter;
       uint256 tokenAmountOut = IERC20(_tokenAddressOut).balanceOf(address(pool)) - tokenAmountOutAfter;
 
       // ensure that pool is not competely emptied
       if (tokenAmountOut == IERC20(_tokenAddressOut).balanceOf(address(pool))) tokenAmountOut--;
 
       // approve exchange to transfer pool tokens
       pool.approvePoolTokenAmount(_tokenAddressOut, tokenAmountOut);
 
       // make a swap
       ERC20(_tokenAddressIn).transferFrom(msg.sender, address(pool), _tokenAmountIn);
       ERC20(_tokenAddressOut).transferFrom(address(pool), msg.sender, tokenAmountOut);
   }
 
   //================
   // Owner methods
   //================
 
   /**
    * @notice Creates a new pool
    * @param _tokenAddress0 1st ERC20 token address in the pool
    * @param _tokenAddress1 2nd ERC20 token address in the pool
    */
   function createPool(address _tokenAddress0, address _tokenAddress1) external onlyOwner {
       // sort addresses
       (address sortedTokenAddress0, address sortedTokenAddress1) = sortAddresses(_tokenAddress0, _tokenAddress1);
       // check that pool does not exist
       require(pools[sortedTokenAddress0][sortedTokenAddress1] == address(0), 'POOL_EXISTS');
       // create a pool
       ExchangePool pool = new ExchangePool(sortedTokenAddress0, sortedTokenAddress1);
       pools[sortedTokenAddress0][sortedTokenAddress1] = address(pool);
   }
}

Owner method createPool() creates a new liquidity pool. Token addresses can be passed in any order as they are sorted inside.

Public method getPoolAddress() returns a pool address by 2 provided ERC20 token addresses.

Public method sortAddresses() sorts 2 addresses as strings.

Public method addLiquidity() adds 2 ERC20 token amounts to the liquidity pool. 

How it works:

  1. User approves an exchange contract to spend his CAT and DOG tokens.
  2. User calls addLiquidity() method.
  3. Exchange transfers user’s CAT and DOG tokens to the liquidity pool address.
  4. Exchange (via pool) mints user LP tokens for provided liquidity.

Public method removeLiquidity() burns user’s LP tokens and sends ERC20 tokens from the pool to the user.

How it works:

  1. User calls removeLiquidity() and provides the amount of LP tokens he wants to burn.
  2. Exchange (via pool) burns user’s LP tokens.
  3. Exchange asks pool approval to transfer tokens from the pool address.
  4. Exchange transfers a pair of ERC20 tokens from the pool address to the user address.

Public method swap() sells the provided amount of token A to buy a maximum amount of token B.

How it works:

  1. User approves exchange to transfer 1 CAT token from user address
  2. User sells 1 CAT token to get a maximum amount of DOG token
  3. Exchange calculates amount of DOG token that user will get for 1 CAT token
  4. Exchange asks pool contract to approve transfer of DOG tokens from the pool address
  5. Exchange transfers 1 CAT token from user address to the pool address
  6. Exchange transfers a calculated amount of DOG token form the pool address to the user address.

Now run truffle compile to check that there are no errors:

Compiling your contracts...
===========================
> Compiling ./contracts/ERC20Testable.sol
> Compiling ./contracts/Exchange.sol
> Compiling ./contracts/ExchangePool.sol
> Compiling ./contracts/Migrations.sol
> Compiling @openzeppelin/contracts/access/Ownable.sol
> Compiling @openzeppelin/contracts/token/ERC20/ERC20.sol
> Compiling @openzeppelin/contracts/token/ERC20/IERC20.sol
> Compiling @openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol
> Compiling @openzeppelin/contracts/utils/Context.sol
> Artifacts written to /Users/user/Public/projects/truffle/exchange/build/contracts
> Compiled successfully using:
   - solc: 0.8.13+commit.abaa5c0e.Emscripten.clang

Creating a migration

Open a new console window and run ganache to start a development blockchain.

Add ganache config to the truffle-config.js file:

module.exports = {
 networks: {
   development: {
     host: "127.0.0.1",     // Localhost (default: none)
     port: 8545,            // Standard Ethereum port (default: none)
     network_id: "*",       // Any network (default: none)
   },
 },
 
 // Set default mocha options here, use special reporters etc.
 mocha: {
   // timeout: 100000
 },
 
 // Configure your compilers
 compilers: {
   solc: {
     version: "0.8.13",      // Fetch exact version from solc-bin (default: truffle's version)
   }
 },
};

In the migrations folder create a new file 2_deploy_exchange.js with the following content:

const Exchange = artifacts.require("Exchange");
 
module.exports = function (deployer) {
 deployer.deploy(Exchange);
};

Now run truffle migrate to check that migration to blockchain works:

Compiling your contracts...
===========================
> Everything is up to date, there is nothing to compile.


Starting migrations...
======================
> Network name:    'development'
> Network id:      1653052637674
> Block gas limit: 30000000 (0x1c9c380)


1_initial_migration.js
======================

   Deploying 'Migrations'
   ----------------------
   > transaction hash:    0x4e216c8fea1f31339bf55153d735ef93de9c2c8926d78a19def44656cd5c68b7
   > Blocks: 0            Seconds: 0
   > contract address:    0xb4Bdb14518fe27C4ba302a1aD01DF08A9DBFc85b
   > block number:        1
   > block timestamp:     1653052653
   > account:             0x712C05fC76E6aE01E699b0054445F6AC557E4aFa
   > balance:             999.99915573025
   > gas used:            250154 (0x3d12a)
   > gas price:           3.375 gwei
   > value sent:          0 ETH
   > total cost:          0.00084426975 ETH

   > Saving migration to chain.
   > Saving artifacts
   -------------------------------------
   > Total cost:       0.00084426975 ETH


2_deploy_exchange.js
====================

   Deploying 'Exchange'
   --------------------
   > transaction hash:    0x35321ca4f1fb87022c52f87c965ed8d3431a62d3eace2d8a1364b7b26e1f4aa8
   > Blocks: 0            Seconds: 0
   > contract address:    0x132631C05E87F0Db5901Ec8BA2Ef176264EC8049
   > block number:        3
   > block timestamp:     1653052654
   > account:             0x712C05fC76E6aE01E699b0054445F6AC557E4aFa
   > balance:             999.985869847837396103
   > gas used:            4141439 (0x3f317f)
   > gas price:           3.171811543 gwei
   > value sent:          0 ETH
   > total cost:          0.013135864024830377 ETH

   > Saving migration to chain.
   > Saving artifacts
   -------------------------------------
   > Total cost:     0.013135864024830377 ETH

Summary
=======
> Total deployments:   2
> Final cost:          0.013980133774830377 ETH

Writing tests

In the test folder create a new file Exchange.test.js with the following content:

const Exchange = artifacts.require('Exchange');
const ExchangePool = artifacts.require('ExchangePool');
const ERC20 = artifacts.require('ERC20Testable');
 
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
 
/**
* Helper methods
*/
async function getPoolContract(exchange, tokenAddress1, tokenAddress2) {
   const sortedAddresses = sortStrings(tokenAddress1, tokenAddress2);
   const poolAddress = await exchange.pools(sortedAddresses[0], sortedAddresses[1]);
   return ExchangePool.at(poolAddress);
}
 
function sortStrings(str1, str2) {
   return str1 < str2 ? [str1, str2] : [str2, str1];
}
 
contract('Exchange', (accounts) => {
   let exchange = null;
   let catToken = null;
   let dogToken = null;
   const ownerAddress = accounts[0];
   const userAddress = accounts[1];
 
   beforeEach(async () => {
       // deploy exchange
       exchange = await Exchange.new({from: ownerAddress});
       // deploy CAT and DOG tokens
       catToken = await ERC20.new('CAT TOKEN', 'CAT');
       dogToken = await ERC20.new('DOG TOKEN', 'DOG');
   });
});

Here we added 2 helper methods for convenience. Also before each test we’re going to create a new exchange contract, a new instance of the CAT token and a new instance of the DOG token.

We’re going to cover only basic methods. All tests can be found here: https://github.com/ryzhak/dex-demo/blob/master/test/Exchange.test.js

Let’s add some liquidity:

it('should mint LP tokens and transfer ERC20 tokens to the pool', async () => {
           // owner creates CAT/DOG pool
           await exchange.createPool(catToken.address, dogToken.address, { from: ownerAddress });
           // get pool contract
           const pool = await getPoolContract(exchange, catToken.address, dogToken.address);
           // mint 10 CAT and 100 DOG tokens to user address
           await catToken.mint(userAddress, web3.utils.toWei('10'), { from: ownerAddress });
           await dogToken.mint(userAddress, web3.utils.toWei('100'), { from: ownerAddress });
           // approve exchange to spend tokens
           await catToken.approve(exchange.address, web3.utils.toWei('10'), { from: userAddress });
           await dogToken.approve(exchange.address, web3.utils.toWei('100'), { from: userAddress });
 
           // balances before
           assert.equal((await catToken.balanceOf(userAddress)).toString(), web3.utils.toWei('10'));
           assert.equal((await dogToken.balanceOf(userAddress)).toString(), web3.utils.toWei('100'));
           assert.equal((await pool.balanceOf(userAddress)).toString(), web3.utils.toWei('0'));
           assert.equal((await catToken.balanceOf(pool.address)).toString(), web3.utils.toWei('0'));
           assert.equal((await dogToken.balanceOf(pool.address)).toString(), web3.utils.toWei('0'));
 
           // user adds liquidity
           await exchange.addLiquidity(catToken.address, dogToken.address, web3.utils.toWei('10'), web3.utils.toWei('100'), { from: userAddress });
 
           // balances after
           assert.equal((await catToken.balanceOf(userAddress)).toString(), web3.utils.toWei('0'));
           assert.equal((await dogToken.balanceOf(userAddress)).toString(), web3.utils.toWei('0'));
           assert.equal((await pool.balanceOf(userAddress)).toString(), web3.utils.toWei('10') * web3.utils.toWei('100'));
           assert.equal((await catToken.balanceOf(pool.address)).toString(), web3.utils.toWei('10'));
           assert.equal((await dogToken.balanceOf(pool.address)).toString(), web3.utils.toWei('100'));
});

Run truffle test:

Using network 'development'.


Compiling your contracts...
===========================
> Everything is up to date, there is nothing to compile.


  Contract: Exchange
    addLiquidity()
      ✔ should mint LP tokens and transfer ERC20 tokens to the pool (1131ms)


  1 passing (2s)

Now let’s make a swap:

it('should sell ERC20 token', async () => {
           // owner creates CAT/DOG pool
           await exchange.createPool(catToken.address, dogToken.address, { from: ownerAddress });
           // get pool contract
           const pool = await getPoolContract(exchange, catToken.address, dogToken.address);
           // mint 10 CAT and 100 DOG tokens to user address
           await catToken.mint(userAddress, web3.utils.toWei('10'), { from: ownerAddress });
           await dogToken.mint(userAddress, web3.utils.toWei('100'), { from: ownerAddress });
           // approve exchange to spend tokens
           await catToken.approve(exchange.address, web3.utils.toWei('10'), { from: userAddress });
           await dogToken.approve(exchange.address, web3.utils.toWei('100'), { from: userAddress });
           // user adds liquidity
           await exchange.addLiquidity(catToken.address, dogToken.address, web3.utils.toWei('10'), web3.utils.toWei('100'), { from: userAddress });
 
           // mint 1 CAT token to user address
           await catToken.mint(userAddress, web3.utils.toWei('1'), { from: ownerAddress });
           // approve exchange to transfer 1 CAT token
           await catToken.approve(exchange.address, web3.utils.toWei('1'), { from: userAddress });
 
           // balances before
           assert.equal((await catToken.balanceOf(userAddress)).toString(), web3.utils.toWei('1'));
           assert.equal((await dogToken.balanceOf(userAddress)).toString(), web3.utils.toWei('0'));
           assert.equal((await catToken.balanceOf(pool.address)).toString(), web3.utils.toWei('10'));
           assert.equal((await dogToken.balanceOf(pool.address)).toString(), web3.utils.toWei('100'));
 
           // user sells 1 CAT token
           await exchange.swap(catToken.address, web3.utils.toWei('1'), dogToken.address, { from: userAddress });
 
           // balances after
           assert.equal((await catToken.balanceOf(userAddress)).toString(), web3.utils.toWei('0'));
           assert.equal((await dogToken.balanceOf(userAddress)).toString(), '9090909090909090910');
           assert.equal((await catToken.balanceOf(pool.address)).toString(), web3.utils.toWei('11'));
           assert.equal((await dogToken.balanceOf(pool.address)).toString(), '90909090909090909090');
});

Again run truffle test:

Using network 'development'.


Compiling your contracts...
===========================
> Everything is up to date, there is nothing to compile.


  Contract: Exchange
    swap()
      ✔ should sell ERC20 token (1840ms)


  1 passing (3s)

And finally let’s remove liquidity:

it('should burn LP tokens and transfer ERC20 tokens back to the user', async () => {
           // owner creates CAT/DOG pool
           await exchange.createPool(catToken.address, dogToken.address, { from: ownerAddress });
           // get pool contract
           const pool = await getPoolContract(exchange, catToken.address, dogToken.address);
           // mint 10 CAT and 100 DOG tokens to user address
           await catToken.mint(userAddress, web3.utils.toWei('10'), { from: ownerAddress });
           await dogToken.mint(userAddress, web3.utils.toWei('100'), { from: ownerAddress });
           // approve exchange to spend tokens
           await catToken.approve(exchange.address, web3.utils.toWei('10'), { from: userAddress });
           await dogToken.approve(exchange.address, web3.utils.toWei('100'), { from: userAddress });
 
           // user adds liquidity
           await exchange.addLiquidity(catToken.address, dogToken.address, web3.utils.toWei('10'), web3.utils.toWei('100'), { from: userAddress });
 
           // balances before
           assert.equal((await catToken.balanceOf(userAddress)).toString(), web3.utils.toWei('0'));
           assert.equal((await dogToken.balanceOf(userAddress)).toString(), web3.utils.toWei('0'));
           assert.equal((await pool.balanceOf(userAddress)).toString(), web3.utils.toWei('10') * web3.utils.toWei('100'));
           assert.equal((await catToken.balanceOf(pool.address)).toString(), web3.utils.toWei('10'));
           assert.equal((await dogToken.balanceOf(pool.address)).toString(), web3.utils.toWei('100'));
 
           // user removes liquidity
           const lpTokensAmount = web3.utils.toBN(web3.utils.toWei('10')).mul(web3.utils.toBN(web3.utils.toWei('100'))).toString();
           await exchange.removeLiquidity(catToken.address, dogToken.address, lpTokensAmount, { from: userAddress });
 
           // balances after
           assert.equal((await catToken.balanceOf(userAddress)).toString(), web3.utils.toWei('10'));
           assert.equal((await dogToken.balanceOf(userAddress)).toString(), web3.utils.toWei('100'));
           assert.equal((await pool.balanceOf(userAddress)).toString(), web3.utils.toWei('0'));
           assert.equal((await catToken.balanceOf(pool.address)).toString(), web3.utils.toWei('0'));
           assert.equal((await dogToken.balanceOf(pool.address)).toString(), web3.utils.toWei('0'));
});

Run truffle test:

Using network 'development'.


Compiling your contracts...
===========================
> Everything is up to date, there is nothing to compile.


  Contract: Exchange
    removeLiquidity()
      ✔ should burn LP tokens and transfer ERC20 tokens back to the user (1304ms)


  1 passing (2s)

Summary

In this tutorial we learned how decentralized exchanges work, learned what AMM is, created a basic DEX with add/remove liquidity and swap features, and wrote tests to check that everything works as expected. Now you should have a basic understanding of how DEX works.

P.S. We want to help you with your next blockchain project. Check our contacts at https://ryzhak.studio/contacts/