☰
基于以太坊的Dapp众筹项目实战:Solidity合约、Hardhat测试与React前端完整闭环
2026/11/1 1:13:12 网站建设 项目流程

简介:这是一份面向计算机相关专业学生、教师及企业员工的以太坊Dapp众筹项目完整资料,可用于毕业设计、课程设计、大作业或项目初期演示,也适合区块链初学者进阶学习。项目以智能合约为核心,串联前端界面、后端服务与代币众筹流程,帮助读者理解去中心化融资从理论到落地的完整路径。压缩包共37个文件,约34.86MB,包含14个js脚本、6个zbak备份、4个xml配置、3个json数据文件,以及Solidity合约、HTML页面、Markdown说明、xmind思维导图和PDF设计文档等,覆盖编译部署脚本、合约源码与项目结构说明。已有56人学习。资料提供系统架构、代码结构、数据库与接口设计等文档,并附本地编译运行说明,便于读者快速跑通项目、理解资金分配与代币发行逻辑,并在此基础上修改扩展功能。

1. 基于以太坊的 Dapp 众筹项目:从合约到前端,一个能跑通的完整闭环

很多人第一次听到「基于以太坊的 Dapp 众筹项目」,脑子里浮现的是白皮书里那种宏大叙事,但真正动手时卡住的地方往往特别具体:合约里contribute函数写完了,前端window.ethereum却连不上;测试网代币领了一堆,approve和transferFrom的授权顺序又搞反了。这个标题讲的不是概念,而是一套能落地的技术组合——用 Solidity 写众筹合约,用 Hardhat 做本地链和测试,用 React 加 ethers.js 把合约接到浏览器里,最后跑出一个「发起项目、投入 ETH、达标提款、未达标退款」的完整闭环。它适合两类人:一类是刚学完 Solidity 语法、想找一个有状态流转的真实项目练手的开发者;另一类是需要一套可演示、可复现的 Dapp 样板,用来做课程设计或技术验证的工程师。下面我按自己实际搭过一遍的顺序,把选型理由、关键参数和踩过的坑讲清楚。

2. 合约层怎么设计:状态机、资金流转与三个必调参数

众筹合约看着简单,但它是整个 Dapp 的地基。地基没打好,前端再怎么调都是白费。这一章先把合约的状态流转讲透,再落到具体代码。

2.1 众筹合约的状态机:为什么必须有 deadline 和 goal 两个硬约束

一个众筹项目在链上其实就是一个状态机,核心状态只有三个:募资中、成功、失败。驱动状态切换的条件有两个——截止时间deadline和募资目标goal。这两个参数不是随便填的,它们决定了资金能不能动、什么时候动。

常见做法是把deadline设成部署时的时间戳加上一个固定周期,比如 30 天。goal则根据项目实际需要设定,单位是 wei。这里有个新手最容易忽略的点:Solidity 里没有「自动触发」,状态不会自己从「募资中」跳到「成功」。你必须显式调用一个检查函数,或者在提款函数里用require把条件卡死。我一般会在提款函数里直接判断block.timestamp >= deadline && address(this).balance >= goal,两个条件同时满足才允许发起人提款。这样即使没人主动查状态,资金安全也由合约逻辑兜底。

另一个设计决策是:捐款记录要不要单独存。如果只是展示总额,一个mapping(address => uint256)就够了;但如果要支持「按捐款额分配权益」或者「退款时精确到人」,就必须在contribute时把每笔记录写进数组或映射。我倾向于两者都留:一个mapping记每人累计额,一个数组记每笔明细,方便前端做列表展示。

2.2 用 Hardhat 初始化项目并写第一版合约

先建目录、装依赖。Hardhat 是目前本地开发以太坊合约最顺手的工具链,自带本地节点、编译、测试和部署脚本。

mkdir eth-crowdfund && cd eth-crowdfund npm init -y npm install --save-dev hardhat npx hardhat init # 选择 "Create a JavaScript project" npm install --save-dev @nomicfoundation/hardhat-toolbox

初始化完成后,把contracts/下的示例合约删掉,新建Crowdfund.sol:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract Crowdfund { address public creator; uint256 public goal; // 募资目标,单位 wei uint256 public deadline; // 截止时间戳 uint256 public raised; // 已募资金额 bool public withdrawn; // 防止重复提款 mapping(address => uint256) public contributions; address[] public contributors; event Contributed(address indexed backer, uint256 amount); event Withdrawn(address indexed creator, uint256 amount); event Refunded(address indexed backer, uint256 amount); constructor(uint256 _goalInEther, uint256 _durationInDays) { creator = msg.sender; goal = _goalInEther * 1 ether; deadline = block.timestamp + (_durationInDays * 1 days); } function contribute() external payable { require(block.timestamp < deadline, "campaign ended"); require(msg.value > 0, "zero contribution"); if (contributions[msg.sender] == 0) { contributors.push(msg.sender); } contributions[msg.sender] += msg.value; raised += msg.value; emit Contributed(msg.sender, msg.value); } function withdraw() external { require(msg.sender == creator, "not creator"); require(block.timestamp >= deadline, "not ended"); require(raised >= goal, "goal not met"); require(!withdrawn, "already withdrawn"); withdrawn = true; (bool ok, ) = creator.call{value: address(this).balance}(""); require(ok, "transfer failed"); emit Withdrawn(creator, address(this).balance); } function refund() external { require(block.timestamp >= deadline, "not ended"); require(raised < goal, "goal met, no refund"); uint256 amount = contributions[msg.sender]; require(amount > 0, "nothing to refund"); contributions[msg.sender] = 0; (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "refund failed"); emit Refunded(msg.sender, amount); } }

逻辑说明:构造函数接收两个参数,_goalInEther是人类可读的以太数量,内部乘以1 ether转成 wei;_durationInDays乘以1 days得到秒数。contribute里先判断是否在截止前,再把捐款人加入contributors数组(只在首次捐款时加入,避免重复)。withdraw用withdrawn布尔值做重入保护,同时用call而不是transfer来发送资金,这是 Solidity 0.8 之后的推荐写法。refund只允许在未达标时调用,退款前先把该用户的contributions清零,防止重复退款。

参数说明:goal建议在测试时设成 1 到 5 ETH,方便快速达标;deadline的_durationInDays在本地测试时设成 1 天甚至更短,但注意 Hardhat 本地链的时间戳可以手动推进,后面会讲。

2.3 编译、测试与本地部署:三个必调参数和一条时间推进命令

合约写完后先编译:

npx hardhat compile

编译通过后,在test/下写一个最小测试,验证捐款和提款流程:

const { expect } = require("chai"); const { ethers } = require("hardhat"); describe("Crowdfund", function () { let crowdfund, creator, backer; beforeEach(async function () { [creator, backer] = await ethers.getSigners(); const Factory = await ethers.getContractFactory("Crowdfund"); crowdfund = await Factory.deploy(2, 1); // 目标 2 ETH,持续 1 天 await crowdfund.waitForDeployment(); }); it("should accept contributions and allow withdraw after goal met", async function () { await crowdfund.connect(backer).contribute({ value: ethers.parseEther("2") }); expect(await crowdfund.raised()).to.equal(ethers.parseEther("2")); // 推进本地链时间 1 天 await ethers.provider.send("evm_increaseTime", [86400]); await ethers.provider.send("evm_mine"); await expect(crowdfund.connect(creator).withdraw()) .to.emit(crowdfund, "Withdrawn"); }); });

这里三个必调参数分别是:部署时的goal、duration,以及测试里evm_increaseTime的秒数。evm_increaseTime是 Hardhat 本地链的「后悔药」,不用真的等一天。跑测试:

npx hardhat test

部署到本地节点:

npx hardhat node # 另开终端 npx hardhat run scripts/deploy.js --network localhost

部署脚本里记得把合约地址打印出来,前端要用。到这一步,合约层就算跑通了。

3. 前端怎么接:ethers.js 连接钱包与合约调用的四个关键点

合约在本地跑通只是第一步,Dapp 的「D」要体现在浏览器里。这一章讲前端怎么用 ethers.js 把合约接起来,以及连接钱包时最容易翻车的几个地方。

3.1 用 ethers.js v6 连接 MetaMask 并读取合约状态

前端我一般用 React 加 Vite 起项目,依赖装ethers和react。核心逻辑是:检测window.ethereum,请求账户授权,然后用BrowserProvider和合约 ABI 构造合约实例。

import { BrowserProvider, Contract, formatEther } from "ethers"; import CrowdfundABI from "./Crowdfund.json"; const CONTRACT_ADDRESS = "0x你的合约地址"; export async function connectWallet() { if (!window.ethereum) { throw new Error("请先安装 MetaMask"); } const provider = new BrowserProvider(window.ethereum); const accounts = await provider.send("eth_requestAccounts", []); const signer = await provider.getSigner(); const contract = new Contract(CONTRACT_ADDRESS, CrowdfundABI.abi, signer); return { provider, signer, contract, account: accounts[0] }; } export async function getCampaignInfo(contract) { const [goal, raised, deadline] = await Promise.all([ contract.goal(), contract.raised(), contract.deadline(), ]); return { goal: formatEther(goal), raised: formatEther(raised), deadline: Number(deadline), }; }

逻辑说明:BrowserProvider是 ethers v6 里连接浏览器钱包的入口,eth_requestAccounts会弹出 MetaMask 授权窗口。拿到signer后构造的合约实例才有写权限,只读的话用provider就够了。formatEther把 wei 转成人类可读的 ETH 字符串,前端展示必须转,否则用户看到一长串数字会懵。

参数说明:CONTRACT_ADDRESS必须和部署脚本输出的一致;ABI 从 Hardhat 编译产物artifacts/contracts/Crowdfund.sol/Crowdfund.json里复制abi字段。注意 ethers v6 和 v5 的 API 差别很大,网上很多教程还是 v5 的写法,ethers.providers.Web3Provider在 v6 里已经改成了BrowserProvider,照抄旧教程会直接报错。

3.2 捐款与退款的前端交互:gas 估算和错误处理

捐款按钮的核心是调用contribute并附带value:

export async function contribute(contract, amountInEther) { try { const tx = await contract.contribute({ value: ethers.parseEther(amountInEther), }); const receipt = await tx.wait(); return receipt; } catch (err) { if (err.code === "ACTION_REJECTED") { throw new Error("用户取消了交易"); } if (err.message.includes("campaign ended")) { throw new Error("众筹已结束"); } throw err; } }

逻辑说明:parseEther把用户输入的 ETH 数量转成 wei,这是合约能识别的单位。tx.wait()等待交易被打包,返回收据。错误处理里ACTION_REJECTED是 MetaMask 用户主动取消时的错误码,这个必须单独处理,否则用户点了取消却看到一堆乱码报错,体验很差。

参数说明:amountInEther是字符串类型,比如"0.5"。不要传数字类型,浮点数在 JavaScript 里有精度问题,parseEther接收字符串更安全。gas 一般不用手动设,ethers 会自动估算,但如果合约逻辑复杂导致估算失败,可以手动传gasLimit,不过这是下策,优先检查合约是不是有require没通过。

3.3 监听合约事件刷新 UI:为什么轮询不如事件订阅

众筹页面需要实时显示「谁捐了多少」。最笨的办法是setInterval轮询,但这样既费资源又有延迟。更好的做法是监听合约事件:

export function listenToContributions(contract, onNewContribution) { contract.on("Contributed", (backer, amount) => { onNewContribution({ backer, amount: formatEther(amount), }); }); return () => { contract.off("Contributed"); }; }

逻辑说明:contract.on会订阅链上事件,一旦有新的Contributed触发,回调立刻执行。返回的清理函数在组件卸载时调用contract.off,防止内存泄漏。这个模式比轮询优雅得多,也是 Dapp 前端区别于传统 Web 前端的典型写法。

参数说明:事件回调的参数顺序必须和合约里event Contributed(address indexed backer, uint256 amount)的声明一致。indexed修饰的backer可以被过滤,如果只想监听某个地址的捐款,可以传contract.filters.Contributed(someAddress)。

4. 避坑与排查:五个让项目卡住的真实问题

这一章是我自己搭的时候踩过的坑,每一个都卡了不止半小时。按「现象 → 原因 → 解决」写,方便对照排查。

4.1 合约部署后前端读不到数据,报 call revert exception

现象:前端调用contract.goal()直接抛call revert exception,但合约明明部署成功了。

原因:最常见的是合约地址填错,或者 ABI 用的是旧版本编译产物。另一个可能是前端连的链和合约部署的链不一致——比如合约部署在 Hardhat localhost(chainId 31337),但 MetaMask 当前连的是以太坊主网。

解决:先确认 MetaMask 网络切换到 Localhost 31337,再核对CONTRACT_ADDRESS和部署脚本输出是否一致。ABI 重新从artifacts目录复制一次,不要手动改。

4.2 捐款交易一直 pending,gas 费显示异常

现象:点捐款后 MetaMask 弹出确认,但交易一直 pending,gas 费显示得特别高或特别低。

原因:Hardhat 本地节点的默认 gasPrice 是 0,但 MetaMask 可能还在按主网逻辑估算。另外如果本地节点没启动,交易根本发不出去。

解决:确认npx hardhat node在运行。在 MetaMask 里把网络 gasPrice 手动设成 0 或者直接用以太坊测试网(如 Sepolia)来跑。本地开发时我一般会在hardhat.config.js里把localhost网络的gasPrice设成"auto",让 Hardhat 自己处理。

4.3 提款时报 "goal not met",但明明已经达标

现象:募资金额已经超过goal,但发起人提款时合约报goal not met。

原因:raised变量没有在contribute里正确累加,或者goal的单位搞错了。比如部署时传了2,以为代表 2 ETH,但合约里乘了1 ether之后是 2 ETH,而测试时只捐了 1 ETH,自然不达标。

解决:在前端加一个raised / goal的进度条,实时显示当前比例。部署参数统一用「以太数量」作为输入,合约内部转换,避免单位混乱。

4.4 退款函数调用后余额没变,交易却成功了

现象:未达标时调用refund,交易成功,但用户余额没增加。

原因:contributions[msg.sender]在退款前已经被清零,或者用户根本没有捐过款。另一种可能是call发送失败但没检查返回值。

解决:退款前先require(amount > 0),并且用call的返回值做检查。如果用户确实捐过款但余额没变,检查是不是退到了合约而不是用户地址——msg.sender在refund里是调用者,没问题,但如果是通过代理合约调用就要注意。

4.5 前端事件监听重复触发,列表出现重复数据

现象:每捐一次款,前端列表就多出两条甚至更多相同记录。

原因:React 的useEffect在开发模式下会执行两次(StrictMode),导致contract.on被注册了两次。

解决:在useEffect的清理函数里调用contract.off,并且确保依赖数组正确。如果还是重复,可以在注册前先contract.removeAllListeners("Contributed")清一遍。

5. 进阶技巧:用测试网验证完整流程与三个验证习惯

本地跑通之后,我强烈建议把合约部署到以太坊测试网(比如 Sepolia)走一遍完整流程。本地链和真实网络最大的差别在于:交易需要真实等待、gas 需要真实支付、MetaMask 的网络切换和账户余额都会影响体验。这一步能暴露很多本地测试发现不了的问题。

部署到 Sepolia 的配置:

// hardhat.config.js require("@nomicfoundation/hardhat-toolbox"); module.exports = { solidity: "0.8.20", networks: { sepolia: { url: "https://sepolia.infura.io/v3/你的项目ID", accounts: ["你的测试网私钥"], }, }, };

部署命令:

npx hardhat run scripts/deploy.js --network sepolia

参数说明:url用 Infura 或 Alchemy 的 Sepolia 节点地址,accounts里填测试网钱包私钥。注意私钥不要提交到 Git,用.env文件加dotenv管理。部署完成后,把合约地址更新到前端,MetaMask 切换到 Sepolia,重新走一遍捐款、等待截止、提款或退款的流程。

三个验证习惯:第一,每次改完合约先跑npx hardhat test,测试通过再动前端;第二,前端每次连接钱包后先打印chainId和合约地址,确认环境对得上;第三,提款和退款这类涉及资金的函数,先在测试网用最小金额跑一遍,确认无误再考虑主网。我自己就吃过亏——本地测试全绿,一上测试网发现deadline设成了 30 天,根本等不到提款那一步,最后只能重新部署一个短周期的合约来演示。所以参数一定要在部署前反复确认,链上可没有后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询