Building an Automated Market Maker (AMM) Model Similar to Uniswap V1 using Hardhat

Galien Dev
Coinmonks
13 min readJan 19, 2024

--

Automated Market Makers (AMMs) have revolutionized decentralized finance (DeFi) by providing a decentralized and automated way to trade tokens. In this tutorial, we will guide you through the process of building a simple AMM model similar to Uniswap V1 using Hardhat and Solidity. The AMM model will allow users to add liquidity, remove liquidity, and swap Olame tokens (OLA) with CELO tokens.

Prerequisites

Before getting started, make sure you have the following installed:
Before we embark on this exciting journey, make sure you have the following prerequisites in place:

  1. Solid Understanding of Blockchain and Smart Contracts: You should have a solid grasp of blockchain technology and how smart contracts work.
  2. Ethereum and Hardhat Knowledge: Familiarity with Ethereum and the Hardhat development environment is essential. If you’re new to Hardhat, consider going through their official documentation first.
  3. Node.js and npm: Ensure you have Node.js and npm (Node Package Manager) installed on your machine.

Now that we have our prerequisites sorted, let’s get started with setting up our project and diving into the fascinating world of flash loan arbitrage!

Setting Up the Project

Step 1: Initialize a New Hardhat Project

Open your terminal and navigate to your desired project directory. Run the following commands:

npm install --save-dev hardhat
npx hardhat

Follow the prompts to create a new Hardhat project. Choose the default settings for simplicity.

Step 2: Install Dependencies

We’ll need to install some additional dependencies for our project. Open your terminal and run the following commands:

yarn add --dev @nomiclabs/hardhat-ethers@npm:hardhat-deploy-ethers ethers @nomiclabs/hardhat-etherscan @nomiclabs/hardhat-waffle chai ethereum-waffle hardhat hardhat-contract-sizer hardhat-deploy hardhat-gas-reporter prettier prettier-plugin-solidity solhint solidity-coverage dotenv

Step 3: Project Structure

Your project directory should now have the following structure:

- contracts/
- Token.sol
- AMM.sol
- deploy/
- 00-deployToken.ts
- 01-deployAMM.ts
- scripts/
- test/
- hardhat.config.ts
- package.json
- README.md

create .env file, add your PRIVATE_KEY by your proper credentials as follows:

PRIVATE_KEY=....

Open hardhat.config.ts, and update it with the details below:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
require("@nomiclabs/hardhat-ethers");
require("@nomiclabs/hardhat-etherscan");

import * as dotenv from "dotenv";
dotenv.config();

const WALLET_PRIVATE_KEY = process.env.PRIVATE_KEY as string;

const config: HardhatUserConfig = {
solidity: "0.8.17",
networks: {
hardhat: {
},
alfajores: {
url: "https://alfajores-forno.celo-testnet.org",
chainId: 44787,
gas: 10000000,
// replace this ox by you private key
accounts: [WALLET_PRIVATE_KEY]
}
},
};

export default config;

Step 4: Writing OlameToken Contract

// contracts/OlameToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

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

contract OlameToken is ERC20 {
event Faucet(address indexed minter, uint256 amount);

uint256 public DEFAULT_MINT_AMOUNT = 1000;

constructor() ERC20("Olame Token", "OLA") {}

function faucet() public {
uint256 amount = DEFAULT_MINT_AMOUNT * (10 ** decimals());
_mint(msg.sender, amount);
emit Faucet(msg.sender, DEFAULT_MINT_AMOUNT);
}
}

This contract defines the OlameToken ERC20 token and includes a faucet function to mint tokens for testing.

Step 5: Writing AMM Contract

Create a new file named AMM.sol in the contracts directory and paste the following content:

// contracts/AMM.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

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

contract AMM is ERC20 {
// ... (paste the rest of the AMM contract code here)
}

This file includes the skeleton of the AMM contract. We will add the AMM contract code portion by portion in the following steps.

1. Adding Constructor and Initialization

Now, let’s add the constructor and initialization code to the AMM contract. Update the AMM.sol file:

// contracts/AMM.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

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

contract AMM is ERC20 {
/**
* @dev The address of the Olame token contract.
*/
address public olameTokenAddress;

/**
* @dev Emitted when liquidity is added to the contract.
* @param provider The address of the liquidity provider.
* @param amount The amount of tokens provided.
* @param liquidity The amount of liquidity tokens minted.
*/
event LiquidityAdded(address indexed provider, uint256 amount, uint256 liquidity);

/**
* @dev Emitted when liquidity is removed from the contract.
* @param provider The address of the liquidity provider.
* @param amount The amount of liquidity tokens burned.
* @param celoAmount The amount of CELO tokens transferred to the provider.
* @param olameTokenAmount The amount of Olame tokens transferred to the provider.
*/
event LiquidityRemoved(address indexed provider, uint256 amount, uint256 celoAmount, uint256 olameTokenAmount);

/**
* @dev Emitted when tokens are purchased from the contract.
* @param buyer The address of the buyer.
* @param celoAmount The amount of CELO tokens provided.
* @param tokensBought The amount of Olame tokens bought.
*/
event TokensPurchased(address indexed buyer, uint256 celoAmount, uint256 tokensBought);

/**
* @dev Emitted when tokens are sold to the contract.
* @param seller The address of the seller.
* @param tokensSold The amount of Olame tokens sold.
* @param celoAmount The amount of CELO tokens transferred to the seller.
*/
event TokensSold(address indexed seller, uint256 tokensSold, uint256 celoAmount);


/**
* @dev Initializes the AMM contract.
* @param _olameTokenAddress The address of the Olame token.
*/
constructor(address _olameTokenAddress) ERC20("Olame LP Token", "OLA-LP") {
require(_olameTokenAddress != address(0), "Token address passed is a null address");
olameTokenAddress = _olameTokenAddress;
}

// ... (continue with the next steps)
}

In this step, we added the constructor and declared events for liquidity addition, removal, token purchase, and token sale.

2. Adding Liquidity Functions

Now, let’s add the functions responsible for adding liquidity to the AMM. Update the AMM.sol file:

// contracts/AMM.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

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

contract AMM is ERC20 {
// ... (previous code)

/**
* @dev Returns the reserve of Olame tokens held by the contract.
* @return The reserve of Olame tokens.
*/
function getReserve() public view returns (uint) {
return ERC20(olameTokenAddress).balanceOf(address(this));
}

/**
* @dev Adds liquidity to the AMM contract.
* @param _amount The amount of Olame tokens to add.
* @return The amount of liquidity added.
*/
function addLiquidity(uint _amount) public payable returns (uint) {
uint liquidity;
uint celoBalance = address(this).balance;
uint olameTokenReserve = getReserve();
ERC20 olameToken = ERC20(olameTokenAddress);
if(olameTokenReserve == 0) {
require(olameToken.transferFrom(msg.sender, address(this), _amount), "Token transfer failed");
liquidity = celoBalance;
_mint(msg.sender, liquidity);
} else {
uint celoReserve = celoBalance - msg.value;
uint olameTokenAmount = (msg.value * olameTokenReserve)/(celoReserve);
require(_amount >= olameTokenAmount, "Amount of tokens sent is less than the minimum tokens required");
require(olameToken.transferFrom(msg.sender, address(this), olameTokenAmount), "Token transfer failed");
liquidity = (msg.value * totalSupply()) / celoReserve;
require(liquidity > 0, "Liquidity amount is zero");
_mint(msg.sender, liquidity);
}
emit LiquidityAdded(msg.sender, _amount, liquidity);
return liquidity;
}

// ... (continue with the next steps)
}

In this step, we added the getReserve function to retrieve the Olame token reserve and the addLiquidity function for liquidity addition.

3. Adding Liquidity Removal Function

Now, let’s add the function responsible for removing liquidity from the AMM. Update the AMM.sol file:

   // contracts/AMM.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

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

contract AMM is ERC20 {
// ... (previous code) /**
* @dev Removes liquidity from the AMM contract.
* @param _amount The amount of LP tokens to remove.
* @return The amount of CELO and Olame tokens received.
*/
function removeLiquidity(uint _amount) public returns (uint , uint) {
require(_amount > 0, "LP amount must be greater than zero");
require(balanceOf(msg.sender) >= _amount, "Insufficient LP tokens to burn");
uint celoReserve = address(this).balance;
uint _totalSupply = totalSupply();
uint celoAmount = (celoReserve * _amount)/ _totalSupply;
uint olameTokenAmount = (getReserve() * _amount)/ _totalSupply;
_burn(msg.sender, _amount);
payable(msg.sender).transfer(celoAmount);
ERC20(olameTokenAddress).transfer(msg.sender, olameTokenAmount);
emit LiquidityRemoved(msg.sender, _amount, celoAmount, olameTokenAmount);
return (celoAmount, olameTokenAmount);
}
}

In this step, we added the removeLiquidity function for liquidity removal.

4. Adding Token Swap Functions

Now, let’s add the functions responsible for swapping CELO for Olame tokens and vice versa. Update the AMM.sol file:

// contracts/AMM.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

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

contract AMM is ERC20 {
// ... (previous code)


/**
* @dev Calculates the amount of output tokens for a given input amount and reserves.
* @param inputAmount The input amount of tokens.
* @param inputReserve The input reserve of tokens.
* @param outputReserve The output reserve of tokens.
* @return The amount of output tokens.
*/
function getAmountOfTokens(
uint256 inputAmount,
uint256 inputReserve,
uint256 outputReserve
) public 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;
}

/**
* @dev Swaps CELO for Olame tokens.
* @param _minTokens The minimum amount of Olame tokens expected to be received.
*/
function celoToOlameToken(uint _minTokens) public payable {
uint256 tokenReserve = getReserve();

uint256 tokensBought = getAmountOfTokens(
msg.value,
address(this).balance - msg.value,
tokenReserve
);

require(tokensBought >= _minTokens, "Insufficient output amount");
ERC20(olameTokenAddress).transfer(msg.sender, tokensBought);
require(checkTransferSuccess(), "Token transfer failed");
emit TokensPurchased(msg.sender, msg.value, tokensBought);
}
/**
* @dev Swaps Olame tokens for CELO.
* @param _tokensSold The amount of Olame tokens to sell.
* @param _minCelo The minimum amount of CELO expected to be received.
*/
function olameTokenToCelo(uint _tokensSold, uint _minCelo) public {
uint256 tokenReserve = getReserve();

uint256 celoBought = getAmountOfTokens(
_tokensSold,
tokenReserve,
address(this).balance
);
require(celoBought >= _minCelo, "Insufficient output amount");
require(
ERC20(olameTokenAddress).transferFrom(
msg.sender,
address(this),
_tokensSold
),
"Token transfer failed"
);
payable(msg.sender).transfer(celoBought);
emit TokensSold(msg.sender, _tokensSold, celoBought);
}

// ... (continue with the next steps)
}

In this step, we added the getAmountOfTokens function for calculating token swap amounts and the celoToOlameToken and olameTokenToCelo functions for token swaps.

5. Completing the Check Transfer Function

Finally, let’s add the checkTransferSuccess function to ensure successful token transfers. Update the AMM.sol file:

// contracts/AMM.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

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

contract AMM is ERC20 {
// ... (previous code)

/**
* @dev Checks if the token transfer from the contract to itself is successful.
* @return A boolean indicating whether the transfer was successful or not.
*/
function checkTransferSuccess() private returns (bool) {
uint256 tokenBalance = ERC20(olameTokenAddress).balanceOf(address(this));
return (tokenBalance == 0 || ERC20(olameTokenAddress).transfer(address(this), tokenBalance));
}
}

And here’s how the full AMM.solsmart contract should look like:

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

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

contract AMM is ERC20 {

/**
* @dev The address of the Olame token contract.
*/
address public olameTokenAddress;

/**
* @dev Emitted when liquidity is added to the contract.
* @param provider The address of the liquidity provider.
* @param amount The amount of tokens provided.
* @param liquidity The amount of liquidity tokens minted.
*/
event LiquidityAdded(address indexed provider, uint256 amount, uint256 liquidity);

/**
* @dev Emitted when liquidity is removed from the contract.
* @param provider The address of the liquidity provider.
* @param amount The amount of liquidity tokens burned.
* @param celoAmount The amount of CELO tokens transferred to the provider.
* @param olameTokenAmount The amount of Olame tokens transferred to the provider.
*/
event LiquidityRemoved(address indexed provider, uint256 amount, uint256 celoAmount, uint256 olameTokenAmount);

/**
* @dev Emitted when tokens are purchased from the contract.
* @param buyer The address of the buyer.
* @param celoAmount The amount of CELO tokens provided.
* @param tokensBought The amount of Olame tokens bought.
*/
event TokensPurchased(address indexed buyer, uint256 celoAmount, uint256 tokensBought);

/**
* @dev Emitted when tokens are sold to the contract.
* @param seller The address of the seller.
* @param tokensSold The amount of Olame tokens sold.
* @param celoAmount The amount of CELO tokens transferred to the seller.
*/
event TokensSold(address indexed seller, uint256 tokensSold, uint256 celoAmount);


/**
* @dev Initializes the AMM contract.
* @param _olameTokenAddress The address of the Olame token.
*/
constructor(address _olameTokenAddress) ERC20("Olame LP Token", "ICB-LP") {
require(_olameTokenAddress != address(0), "Token address passed is a null address");
olameTokenAddress = _olameTokenAddress;
}


/**
* @dev Returns the reserve of Olame tokens held by the contract.
* @return The reserve of Olame tokens.
*/
function getReserve() public view returns (uint) {
return ERC20(olameTokenAddress).balanceOf(address(this));
}

/**
* @dev Adds liquidity to the AMM contract.
* @param _amount The amount of Olame tokens to add.
* @return The amount of liquidity added.
*/
function addLiquidity(uint _amount) public payable returns (uint) {
uint liquidity;
uint celoBalance = address(this).balance;
uint olameTokenReserve = getReserve();
ERC20 olameToken = ERC20(olameTokenAddress);
if(olameTokenReserve == 0) {
require(olameToken.transferFrom(msg.sender, address(this), _amount), "Token transfer failed");
liquidity = celoBalance;
_mint(msg.sender, liquidity);
} else {
uint celoReserve = celoBalance - msg.value;
uint olameTokenAmount = (msg.value * olameTokenReserve)/(celoReserve);
require(_amount >= olameTokenAmount, "Amount of tokens sent is less than the minimum tokens required");
require(olameToken.transferFrom(msg.sender, address(this), olameTokenAmount), "Token transfer failed");
liquidity = (msg.value * totalSupply()) / celoReserve;
require(liquidity > 0, "Liquidity amount is zero");
_mint(msg.sender, liquidity);
}
emit LiquidityAdded(msg.sender, _amount, liquidity);
return liquidity;
}

/**
* @dev Removes liquidity from the AMM contract.
* @param _amount The amount of LP tokens to remove.
* @return The amount of CELO and Olame tokens received.
*/
function removeLiquidity(uint _amount) public returns (uint , uint) {
require(_amount > 0, "LP amount must be greater than zero");
require(balanceOf(msg.sender) >= _amount, "Insufficient LP tokens to burn");
uint celoReserve = address(this).balance;
uint _totalSupply = totalSupply();
uint celoAmount = (celoReserve * _amount)/ _totalSupply;
uint olameTokenAmount = (getReserve() * _amount)/ _totalSupply;
_burn(msg.sender, _amount);
payable(msg.sender).transfer(celoAmount);
ERC20(olameTokenAddress).transfer(msg.sender, olameTokenAmount);
emit LiquidityRemoved(msg.sender, _amount, celoAmount, olameTokenAmount);
return (celoAmount, olameTokenAmount);
}

/**
* @dev Calculates the amount of output tokens for a given input amount and reserves.
* @param inputAmount The input amount of tokens.
* @param inputReserve The input reserve of tokens.
* @param outputReserve The output reserve of tokens.
* @return The amount of output tokens.
*/
function getAmountOfTokens(
uint256 inputAmount,
uint256 inputReserve,
uint256 outputReserve
) public 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;
}


/**
* @dev Swaps CELO for Olame tokens.
* @param _minTokens The minimum amount of Olame tokens expected to be received.
*/
function celoToOlameToken(uint _minTokens) public payable {
uint256 tokenReserve = getReserve();

uint256 tokensBought = getAmountOfTokens(
msg.value,
address(this).balance - msg.value,
tokenReserve
);

require(tokensBought >= _minTokens, "Insufficient output amount");
ERC20(olameTokenAddress).transfer(msg.sender, tokensBought);
require(checkTransferSuccess(), "Token transfer failed");
emit TokensPurchased(msg.sender, msg.value, tokensBought);
}

/**
* @dev Checks if the token transfer from the contract to itself is successful.
* @return A boolean indicating whether the transfer was successful or not.
*/
function checkTransferSuccess() private returns (bool) {
uint256 tokenBalance = ERC20(olameTokenAddress).balanceOf(address(this));
return (tokenBalance == 0 || ERC20(olameTokenAddress).transfer(address(this), tokenBalance));
}


/**
* @dev Swaps Olame tokens for CELO.
* @param _tokensSold The amount of Olame tokens to sell.
* @param _minCelo The minimum amount of CELO expected to be received.
*/
function olameTokenToCelo(uint _tokensSold, uint _minCelo) public {
uint256 tokenReserve = getReserve();

uint256 celoBought = getAmountOfTokens(
_tokensSold,
tokenReserve,
address(this).balance
);
require(celoBought >= _minCelo, "Insufficient output amount");
require(
ERC20(olameTokenAddress).transferFrom(
msg.sender,
address(this),
_tokensSold
),
"Token transfer failed"
);
payable(msg.sender).transfer(celoBought);
emit TokensSold(msg.sender, _tokensSold, celoBought);
}
}

let’s go through the key functionalities of each contract:

OlameToken Contract:

  1. Constructor:
  • Initializes the OlameToken contract with the name “Olame Token” and symbol “OLA”.

2. Faucet Function:

  • Mints a default amount of Olame Tokens (1000 OLA tokens) to the caller.
  • Emits a Faucet event indicating the minter's address and the amount of tokens minted.

AMM Contract:

  1. Constructor:
  • Initializes the AMM contract with the OlameToken contract’s address.
  • Requires a non-null OlameToken address to be provided.

2. getReserve Function:

  • Returns the current reserve of Olame tokens held by the AMM contract.

3. addLiquidity Function:

  • Allows liquidity providers to add liquidity by providing Olame tokens.
  • Computes the corresponding amount of CELO tokens to be added based on the current reserves.
  • Mints new liquidity tokens (OLA-LP) and transfers them to the liquidity provider.
  • Emits a LiquidityAdded event indicating the provider's address, the amount of Olame tokens provided, and the amount of liquidity tokens minted.

4. removeLiquidity Function:

  • Allows liquidity providers to remove liquidity by burning their liquidity tokens (OLA-LP).
  • Computes the corresponding amounts of CELO and Olame tokens to be returned based on the current reserves and the amount of liquidity tokens burned.
  • Transfers the calculated amounts of CELO and Olame tokens to the liquidity provider.
  • Emits a LiquidityRemoved event indicating the provider's address, the amount of liquidity tokens burned, the amount of CELO tokens transferred, and the amount of Olame tokens transferred.

5. getAmountOfTokens Function:

  • Calculates the amount of output tokens for a given input amount and reserves, using a simple constant product formula.

6. celoToOlameToken Function:

  • Allows users to swap CELO for Olame tokens.
  • Calculates the amount of Olame tokens to be bought based on the input amount of CELO and current reserves.
  • Transfers the calculated amount of Olame tokens to the user.
  • Emits a TokensPurchased event indicating the buyer's address, the amount of CELO tokens provided, and the amount of Olame tokens bought.

7. checkTransferSuccess Function:

  • Checks if the transfer of Olame tokens from the contract to itself is successful.

8. olameTokenToCelo Function:

  • Allows users to swap Olame tokens for CELO.
  • Calculates the amount of CELO to be received based on the input amount of Olame tokens and current reserves.
  • Transfers the input amount of Olame tokens from the user to the contract and transfers the calculated amount of CELO to the user.
  • Emits a TokensSold event indicating the seller's address, the amount of Olame tokens sold, and the amount of CELO received.

These contracts together form an Automated Market Maker (AMM) where users can add liquidity, remove liquidity, and swap Olame tokens with CELO and vice versa. The OlameToken contract primarily provides an ERC-20 token (OLA), and the AMM contract facilitates liquidity provision and token swapping.

Step 6: Deploying Smart Contracts

Deploying your smart contracts is the next crucial step. Let’s take a look at the deployment scripts.

00-deployToken.js

The deployment script for the Token.sol contract:

import { ethers } from "hardhat";

async function main() {
const OlameToken = await ethers.getContractFactory("OlameToken");
const token = await OlameToken.deploy();

await token.deployed();

console.log(
`OlameToken contract deployed to ${token.address}`
);
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

/*
npx hardhat run scripts/deployToken.ts --network alfajores
*/

01-deployAMM.js

The deployment script for the AMM.sol contract:

import { ethers } from "hardhat";

async function main() {
const olameTokenAddress = "0x296d5bF623c2db0F54A373669E64D757C9A2e537";

const AMM = await ethers.getContractFactory("AMM");
const amm = await AMM.deploy(olameTokenAddress);

await amm.deployed();

console.log(
`AMM contract deployed to ${amm.address}`
);
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

Let’s start by deploying the Token.sol contract:

npx hardhat run scripts/deployToken.ts --network alfajores

Deployed to CELO Alfajores Testnet

Step 5: Testing Smart Contracts (Interact with our AMM on FE)

Here are the steps to consider to interacct with out AMM model on the frontend:

  1. Go to the link below and add celo (alfajores to your Metamask wallet)
    https://revoke.cash/learn/wallets/add-network/celo-alfajores
  2. Go to the link below to get some testnet token
    https://faucet.celo.org/alfajores
  3. Acces this link to interact with Our DEX in the browser
    https://o-lame-exchange.vercel.app/

Conclusion

Congratulations! You’ve successfully built a basic Automated Market Maker (AMM) model similar to Uniswap V1 using Solidity and Hardhat. This tutorial covered the step-by-step process of creating and testing the OlameToken and AMM contracts. Feel free to explore further optimizations and enhancements for your AMM model.

--

--

Galien Dev
Coinmonks

Software Engineer & Tech Writer | Smart Contract Developer|Node.js|React.js Reach out: 📨galiencodes13@gmail.com, or https://www.linkedin.com/in/muhindo-galien/