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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
// 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
// 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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:
1 2 3 4 5 |
const Exchange = artifacts.require("Exchange"); module.exports = function (deployer) { deployer.deploy(Exchange); }; |
Now run truffle migrate to check that migration to blockchain works:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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.