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
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
Overview of the Liquidity Pool Contract
The contract will allow:
- Users to deposit the allowed underlying token into the vault
- Users to get liquidity tokens when they make deposits
- Users to withdraw deposits and interest or yield generated
- 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";
State Variables
IERC20 public underlyingToken
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);
}
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);
}
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);
}
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);
}
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));
}
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
Install the necessary dependencies.
npm install npm install ethers react-toastify
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";
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);
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;
};
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,
});
}
};
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,
});
}
};
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,
});
}
};
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);
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);
};
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.
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.