Build your own decentralized exchange like Uniswap v1

Oleh Rubanik
Coinmonks
9 min readApr 14, 2024

--

Previously, we learned about how decentralized exchanges like Uniswap v1 work. If you haven’t gone through that lesson yet — go read it first and come back as it explains the mathematics behind an AMM like Uniswap v1. Now, we’re going to use that knowledge to build our decentralized exchange from scratch that allows swapping ETH <> TOKEN.

Although Uniswap v1 is currently outdated, understanding how AMM works will help you in the future when studying newer versions. Without knowledge of the base, you will not be able to understand more modern logic.

Requirements

  • Build an exchange that allows swapping ETH <> TOKEN
  • DEX must charge a 1% fee on swaps
  • When user adds liquidity, they must be given an LP Token that represents their share of the pool
  • LP must be able to burn their LP tokens to receive back ETH and TOKEN

Foundry Setup

We will be building out the smart contracts necessary for this project. We will use Foundry for the smart contracts.

Start off by creating a new folder on your computer — I named mine dex-app.

Open up a Terminal pointing to the dex-app folder and run the following commands:

forge init foundry-app

We also need to install OpenZeppelin’s contracts as we will use it to build out the ERC-20 Token. Run the following commands:

cd foundry-app
forge install OpenZeppelin/openzeppelin-contracts

To configure remappings in your project and make sure they’re picked up when compiling your code, run the following command:

forge remappings > remappings.txt

Environment Variables

Start off by going to the .env file inside the foundry-app folder and add the following placeholder lines:

PRIVATE_KEY="..."
RPC_URL="..."
ETHERSCAN_API_KEY="..."

For the PRIVATE_KEY variable, export it from MetaMask. Again, make sure to use an account that only has testnet funds in it, no mainnet funds, to not risk accidentally leaking a private key with real money in it. Replace the value of PRIVATE_KEY in the .env file with the key you export.

For the RPC_URL, create an account at QuickNode if you don't have one already. After creating an account, click on Create an endpoint, select Ethereum, and then select the Sepolia testnet. Continue with the process until finished, and then copy the HTTP Provider link from the dashboard there.

Replace the value of the RPC_URL variable in the .env file with the HTTP Provider link you copied.

NOTE: If you have previously set up a Sepolia endpoint on QuickNode during an earlier lesson, you can continue using that. You do not need to create a new one.

Lastly, we need an Etherscan API Key so our contract can be verified on Etherscan. You can grab an Etherscan API Key by creating an account on https://etherscan.io if you don’t have one already. Once you have it, replace the value of ETHERSCAN_API_KEY in your .env file.

Making an ERC-20 Token

Now, let’s start off by creating a really simple ERC-20 Token that will be used to create the trading pool on our exchange. We will just use a dummy token.

Create a new file named Token.sol under foundry-app/src and write the following code there:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Token is ERC20 {
// Initialize contract with 1 million tokens minted to the creator of the contract
constructor() ERC20("Token", "TKN") {
_mint(msg.sender, 1000000 * 10 ** decimals());
}
}

This contract is a very basic ERC-20 that just mints a million tokens to the deployer address.

Making the Exchange Contract

Now for the fun part. Create a file named “Exchange.sol” under foundry-app/src and write the following starter code there:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract Exchange is ERC20 {
// Future code goes here
}

Now, let’s go step by step in adding more functionality:

First, let’s create a constructor for the contract. This constructor takes the contract address of the token and saves it. The exchange will then further behave as an exchange for ETH <> Token.

address public tokenAddress;

// Exchange is inheriting ERC20, because our exchange itself is an ERC-20 contract
// as it is responsible for minting and issuing LP Tokens
constructor(address token) ERC20("ETH TOKEN LP Token", "lpETHTOKEN") {
require(token != address(0), "Token address passed is a null address");
tokenAddress = token;
}

Now, let’s create a simply view function that returns the balance of Token for the exchange contract itself i.e. how many Tokens are in the exchange contract.

// getReserve returns the balance of `token` held by `this` contract
function getReserve() public view returns (uint256) {
return ERC20(tokenAddress).balanceOf(address(this));
}

Now, let’s create the function to add liquidity to the exchange. This code is explained in the comments. If you are confused about how we are calculating certain things — refer back to the lesson which introduced how decentralized exchanges work and went over the math equations.

// addLiquidity allows users to add liquidity to the exchange
function addLiquidity(
uint256 amountOfToken
) public payable returns (uint256) {
uint256 lpTokensToMint;
uint256 ethReserveBalance = address(this).balance;
uint256 tokenReserveBalance = getReserve();

ERC20 token = ERC20(tokenAddress);

// If the reserve is empty, take any user supplied value for initial liquidity
if (tokenReserveBalance == 0) {
// Transfer the token from the user to the exchange
token.transferFrom(msg.sender, address(this), amountOfToken);

// lpTokensToMint = ethReserveBalance = msg.value
lpTokensToMint = ethReserveBalance;

// Mint LP tokens to the user
_mint(msg.sender, lpTokensToMint);

return lpTokensToMint;
}

// If the reserve is not empty, calculate the amount of LP Tokens to be minted
uint256 ethReservePriorToFunctionCall = ethReserveBalance - msg.value;
uint256 minTokenAmountRequired = (msg.value * tokenReserveBalance) /
ethReservePriorToFunctionCall;

require(
amountOfToken >= minTokenAmountRequired,
"Insufficient amount of tokens provided"
);

// Transfer the token from the user to the exchange
token.transferFrom(msg.sender, address(this), minTokenAmountRequired);

// Calculate the amount of LP tokens to be minted
lpTokensToMint =
(totalSupply() * msg.value) /
ethReservePriorToFunctionCall;

// Mint LP tokens to the user
_mint(msg.sender, lpTokensToMint);

return lpTokensToMint;
}

With adding liquidity now possible, let us also create an equivalent removeLiquidity function:

// removeLiquidity allows users to remove liquidity from the exchange
function removeLiquidity(
uint256 amountOfLPTokens
) public returns (uint256, uint256) {
// Check that the user wants to remove >0 LP tokens
require(
amountOfLPTokens > 0,
"Amount of tokens to remove must be greater than 0"
);

uint256 ethReserveBalance = address(this).balance;
uint256 lpTokenTotalSupply = totalSupply();

// Calculate the amount of ETH and tokens to return to the user
uint256 ethToReturn = (ethReserveBalance * amountOfLPTokens) /
lpTokenTotalSupply;
uint256 tokenToReturn = (getReserve() * amountOfLPTokens) /
lpTokenTotalSupply;

// Burn the LP tokens from the user, and transfer the ETH and tokens to the user
_burn(msg.sender, amountOfLPTokens);
payable(msg.sender).transfer(ethToReturn);
ERC20(tokenAddress).transfer(msg.sender, tokenToReturn);

return (ethToReturn, tokenToReturn);
}

Now, let's create a pure function that can perform the calculation of x * y = (x + dx)(y - dy) to estimate how much ETH/Token would a user get back given they want to sell a certain amount of Token/ETH to the exchange.

// getOutputAmountFromSwap calculates the amount of output tokens to be received based on xy = (x + dx)(y - dy)
function getOutputAmountFromSwap(
uint256 inputAmount,
uint256 inputReserve,
uint256 outputReserve
) public pure returns (uint256) {
require(
inputReserve > 0 && outputReserve > 0,
"Reserves must be greater than 0"
);

uint256 inputAmountWithFee = inputAmount * 99;

uint256 numerator = inputAmountWithFee * outputReserve;
uint256 denominator = (inputReserve * 100) + inputAmountWithFee;

return numerator / denominator;
}

With this in place, we can now create our two core swapping functions: ethToTokenSwap and tokenToEthSwap.

// ethToTokenSwap allows users to swap ETH for tokens
function ethToTokenSwap(uint256 minTokensToReceive) public payable {
uint256 tokenReserveBalance = getReserve();
uint256 tokensToReceive = getOutputAmountFromSwap(
msg.value,
address(this).balance - msg.value,
tokenReserveBalance
);

require(
tokensToReceive >= minTokensToReceive,
"Tokens received are less than minimum tokens expected"
);

ERC20(tokenAddress).transfer(msg.sender, tokensToReceive);
}

// tokenToEthSwap allows users to swap tokens for ETH
function tokenToEthSwap(
uint256 tokensToSwap,
uint256 minEthToReceive
) public {
uint256 tokenReserveBalance = getReserve();
uint256 ethToReceive = getOutputAmountFromSwap(
tokensToSwap,
tokenReserveBalance,
address(this).balance
);

require(
ethToReceive >= minEthToReceive,
"ETH received is less than minimum ETH expected"
);

ERC20(tokenAddress).transferFrom(
msg.sender,
address(this),
tokensToSwap
);

payable(msg.sender).transfer(ethToReceive);
}

Deployment

With the code now done for both the contracts, let’s deploy our contracts

Now we’ll have to load our environment variables into the terminal’s environment. For doing this, run the following command in your terminal

source .env

For deploying your token contract, make sure your terminal points to foundry-app and run this:

forge create --rpc-url $QUICKNODE_RPC_URL --private-key $PRIVATE_KEY --etherscan-api-key $ETHERSCAN_API_KEY --verify src/Token.sol:Token

Now copy the obtained address since we’re gonna need it for our Exchange contract’s constructor. Run the following command

forge create --rpc-url $QUICKNODE_RPC_URL --private-key $PRIVATE_KEY --constructor-args <address of the token contract you just deployed> --etherscan-api-key $ETHERSCAN_API_KEY --verify src/Exchange.sol:Exchange

You might get messages saying that your contracts are already verified, even if they are not. This happens because you can’t re-verify a contract identical to one that has already been verified. To circumvent this, you can run:

forge verify-contract <contract_address> <contract_name> --chain <chain_name>

Take a note of both contract addresses, as we will need them soon!

Test on Etherscan

Let’s now test that everything works by playing around with our contracts on Etherscan.

Open up both contracts — the Token and the Exchange — on Sepolia Etherscan — https://sepolia.etherscan.io/

You should have some TOKEN in the account you used to deploy the contract.

Adding Liquidity

Let’s start off by trying to add some liquidity to the exchange. The Exchange relies on the ERC-20 Approve and Transfer Flow, so we need to go give it allowance for TOKEN from the Token contract.

On the TOKEN contract on Etherscan, go to the Contract tab and then Write Contract. Connect your wallet to that page, and click on approve

Input the Exchange contract address in the spender field, and set amount to 10000000000000000000. Click on Write and confirm that transaction.

Once that transaction goes through, open up the Exchange contract on Etherscan - and go to ContractWrite Contract and connect your wallet there.

Click on addLiquidity and input some values for payableAmount and amountOfToken. I chose 0.1 and 10000000respectively - but feel free to do whatever you'd like. Just make sure that amountOfToken is less than or equal to the amount you previously approved. Click on View your transaction and wait for the trasnaction to go through. You will notice that some TOKENs were transfered out of your wallet into the Exchange, and then some new ETH TOKEN LP tokens were transfered into your wallet.

Swap

Now with some liquidity in our exchange, let’s try to swap. I’m gonna first try to do an ETH → Token Swap. Click on the ethToTokenSwap function under Write Contract and input a payableAmount and minTokensToReceive. Since I only added 0.1 ETH in liquidity, I put in 0.01 ETH for the payableAmount and 0 for the minTokensToReceive.

Click Write and then View your transaction. Wait for it to go through. You will notice that you received some amount of TOKEN back to you in exchange for your ETH.

Similarly, let’s try a tokenToEthSwap. I'm gonna do 10000 for tokensToSwap and then 0 for minEthToReceive. Click Write and then View your transaction.

NOTE: Normally, the minimum amount to receive is not set to zero. It is the job of the client (website, app, etc) to provide an estimate on how much tokens the user will receive before they even attempt a swap, and then the user agrees to a slippage percentage. So for example, if I was told to expect 100 TKN back for my swap, and I agree to 5% slippage — that means the minimum I will get is 95% of the original estimated amount i.e. 95 TKN — else the transaction will fail.

Same here — I got some ETH back for my tokens!

Removing Liquidity

Let’s check how much LP tokens I have exactly and let’s try to remove some of the liquidity.

Go to the Read Contract tab on the Exchange on Etherscan, and check balanceOf for your own address. This should tell you the balance of how many LP tokens you own. For me, that was 100000000000000000.

Now, go back to Write Contract and then remove liquidity. Put down a amountOfLPTokens less than your balance - I did 5000000000000 and then Write and View your transaction. Wait for it to go through.

Once the transaction is successful, you’ll notice you get back some amount of ETH and some amount of TOKEN back.

Congratulations!

At this point, we’ve verified that everything in our exchange works perfectly! We’ve implemented the knowledge we gained from understanding the equations behind AMMs — and we’ve successfully rebuilt Uniswap v1 from scratch.

My Links:

LinkedIn | X | BuyMeACoffe

--

--

Oleh Rubanik
Coinmonks

Senior Solidity Smart Contract Developer passionate about DeFi and RWA. From beginner guides to advanced topics, explore diverse articles with me. Welcome! ;)