Introduction
In the evolving landscape of blockchain-based applications, ensuring fair and transparent token distribution is critical—especially in scenarios involving investors, teams, and contributors. Token vesting contracts solve this challenge by locking tokens and releasing them over time, thus preventing early dumps and fostering long-term commitment.
In this article, we will walk through the complete development of a token vesting decentralized application (dApp) deployed on the Polygon network. We will start by writing two robust smart contracts using Solidity: one for a custom ERC-20 token and another for managing time-based token vesting schedules. We’ll then deploy these contracts using the Hardhat framework and finally create an intuitive and responsive frontend using React.js that allows users to connect their MetaMask wallets and view their vesting details in real time. Whether you're a smart contract developer, a frontend engineer, or a Web3 enthusiast, this guide will help you understand the technical underpinnings and architectural decisions involved in building a real-world dApp from scratch.
Prerequisites
Ensure you have the following installed:
- Node.js (v18+ recommended)
- Hardhat (npm install --save-dev hardhat)
- MetaMask extension in your browser
- Polygon wallet funded with test or real POL
- Polygon RPC URL
Step 1. Setting Up the Project Directory
Create a folder for your project and initialize it
mkdir vesting-dapp
cd vesting-dapp
npm init -y
Install Hardhat
npm install --save-dev hardhat
Initialize Hardhat project
npx hardhat
Choose “Create a basic sample project” and install dependencies when prompted.
Install OpenZeppelin contracts
npm install @openzeppelin/contracts
Install dotenv for environment variables
npm install dotenv
![Directory Structure]()
Step 2. Writing Smart Contracts
Inside contracts/ directory, create two files
Token.sol
This Solidity contract defines a simple ERC-20 token named "MyToken" (symbol: MTK) using OpenZeppelin's standard. It mints an initial supply to the deployer's address during deployment.
// SPDX-License-Identifier: MIT
pragma solidity ^ 0.8 .20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MyToken is ERC20 {
constructor(uint256 initialSupply) ERC20("MyToken", "MTK") {
_mint(msg.sender, initialSupply);
}
}
VestingContract.sol
- This Solidity smart contract implements a secure time-based token vesting system using OpenZeppelin libraries.
- It allows the contract owner to lock a specified amount of ERC-20 tokens for a beneficiary, releasing them periodically over a predefined schedule.
- The VestingSchedule struct tracks each beneficiary's vesting parameters, including total tokens, release interval, and progress.
- The createTimeBasedVesting() function initializes a vesting plan by transferring tokens into the contract, while releaseTokens() lets users claim the tokens they've earned based on elapsed time.
- It includes helper view functions like getReleasableAmount and getVestingInfo for frontend integration and transparency.
// SPDX-License-Identifier: MIT
pragma solidity ^ 0.8 .20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract VestingContract is Ownable {
struct VestingSchedule {
uint256 totalAmount;
uint256 amountReleased;
uint256 startTime;
uint256 interval;
uint256 amountPerInterval;
uint256 numberOfIntervals;
bool revoked;
}
IERC20 public immutable token;
mapping(address => VestingSchedule) public vestingSchedules;
event VestingCreated(address indexed beneficiary, uint256 totalAmount);
event TokensReleased(address indexed beneficiary, uint256 amount);
event VestingRevoked(address indexed beneficiary);
constructor(IERC20 _token) Ownable(msg.sender) {
token = _token;
}
function createTimeBasedVesting(
address beneficiary,
uint256 totalAmount,
uint256 startTime,
uint256 endTime,
uint256 interval
) external onlyOwner {
require(vestingSchedules[beneficiary].totalAmount == 0, "Already exists");
require(totalAmount > 0 && interval > 0);
require(endTime > startTime);
uint256 duration = endTime - startTime;
uint256 numberOfIntervals = duration / interval;
require(totalAmount % numberOfIntervals == 0);
uint256 amountPerInterval = totalAmount / numberOfIntervals;
vestingSchedules[beneficiary] = VestingSchedule({
totalAmount,
amountReleased: 0,
startTime,
interval,
amountPerInterval,
numberOfIntervals,
revoked: false
});
require(token.transferFrom(msg.sender, address(this), totalAmount));
emit VestingCreated(beneficiary, totalAmount);
}
function releaseTokens() external {
VestingSchedule storage schedule = vestingSchedules[msg.sender];
require(schedule.totalAmount > 0 && !schedule.revoked);
uint256 elapsed = block.timestamp - schedule.startTime;
uint256 intervals = elapsed / schedule.interval;
if (intervals > schedule.numberOfIntervals) {
intervals = schedule.numberOfIntervals;
}
uint256 releasable = (intervals * schedule.amountPerInterval) - schedule.amountReleased;
require(releasable > 0);
schedule.amountReleased += releasable;
require(token.transfer(msg.sender, releasable));
emit TokensReleased(msg.sender, releasable);
}
function getReleasableAmount(address beneficiary) external view returns(uint256) {
VestingSchedule memory schedule = vestingSchedules[beneficiary];
if (schedule.totalAmount == 0 || schedule.revoked) return 0;
uint256 elapsed = block.timestamp - schedule.startTime;
uint256 intervals = elapsed / schedule.interval;
if (intervals > schedule.numberOfIntervals) {
intervals = schedule.numberOfIntervals;
}
uint256 totalReleasable = intervals * schedule.amountPerInterval;
return totalReleasable - schedule.amountReleased;
}
function getVestingInfo(address beneficiary) external view returns(VestingSchedule memory) {
return vestingSchedules[beneficiary];
}
Step 3. Deployment Scripts
Creating the ERC-20 Token Smart Contract
- This Hardhat script is used to deploy a standard ERC-20 token named TestToken. It first accesses the contract factory using hre.ethers.getContractFactory and deploys the contract without any constructor parameters.
- After deployment, it waits until the contract is fully mined on the blockchain using token.deployed(). Once the deployment is complete, it prints the deployed contract address to the console.
- The main function is wrapped with error handling to catch and log any deployment errors.
const hre = require("hardhat");
async function main() {
const Token = await hre.ethers.getContractFactory("TestToken");
const token = await Token.deploy();
await token.deployed();
console.log("ERC20 Token deployed to:", token.address);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Creating the Monthly Token Vesting Smart Contract
- This Hardhat script deploys the MonthlyTokenVesting smart contract using a previously deployed ERC-20 token address. It first retrieves the contract factory using hre.ethers.getContractFactory, then deploys the vesting contract by passing the token address to the constructor.
- Once deployed, it logs the newly created vesting contract's address to the console. The main function is wrapped with error handling to gracefully catch and log any deployment issues.
const hre = require("hardhat");
async function main() {
const tokenAddress = "Your Deployed Token Address"; // Replace this after first deployment
const Vesting = await hre.ethers.getContractFactory("MonthlyTokenVesting");
const vesting = await Vesting.deploy(tokenAddress);
await vesting.deployed();
console.log("Vesting contract deployed to:", vesting.address);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Creating a Vesting Schedule via Hardhat Ignition Module
- This Hardhat Ignition module automates the process of creating a vesting schedule on a deployed vesting contract. It accepts configurable parameters like the ERC-20 token address, vesting contract address, beneficiary wallet, total tokens, and timing details.
- It uses contractAt to reference already deployed contracts and calls the approve method to allow the vesting contract to spend tokens on the user’s behalf.
- Finally, it invokes createVesting to register a new vesting schedule for the specified user.
const {
buildModule
} = require("@nomicfoundation/hardhat-ignition/modules");
module.exports = buildModule("CreateVestingScheduleModule", (m) => {
// Parameters
const vestingAddress = m.getParameter(
"vestingAddress",
"Vesting Contract Address"
);
const tokenAddress = m.getParameter(
"tokenAddress",
"ERC-20 Token Deployed Address"
);
const beneficiary = m.getParameter(
"beneficiary",
"Users Wallet Address"
);
const totalAmount = m.getParameter("totalAmount", BigInt(1000e18)); // 1000 TST
const startTime = m.getParameter("startTime", Math.floor(Date.now() / 1000));
const interval = m.getParameter("interval", 60); // 1 min
const numberOfIntervals = m.getParameter("numberOfIntervals", 10); // 10 minutes
// Reference deployed contracts
const token = m.contractAt("TestToken", tokenAddress);
const vesting = m.contractAt("MonthlyTokenVesting", vestingAddress);
// Approve vesting contract to spend deployer's tokens
m.call(token, "approve", [vesting, totalAmount]);
// Create vesting schedule for user
m.call(vesting, "createVesting", [
beneficiary,
totalAmount,
startTime,
interval,
numberOfIntervals
]);
return {
token,
vesting
};
});
Update hardhat.config.js with your Polygon RPC and private key.
require("@nomicfoundation/hardhat-ignition");
require("@nomiclabs/hardhat-ethers");
require("dotenv").config();
module.exports = {
solidity: "0.8.20",
networks: {
amoy: {
url: process.env.AMOY_RPC,
accounts: ["0x" + process.env.PRIVATE_KEY]
}
}
};
Step 4. Run Deployment
npx hardhat run scripts/deploy-token.js --network amoy
It is a successful deployment of an ERC-20 token smart contract using Hardhat on the Polygon Amoy testnet. The command npx hardhat run scripts/deploy-token.js --network amoy executed the deployment, and the terminal confirms the deployed token contract address.
![Deploy-token]()
npx hardhat run scripts/deploy-vesting.js --network amoy
![Deploy-Vesting]()
Step 5. Creating the React Frontend
Inside the root directory.
npx create-react-app frontend
cd frontend
npm install ethers
Create abi.json inside the src with ABI of VestingContract.
Step 6. Writing React Code
- This React.js code connects to a user's MetaMask wallet, then interacts with a deployed token vesting smart contract on the Polygon network.
- After connecting, it fetches the user's vesting information—such as the total releasable amount, start time, interval, and amount released—and displays it on the frontend.
- It uses the ethers.js library to handle blockchain interactions. The contract address and ABI are used to instantiate the smart contract.
import React, {
useState
} from "react";
import {
ethers
} from "ethers";
import abi from "./abi.json";
const CONTRACT_ADDRESS = "Deployed Created Vesting contract address";
const BENEFICIARY = "User-Account address"; // Replace with actual if needed
function App() {
const [provider, setProvider] = useState(null);
const [signer, setSigner] = useState(null);
const [contract, setContract] = useState(null);
const [userAddress, setUserAddress] = useState("");
const [info, setInfo] = useState({});
const connectWallet = async () => {
try {
if (!window.ethereum) return alert("Please install MetaMask");
await window.ethereum.request({
method: "eth_requestAccounts"
});
const newProvider = new ethers.BrowserProvider(window.ethereum);
const newSigner = await newProvider.getSigner();
const address = await newSigner.getAddress();
const network = await newProvider.getNetwork();
if (network.chainId !== 137n) {
alert("Please switch to the Polygon Mainnet");
return;
}
const newContract = new ethers.Contract(CONTRACT_ADDRESS, abi, newSigner);
setProvider(newProvider);
setSigner(newSigner);
setUserAddress(address);
setContract(newContract);
} catch (err) {
console.error("Wallet connection error:", err);
alert("Failed to connect wallet");
}
};
const fetchData = async () => {
if (!contract || !userAddress) return;
try {
const [releasable, vestingInfo, owner, token] = await Promise.all([
contract.getReleasableAmount(BENEFICIARY),
contract.getVestingInfo(BENEFICIARY),
contract.owner(),
contract.token(),
]);
if (vestingInfo.totalAmount === 0n) {
alert("No vesting schedule found for this wallet");
return;
}
setInfo({
releasable: ethers.formatUnits(releasable, 18),
vestingInfo,
owner,
token,
});
} catch (err) {
console.error("Error fetching vesting info:", err);
alert("Error fetching vesting info. Check console.");
}
};
return ( <
div className = "min-h-screen bg-gray-100 p-6" >
<
div className = "max-w-3xl mx-auto bg-white shadow-lg rounded-lg p-6" >
<
h1 className = "text-2xl font-bold mb-4" > 📜Token Vesting Info < /h1>
{
!userAddress ? ( <
button onClick = {
connectWallet
}
className = "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" >
Connect Wallet <
/button>
) : ( <
>
<
p className = "mb-4" > Connected: {
"User"
} < /p> <
button onClick = {
fetchData
}
className = "mb-4 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700" >
Fetch Vesting Info <
/button>
{
info.owner && ( <
div className = "space-y-2 text-sm" >
<
p > < strong > Owner: < /strong> {info.owner}</p >
<
p > < strong > Token Address: < /strong> {info.token}</p >
<
p > < strong > Releasable Amount: < /strong> {info.releasable} Tokens</p >
<
p > < strong > Total Amount: < /strong> {ethers.formatUnits(info.vestingInfo.totalAmount, 18)} Tokens</p >
<
p > < strong > Amount Released: < /strong> {ethers.formatUnits(info.vestingInfo.amountReleased, 18)} Tokens</p >
<
p > < strong > Start Time: < /strong> {new Date(Number(info.vestingInfo.startTime) * 1000).toLocaleString()}</p >
<
p > < strong > Interval(sec): < /strong> {info.vestingInfo.interval.toString()}</p >
<
p > < strong > Amount per Interval: < /strong> {ethers.formatUnits(info.vestingInfo.amountPerInterval, 18)} Tokens</p >
<
p > < strong > Total Intervals: < /strong> {info.vestingInfo.numberOfIntervals.toString()}</p >
<
p > < strong > Revoked: < /strong> {info.vestingInfo.revoked ? "Yes" : "No"}</p >
<
/div>
)
} <
/>
)
} <
/div> <
/div>
);
}
export default App;
Step 7. Run the Frontend
cd frontend
npm start
Open http://localhost:3000 and connect MetaMask.
![Connect-Wallet]()
After that, you have to click on the 'Fetch Vesting Info' button. Then you will get all the details such as Owner Address, Token Address, Total Amount, Amount Released, Start Time, Interval (in seconds), Amount Per Interval, Total Intervals, and Revoked status.
![Fetching Information]()
Conclusion
You’ve now built a full-stack dApp from scratch that enables token vesting on the Polygon network. From Solidity contracts to real-time React.js interactions, this project demonstrates how blockchain tools integrate with modern frontend frameworks. You can expand it further by adding admin controls, CSV exports, charts, and support for multiple networks.