简介:这是一份基于以太坊的 DApp 众筹项目完整毕业设计/课程设计资料包,面向区块链方向在校生、毕业设计选题者及希望快速上手 Solidity 与 Web3 开发的学习者,解决从智能合约编写到前端交互演示的落地难题。资源共 31 个文件,包含 14 个 JS 脚本(编译、部署、交互等流程控制)、1 个 Solidity 合约源文件、3 个 JSON 配置、2 个 Markdown 文档,以及 XMind 项目地图和 PDF 详细设计文档,合计约 34.86MB;目录覆盖 contracts、public、src 等典型模块,便于按图索骥。已有 110 人学习下载,项目经助教审核且答辩评价 95 分以上,核心代码均已本地编译运行通过。下载后可获得可直接运行的 DApp 众筹项目源码、详细设计文档、部署脚本和项目思维导图,既能支撑毕设/课设文档撰写,也能作为区块链 DApp 开发的入门与进阶范本。
1. 基于以太坊的 Dapp 众筹项目,到底在“设计”什么
如果你只是想把“项目介绍 + 收款地址 + 进度条”放在网页上,那根本不需要区块链。基于以太坊的 Dapp 众筹项目,真正要设计的是“资金如何被约束”:目标金额、截止时间、谁有权提款、失败之后钱怎么退,全部写成智能合约,前端只负责展示和组装交易。参与者的每一笔资助都直接打进合约地址,项目方拿不到私钥就无法挪用。这套方案的读者通常有三类:正在做课程设计或毕业设计的学生,想从静态页面转向链上开发的前端工程师,以及需要快速验证一个众筹 idea 却不想先搭建中心化结算系统的产品开发者。下面按工程落地的顺序,把合约设计、Truffle 工程、钱包接入和测试网验证拆开讲。
2. 众筹 Dapp 的模块边界:谁记账、谁展示、谁签交易
2.1 为什么不能只做一个网页
传统众筹的流程是:项目方提交资料,平台审核,用户把钱付给平台,平台在活动结束后再结算给项目方。整个过程中,资金托管和规则执行都由平台承担,用户只能相信平台的后台数据。区块链众筹把这段信任关系压缩到合约代码里:合约在部署时就锁定了收款地址、目标金额和截止时间,后续任何人无法修改这些参数。
当然,这不代表网页没有存在价值。用户需要看到项目说明、实时进度、自己的资助记录,这些展示型内容仍然由前端完成。真正发生变化的是数据的权威来源:页面上的数字来自链上调用,而不是某个数据库接口。客户端可以随时校验进度条和合约余额是否一致,这是 Dapp 与普通网站最本质的区别。
这里有个常见的误解:认为 Dapp 必须完全去掉服务端,前端不能有服务器。实际上众筹项目最常见的做法是静态托管前端加链上合约,项目介绍、图片这些非关键数据放传统服务器完全没问题。需要保持权威性的只有资金和状态,也就是合约那一层。
2.2 众筹生命周期与状态转移
我建议把众筹合约设计成有限状态机,三个状态就够用。合约里的声明如下:
// 合约中的状态机声明,后文会展开完整实现 enum State { Fundraising, Successful, Failed }筹集中(Fundraising)是合约部署后的初始状态。这个阶段用户可以调用 fund 函数转入 ETH,合约按地址累计每个人的出资额。到达截止时间后,合约不会自动切换状态,必须由任意账户调用 finalize 函数,合约才会根据“总筹集额是否达到目标”进入成功或失败状态。这里要特别说明:链上没有定时器,任何“到时间自动执行”的逻辑都必须依赖外部触发,这是新手最容易漏掉的设计点。
成功(Successful)状态只开放提款操作,且只有合约里预先写死的项目方地址能调用。提款会把合约中全部余额转给项目方。失败(Failed)状态则开放退款操作,每个资助者只能取回自己记录在映射里的那部分,合约本身不参与分配,只是执行者。
2.3 三层职责与常见误判
| 层 | 技术载体 | 负责的事 | 容易做的错误设计 |
|---|---|---|---|
| 账本层 | 以太坊合约 | 保存目标金额、截止时间、资助明细;执行提款与退款 | 把关键状态放在数据库里,合约只存一个总金额 |
| 展示层 | 前端页面 | 读取合约数据、组装交易、展示事件流 | 用服务端接口代替合约读操作,进度条领先于链上事实 |
| 钱包层 | MetaMask 等浏览器钱包 | 保存私钥、签名交易、支付 gas | 私自托管用户私钥,或要求用户把助记词交给服务器 |
判断误设计有个简单标准:关闭数据库,众筹还能不能查出每个人投了多少、能不能完成退款?如果能,说明权威状态在链上;如果不能,那只是一个画了区块链界面的传统网站。后面所有章节的代码都围绕这张表的分工展开,合约只做账本层的事,展示层不保存资金状态。
3. 用 Truffle 搭出众筹工程骨架:目录、迁移与本地链
3.1 为什么选 Truffle 加 Ganache 加 MetaMask
以太坊开发工具链这些年已经有很多选择,Hardhat、Foundry 都很成熟,但这个题目下的“设计与实现”类项目,用 Truffle 仍然是最短路径。原因在于:Truffle 把编译、部署、测试串成一套命令,Ganache 提供本地链和即时出块的测试账户,MetaMask 可以把浏览器钱包直接指向本地链,三个工具组合正好覆盖合约开发、部署、前端交互三个环节。很多教程里的 pet-shop 和 metacoin 案例都是这套骨架,众筹项目不需要推翻它,只需要把合约层换成自己的逻辑。
如果你之前没有接触过 pet-shop 这类完整案例,可以把它当作参考:先跑通一个最小案例,确认钱包能连上本地链,再着手替换合约。下面我直接给出众筹项目的最小骨架。
3.2 最小工程目录与初始化命令
先准备 Node 环境,然后全局安装 Truffle,在空目录里初始化工程:
npm install -g truffle truffle init再启动 Ganache。命令行版本和桌面版都能用,关键是固定端口和网络 ID,方便后面 MetaMask 连接:
ganache --port 7545 --networkId 5777 --chain.chainId 5777这里把端口固定在 7545、网络 ID 固定在 5777,是为了和后续 truffle-config.js 的默认配置保持一致。初始化后的目录结构如下:
contracts/ Migration.sol Crowdfunding.sol migrations/ 1_initial_migration.js 2_deploy_contracts.js test/ crowdfunding.test.js build/ contracts/ Crowdfunding.json truffle-config.jscontracts 目录放 Solidity 源文件;migrations 目录按编号顺序执行部署;test 目录放合约测试;build/contracts 是编译产物,前端要用的 ABI 和部署地址都会出现在对应 JSON 文件里。
3.3 truffle-config 里必须确认的三项配置
module.exports = { networks: { development: { host: "127.0.0.1", port: 7545, network_id: 5777, }, }, compilers: { solc: { version: "0.8.19", settings: { optimizer: { enabled: true, runs: 200 }, }, }, }, };配置有三处需要理解:host 和 port 指向本地 Ganache 的 RPC 地址,network_id 必须等于 Ganache 启动时输出的网络 ID;solc.version 是编译器版本,建议直接写死一个 0.8.x 版本而不是用默认值,否则换机器或换环境时,编译结果可能和上次不一致;optimizer 的 runs 参数影响合约体积和 gas 成本的平衡,对演示项目来说,开启优化并把 runs 设在 200 左右是常见选择。
| 配置项 | 作用 | 踩坑点 |
|---|---|---|
| host / port | 本地链 RPC 监听地址 | 桌面版 Ganache 默认 7545,命令行版常用 8545,不统一会导致部署超时 |
| network_id | 链的标识 | MetaMask 切链时也要用同一个 ID,否则交易会一直 pending |
| solc.version | Solidity 编译器版本 | 合约 pragma 与配置版本不一致时,Truffle 会重新拉取编译器 |
3.4 第一个迁移脚本
部署脚本写在 migrations/2_deploy_contracts.js 里。构造函数需要三个参数:项目方收款地址、目标金额(单位 wei)、筹款时长(秒):
const Crowdfunding = artifacts.require("Crowdfunding"); module.exports = async function (deployer) { const beneficiary = "0x你的Ganache账户地址"; const goalWei = web3.utils.toWei("5", "ether"); const duration = 600; await deployer.deploy(Crowdfunding, beneficiary, goalWei, duration); const instance = await Crowdfunding.deployed(); console.log("Crowdfunding at:", instance.address); };beneficiary 建议从 Ganache 生成的账户列表里随便选一个,单独留作项目方地址;goalWei 用 toWei 把 5 个 ETH 转成 wei,避免在合约层反复换算;duration 写 600 秒,演示时 10 分钟足够走完一次流程。部署后控制台会打印合约地址,先记下这个地址,第 5 章初始化合约实例时会用到。如果迁移过程遇到 “network id not valid” 之类的报错,优先检查 truffle-config.js 的 network_id 和 Ganache 启动参数是否一致。
4. 众筹合约与凭证代币合约的实现:状态、权限与事件
4.1 用状态机约束资金流向
合约是众筹项目的账本层,设计目标只有一个:让每一笔钱只能按照预设规则流动。下面是完整实现,我会逐段说明关键决策。
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; contract Crowdfunding { enum State { Fundraising, Successful, Failed } address public beneficiary; uint256 public goalWei; uint256 public deadline; uint256 public totalRaised; State public state = State.Fundraising; bool public closed; mapping(address => uint256) public contributions; event Funded(address indexed backer, uint256 amount); event Withdrawn(address indexed beneficiary, uint256 amount); event Refunded(address indexed backer, uint256 amount); modifier onlyBeneficiary() { require(msg.sender == beneficiary, "only beneficiary"); _; } modifier inState(State expected) { require(state == expected, "wrong state"); _; } constructor(address _beneficiary, uint256 _goalWei, uint256 _duration) { beneficiary = _beneficiary; goalWei = _goalWei; deadline = block.timestamp + _duration; } function fund() external payable inState(State.Fundraising) { require(block.timestamp < deadline, "deadline passed"); require(msg.value > 0, "zero value"); contributions[msg.sender] += msg.value; totalRaised += msg.value; emit Funded(msg.sender, msg.value); } function finalize() external inState(State.Fundraising) { require(block.timestamp >= deadline, "still fundraising"); state = totalRaised >= goalWei ? State.Successful : State.Failed; } function withdraw() external onlyBeneficiary inState(State.Successful) { require(!closed, "closed"); closed = true; uint256 amount = address(this).balance; (bool ok, ) = beneficiary.call{value: amount}(""); require(ok, "transfer failed"); emit Withdrawn(beneficiary, amount); } function refund() external inState(State.Failed) { require(!closed, "closed"); uint256 amount = contributions[msg.sender]; require(amount > 0, "no contribution"); contributions[msg.sender] = 0; (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "refund failed"); emit Refunded(msg.sender, amount); } }fund 函数加 payable,用户转 ETH 时按地址累加;totalRaised 每次同步增加,作为 finalize 判定成功与否的依据。deadline 在构造函数里用 block.timestamp 加时长算出,不使用前端传过来的时间——前端时间可以被篡改,而 block.timestamp 由矿工写入,所有参与者读到的都是同一个值。
finalize 任何人都可以调用,这是有意设计:链上没有定时任务,需要一个公开入口在截止后切换状态。它只做状态切换,不转移任何资金。withdraw 和 refund 都加上了 inState 修饰器,成功状态只能提款,失败状态只能退款,状态机在语法层面限制了资金流向。closed 标记把提款和退款变成一次性操作,配合“先修改状态再转账”的顺序,可以从结构上避免重入攻击。
4.2 提款和退款为什么不直接 transfer
合约里没有用 Solidity 早期常见的 transfer 方法,而是用 call 加 value 的方式转账。transfer 会把 gas 限制在 2300,目标地址是合约时容易因为逻辑复杂而失败;call 把 gas 交给接收方,配合检查和效果分离模式更安全。真正防止重入的关键在 refund 函数:转账前先把 contributions 清零,withdraw 先置 closed 再转账,就算接收方合约在回调里再次调用 refund,第二次调用也会因为余额为 0 或 closed 为 true 而直接失败。
对于这个众筹合约,退款逻辑用的是“拉取模式”:资助者自己调用 refund 取钱,而不是合约在 finalize 后逐个地址推送。这样项目方不需要维护地址列表,每个资助者只对自己负责,合约也不用遍历大数组,gas 成本更可控。
4.3 凭证代币:把资助记录变成链上资产
如果项目需要给资助者发放凭证,可以在众筹合约旁边加一个简版代币合约。它不需要完整实现 ERC20 的全部接口,只要能记账和转账就够演示。
contract ReceiptToken { mapping(address => uint256) public balanceOf; uint256 public totalSupply; address public owner; event Transfer(address indexed from, address indexed to, uint256 amount); modifier onlyOwner() { require(msg.sender == owner, "only owner"); _; } constructor() { owner = msg.sender; } function mint(address to, uint256 amount) external onlyOwner { balanceOf[to] += amount; totalSupply += amount; emit Transfer(address(0), to, amount); } function transfer(address to, uint256 amount) external returns (bool) { require(balanceOf[msg.sender] >= amount, "insufficient balance"); balanceOf[msg.sender] -= amount; balanceOf[to] += amount; emit Transfer(msg.sender, to, amount); return true; } }部署顺序是先把 ReceiptToken 部署给 Crowdfunding 持有,也就是把代币合约的 owner 指向众筹合约地址,然后在 fund 函数里增加一次 mint 调用,金额与资助额等量。这样每个资助者的链上余额既是出资凭证,又可以在后续活动中被项目方识别。注意 mint 只对 owner 开放,而 owner 是众筹合约,所以这个接口不能被任意地址调用。
4.4 合约函数权限速查表
| 函数 | 调用条件 | 资金变化 | 触发事件 |
|---|---|---|---|
| fund | 筹集中且未到截止时间 | 转入合约,余额增加 | Funded |
| finalize | 筹集中且已到截止时间 | 无 | 无 |
| withdraw | 成功状态,仅项目方,一次 | 余额全部转给项目方 | Withdrawn |
| refund | 失败状态且未 closed | 单个资助者取回记录金额 | Refunded |
| mint | 仅 owner(众筹合约) | 代币总供应量增加 | Transfer |
写这几个函数时,我建议把“谁能调用、什么状态能调用、会不会动钱、动了钱有没有事件”作为检查清单,逐行核对完再编译。这个习惯比依赖任何测试框架都能更早发现设计漏洞。
5. Dapp 前端怎么连钱包:读合约、写交易与监听事件
5.1 从 window.ethereum 到 Web3 实例
浏览器端接入以太坊,核心对象是 window.ethereum,也就是 MetaMask 注入的 API。页面加载时先判断它存不存在,然后请求账户授权。第一次连接时 MetaMask 会弹出确认框,这是正常的,用户确认后返回账户地址:
async function connectWallet() { if (!window.ethereum) { throw new Error("请先安装 MetaMask"); } const accounts = await window.ethereum.request({ method: "eth_requestAccounts", }); // 切换网络:5777 转十六进制是 0x1691 await window.ethereum.request({ method: "wallet_switchEthereumChain", params: [{ chainId: "0x1691" }], }); return accounts[0]; }eth_requestAccounts 返回的是一个数组,当前选中的账户在第一位;wallet_switchEthereumChain 里的 chainId 必须写十六进制字符串,Ganache 网络 ID 5777 对应 0x1691。如果 MetaMask 里还没有这个网络,switch 会失败,此时需要用 wallet_addEthereumChain 先添加,参数里带上 rpcUrls 和 chainId。
5.2 初始化合约实例:ABI 从哪来
Truffle 编译后,build/contracts/Crowdfunding.json 里有完整的 ABI,以及按网络 ID 记录的部署地址。初始化合约实例的常见做法是:
import artifacts from "./build/contracts/Crowdfunding.json"; import Web3 from "web3"; const web3 = new Web3(window.ethereum); const networkId = await web3.eth.net.getId(); const deployed = artifacts.networks[networkId]; const crowdfunding = new web3.eth.Contract(artifacts.abi, deployed.address);artifacts.networks 是编译产物里的部署记录,key 是网络 ID,value 是地址。这里不要手动维护一份地址,否则每切一次网络就要改一次代码。把前端配置和编译产物放在一起,是 Truffle 项目最省心的接法。
5.3 读与写分离:call 和 send 的区别
读操作不需要钱包签名,也不消耗 gas;写操作必须由用户确认并支付 gas。众筹页面上最常见的两个操作对应两种调用方式:
// 读:查询目标金额和当前进度 const goal = await crowdfunding.methods.goalWei().call(); const raised = await crowdfunding.methods.totalRaised().call(); const deadline = await crowdfunding.methods.deadline().call(); // 写:用户资助 0.1 ETH await crowdfunding.methods.fund().send({ from: account, value: web3.utils.toWei("0.1", "ether"), gas: 150000, });| 调用方式 | 是否需要签名 | 是否消耗 gas | 返回值 | 典型场景 |
|---|---|---|---|---|
| call | 否 | 否 | 函数返回值 | 查询 goalWei、totalRaised、deadline |
| send | 是 | 是 | 交易回执 | fund、withdraw、refund、finalize |
send 里的 gas 是给 MetaMask 的提示值。很多新手在这里不写 gas,让 MetaMask 自动估算,结果遇到 fund 的 require 条件不满足时,MetaMask 会直接报出 gas estimation failed,而不是告诉你“截止时间已过”。建议给 gas 留合理余量,同时永远准备好处理用户拒绝签名的情况。
5.4 监听 Funded 事件刷新进度
资助完成后,页面上的进度条要实时变化。比较直接的方式是调用一次 totalRaised,也可以在事件里拿到同样的值:
crowdfunding.events.Funded({ filter: { backer: account }, fromBlock: 0, }) .on("data", (event) => { const amount = web3.utils.fromWei(event.returnValues.amount, "ether"); console.log(`backer ${event.returnValues.backer} 资助 ${amount} ETH`); });这里的 fromBlock 建议从 0 开始监听。如果写成 latest,在 Ganache 这种即时出块的链上,事件可能在订阅建立之前就已经被打包,用户刷新页面后发现进度条没更新。真正的生产项目应该自己保存已处理的块高,断线重连后从上次位置继续,避免事件重复处理;课程设计做到从 0 监听加页面刷新兜底,已经够演示。
5.5 三个常见的前端失败点
MetaMask 连接的链和合约部署链不一致,是最难排查的问题。调试时先打开 MetaMask 看网络名称,再对比 truffle-config.js 里的 network_id。第二个是用户切换账户后前端还持有旧的 account 变量,send 时 from 传入旧地址导致签名错误,建议在每次交易前重新调用 eth_requestAccounts。第三个是数字精度问题,前端拿到 wei 时应该用字符串处理,或者用 web3.utils.toWei 与 fromWei 双向转换,不要直接用 JavaScript 的 number 类型做金额比较。
6. 部署测试网后的验证动作与回滚测试技巧
6.1 用一条命令切到测试网
本地验证通过后,把同样的合约部署到 Sepolia 测试网,迁移命令只需要加网络参数:
truffle migrate --network sepolia --resettruffle-config.js 里需要新增 sepolia 网络配置,通过环境变量传入私钥和 RPC 地址,避免把私钥写进代码仓库:
const HDWalletProvider = require("@truffle/hdwallet-provider"); module.exports = { networks: { sepolia: { provider: () => new HDWalletProvider( process.env.PRIVATE_KEY, process.env.SEPOLIA_RPC_URL ), network_id: 11155111, }, }, };配置里的 PRIVATE_KEY 是部署账户的私钥,建议单独建一个账户专门部署,不要和你日常使用的钱包混用;SEPOLIA_RPC_URL 填写测试网公开 RPC 地址或自建 RPC 地址。部署前确认账户里有测试币,否则第一步迁移就会因为余额不足失败。
6.2 在区块浏览器上核对三件事
部署完成后先别急着接前端,打开区块浏览器按地址核对三件事:
- 合约地址是否与迁移日志一致,确认没有把测试网地址复制到别的网络;
- 源码验证时,编译器版本、optimizer 开关和 runs 值必须和 truffle-config.js 完全一致,任何一项对不上都会验证失败;
- 用事件标签页过滤 Funded 和 Refunded,确认真实交易产生的记录能被浏览器解析。
| 检查项 | 核对方式 | 失败时的典型表现 |
|---|---|---|
| 合约地址 | 迁移日志与浏览器页面比对 | 前端连的旧地址,交易 pending |
| 源码验证 | 对照 compiler 和 optimizer 配置 | 验证失败,报字节码不匹配 |
| 事件解析 | 浏览器事件过滤器 | 事件参数是乱码或缺失 |
6.3 回滚测试:两个必须写进 test 目录的用例
众筹合约最容易出问题的逻辑不是“成功提款”,而是“失败退款”。下面两个测试覆盖了退款路径和权限边界,直接用 truffle test 跑:
const Crowdfunding = artifacts.require("Crowdfunding"); contract("Crowdfunding", (accounts) => { const [beneficiary, backer] = accounts; it("失败后退款,重复退款会 revert", async () => { const instance = await Crowdfunding.new( beneficiary, web3.utils.toWei("1", "ether"), 2 ); await instance.fund({ from: backer, value: web3.utils.toWei("0.5", "ether"), }); await new Promise((resolve) => setTimeout(resolve, 3000)); await instance.finalize({ from: beneficiary }); await instance.refund({ from: backer }); let reverted = false; try { await instance.refund({ from: backer }); } catch (e) { reverted = true; } assert.isTrue(reverted, "重复退款应该失败"); }); it("非项目方调用 withdraw 会被拒绝", async () => { const instance = await Crowdfunding.new( beneficiary, web3.utils.toWei("1", "ether"), 2 ); await instance.fund({ from: backer, value: web3.utils.toWei("1", "ether"), }); await new Promise((resolve) => setTimeout(resolve, 3000)); await instance.finalize({ from: beneficiary }); let reverted = false; try { await instance.withdraw({ from: backer }); } catch (e) { reverted = true; } assert.isTrue(reverted, "非项目方不能提款"); }); });两个测试都先用较短时长快速走完筹款周期,再主动触发 finalize,缩短测试运行时间。第二段测试的侧重点在修饰器:withdraw 上同时挂了 onlyBeneficiary 和 inState,任意一个条件不满足都应 revert。把这两个用例留在 test 目录里,之后每改一次合约就运行 truffle test,回滚路径是否被破坏一眼就能看出来。
本文还有配套的精品资源,点击获取