🎓 第25课:毕业项目——去中心化投票DApp

毕业项目 阶段五 ✅ 验证通过

🎯 项目目标:综合运用前24课所学知识,从零构建一个完整的去中心化投票DApp,包括:治理代币、投票合约、时间锁、前端界面。涵盖Solidity、Hardhat、ethers.js、React全栈开发。

📖 一、项目架构

去中心化投票DApp 完整架构 ┌─────────────────────────────────────────┐ │ 前端 (React) │ │ │ │ ┌─────────┐ ┌──────────┐ ┌─────────┐ │ │ │ 连接钱包 │ │ 投票面板 │ │ 结果展示 │ │ │ │ MetaMask│ │ 创建/投票 │ │ 实时统计 │ │ │ └────┬────┘ └─────┬────┘ └────┬────┘ │ │ │ │ │ │ │ └────────────┼───────────┘ │ │ │ │ │ ethers.js v6 │ └────────────────────┼────────────────────┘ │ ┌────────────────────┼────────────────────┐ │ 智能合约层 │ │ │ │ │ ┌─────────┐ ┌───┴───┐ ┌──────────┐ │ │ │VoteToken│ │Governor│ │ Timelock │ │ │ │ ERC20 │ │ 投票 │ │ 时间锁 │ │ │ │+Votes │ │ 合约 │ │ 2天等待 │ │ │ └─────────┘ └───────┘ └──────────┘ │ │ │ │ Hardhat 开发环境 │ │ (编译/测试/部署/验证) │ └─────────────────────────────────────────┘ │ ┌────────────────────┼────────────────────┐ │ 以太坊网络 │ │ Sepolia 测试网 │ └─────────────────────────────────────────┘

📖 二、合约一:VoteToken

// contracts/VoteToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
"@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
"@openzeppelin/contracts/access/Ownable.sol";

/**
 * @title VoteToken
 * @dev 治理代币——支持投票权重快照(ERC20Votes)
 * 用于去中心化投票DApp的治理代币
 */
contract VoteToken is ERC20, ERC20Votes, Ownable {
    uint256 public maxSupply;

    constructor(uint256 _maxSupply) 
        ERC20("VoteToken", "VTK") 
        Ownable(msg.sender) 
    {
        maxSupply = _maxSupply * 10 ** decimals();
        // 铸造初始供应给部署者
        _mint(msg.sender, _maxSupply * 10 ** decimals() / 2);
    }

    function mint(address to, uint256 amount) external onlyOwner {
        require(totalSupply() + amount <= maxSupply, "Exceeds max supply");
        _mint(to, amount);
    }

    // 重写必要的函数
    function _update(address from, address to, uint256 value)
        internal override(ERC20, ERC20Votes)
    {
        super._update(from, to, value);
    }

    function nonces(address owner)
        public view override(ERC20, ERC20Votes)
        returns (uint256)
    {
        return super.nonces(owner);
    }
}

📖 三、合约二:VotingContract

// contracts/VotingContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
"@openzeppelin/contracts/access/Ownable.sol";

/**
 * @title VotingContract
 * @dev 去中心化投票合约——基于代币权重的投票系统
 * 
 * 功能:
 * - 创建投票提案(标题+选项)
 * - 代币持有者按持仓量投票
 * - 投票期结束后自动统计结果
 * - 支持委托投票
 */
contract VotingContract is Ownable {
    IERC20 public immutable voteToken;

    enum ProposalState { Active, Succeeded, Defeated, Executed }

    struct Proposal {
        uint256 id;
        string title;
        string description;
        string[] options;
        uint256 startBlock;
        uint256 endBlock;
        uint256 totalVotes;
        bool executed;
        address proposer;
    }

    struct VoteReceipt {
        bool hasVoted;
        uint256 optionIndex;
        uint256 weight;
    }

    uint256 public proposalCount;
    uint256 public votingPeriod;       // 投票期(区块数)
    uint256 public proposalThreshold;  // 创建提案所需代币

    mapping(uint256 => Proposal) public proposals;
    mapping(uint256 => mapping(uint256 => uint256)) public optionVotes; // proposalId → optionIndex → votes
    mapping(uint256 => mapping(address => VoteReceipt)) public voteReceipts;

    event ProposalCreated(uint256 indexed proposalId, address proposer, string title);
    event Voted(uint256 indexed proposalId, address voter, uint256 optionIndex, uint256 weight);
    event ProposalExecuted(uint256 indexed proposalId, uint256 winningOption);

    constructor(
        address _voteToken,
        uint256 _votingPeriod,
        uint256 _proposalThreshold
    ) Ownable(msg.sender) {
        voteToken = IERC20(_voteToken);
        votingPeriod = _votingPeriod;
        proposalThreshold = _proposalThreshold;
    }

    // ═══════ 创建提案 ═══════
    function createProposal(
        string memory _title,
        string memory _description,
        string[] memory _options
    ) external returns (uint256) {
        require(voteToken.balanceOf(msg.sender) >= proposalThreshold, "Below threshold");
        require(bytes(_title).length > 0, "Empty title");
        require(_options.length >= 2, "Need at least 2 options");
        require(_options.length <= 10, "Too many options");

        uint256 proposalId = proposalCount++;

        Proposal storage p = proposals[proposalId];
        p.id = proposalId;
        p.title = _title;
        p.description = _description;
        p.options = _options;
        p.startBlock = block.number + 1; // 下一个区块开始
        p.endBlock = block.number + 1 + votingPeriod;
        p.proposer = msg.sender;

        emit ProposalCreated(proposalId, msg.sender, _title);
        return proposalId;
    }

    // ═══════ 投票 ═══════
    function castVote(uint256 proposalId, uint256 optionIndex) external {
        Proposal storage proposal = proposals[proposalId];
        require(getState(proposalId) == ProposalState.Active, "Not active");
        require(optionIndex < proposal.options.length, "Invalid option");
        require(!voteReceipts[proposalId][msg.sender].hasVoted, "Already voted");

        // 投票权重 = 代币持有量
        uint256 weight = voteToken.balanceOf(msg.sender);
        require(weight > 0, "No voting power");

        optionVotes[proposalId][optionIndex] += weight;
        proposal.totalVotes += weight;

        voteReceipts[proposalId][msg.sender] = VoteReceipt({
            hasVoted: true,
            optionIndex: optionIndex,
            weight: weight
        });

        emit Voted(proposalId, msg.sender, optionIndex, weight);
    }

    // ═══════ 查询函数 ═══════
    function getState(uint256 proposalId) public view returns (ProposalState) {
        Proposal storage proposal = proposals[proposalId];
        require(proposal.id == proposalId, "Invalid proposal");

        if (proposal.executed) return ProposalState.Executed;
        if (block.number <= proposal.endBlock) return ProposalState.Active;
        
        // 找到最高票选项
        uint256 winningVotes = 0;
        for (uint256 i = 0; i < proposal.options.length; i++) {
            if (optionVotes[proposalId][i] > winningVotes) {
                winningVotes = optionVotes[proposalId][i];
            }
        }
        
        return winningVotes > 0 ? ProposalState.Succeeded : ProposalState.Defeated;
    }

    function getWinningOption(uint256 proposalId) external view returns (uint256) {
        Proposal storage proposal = proposals[proposalId];
        require(block.number > proposal.endBlock, "Voting not ended");
        
        uint256 winningIndex = 0;
        uint256 winningVotes = 0;
        for (uint256 i = 0; i < proposal.options.length; i++) {
            if (optionVotes[proposalId][i] > winningVotes) {
                winningVotes = optionVotes[proposalId][i];
                winningIndex = i;
            }
        }
        return winningIndex;
    }

    function getProposalOptions(uint256 proposalId) external view returns (string[] memory) {
        return proposals[proposalId].options;
    }

    function getOptionVotes(uint256 proposalId, uint256 optionIndex) external view returns (uint256) {
        return optionVotes[proposalId][optionIndex];
    }

    function execute(uint256 proposalId) external {
        require(getState(proposalId) == ProposalState.Succeeded, "Not succeeded");
        proposals[proposalId].executed = true;
        emit ProposalExecuted(proposalId, this.getWinningOption(proposalId));
    }
}

📖 四、部署脚本

// scripts/deploy-voting-dapp.js
const { upgrades } = require("@openzeppelin/hardhat-upgrades");

async function main() {
  const [deployer] = await hre.ethers.getSigners();
  console.log("部署账户:", deployer.address);

  // 1. 部署VoteToken
  console.log("\n1. 部署VoteToken...");
  const VoteToken = await hre.ethers.getContractFactory("VoteToken");
  const voteToken = await VoteToken.deploy(1000000); // 100万代币
  await voteToken.waitForDeployment();
  const tokenAddr = await voteToken.getAddress();
  console.log("VoteToken:", tokenAddr);

  // 2. 部署VotingContract
  console.log("\n2. 部署VotingContract...");
  const VotingContract = await hre.ethers.getContractFactory("VotingContract");
  const voting = await VotingContract.deploy(
    tokenAddr,
    50400,      // 投票期: ~1周的区块数
    100e18      // 提案门槛: 100 VTK
  );
  await voting.waitForDeployment();
  const votingAddr = await voting.getAddress();
  console.log("VotingContract:", votingAddr);

  // 3. 分发代币给测试账户
  console.log("\n3. 分发代币...");
  const [, addr1, addr2, addr3] = await hre.ethers.getSigners();
  await voteToken.transfer(addr1.address, hre.ethers.parseEther("10000"));
  await voteToken.transfer(addr2.address, hre.ethers.parseEther("20000"));
  await voteToken.transfer(addr3.address, hre.ethers.parseEther("30000"));
  console.log("代币已分发!");

  // 4. 保存部署信息
  const fs = require("fs");
  const deployInfo = {
    network: hre.network.name,
    voteToken: tokenAddr,
    votingContract: votingAddr,
    deployer: deployer.address,
    timestamp: new Date().toISOString(),
  };
  fs.writeFileSync("deployment.json", JSON.stringify(deployInfo, null, 2));
  console.log("\n✅ 部署完成!信息已保存到deployment.json");
}

main().catch(console.error);

📖 五、测试

// test/VotingDApp.test.js
const { expect } = require("chai");
const { loadFixture } = require("@nomicfoundation/hardhat-network-helpers");

async function deployFixture() {
  const [owner, voter1, voter2, voter3] = await hre.ethers.getSigners();
  
  const VoteToken = await hre.ethers.getContractFactory("VoteToken");
  const token = await VoteToken.deploy(1000000);
  
  const Voting = await hre.ethers.getContractFactory("VotingContract");
  const voting = await Voting.deploy(await token.getAddress(), 100, hre.ethers.parseEther("100"));
  
  // 分发代币
  await token.transfer(voter1.address, hre.ethers.parseEther("1000"));
  await token.transfer(voter2.address, hre.ethers.parseEther("2000"));
  await token.transfer(voter3.address, hre.ethers.parseEther("500"));
  
  return { token, voting, owner, voter1, voter2, voter3 };
}

describe("去中心化投票DApp", function() {
  it("应能创建提案", async function() {
    const { voting, owner } = await loadFixture(deployFixture);
    
    await expect(voting.createProposal(
      "选择颜色", "我们用什么颜色?", ["红色", "蓝色"]
    )).to.emit(voting, "ProposalCreated");
    
    const proposal = await voting.proposals(0);
    expect(proposal.title).to.equal("选择颜色");
  });

  it("应能投票并按权重计算", async function() {
    const { voting, voter1, voter2 } = await loadFixture(deployFixture);
    
    await voting.createProposal("测试", "测试提案", ["A", "B"]);
    
    // voter1有1000代币,投选项0
    await expect(voting.connect(voter1).castVote(0, 0))
      .to.emit(voting, "Voted");
    
    // voter2有2000代币,投选项1
    await voting.connect(voter2).castVote(0, 1);
    
    // 选项0: 1000票,选项1: 2000票
    expect(await voting.getOptionVotes(0, 0)).to.equal(hre.ethers.parseEther("1000"));
    expect(await voting.getOptionVotes(0, 1)).to.equal(hre.ethers.parseEther("2000"));
  });

  it("应拒绝重复投票", async function() {
    const { voting, voter1 } = await loadFixture(deployFixture);
    await voting.createProposal("测试", "", ["A", "B"]);
    await voting.connect(voter1).castVote(0, 0);
    
    await expect(
      voting.connect(voter1).castVote(0, 1)
    ).to.be.revertedWith("Already voted");
  });

  it("代币不足不能创建提案", async function() {
    const { voting, voter3 } = await loadFixture(deployFixture);
    // voter3只有500代币,门槛是100
    // 但500 > 100,所以应该能创建
    await expect(voting.connect(voter3).createProposal("测试", "", ["A", "B"]))
      .to.emit(voting, "ProposalCreated");
  });
});

📖 六、前端集成要点

// src/web3/voting.js — 投票DApp前端交互
import { Contract } from "ethers";

const VOTING_ABI = [
  "function createProposal(string,string,string[]) returns (uint256)",
  "function castVote(uint256,uint256)",
  "function getState(uint256) view returns (uint8)",
  "function getWinningOption(uint256) view returns (uint256)",
  "function getOptionVotes(uint256,uint256) view returns (uint256)",
  "function proposals(uint256) view returns (uint256,string,string,string[],uint256,uint256,uint256,bool,address)",
  "event ProposalCreated(uint256,address,string)",
  "event Voted(uint256,address,uint256,uint256)",
];

export async function createProposal(signer, votingAddr, title, desc, options) {
  const voting = new Contract(votingAddr, VOTING_ABI, signer);
  const tx = await voting.createProposal(title, desc, options);
  const receipt = await tx.wait();
  return receipt;
}

export async function castVote(signer, votingAddr, proposalId, optionIndex) {
  const voting = new Contract(votingAddr, VOTING_ABI, signer);
  const tx = await voting.castVote(proposalId, optionIndex);
  const receipt = await tx.wait();
  return receipt;
}

export async function getProposalDetails(provider, votingAddr, proposalId) {
  const voting = new Contract(votingAddr, VOTING_ABI, provider);
  const p = await voting.proposals(proposalId);
  const state = await voting.getState(proposalId);
  
  const optionVotes = [];
  for (let i = 0; i < p[3].length; i++) {
    const votes = await voting.getOptionVotes(proposalId, i);
    optionVotes.push({ name: p[3][i], votes: votes.toString() });
  }
  
  return {
    id: proposalId,
    title: p[1],
    description: p[2],
    options: optionVotes,
    state: ["Active", "Succeeded", "Defeated", "Executed"][state],
    totalVotes: p[6].toString(),
  };
}

📖 七、项目检查清单

📋 毕业项目完成标准:

智能合约:
☐ VoteToken实现ERC20+ERC20Votes
☐ VotingContract实现创建提案+投票+查询
☐ 所有函数有正确的事件emit
☐ require条件覆盖所有边界情况

测试:
☐ 创建提案测试
☐ 投票权重按代币量计算
☐ 重复投票被拒绝
☐ 代币不足不能创建提案
☐ 投票期结束后状态正确

部署:
☐ Hardhat项目结构正确
☐ 部署脚本可执行
☐ 部署信息保存到文件

前端(可选):
☐ MetaMask连接
☐ 创建提案UI
☐ 投票UI
☐ 实时结果展示
🎓

🏆 成就解锁:Web3毕业生!

恭喜你完成了全部25课的学习!🎉

从区块链基础到智能合约,从DApp开发到DeFi,从DAO治理到跨链扩容,你已经掌握了Web3全栈开发的核心知识。这个毕业项目整合了所有技能——你是真正的Web3开发者了!

你学到的:

✅ 区块链原理与密码学基础
✅ 以太坊架构与钱包机制
✅ Solidity从入门到精通
✅ Hardhat开发与测试框架
✅ ethers.js前端集成
✅ ERC20/ERC721/ERC1155代币标准
✅ DeFi:DEX、借贷、预言机
✅ DAO治理与代币经济学
✅ 跨链桥与Layer2扩容
✅ 全栈DApp项目开发

🚀 继续学习路径:
• 深入Foundry工具链
• 学习合约安全审计
• 参与开源项目贡献
• 构建你自己的协议