DeFi is now a major topic of discussion in the cryptocurrency space. DeFi stands for “Decentralized finance,” which means that there’s no central authority keeping an eye on and controlling the transfer of funds. This also means that transactions in DeFi are P2P (peer to peer), which means that no central authority is responsible for transferral, and funds are sent directly from one entity to another.
In this article we will learn how to get started with DeFi by making a full-stack DeFi app on the Polygon chain using Next.js as the frontend. This app will sell and purchase OKToken (a fictional token) from the user. However, every purchase transaction reduces one token from the amount of tokens you can get per MATIC (selling increases this number by one). This is not an ideal demonstration, but this way you can understand how to use your own logic in Solidity smart contracts and learn to create your own full-stack DeFi app using Polygon.
To get started with this tutorial, make sure you have the following:
Now that you have checked the requirements, let’s proceed with creating our Hardhat project to work with our Solidity smart contracts.
Navigate to a safe directory and run the following command in the terminal to initialize your Hardhat project:
npx hardhat
Once you run the command, you should see the following Hardhat initialization wizard in your terminal.
From the list, choose Create an advanced sample project. Then you will be asked where you want to initialize the Hardhat project; don’t change the field, just press Enter so that the project gets initialized in the current directory.
Then you will be asked whether or not you want to install dependencies required for your Hardhat project to run. Press y because we will be needing these dependencies, and installing them right now is the best idea.
Installation of dependencies will start, and might take a few seconds or minutes depending upon the machine you’re running. Now, run the following command in the terminal to install another dependency we will need to ease our Solidity contract development:
npm install @openzeppelin/contracts
OpenZeppelin provides smart contract standards that we can use in our own smart contracts to easily create an Ownable, ERC-20 and ERC-721 contracts, and more.
Once the dependencies are successfully installed, open the directory in a code editor. I’ll be using VS Code for this tutorial.
We will be creating two smart contracts: the first one will be our ERC-20 token itself and the second will be a vendor contract, which will facilitate buying and selling of these tokens.
Now, go to the contracts
folder and create a new Solidity file named OKToken.sol
, which will contain our ERC-20 token contract.
Use the following code for this file:
// SPDX-License-Identifier: Unlicense pragma solidity ^0.8.4; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract OKToken is ERC20 { constructor() ERC20("OKT", "OKToken"){ _mint(msg.sender, 10000 * 10 ** 18); } }
In the above code, we are importing the ERC20.sol
file from @openzeppelin/contracts
which will help us get started with an ERC-20 token easily. Then, in the constructor, we are providing the symbol "OKT"
and name "OKToken"
for our token.
That’s all for the token contract! Now, let’s work on the vendor contract. Under the contracts
folder, create a new file named OKVendor.sol
with the following code:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.4; import "./OKToken.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; contract OKVendor is Ownable { OKToken yourToken; uint256 public tokensPerNativeCurrency = 100; event BuyTokens(address buyer, uint256 amountOfNativeCurrency, uint256 amountOfTokens); constructor(address tokenAddress) { yourToken = OKToken(tokenAddress); } function buyTokens() public payable returns (uint256 tokenAmount) { require(msg.value > 0, "You need to send some NativeCurrency to proceed"); uint256 amountToBuy = msg.value * tokensPerNativeCurrency; uint256 vendorBalance = yourToken.balanceOf(address(this)); require(vendorBalance >= amountToBuy, "Vendor contract has not enough tokens to perform transaction"); (bool sent) = yourToken.transfer(msg.sender, amountToBuy); require(sent, "Failed to transfer token to user"); tokensPerNativeCurrency = tokensPerNativeCurrency - 1; emit BuyTokens(msg.sender, msg.value, amountToBuy); return amountToBuy; } function sellTokens(uint256 tokenAmountToSell) public { require(tokenAmountToSell > 0, "Specify an amount of token greater than zero"); uint256 userBalance = yourToken.balanceOf(msg.sender); require(userBalance >= tokenAmountToSell, "You have insufficient tokens"); uint256 amountOfNativeCurrencyToTransfer = tokenAmountToSell / tokensPerNativeCurrency; uint256 ownerNativeCurrencyBalance = address(this).balance; require(ownerNativeCurrencyBalance >= amountOfNativeCurrencyToTransfer, "Vendor has insufficient funds"); (bool sent) = yourToken.transferFrom(msg.sender, address(this), tokenAmountToSell); require(sent, "Failed to transfer tokens from user to vendor"); (sent,) = msg.sender.call{value: amountOfNativeCurrencyToTransfer}(""); tokensPerNativeCurrency = tokensPerNativeCurrency + 1; require(sent, "Failed to send NativeCurrency to the user"); } function getNumberOfTokensInNativeCurrency() public view returns(uint256) { return tokensPerNativeCurrency; } function withdraw() public onlyOwner { uint256 ownerBalance = address(this).balance; require(ownerBalance > 0, "No NativeCurrency present in Vendor"); (bool sent,) = msg.sender.call{value: address(this).balance}(""); require(sent, "Failed to withdraw"); } }
This will help us facilitate the buying and selling of tokens.
In the above contract, first we are importing our token contract, which we need in order to interact with our token contract using the vendor contract and call functions.
We are also importing Ownable.sol
from @openzeppelin/contracts
. This means that the owner of the smart contract can transfer its ownership and have access to owners-only functions.
After initializing the smart contract, we define the variable tokensPerNativeCurrency
which states the number of tokens which can be purchased using 1 MATIC. We will be altering this number based on the transactions made.
We then have a constructor which will take OKToken’s contract address so that we can communicate with the deployed contract and perform functions on them.
In the buyTokens()
function, we are performing checks to ensure the proper amount of MATIC is sent to the smart contract, and that the vendor contract has the required amount of tokens. Then we call the function transfer()
from the OKToken instance we previously created to transfer the tokens to the request sender.
In the sellTokens()
function, we are performing checks to ensure that the request sender has enough tokens and if the vendor contract has enough MATIC to send back to the request sender. Then, we use the transferFrom()
function from the OKToken instance we previously created to transfer the tokens from the request sender’s wallet to the smart contract. However, the sender needs to approve this transaction; we perform this approval on the client side before making the request. We will cover this part when we make the front end of this application.
Finally, we have the withdraw()
function, which is only accessible by the owner of the contracts. It allows them to withdraw all the MATIC present on the contract.
Now that we have the smart contracts ready, let’s deploy them to Polygon Mumbai testnet!
We will be creating a script to deploy our contract to Polygon Mumbai. Once the contracts are deployed, we will programmatically send all the tokens stored on the deployer’s wallet to the vendor contract.
First go to hardhat.config.js
and under module.exports
, add the following object so that Hardhat knows which network to connect to:
networks: { mumbai: { url: "https://matic-mumbai.chainstacklabs.com", accounts: ["PRIVATE KEY HERE"], } }
We are providing the network a name (mumbai
in this case) and providing an RPC URL. The mentioned RPC URL is for Polygon Mumbai. If you want to use Polygon Mainnet you can choose your RPC URL. Remember to enter your own wallet private key with some test MATIC to pay for gas fees involved in the smart contract deployment process.
Now, under the scripts
folder, create a new file called deploy.js
. Paste in the following:
const { BigNumber, utils } = require("ethers"); const hardhat = require("hardhat"); async function main() { const OKToken = await hardhat.ethers.getContractFactory("OKToken"); const oktoken = await OKToken.deploy(); await oktoken.deployed(); console.log("[📥] OKToken deployed to address: " + oktoken.address); const OKVendor = await hardhat.ethers.getContractFactory("OKVendor"); const okvendor = await OKVendor.deploy(oktoken.address); console.log("[📥] OKVendor deployed to address: " + okvendor.address); await oktoken.deployed(); // Transfer oktokens to vendor await oktoken.functions.transfer(okvendor.address, utils.parseEther("10000")); console.log("[🚀] Tokens transferred to OKVendor"); } main() .then(() => process.exit(0)) .catch((error) => { console.error(error); process.exit(1); });
In the above file, we are instructing Hardhat how to deploy our contract. The main()
function is the entry point here. First, we get the OKToken
contract and deploy it. Then, we get the OKVendor
contract, provide OKToken
contract address in the constructor, and deploy the contract. Then, we transfer all the funds from OKToken
contract to OKVendor
contract.
Run the following command in the terminal to run the script and deploy our contracts to the Polygon Mumbai network:
npx hardhat run --network mumbai scripts/deploy.js --show-stack-traces
Note that the network name must match the one mentioned in hardhat.config.js
. After running the script, the contracts should be deployed and you should see the following in your terminal:
If you see an output similar to this, your smart contracts have been deployed and configured successfully. Now, let’s proceed with creating our Next.js application.
Under the same directory, run the following command in the terminal to create your Next.js app:
npx create-next-app frontend
The above command will create a new app and automatically install necessary dependencies.
Navigate to the frontend
folder and use the following command in the terminal to install additional dependencies, which will help us interact with our smart contracts:
yarn add @thirdweb-dev/react @thirdweb-dev/sdk ethers web3
We are installing @thirdweb-dev/react
and @thirdweb-dev/sdk
so that we can easily authenticate the user and connect their wallets to our app using MetaMask. ethers
is a required dependency for thirdweb so we need to install that as well. Finally, we are installing web3
so that we can interact with our smart contract.
To get started, we need to wrap our app within a thirdwebProvider
so that thirdweb can function properly.
Go to your _app.js
file under pages
folder and add in the following:
import { thirdwebProvider, ChainId } from "@thirdweb-dev/react"; import "../styles/globals.css"; function MyApp({ Component, pageProps }) { return ( <thirdwebProvider desiredChainId={ChainId.Mumbai}> <Component {...pageProps} /> </thirdwebProvider> ); } export default MyApp;
In the above code, we are importing thirdwebProvider
and enclosing our app within it. We are also providing a desiredChainId
of the chain ID of Polygon Mumbai. You can also use the chain ID for Polygon Mainnet if you wish to do so.
Create a new file in your Next.js app root called contracts.js
and add the following content:
export const oktoken = { contractAddress: "0xE83DD81890C76BB8c4b8Bc6365Ad95E5e71495E5", abi: [ { inputs: [], stateMutability: "nonpayable", type: "constructor", }, { anonymous: false, inputs: [ { indexed: true, internalType: "address", name: "owner", type: "address", }, { indexed: true, internalType: "address", name: "spender", type: "address", }, { indexed: false, internalType: "uint256", name: "value", type: "uint256", }, ], name: "Approval", type: "event", }, { anonymous: false, inputs: [ { indexed: true, internalType: "address", name: "from", type: "address", }, { indexed: true, internalType: "address", name: "to", type: "address", }, { indexed: false, internalType: "uint256", name: "value", type: "uint256", }, ], name: "Transfer", type: "event", }, { inputs: [ { internalType: "address", name: "owner", type: "address", }, { internalType: "address", name: "spender", type: "address", }, ], name: "allowance", outputs: [ { internalType: "uint256", name: "", type: "uint256", }, ], stateMutability: "view", type: "function", }, { inputs: [ { internalType: "address", name: "spender", type: "address", }, { internalType: "uint256", name: "amount", type: "uint256", }, ], name: "approve", outputs: [ { internalType: "bool", name: "", type: "bool", }, ], stateMutability: "nonpayable", type: "function", }, { inputs: [ { internalType: "address", name: "account", type: "address", }, ], name: "balanceOf", outputs: [ { internalType: "uint256", name: "", type: "uint256", }, ], stateMutability: "view", type: "function", }, { inputs: [], name: "decimals", outputs: [ { internalType: "uint8", name: "", type: "uint8", }, ], stateMutability: "view", type: "function", }, { inputs: [ { internalType: "address", name: "spender", type: "address", }, { internalType: "uint256", name: "subtractedValue", type: "uint256", }, ], name: "decreaseAllowance", outputs: [ { internalType: "bool", name: "", type: "bool", }, ], stateMutability: "nonpayable", type: "function", }, { inputs: [ { internalType: "address", name: "spender", type: "address", }, { internalType: "uint256", name: "addedValue", type: "uint256", }, ], name: "increaseAllowance", outputs: [ { internalType: "bool", name: "", type: "bool", }, ], stateMutability: "nonpayable", type: "function", }, { inputs: [], name: "name", outputs: [ { internalType: "string", name: "", type: "string", }, ], stateMutability: "view", type: "function", }, { inputs: [], name: "symbol", outputs: [ { internalType: "string", name: "", type: "string", }, ], stateMutability: "view", type: "function", }, { inputs: [], name: "totalSupply", outputs: [ { internalType: "uint256", name: "", type: "uint256", }, ], stateMutability: "view", type: "function", }, { inputs: [ { internalType: "address", name: "to", type: "address", }, { internalType: "uint256", name: "amount", type: "uint256", }, ], name: "transfer", outputs: [ { internalType: "bool", name: "", type: "bool", }, ], stateMutability: "nonpayable", type: "function", }, { inputs: [ { internalType: "address", name: "from", type: "address", }, { internalType: "address", name: "to", type: "address", }, { internalType: "uint256", name: "amount", type: "uint256", }, ], name: "transferFrom", outputs: [ { internalType: "bool", name: "", type: "bool", }, ], stateMutability: "nonpayable", type: "function", }, ], }; export const okvendor = { contractAddress: "0xAa3b8cbB24aF3EF68a0B1760704C969E57c53D7A", abi: [ { inputs: [ { internalType: "address", name: "tokenAddress", type: "address", }, ], stateMutability: "nonpayable", type: "constructor", }, { anonymous: false, inputs: [ { indexed: false, internalType: "address", name: "buyer", type: "address", }, { indexed: false, internalType: "uint256", name: "amountOfNativeCurrency", type: "uint256", }, { indexed: false, internalType: "uint256", name: "amountOfTokens", type: "uint256", }, ], name: "BuyTokens", type: "event", }, { anonymous: false, inputs: [ { indexed: true, internalType: "address", name: "previousOwner", type: "address", }, { indexed: true, internalType: "address", name: "newOwner", type: "address", }, ], name: "OwnershipTransferred", type: "event", }, { inputs: [], name: "buyTokens", outputs: [ { internalType: "uint256", name: "tokenAmount", type: "uint256", }, ], stateMutability: "payable", type: "function", }, { inputs: [], name: "getNumberOfTokensInNativeCurrency", outputs: [ { internalType: "uint256", name: "", type: "uint256", }, ], stateMutability: "view", type: "function", }, { inputs: [], name: "owner", outputs: [ { internalType: "address", name: "", type: "address", }, ], stateMutability: "view", type: "function", }, { inputs: [], name: "renounceOwnership", outputs: [], stateMutability: "nonpayable", type: "function", }, { inputs: [ { internalType: "uint256", name: "tokenAmountToSell", type: "uint256", }, ], name: "sellTokens", outputs: [], stateMutability: "nonpayable", type: "function", }, { inputs: [], name: "tokensPerNativeCurrency", outputs: [ { internalType: "uint256", name: "", type: "uint256", }, ], stateMutability: "view", type: "function", }, { inputs: [ { internalType: "address", name: "newOwner", type: "address", }, ], name: "transferOwnership", outputs: [], stateMutability: "nonpayable", type: "function", }, { inputs: [], name: "withdraw", outputs: [], stateMutability: "nonpayable", type: "function", }, ], };
Remember to replace the contract addresses with your own so that the Next.js app tries to connect to the correct smart contract.
Now let’s start coding up our app. Open index.js
file under pages
folder and add the following:
import { useAddress, useContract, useMetamask } from "@thirdweb-dev/react"; import Head from "next/head"; import Image from "next/image"; import { oktoken, okvendor } from "../contracts"; import styles from "../styles/Home.module.css"; import { useEffect, useState } from "react"; import Web3 from "web3"; const web3 = new Web3(Web3.givenProvider); export default function Home() { const [tokensPerCurrency, setTokensPerCurrency] = useState(0); const [tokens, setTokens] = useState(0); const address = useAddress(); const connectUsingMetamask = useMetamask(); const account = web3.defaultAccount; const purchase = async () => { const contract = new web3.eth.Contract( okvendor.abi, okvendor.contractAddress ); const ethToSend = tokens / tokensPerCurrency; const purchase = await contract.methods.buyTokens().send({ from: address, value: web3.utils.toWei(ethToSend.toString(), "ether"), }); console.log(purchase); await fetchPrice(); }; const sell = async () => { const vendorContract = new web3.eth.Contract( okvendor.abi, okvendor.contractAddress ); const tokenContract = new web3.eth.Contract( oktoken.abi, oktoken.contractAddress ); const approve = await tokenContract.methods .approve( okvendor.contractAddress, web3.utils.toWei(tokens.toString(), "ether") ) .send({ from: address, }); const sellTokens = await vendorContract.methods.sellTokens(tokens).send({ from: address, }); await fetchPrice(); }; const fetchPrice = async () => { const contract = new web3.eth.Contract( okvendor.abi, okvendor.contractAddress ); const priceFromContract = await contract.methods .getNumberOfTokensInNativeCurrency() .call(); setTokensPerCurrency(priceFromContract); }; useEffect(() => { fetchPrice(); }, []); return ( <div> <Head> <title>Exchange OKTokens</title> </Head> {address ? ( <div> <p>Tokens per currency: {tokensPerCurrency}</p> <div> <input type="number" value={tokens} onChange={(e) => setTokens(e.target.value)} /> </div> <button onClick={purchase}>Purchase</button> <button onClick={sell}>Sell</button> </div> ) : ( <div> <button onClick={connectUsingMetamask}>Connect using MetaMask</button> </div> )} </div> ); }
This is a long code block, so let’s see what the code is doing step by step:
web3
package using a provider set up by thirdwebuseMetamask()
to authenticate and useAddress()
to check authentication state, then rendering the login button if the user doesn’t have wallet connected using MetaMaskfetchPrice()
function to interact with our smart contract and check how many tokens one MATIC can get, while also creating an useEffect
to check this price whenever the page loadspurchase()
function, which initializes our vendor contract and calls the buyTokens()
function from the contract, then sending some MATIC along with this transaction. Then, we call fetchPrice()
so that the latest price is shownFinally, we are creating a sell()
function, which initializes both token and vendor contract. First we interact with token contract’s approve()
function and allow the vendor contract to transfer funds on our behalf. We then are calling sellTokens()
function from the vendor contract to finally sell the tokens and receive MATIC. We are also calling fetchPrice()
to get the latest price after transaction.
Our simple DeFi app is complete! You can view this app in your browser by running the following command:
yarn dev
Now once you visit http://localhost:3000, you should see the following screen, and you should be able to make transactions.
This was a simple tutorial on how to create your own full-stack DeFi app based on Polygon. You can implement your own logic on the smart contracts to make it even better depending on your organization. I suggest tinkering around with the code so that you can learn in the best way possible.
LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app or site. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.
Modernize how you debug web and mobile apps — Start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowThe useReducer React Hook is a good alternative to tools like Redux, Recoil, or MobX.
Node.js v22.5.0 introduced a native SQLite module, which is is similar to what other JavaScript runtimes like Deno and Bun already have.
Understanding and supporting pinch, text, and browser zoom significantly enhances the user experience. Let’s explore a few ways to do so.
Playwright is a popular framework for automating and testing web applications across multiple browsers in JavaScript, Python, Java, and C#. […]