How to build a Liquidity Pool Smart Contract Using ERC-4626

Ernesto - Oct 22 - - Dev Community

In this tutorial we will walk you through how to build a functional liquidity pool which leverages the ERC-4626 standard for tokenized vaults, we will be using the ERC20 standard for the underlying token. This contract will allow users to deposit ERC20 tokens into a liquidity pool and receive Liquidity Pool tokens in return which represents their share of the pool.

Prerequisite

  • You should have a basic understanding of Solidity
  • You should have a basic knowledge of React Js.
  • You should have Nodejs and Foundry installed on your PC.

Project Setup and Installation For Solidity

Run the command below to initiate a foundry project, we will be using foundry framework to build the smart contract.

 forge init vault
Enter fullscreen mode Exit fullscreen mode

Open the vault folder on Vscode or your favorite code editor, and delete the scripts/counter.s.sol, src/counter.sol, and test/counter.t.sol.

Install all dependencies

    forge install foundry-rs/forge-std --no-commit && forge install OpenZeppelin/openzeppelin-contracts --no-commit
Enter fullscreen mode Exit fullscreen mode

Overview of the Liquidity Pool Contract

The contract will allow:

  1. Users to deposit the allowed underlying token into the vault
  2. Users to get liquidity tokens when they make deposits
  3. Users to withdraw deposits and interest or yield generated
  4. Owner to add yield to the vault.

Code Overview

The complete code for the smart contract can be found here . The Vault contract is deployed on sepolia-0xfB3F6E30e0A79d6D2213A91cfbc819e81E4757Af.

You can also check out the live dapp here

ERC-4626 (Tokenized Vault Standard)

ERC-4626 is a standard on Ethereum that defines a tokenized vault for yield-bearing tokens, aimed at improving the efficiency and uniformity of DeFi protocols.

It is an extension of the ERC-20 token standard, specifically designed to make it easier to create vaults that accept ERC-20 tokens as deposits and automatically generate yield on those assets.

It allows assets to be wrapped and tokenized, enabling depositors to receive yield-bearing shares. It provides functions to deposit and withdraw assets, and track total assets in the vault.

ERC-20 (Fungible Token Standard)

ERC-20 is a widely used standard for creating fungible tokens on the Ethereum blockchain.

Fungible tokens are interchangeable, meaning that one token is equivalent to another of the same type (like how 1 US dollar is equal to another dollar).

The ERC-20 standard provides a set of rules that developers must follow to ensure that tokens are compatible with the broader Ethereum ecosystem, including wallets, exchanges, and decentralized applications (dApps).

Imports

We import three contracts from OpenZeppelin:

  • ** ERC20**: Standard implementation for fungible tokens.
  • ERC4626: Implements the tokenized vault functionality.
  • Ownable: Provides the access control mechanism for the owner.
 import "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
    import "lib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol";
    import "lib/openzeppelin-contracts/contracts/access/Ownable.sol";
Enter fullscreen mode Exit fullscreen mode

State Variables

IERC20 public underlyingToken
Enter fullscreen mode Exit fullscreen mode

This stores the ERC-20 token that users will deposit into the liquidity pool.

Constructor

The constructor initializes the liquidity pool and sets the underlying ERC-20 token. The vault’s name is set to "Liquidity Pool Token" (LPT). The contract inherits from ERC4626, which will handle vault shares. It also inherits from Ownable to restrict certain functions (like adding yield) to the owner.

  // ERC4626 constructor: Underlying token, Vault Token (e.g., "LP Token", "LPT")
        constructor(address _underlyingToken)
            ERC4626(IERC20(_underlyingToken))
            ERC20("Liquidity Pool Token", "LPT")
            Ownable(msg.sender)
        {
            underlyingToken =  IERC20(_underlyingToken);
        }
Enter fullscreen mode Exit fullscreen mode

Deposit Function

Users can deposit ERC-20 tokens into the liquidity pool by calling the deposit() function. The function checks that the user is depositing more than zero tokens. After depositing, the user will receive vault tokens representing their share of the pool.

  // Function to deposit tokens into the pool
        function deposit(uint256 assets, address receiver) public override returns (uint256) {
            require(assets > 0, "Cannot deposit 0 assets");
            return super.deposit(assets, receiver);
        }
Enter fullscreen mode Exit fullscreen mode

Withdraw Function

Users can withdraw their deposited ERC-20 tokens by calling the withdraw() function. This burns their vault tokens and transfers the corresponding amount of the underlying tokens back to them.

  // Function to withdraw tokens from the pool
        function withdraw(uint256 assets, address receiver, address owner) public override     returns (uint256) {
            return super.withdraw(assets, receiver, owner);
        }
Enter fullscreen mode Exit fullscreen mode

Add Yield Function

The addYield() function allows the contract owner to manually add yield (extra tokens) to the pool. This simulates the generation of interest or returns on the underlying assets. Only the contract owner can call this function.

// Example function: Simulate adding yield to the pool
        function addYield(uint256 amount) external onlyOwner {
            underlyingToken.transferFrom(msg.sender, address(this), amount);
        }
Enter fullscreen mode Exit fullscreen mode

Total Assets Function

This function returns the total number of ERC-20 tokens currently held in the pool. It overrides the ERC-4626 totalAssets() function to reflect the pool's holdings.

    // Returns the total amount of underlying assets (ERC20 tokens) in the pool
        function totalAssets() public view override returns (uint256) {
            return underlyingToken.balanceOf(address(this));
        }
Enter fullscreen mode Exit fullscreen mode

Project Setup and Installation For React.js

Run the command below and follow the prompt to initiate a React project, we will be using react.js to build the frontend for the Liquidity Pool platform.

  npm create vite@latest
Enter fullscreen mode Exit fullscreen mode

Install the necessary dependencies.

  npm install npm install ethers react-toastify 
Enter fullscreen mode Exit fullscreen mode

App.js Overview

Imports

App.css - contains styling for the components.

tokenABI and vaultABI - These JSON files contain the ABI (Application Binary Interface) definitions of the token and vault contracts, respectively, which are necessary for interacting with the contracts via ethers.js.

ethers.js - A JavaScript library that allows interactions with the Ethereum blockchain. It is used to create contract instances, send transactions, and interact with smart contracts.

react-toastify - A notification system used to display messages to users, such as transaction success or failure.

    import './App.css'
    import tokenABI from "../abi/token.json";
    import vaultABI from "../abi/vault.json";
    import { ethers } from "ethers";
    import { toast } from "react-toastify";
    import { useState, useRef, useEffect } from "react";
    import "react-toastify/dist/ReactToastify.css";
Enter fullscreen mode Exit fullscreen mode

State and Refs

useRef: Used to track form input values for deposit, withdrawal, and yield operations.

useState: Manages different states such as user address, wallet balance, token allowances, and vault asset data.

    const amountRef = useRef();
      const amountRef2 = useRef();
      const amountRef3 = useRef();
      const receiverRef = useRef();
      const receiverRef2 = useRef();

      const [address, setAddress] = useState("");
      const [balance, setBalance] = useState(0);
      const [allowance, setAllowance] = useState(0);
      const [totalA, setTotalAsset] = useState(0);
      const [UserA, setUSerAsset] = useState(0);
      const [amount, setAmount] = useState(0);

Enter fullscreen mode Exit fullscreen mode

Contract Creation Functions

These functions create instances of the token and vault contracts, enabling read and write operations on the blockchain.

  • createWriteContractToken: Returns a token contract instance that can send transactions.
  • createGetContractToken: Returns a token contract instance for reading values without modifying blockchain state.
  • createWriteContractVault: Returns a vault contract instance for sending transactions.
  • createGetContractVault: Returns a vault contract instance for reading data.
    const createWriteContractToken = async () => {
        const { ethereum } = window;
        const provider = new ethers.providers.Web3Provider(ethereum)
        const signer = await provider.getSigner();
        const tokenContract = new ethers.Contract(tokenAddress, tokenABI.abi, signer);
        return tokenContract;
      };
Enter fullscreen mode Exit fullscreen mode

This function uses the ethereum injected into the browser by Metamask, this allows the dapp to interact with ethereum provider and allows users to connect to the blockchain with metamask, the signer represents the user account and enables the contract to send transaction on behalf of the user. The tokenContract instantiates the smart contract using the ABI, contract address, and signer/provider.

Approve Function

The approve function allows the user to give permission to the vault contract to spend their tokens. This function allows the vault to spend tokens on the user’s behalf, it uses the parseEther function to convert amount entered by the user into wei. The function also displays a loading message when the transaction is still loading.


    const approve = async (evt) => {
        evt.preventDefault();
        const contract = await createWriteContractToken();
        const id = toast.loading("Approval in progress..");
        try {
          const tx = await contract.approve(vaultAddress, ethers.utils.parseEther(amountRef.current.value));
          await tx.wait();
          setTimeout(() => {
            window.location.href = "/";
          }, 10000);
          toast.update(id, {
            render: "Transaction successfull",
            type: "success",
            isLoading: false,
            autoClose: 10000,
            closeButton: true,
          });
        } catch (error) {
          console.log(error);
          toast.update(id, {
            render: `${error.reason}`,
            type: "error",
            isLoading: false,
            autoClose: 10000,
            closeButton: true,
          });
        }
      };
Enter fullscreen mode Exit fullscreen mode

Deposit and Withdraw Functions

These functions allow users to deposit tokens into the vault and withdraw their share of the pool's assets.

  • Deposit: Transfers the specified amount of tokens to the vault contract and assigns vault shares to the user.

  • Withdraw: Redeems the user's shares for the underlying token.

The deposit function:

  • Calls the vault contract's deposit function.
  • Transfers the tokens from the user to the vault.
  • The user receives shares representing ownership of a portion of the vault's assets.

The withdraw function:

  • Calls the vault contract's withdraw function.
  • Transfers the tokens from the vault to the user.
 const deposit = async (evt) => {
        evt.preventDefault();
        const contract = await createWriteContractVault();
        const id = toast.loading("Deposit in progress..");
        try {
          const tx = await contract.deposit(ethers.utils.parseEther(amountRef.current.value), receiverRef.current.value);
          await tx.wait();
          setTimeout(() => {
            window.location.href = "/";
          }, 10000);
          toast.update(id, {
            render: "Transaction successfull",
            type: "success",
            isLoading: false,
            autoClose: 10000,
            closeButton: true,
          });
        } catch (error) {
          console.log(error);
          toast.update(id, {
            render: `${error.reason}`,
            type: "error",
            isLoading: false,
            autoClose: 10000,
            closeButton: true,
          });
        }
      };
      const withdraw = async (evt) => {
        evt.preventDefault();
        const contract = await createWriteContractVault();
        const id = toast.loading("Withdrawal in progress..");
        try {
          const tx = await contract.withdraw(ethers.utils.parseEther(amountRef2.current.value), receiverRef2.current.value, address);
          await tx.wait();
          setTimeout(() => {
            window.location.href = "/";
          }, 10000);
          toast.update(id, {
            render: "Transaction successfull",
            type: "success",
            isLoading: false,
            autoClose: 10000,
            closeButton: true,
          });
        } catch (error) {
          console.log(error);
          toast.update(id, {
            render: `${error.reason}`,
            type: "error",
            isLoading: false,
            autoClose: 10000,
            closeButton: true,
          });
        }
      };

Enter fullscreen mode Exit fullscreen mode

Add Yield Function

This is an administrative function that allows the contract owner to add yield to the vault, increasing the overall pool assets. This means users can have more assets based on the shares they have in the pool.

 const addYield = async (evt) => {
        evt.preventDefault();
        const contract = await createWriteContractVault();
        const id = toast.loading("Add Yield in progress..");
        try {
          const tx = await contract.addYield(ethers.utils.parseEther(amountRef3.current.value));
          await tx.wait();
          setTimeout(() => {
            window.location.href = "/";
          }, 10000);
          toast.update(id, {
            render: "Transaction successfull",
            type: "success",
            isLoading: false,
            autoClose: 10000,
            closeButton: true,
          });
        } catch (error) {
          console.log(error);
          toast.update(id, {
            render: `${error.reason}`,
            type: "error",
            isLoading: false,
            autoClose: 10000,
            closeButton: true,
          });
        }
      };

Enter fullscreen mode Exit fullscreen mode

Fetching Data and State

getBalance: This function fetches the user’s wallet balance, total assets in the vault, and the user’s share of the assets.

    const getBalance = async () => {
        const { ethereum } = window;
        const provider = new ethers.providers.Web3Provider(ethereum);
        const contract = await createGetContractVault();
        const signer = await provider.getSigner();
        const address = await signer.getAddress()
        const balance = await provider.getBalance(address);
        const total = await contract.totalAssets();
        setTotalAsset(Number(total));
        const userAsset = await contract.maxWithdraw(address);
        setBalance(Number(balance));
        setUSerAsset(Number(userAsset));
        setAddress(address);
Enter fullscreen mode Exit fullscreen mode

getAllowance: This function checks if the user has already approved the vault to spend their tokens.

    const getAllowance = async () => {
        const { ethereum } = window;
        const provider = new ethers.providers.Web3Provider(ethereum);
        const signer = await provider.getSigner();
        const taddress = await signer.getAddress()
        const contract = await createGetContractToken();
        const allow = await contract.allowance(taddress, vaultAddress);
        setAllowance(allow);
      };

Enter fullscreen mode Exit fullscreen mode

User Interface

The UI consists of multiple sections where users can:

  • View their current balance and shares in the liquidity pool.
  • Deposit tokens into the vault.
  • Withdraw their shares from the vault.
  • Admins can add yield to the vault.

final ui

Each function is linked to a button that triggers the appropriate smart contract interaction. Notifications are handled by react-toastify, providing feedback on whether transactions succeed or fail.

Conclusion

In this tutorial we covered how to build a Liquidity pool platform with Solidity and React, we explored creating a vault, depositing into the liquidity pool, adding yield to the pool, withdrawing assets from the pool, and viewing information about users and the liquidity pool. We now have a functional liquidity pool that runs on the ethereum blockchain.

This setup can be further extended to include more complex yield strategies, tokenomics, and governance systems.

. .