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

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

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

  1. Minting: Only owner can create new tokens up to max supply

  2. Burning: Anyone can destroy their own tokens

  3. Pausable: Owner can pause all transfers in emergencies

  4. Transaction Fees: 0.25% fee on transfers (configurable exclusions)

  5. 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


Last updated