Create an ERC-20 Token
Create an ERC-20 Token
Overview
This tutorial will guide you through creating, deploying, and managing your own ERC-20 token on OPN Chain. We'll build a feature-rich token with minting, burning, pausable functionality, and more.
What you'll learn:
ERC-20 token standard
Using OpenZeppelin contracts
Adding advanced features
Creating a token sale contract
Building a token dashboard
Time required: 45 minutes
Prerequisites
Completed First Contract Tutorial
Basic Solidity knowledge
MetaMask with OPN testnet
Test OPN tokens from faucet
Understanding ERC-20
What is ERC-20?
ERC-20 is the standard interface for fungible tokens on Ethereum-compatible blockchains. It defines:
Balance tracking: Who owns how many tokens
Transfers: Moving tokens between addresses
Approvals: Allowing others to spend your tokens
Events: Notifying when transfers occur
Core Functions
interface IERC20 {
// View functions
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
// State-changing functions
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
// Events
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
Step 1: Project Setup
Create New Project
mkdir opn-token
cd opn-token
npm init -y
Install Dependencies
npm install --save-dev hardhat @nomiclabs/hardhat-waffle @nomiclabs/hardhat-ethers ethereum-waffle chai ethers
npm install @openzeppelin/contracts dotenv
Initialize Hardhat
npx hardhat
Select "Create a JavaScript project" and accept defaults.
Configure Hardhat
Update hardhat.config.js
:
require("@nomiclabs/hardhat-waffle");
require("dotenv").config();
module.exports = {
solidity: {
version: "0.8.19",
settings: {
optimizer: {
enabled: true,
runs: 200
}
}
},
networks: {
opnTestnet: {
url: "https://testnet-rpc.iopn.tech",
chainId: 984,
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
gasPrice: 7000000000 // 7 Gwei
}
}
};
Environment Setup
Create .env
:
PRIVATE_KEY=your_private_key_here
TOKEN_NAME="OPN Test Token"
TOKEN_SYMBOL="OTT"
INITIAL_SUPPLY="1000000"
Step 2: Basic ERC-20 Token
Simple Implementation
Create contracts/BasicToken.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract BasicToken is ERC20 {
constructor(
string memory name,
string memory symbol,
uint256 initialSupply
) ERC20(name, symbol) {
_mint(msg.sender, initialSupply * 10**decimals());
}
}
This basic token:
Uses OpenZeppelin's tested implementation
Mints initial supply to deployer
Has 18 decimal places by default
Deploy Basic Token
Create scripts/deploy-basic.js
:
const hre = require("hardhat");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying Basic Token with account:", deployer.address);
const BasicToken = await ethers.getContractFactory("BasicToken");
const token = await BasicToken.deploy(
"Basic Token",
"BASIC",
1000000 // 1 million tokens
);
await token.deployed();
console.log("Basic Token deployed to:", token.address);
console.log("Total supply:", await token.totalSupply());
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
Deploy:
npx hardhat run scripts/deploy-basic.js --network opnTestnet
Step 3: Advanced Token Features
Full-Featured Token
Create contracts/AdvancedToken.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
/**
* @title AdvancedToken
* @dev ERC20 token with:
* - Minting capability (only owner)
* - Burning capability (anyone can burn their tokens)
* - Pausable transfers
* - Supply cap
* - Transaction fee
*/
contract AdvancedToken is ERC20, ERC20Burnable, ERC20Pausable, Ownable(msg.sender), ReentrancyGuard {
uint256 public constant MAX_SUPPLY = 100_000_000 * 10**18; // 100 million tokens
uint256 public constant TRANSACTION_FEE = 25; // 0.25% = 25/10000
address public feeRecipient;
mapping(address => bool) public isExcludedFromFees;
event FeeCollected(address indexed from, address indexed to, uint256 amount);
event FeeRecipientChanged(address indexed oldRecipient, address indexed newRecipient);
event ExclusionUpdated(address indexed account, bool excluded);
constructor(
string memory name,
string memory symbol,
uint256 initialSupply,
address _feeRecipient
) ERC20(name, symbol) {
require(initialSupply <= MAX_SUPPLY, "Initial supply exceeds max supply");
require(_feeRecipient != address(0), "Fee recipient cannot be zero address");
feeRecipient = _feeRecipient;
isExcludedFromFees[msg.sender] = true;
isExcludedFromFees[address(this)] = true;
_mint(msg.sender, initialSupply);
}
/**
* @dev Mint new tokens (only owner)
*/
function mint(address to, uint256 amount) public onlyOwner {
require(totalSupply() + amount <= MAX_SUPPLY, "Minting would exceed max supply");
_mint(to, amount);
}
/**
* @dev Pause token transfers (only owner)
*/
function pause() public onlyOwner {
_pause();
}
/**
* @dev Unpause token transfers (only owner)
*/
function unpause() public onlyOwner {
_unpause();
}
/**
* @dev Update fee recipient
*/
function setFeeRecipient(address newRecipient) public onlyOwner {
require(newRecipient != address(0), "Fee recipient cannot be zero address");
address oldRecipient = feeRecipient;
feeRecipient = newRecipient;
emit FeeRecipientChanged(oldRecipient, newRecipient);
}
/**
* @dev Update fee exclusion status
*/
function setFeeExclusion(address account, bool excluded) public onlyOwner {
isExcludedFromFees[account] = excluded;
emit ExclusionUpdated(account, excluded);
}
/**
* @dev Calculate transaction fee
*/
function calculateFee(uint256 amount) public pure returns (uint256) {
return (amount * TRANSACTION_FEE) / 10000;
}
/**
* @dev Get circulating supply (total - burned)
*/
function circulatingSupply() public view returns (uint256) {
return totalSupply();
}
/**
* @dev Get remaining mintable supply
*/
function remainingMintableSupply() public view returns (uint256) {
return MAX_SUPPLY - totalSupply();
}
/**
* @dev Override transfer to include fees
*/
function _transfer(
address from,
address to,
uint256 amount
) internal override(ERC20, ERC20Pausable) {
require(from != address(0), "Transfer from zero address");
require(to != address(0), "Transfer to zero address");
uint256 feeAmount = 0;
// Calculate fee if not excluded
if (!isExcludedFromFees[from] && !isExcludedFromFees[to]) {
feeAmount = calculateFee(amount);
if (feeAmount > 0) {
super._transfer(from, feeRecipient, feeAmount);
emit FeeCollected(from, feeRecipient, feeAmount);
}
}
// Transfer remaining amount
super._transfer(from, to, amount - feeAmount);
}
/**
* @dev Required override for pausable
*/
function _beforeTokenTransfer(
address from,
address to,
uint256 amount
) internal override(ERC20, ERC20Pausable) {
super._beforeTokenTransfer(from, to, amount);
}
}
Key Features Explained
Minting: Only owner can create new tokens up to max supply
Burning: Anyone can destroy their own tokens
Pausable: Owner can pause all transfers in emergencies
Transaction Fees: 0.25% fee on transfers (configurable exclusions)
Supply Cap: Maximum 100 million tokens
Step 4: Token Sale Contract
Create Crowdsale
Create contracts/TokenSale.sol
:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
/**
* @title TokenSale
* @dev Token sale contract with:
* - Multiple sale phases
* - Whitelist support
* - Purchase limits
* - Refund capability
*/
contract TokenSale is Ownable(msg.sender), ReentrancyGuard {
using SafeERC20 for IERC20;
IERC20 public token;
uint256 public rate; // tokens per OPN
uint256 public tokensSold;
uint256 public totalRaised;
uint256 public minPurchase;
uint256 public maxPurchase;
uint256 public saleStart;
uint256 public saleEnd;
bool public whitelistRequired;
mapping(address => bool) public whitelist;
mapping(address => uint256) public purchases;
enum SaleStatus { Pending, Active, Completed, Cancelled }
SaleStatus public status;
event TokensPurchased(
address indexed buyer,
uint256 opnAmount,
uint256 tokenAmount
);
event SaleStatusChanged(SaleStatus oldStatus, SaleStatus newStatus);
event WhitelistUpdated(address indexed account, bool whitelisted);
event FundsWithdrawn(uint256 amount);
event SaleRefunded(address indexed buyer, uint256 amount);
constructor(
address _token,
uint256 _rate,
uint256 _minPurchase,
uint256 _maxPurchase,
uint256 _saleStart,
uint256 _saleEnd
) {
require(_token != address(0), "Token address cannot be zero");
require(_rate > 0, "Rate must be greater than zero");
require(_minPurchase > 0, "Min purchase must be greater than zero");
require(_maxPurchase >= _minPurchase, "Max must be >= min");
require(_saleEnd > _saleStart, "End must be after start");
require(_saleStart >= block.timestamp, "Start must be in future");
token = IERC20(_token);
rate = _rate;
minPurchase = _minPurchase;
maxPurchase = _maxPurchase;
saleStart = _saleStart;
saleEnd = _saleEnd;
status = SaleStatus.Pending;
}
modifier saleActive() {
require(status == SaleStatus.Active, "Sale not active");
require(block.timestamp >= saleStart, "Sale not started");
require(block.timestamp <= saleEnd, "Sale ended");
_;
}
/**
* @dev Buy tokens with OPN
*/
function buyTokens() public payable saleActive nonReentrant {
uint256 opnAmount = msg.value;
require(opnAmount >= minPurchase, "Below minimum purchase");
require(
purchases[msg.sender] + opnAmount <= maxPurchase,
"Exceeds maximum purchase"
);
if (whitelistRequired) {
require(whitelist[msg.sender], "Not whitelisted");
}
uint256 tokenAmount = calculateTokenAmount(opnAmount);
require(
token.balanceOf(address(this)) >= tokenAmount,
"Insufficient tokens in contract"
);
purchases[msg.sender] += opnAmount;
tokensSold += tokenAmount;
totalRaised += opnAmount;
token.safeTransfer(msg.sender, tokenAmount);
emit TokensPurchased(msg.sender, opnAmount, tokenAmount);
}
/**
* @dev Calculate token amount for given OPN
*/
function calculateTokenAmount(uint256 opnAmount) public view returns (uint256) {
return opnAmount * rate;
}
/**
* @dev Update whitelist
*/
function updateWhitelist(
address[] calldata accounts,
bool whitelisted
) public onlyOwner {
for (uint i = 0; i < accounts.length; i++) {
whitelist[accounts[i]] = whitelisted;
emit WhitelistUpdated(accounts[i], whitelisted);
}
}
/**
* @dev Toggle whitelist requirement
*/
function setWhitelistRequired(bool required) public onlyOwner {
whitelistRequired = required;
}
/**
* @dev Start the sale
*/
function startSale() public onlyOwner {
require(status == SaleStatus.Pending, "Invalid status");
require(block.timestamp >= saleStart, "Too early to start");
status = SaleStatus.Active;
emit SaleStatusChanged(SaleStatus.Pending, SaleStatus.Active);
}
/**
* @dev End the sale
*/
function endSale() public onlyOwner {
require(status == SaleStatus.Active, "Sale not active");
status = SaleStatus.Completed;
emit SaleStatusChanged(SaleStatus.Active, SaleStatus.Completed);
}
/**
* @dev Cancel sale and enable refunds
*/
function cancelSale() public onlyOwner {
require(status != SaleStatus.Completed, "Cannot cancel completed sale");
SaleStatus oldStatus = status;
status = SaleStatus.Cancelled;
emit SaleStatusChanged(oldStatus, SaleStatus.Cancelled);
}
/**
* @dev Claim refund if sale cancelled
*/
function claimRefund() public nonReentrant {
require(status == SaleStatus.Cancelled, "Sale not cancelled");
uint256 amount = purchases[msg.sender];
require(amount > 0, "No purchase to refund");
purchases[msg.sender] = 0;
payable(msg.sender).transfer(amount);
emit SaleRefunded(msg.sender, amount);
}
/**
* @dev Withdraw raised funds (only after sale completed)
*/
function withdrawFunds() public onlyOwner {
require(status == SaleStatus.Completed, "Sale not completed");
uint256 balance = address(this).balance;
require(balance > 0, "No funds to withdraw");
payable(owner()).transfer(balance);
emit FundsWithdrawn(balance);
}
/**
* @dev Withdraw unsold tokens
*/
function withdrawUnsoldTokens() public onlyOwner {
require(
status == SaleStatus.Completed || status == SaleStatus.Cancelled,
"Sale still active"
);
uint256 balance = token.balanceOf(address(this));
require(balance > 0, "No tokens to withdraw");
token.safeTransfer(owner(), balance);
}
/**
* @dev Get sale info
*/
function getSaleInfo() public view returns (
uint256 _rate,
uint256 _tokensSold,
uint256 _totalRaised,
uint256 _saleStart,
uint256 _saleEnd,
SaleStatus _status,
uint256 _tokensAvailable
) {
return (
rate,
tokensSold,
totalRaised,
saleStart,
saleEnd,
status,
token.balanceOf(address(this))
);
}
}
Step 5: Testing
Comprehensive Test Suite
Create test/AdvancedToken.test.js
:
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("AdvancedToken", function () {
let token;
let tokenSale;
let owner;
let addr1;
let addr2;
let feeRecipient;
const NAME = "Advanced Token";
const SYMBOL = "ADV";
const INITIAL_SUPPLY = ethers.utils.parseEther("10000000"); // 10M tokens
const RATE = 1000; // 1000 tokens per OPN
beforeEach(async function () {
[owner, addr1, addr2, feeRecipient] = await ethers.getSigners();
// Deploy token
const AdvancedToken = await ethers.getContractFactory("AdvancedToken");
token = await AdvancedToken.deploy(
NAME,
SYMBOL,
INITIAL_SUPPLY,
feeRecipient.address
);
await token.deployed();
// Deploy token sale
const currentTime = Math.floor(Date.now() / 1000);
const TokenSale = await ethers.getContractFactory("TokenSale");
tokenSale = await TokenSale.deploy(
token.address,
RATE,
ethers.utils.parseEther("0.1"), // min 0.1 OPN
ethers.utils.parseEther("10"), // max 10 OPN
currentTime + 60, // starts in 1 minute
currentTime + 86400 // ends in 1 day
);
await tokenSale.deployed();
// Transfer tokens to sale contract
await token.transfer(
tokenSale.address,
ethers.utils.parseEther("1000000")
);
});
describe("Token Basics", function () {
it("Should have correct name and symbol", async function () {
expect(await token.name()).to.equal(NAME);
expect(await token.symbol()).to.equal(SYMBOL);
expect(await token.decimals()).to.equal(18);
});
it("Should assign initial supply to owner", async function () {
const ownerBalance = await token.balanceOf(owner.address);
expect(ownerBalance).to.equal(INITIAL_SUPPLY);
});
});
describe("Transfers with Fees", function () {
it("Should transfer with fee", async function () {
const amount = ethers.utils.parseEther("1000");
const fee = await token.calculateFee(amount);
await token.transfer(addr1.address, amount);
const addr1Balance = await token.balanceOf(addr1.address);
const feeBalance = await token.balanceOf(feeRecipient.address);
expect(addr1Balance).to.equal(amount.sub(fee));
expect(feeBalance).to.equal(fee);
});
it("Should exclude owner from fees", async function () {
const amount = ethers.utils.parseEther("1000");
await token.transfer(addr1.address, amount);
const addr1Balance = await token.balanceOf(addr1.address);
expect(addr1Balance).to.equal(amount); // No fee
});
});
describe("Minting", function () {
it("Should allow owner to mint", async function () {
const mintAmount = ethers.utils.parseEther("1000000");
await token.mint(addr1.address, mintAmount);
expect(await token.balanceOf(addr1.address)).to.equal(mintAmount);
});
it("Should not exceed max supply", async function () {
const maxSupply = await token.MAX_SUPPLY();
const currentSupply = await token.totalSupply();
const overMint = maxSupply.sub(currentSupply).add(1);
await expect(
token.mint(addr1.address, overMint)
).to.be.revertedWith("Minting would exceed max supply");
});
it("Should not allow non-owner to mint", async function () {
await expect(
token.connect(addr1).mint(addr2.address, 1000)
).to.be.revertedWith("Ownable: caller is not the owner");
});
});
describe("Burning", function () {
it("Should allow users to burn their tokens", async function () {
const amount = ethers.utils.parseEther("1000");
await token.transfer(addr1.address, amount);
const balanceBefore = await token.balanceOf(addr1.address);
await token.connect(addr1).burn(ethers.utils.parseEther("100"));
const balanceAfter = await token.balanceOf(addr1.address);
expect(balanceAfter).to.equal(
balanceBefore.sub(ethers.utils.parseEther("100"))
);
});
});
describe("Pausable", function () {
it("Should allow owner to pause transfers", async function () {
await token.pause();
await expect(
token.transfer(addr1.address, 1000)
).to.be.revertedWith("Pausable: paused");
});
it("Should allow unpause", async function () {
await token.pause();
await token.unpause();
// Should work now
await token.transfer(addr1.address, 1000);
expect(await token.balanceOf(addr1.address)).to.be.gt(0);
});
});
describe("Token Sale", function () {
beforeEach(async function () {
// Wait for sale to start
await ethers.provider.send("evm_increaseTime", [61]);
await ethers.provider.send("evm_mine");
await tokenSale.startSale();
});
it("Should allow token purchase", async function () {
const purchaseAmount = ethers.utils.parseEther("1");
await tokenSale.connect(addr1).buyTokens({
value: purchaseAmount
});
const expectedTokens = purchaseAmount.mul(RATE);
expect(await token.balanceOf(addr1.address)).to.equal(expectedTokens);
});
it("Should enforce purchase limits", async function () {
const overLimit = ethers.utils.parseEther("11");
await expect(
tokenSale.connect(addr1).buyTokens({ value: overLimit })
).to.be.revertedWith("Exceeds maximum purchase");
});
it("Should track sales correctly", async function () {
const amount = ethers.utils.parseEther("1");
await tokenSale.connect(addr1).buyTokens({ value: amount });
const saleInfo = await tokenSale.getSaleInfo();
expect(saleInfo._totalRaised).to.equal(amount);
expect(saleInfo._tokensSold).to.equal(amount.mul(RATE));
});
});
});
Run Tests
npx hardhat test
Step 6: Deployment Script
Complete Deployment
Create scripts/deploy-all.js
:
const hre = require("hardhat");
const fs = require("fs");
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying contracts with account:", deployer.address);
console.log("Account balance:", (await deployer.getBalance()).toString());
// Deploy token
console.log("\n1. Deploying AdvancedToken...");
const AdvancedToken = await ethers.getContractFactory("AdvancedToken");
const token = await AdvancedToken.deploy(
process.env.TOKEN_NAME || "OPN Advanced Token",
process.env.TOKEN_SYMBOL || "OAT",
ethers.utils.parseEther(process.env.INITIAL_SUPPLY || "10000000"),
deployer.address // fee recipient
);
await token.deployed();
console.log("Token deployed to:", token.address);
// Wait for confirmations
console.log("Waiting for confirmations...");
await token.deployTransaction.wait(5);
// Deploy token sale
console.log("\n2. Deploying TokenSale...");
const currentTime = Math.floor(Date.now() / 1000);
const TokenSale = await ethers.getContractFactory("TokenSale");
const tokenSale = await TokenSale.deploy(
token.address,
1000, // 1000 tokens per OPN
ethers.utils.parseEther("0.1"), // min 0.1 OPN
ethers.utils.parseEther("10"), // max 10 OPN
currentTime + 300, // starts in 5 minutes
currentTime + 86400 * 7 // runs for 7 days
);
await tokenSale.deployed();
console.log("TokenSale deployed to:", tokenSale.address);
await tokenSale.deployTransaction.wait(5);
// Transfer tokens to sale
console.log("\n3. Transferring tokens to sale contract...");
const saleAllocation = ethers.utils.parseEther("5000000"); // 5M tokens
const transferTx = await token.transfer(tokenSale.address, saleAllocation);
await transferTx.wait();
console.log("Transferred", ethers.utils.formatEther(saleAllocation), "tokens to sale");
// Exclude sale contract from fees
console.log("\n4. Configuring fee exclusions...");
await token.setFeeExclusion(tokenSale.address, true);
console.log("Sale contract excluded from fees");
// Save deployment info
const deploymentInfo = {
network: "OPN Testnet",
chainId: 984,
deployer: deployer.address,
timestamp: new Date().toISOString(),
contracts: {
token: {
address: token.address,
name: await token.name(),
symbol: await token.symbol(),
decimals: await token.decimals(),
totalSupply: (await token.totalSupply()).toString(),
maxSupply: (await token.MAX_SUPPLY()).toString(),
transactionHash: token.deployTransaction.hash
},
tokenSale: {
address: tokenSale.address,
rate: 1000,
minPurchase: ethers.utils.parseEther("0.1").toString(),
maxPurchase: ethers.utils.parseEther("10").toString(),
saleStart: currentTime + 300,
saleEnd: currentTime + 86400 * 7,
tokensAllocated: saleAllocation.toString(),
transactionHash: tokenSale.deployTransaction.hash
}
}
};
fs.writeFileSync(
"deployment-info.json",
JSON.stringify(deploymentInfo, null, 2)
);
console.log("\n✅ Deployment complete!");
console.log("Deployment info saved to deployment-info.json");
// Display summary
console.log("\n📊 Deployment Summary:");
console.log("====================");
console.log("Token Address:", token.address);
console.log("Token Sale Address:", tokenSale.address);
console.log("Total Supply:", ethers.utils.formatEther(await token.totalSupply()), await token.symbol());
console.log("Sale Allocation:", ethers.utils.formatEther(saleAllocation), await token.symbol());
console.log("Sale Rate: 1 OPN =", 1000, await token.symbol());
console.log("\nSale starts in 5 minutes!");
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
Deploy everything:
npx hardhat run scripts/deploy-all.js --network opnTestnet
Step 7: Token Dashboard
Create Web Interface
Create dashboard.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Token Dashboard - OPN Chain</title>
<script src="https://cdn.jsdelivr.net/npm/web3@latest/dist/web3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
header {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.address {
font-family: monospace;
font-size: 12px;
background: #f3f4f6;
padding: 4px 8px;
border-radius: 4px;
}
.tabs {
display: flex;
border-bottom: 2px solid #e5e7eb;
margin-bottom: 20px;
}
.tab {
padding: 12px 24px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.3s;
}
.tab.active {
color: #2563eb;
border-bottom-color: #2563eb;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Token Dashboard</h1>
<p>Manage and monitor your ERC-20 token on OPN Chain</p>
<p class="address" id="walletAddress">Not connected</p>
</header>
<div class="stats-grid">
<div class="stat-card">
<h3>Total Supply</h3>
<div class="value" id="totalSupply">-</div>
<div class="sub">Maximum: <span id="maxSupply">-</span></div>
</div>
<div class="stat-card">
<h3>Your Balance</h3>
<div class="value" id="userBalance">-</div>
<div class="sub" id="userBalanceUSD">$0.00 USD</div>
</div>
<div class="stat-card">
<h3>Token Price</h3>
<div class="value" id="tokenPrice">-</div>
<div class="sub">1 OPN = <span id="tokenRate">-</span> tokens</div>
</div>
<div class="stat-card">
<h3>Sale Progress</h3>
<div class="value" id="saleProgress">-</div>
<div class="sub">Raised: <span id="totalRaised">-</span> OPN</div>
</div>
</div>
<div class="actions">
<div class="tabs">
<div class="tab active" onclick="switchTab('wallet')">Wallet</div>
<div class="tab" onclick="switchTab('sale')">Token Sale</div>
<div class="tab" onclick="switchTab('admin')">Admin</div>
</div>
<div id="wallet-tab" class="tab-content active">
<div class="action-group">
<h3>Transfer Tokens</h3>
<input type="text" id="transferTo" placeholder="Recipient Address (0x...)">
<input type="number" id="transferAmount" placeholder="Amount">
<button onclick="transferTokens()">Transfer</button>
</div>
<div class="action-group">
<h3>Burn Tokens</h3>
<input type="number" id="burnAmount" placeholder="Amount to burn">
<button onclick="burnTokens()" class="danger">Burn</button>
</div>
<div class="action-group">
<h3>Approve Spender</h3>
<input type="text" id="spenderAddress" placeholder="Spender Address">
<input type="number" id="approveAmount" placeholder="Amount">
<button onclick="approveSpender()">Approve</button>
</div>
</div>
<div id="sale-tab" class="tab-content">
<div class="action-group">
<h3>Buy Tokens</h3>
<p>Sale Status: <span id="saleStatus">-</span></p>
<p>Time Remaining: <span id="timeRemaining">-</span></p>
<input type="number" id="buyAmount" placeholder="OPN Amount" step="0.1" min="0.1" max="10">
<p>You will receive: <span id="receiveAmount">0</span> tokens</p>
<button onclick="buyTokens()">Buy Tokens</button>
</div>
<div class="action-group">
<h3>Your Purchase History</h3>
<p>Total Purchased: <span id="userPurchased">0</span> OPN</p>
<p>Tokens Received: <span id="tokensReceived">0</span></p>
</div>
</div>
<div id="admin-tab" class="tab-content">
<div class="action-group">
<h3>Mint Tokens (Owner Only)</h3>
<input type="text" id="mintTo" placeholder="Recipient Address">
<input type="number" id="mintAmount" placeholder="Amount">
<button onclick="mintTokens()" class="success">Mint</button>
</div>
<div class="action-group">
<h3>Pause/Unpause (Owner Only)</h3>
<p>Current Status: <span id="pauseStatus">-</span></p>
<button onclick="togglePause()" class="warning">Toggle Pause</button>
</div>
<div class="action-group">
<h3>Sale Management (Owner Only)</h3>
<button onclick="startSale()" class="success">Start Sale</button>
<button onclick="endSale()" class="warning">End Sale</button>
<button onclick="withdrawFunds()">Withdraw Funds</button>
</div>
</div>
</div>
<div id="status"></div>
<div class="chart-container">
<h3>Token Distribution</h3>
<canvas id="distributionChart"></canvas>
</div>
<div class="chart-container">
<h3>Transaction History</h3>
<div id="transactionList"></div>
</div>
</div>
<script>
// Configuration - Update these with your deployed addresses
const TOKEN_ADDRESS = 'YOUR_TOKEN_ADDRESS';
const SALE_ADDRESS = 'YOUR_SALE_ADDRESS';
// ABIs (simplified - add full ABIs from artifacts)
const TOKEN_ABI = [
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
"function balanceOf(address) view returns (uint256)",
"function transfer(address, uint256) returns (bool)",
"function approve(address, uint256) returns (bool)",
"function mint(address, uint256)",
"function burn(uint256)",
"function pause()",
"function unpause()",
"function paused() view returns (bool)",
"function owner() view returns (address)",
"function MAX_SUPPLY() view returns (uint256)",
"event Transfer(address indexed from, address indexed to, uint256 value)"
];
const SALE_ABI = [
"function buyTokens() payable",
"function rate() view returns (uint256)",
"function tokensSold() view returns (uint256)",
"function totalRaised() view returns (uint256)",
"function purchases(address) view returns (uint256)",
"function saleStart() view returns (uint256)",
"function saleEnd() view returns (uint256)",
"function status() view returns (uint8)",
"function startSale()",
"function endSale()",
"function withdrawFunds()",
"function getSaleInfo() view returns (uint256, uint256, uint256, uint256, uint256, uint8, uint256)"
];
let web3;
let tokenContract;
let saleContract;
let userAccount;
let isOwner = false;
// Initialize
async function init() {
if (typeof window.ethereum !== 'undefined') {
web3 = new Web3(window.ethereum);
try {
// Request account access
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
});
userAccount = accounts[0];
// Check network
const chainId = await web3.eth.getChainId();
if (chainId !== 984) {
showStatus('Please switch to OPN Testnet!', 'error');
return;
}
// Initialize contracts
tokenContract = new web3.eth.Contract(TOKEN_ABI, TOKEN_ADDRESS);
saleContract = new web3.eth.Contract(SALE_ABI, SALE_ADDRESS);
// Check if user is owner
const owner = await tokenContract.methods.owner().call();
isOwner = owner.toLowerCase() === userAccount.toLowerCase();
// Update UI
document.getElementById('walletAddress').textContent =
`Connected: ${userAccount.substring(0, 6)}...${userAccount.substring(38)}`;
// Load data
await loadTokenData();
await loadSaleData();
// Set up event listeners
tokenContract.events.Transfer()
.on('data', handleTransferEvent)
.on('error', console.error);
// Update data every 10 seconds
setInterval(async () => {
await loadTokenData();
await loadSaleData();
}, 10000);
} catch (error) {
console.error('Error:', error);
showStatus('Error connecting to MetaMask', 'error');
}
} else {
showStatus('Please install MetaMask!', 'error');
}
}
// Load token data
async function loadTokenData() {
try {
const [name, symbol, decimals, totalSupply, maxSupply, paused, userBalance] = await Promise.all([
tokenContract.methods.name().call(),
tokenContract.methods.symbol().call(),
tokenContract.methods.decimals().call(),
tokenContract.methods.totalSupply().call(),
tokenContract.methods.MAX_SUPPLY().call(),
tokenContract.methods.paused().call(),
tokenContract.methods.balanceOf(userAccount).call()
]);
// Update UI
document.getElementById('totalSupply').textContent =
formatTokenAmount(totalSupply, decimals) + ' ' + symbol;
document.getElementById('maxSupply').textContent =
formatTokenAmount(maxSupply, decimals) + ' ' + symbol;
document.getElementById('userBalance').textContent =
formatTokenAmount(userBalance, decimals) + ' ' + symbol;
document.getElementById('pauseStatus').textContent =
paused ? 'Paused' : 'Active';
// Update chart
updateDistributionChart(totalSupply, userBalance);
} catch (error) {
console.error('Error loading token data:', error);
}
}
// Load sale data
async function loadSaleData() {
try {
const saleInfo = await saleContract.methods.getSaleInfo().call();
const userPurchased = await saleContract.methods.purchases(userAccount).call();
const rate = saleInfo[0];
const tokensSold = saleInfo[1];
const totalRaised = saleInfo[2];
const saleStart = parseInt(saleInfo[3]);
const saleEnd = parseInt(saleInfo[4]);
const status = parseInt(saleInfo[5]);
// Update UI
document.getElementById('tokenRate').textContent = rate;
document.getElementById('tokenPrice').textContent =
(1 / rate).toFixed(6) + ' OPN';
document.getElementById('totalRaised').textContent =
web3.utils.fromWei(totalRaised, 'ether');
document.getElementById('userPurchased').textContent =
web3.utils.fromWei(userPurchased, 'ether');
document.getElementById('tokensReceived').textContent =
formatTokenAmount(
web3.utils.toBN(userPurchased).mul(web3.utils.toBN(rate)),
18
);
// Sale status
const statusText = ['Pending', 'Active', 'Completed', 'Cancelled'];
document.getElementById('saleStatus').textContent = statusText[status];
// Time remaining
updateTimeRemaining(saleEnd);
// Sale progress
const maxSale = await tokenContract.methods.balanceOf(SALE_ADDRESS).call();
const progress = (parseFloat(tokensSold) / parseFloat(maxSale) * 100).toFixed(2);
document.getElementById('saleProgress').textContent = progress + '%';
} catch (error) {
console.error('Error loading sale data:', error);
}
}
// Transfer tokens
async function transferTokens() {
const to = document.getElementById('transferTo').value;
const amount = document.getElementById('transferAmount').value;
if (!web3.utils.isAddress(to)) {
showStatus('Invalid recipient address', 'error');
return;
}
if (!amount || amount <= 0) {
showStatus('Invalid amount', 'error');
return;
}
try {
showStatus('Sending transaction...', 'info');
const decimals = await tokenContract.methods.decimals().call();
const value = web3.utils.toBN(amount).mul(web3.utils.toBN(10).pow(web3.utils.toBN(decimals)));
const tx = await tokenContract.methods.transfer(to, value).send({
from: userAccount,
gas: 100000
});
showStatus(`Transfer successful! Hash: ${tx.transactionHash}`, 'success');
// Clear form
document.getElementById('transferTo').value = '';
document.getElementById('transferAmount').value = '';
// Reload data
await loadTokenData();
} catch (error) {
console.error('Transfer error:', error);
showStatus('Transfer failed: ' + error.message, 'error');
}
}
// Buy tokens
async function buyTokens() {
const amount = document.getElementById('buyAmount').value;
if (!amount || amount <= 0) {
showStatus('Invalid amount', 'error');
return;
}
try {
showStatus('Processing purchase...', 'info');
const value = web3.utils.toWei(amount, 'ether');
const tx = await saleContract.methods.buyTokens().send({
from: userAccount,
value: value,
gas: 200000
});
showStatus(`Purchase successful! Hash: ${tx.transactionHash}`, 'success');
// Clear form
document.getElementById('buyAmount').value = '';
// Reload data
await loadTokenData();
await loadSaleData();
} catch (error) {
console.error('Purchase error:', error);
showStatus('Purchase failed: ' + error.message, 'error');
}
}
// Burn tokens
async function burnTokens() {
const amount = document.getElementById('burnAmount').value;
if (!amount || amount <= 0) {
showStatus('Invalid amount', 'error');
return;
}
if (!confirm(`Are you sure you want to burn ${amount} tokens? This cannot be undone!`)) {
return;
}
try {
showStatus('Burning tokens...', 'info');
const decimals = await tokenContract.methods.decimals().call();
const value = web3.utils.toBN(amount).mul(web3.utils.toBN(10).pow(web3.utils.toBN(decimals)));
const tx = await tokenContract.methods.burn(value).send({
from: userAccount,
gas: 100000
});
showStatus(`Burn successful! Hash: ${tx.transactionHash}`, 'success');
// Clear form
document.getElementById('burnAmount').value = '';
// Reload data
await loadTokenData();
} catch (error) {
console.error('Burn error:', error);
showStatus('Burn failed: ' + error.message, 'error');
}
}
// Mint tokens (owner only)
async function mintTokens() {
if (!isOwner) {
showStatus('Only owner can mint tokens', 'error');
return;
}
const to = document.getElementById('mintTo').value;
const amount = document.getElementById('mintAmount').value;
if (!web3.utils.isAddress(to)) {
showStatus('Invalid recipient address', 'error');
return;
}
if (!amount || amount <= 0) {
showStatus('Invalid amount', 'error');
return;
}
try {
showStatus('Minting tokens...', 'info');
const decimals = await tokenContract.methods.decimals().call();
const value = web3.utils.toBN(amount).mul(web3.utils.toBN(10).pow(web3.utils.toBN(decimals)));
const tx = await tokenContract.methods.mint(to, value).send({
from: userAccount,
gas: 150000
});
showStatus(`Mint successful! Hash: ${tx.transactionHash}`, 'success');
// Clear form
document.getElementById('mintTo').value = '';
document.getElementById('mintAmount').value = '';
// Reload data
await loadTokenData();
} catch (error) {
console.error('Mint error:', error);
showStatus('Mint failed: ' + error.message, 'error');
}
}
// Helper functions
function formatTokenAmount(amount, decimals) {
const divisor = web3.utils.toBN(10).pow(web3.utils.toBN(decimals));
const wholePart = web3.utils.toBN(amount).div(divisor).toString();
const fractionalPart = web3.utils.toBN(amount).mod(divisor).toString().padStart(decimals, '0');
// Format with commas
const formatted = wholePart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
// Trim trailing zeros from fractional part
const trimmed = fractionalPart.replace(/0+$/, '');
return trimmed.length > 0 ? `${formatted}.${trimmed}` : formatted;
}
function showStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = `show ${type}`;
setTimeout(() => {
status.classList.remove('show');
}, 5000);
}
function switchTab(tab) {
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(t => {
t.classList.remove('active');
});
document.querySelectorAll('.tab').forEach(t => {
t.classList.remove('active');
});
// Show selected tab
document.getElementById(`${tab}-tab`).classList.add('active');
event.target.classList.add('active');
}
function updateTimeRemaining(saleEnd) {
const now = Math.floor(Date.now() / 1000);
const remaining = saleEnd - now;
if (remaining <= 0) {
document.getElementById('timeRemaining').textContent = 'Sale ended';
return;
}
const days = Math.floor(remaining / 86400);
const hours = Math.floor((remaining % 86400) / 3600);
const minutes = Math.floor((remaining % 3600) / 60);
document.getElementById('timeRemaining').textContent =
`${days}d ${hours}h ${minutes}m`;
}
function handleTransferEvent(event) {
console.log('Transfer event:', event);
// You could add notifications or update UI here
}
let distributionChart;
function updateDistributionChart(totalSupply, userBalance) {
const ctx = document.getElementById('distributionChart').getContext('2d');
if (distributionChart) {
distributionChart.destroy();
}
distributionChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Your Balance', 'Others'],
datasets: [{
data: [
parseFloat(web3.utils.fromWei(userBalance, 'ether')),
parseFloat(web3.utils.fromWei(
web3.utils.toBN(totalSupply).sub(web3.utils.toBN(userBalance)),
'ether'
))
],
backgroundColor: ['#3b82f6', '#e5e7eb']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
}
// Calculate token amount when entering OPN amount
document.getElementById('buyAmount').addEventListener('input', async (e) => {
const amount = e.target.value;
if (amount && amount > 0) {
const rate = await saleContract.methods.rate().call();
const tokens = amount * rate;
document.getElementById('receiveAmount').textContent = tokens.toLocaleString();
} else {
document.getElementById('receiveAmount').textContent = '0';
}
});
// Initialize on load
window.addEventListener('load', init);
// Handle account changes
if (window.ethereum) {
window.ethereum.on('accountsChanged', (accounts) => {
window.location.reload();
});
window.ethereum.on('chainChanged', (chainId) => {
window.location.reload();
});
}
</script>
</body>
</html>
Step 8: Production Considerations
Security Checklist
Before deploying to mainnet:
[ ] Audit smart contracts
[ ] Test all functions thoroughly
[ ] Implement multi-sig for owner
[ ] Add time locks for critical functions
[ ] Consider upgradeable proxy pattern
[ ] Test emergency pause scenarios
[ ] Verify all math operations
[ ] Check for reentrancy vulnerabilities
Gas Optimizations
// Optimized token contract
contract GasOptimizedToken is ERC20 {
// Pack struct variables
struct UserInfo {
uint128 balance;
uint128 lastTransferTime;
}
// Use mappings efficiently
mapping(address => UserInfo) private userInfo;
// Batch operations
function batchTransfer(
address[] calldata recipients,
uint256[] calldata amounts
) external {
require(recipients.length == amounts.length, "Length mismatch");
for (uint i = 0; i < recipients.length; i++) {
_transfer(msg.sender, recipients[i], amounts[i]);
}
}
}
Monitoring Setup
// Monitor contract events
const monitorToken = async () => {
// Transfer events
tokenContract.events.Transfer({
fromBlock: 'latest'
})
.on('data', async (event) => {
console.log('Transfer:', {
from: event.returnValues.from,
to: event.returnValues.to,
amount: web3.utils.fromWei(event.returnValues.value, 'ether'),
txHash: event.transactionHash
});
// Send notifications, update database, etc.
});
// Monitor large transfers
tokenContract.events.Transfer({
filter: {
value: web3.utils.toWei('10000', 'ether') // Large transfers
}
})
.on('data', (event) => {
// Alert for large transfers
sendAlert('Large transfer detected', event);
});
};
Conclusion
Congratulations! You've successfully:
✅ Created a feature-rich ERC-20 token
✅ Implemented advanced features (minting, burning, pausing)
✅ Built a token sale contract
✅ Created a management dashboard
✅ Learned security best practices
✅ Deployed to OPN testnet
Resources
Get Help
Join our Discord
Last updated