基于以太坊的众筹Dapp实战:Solidity合约与Web3.js开发全解析
2026/9/12 22:47:55 网站建设 项目流程

简介:一份面向计算机专业学生、教师及企业开发者的以太坊Dapp众筹项目完整资料包,源于优秀毕业设计/期末大作业,源码可在本地编译运行,评审分达95分以上。项目覆盖智能合约编写(Solidity)、前端界面(HTML5/CSS3及React/Vue框架)、后端服务(Node.js与Express)等全栈实现,并提供详尽设计文档、系统架构图、数据库设计及接口说明。压缩包共37个文件,以JS脚本、Solidity合约、配置文件、Markdown文档、思维导图及PDF说明为主,整体约34.86MB,目录结构清晰,便于按模块检索。目前已有54人学习使用,适合作为毕设参照、课程设计或多层架构Dapp开发入门进阶的实践素材。

1. 从宠物商店到众筹:为什么这个 Dapp 选题能落地

如果你接触过 Truffle 的宠物商店 dapp,会发现它只能演示“领养一只宠物”,整个交互只有一个状态变更。而基于以太坊的 Dapp 众筹项目,把智能合约从“单状态玩具”推到了“多角色资金流”的真实场景:用户可以发起筹款、出资、查看进度,发起人可以提取资金,未达标时出资人可以退款。这套资料恰好把这套逻辑做成了完整工程,前台界面、合约、后端接口、设计文档四件套齐全。很多人的毕业设计或期末大作业都会选这个方向,因为评委能在演示中看到区块链透明性的实际价值,而不是空谈概念。对于已经工作的开发者,这也是理解“链上资产 + 链下展示”分离思想的低成本样本。

2. 基于以太坊的 Dapp 分层架构与工程目录解析

2.1 分层架构:链上资产、链下展示

以太坊 Dapp 与传统 Web 系统最大的区别在于信任边界。传统众筹平台的后端数据库同时保管“订单”和“资金流水”,一旦数据库被改写,用户很难自证。而在这个项目里,资金状态由 Solidity 合约保存在链上,后端服务器即使宕机,合约里的资金和账号记录依然存在。

从工程角度,这套项目按三条线拆开:合约层负责资金规则,前端层负责用户操作,Node.js 后端负责缓存链上事件,给前端提供更友好的查询接口。分层结构可以整理为下面的关系。

分层技术选型职责
智能合约层Solidity记录众筹项目、出资、退款、白名单等核心状态
本地链Ganache / 测试网提供 EVM 环境,部署合约并执行交易
前端层HTML5/CSS3 / React 或 Vue展示众筹列表、项目详情,触发钱包交易
后端层Node.js + Express监听合约事件,缓存列表数据,提供 REST API
钱包层MetaMask管理用户私钥,签名并广播交易

关键点在于:后端不触碰资金。哪怕后端被攻击,攻击者也只能修改前端展示的数据,无法转走合约里的 ETH。验收演示时,只要“前后端停掉,合约的余额还在”这一条,就足够说明去中心化的价值。

2.2 开发环境选型:脚本编译还是框架迁移

拿到压缩包后,先不要急着看业务代码,先确认环境。这个项目没有直接依赖 Truffle 的 migrate 命令,而是提供了01-compile.js02-deploy.js两个脚本,逻辑一目了然:用solc编译合约,再用web3.js广播部署交易。好处是摆脱框架黑盒,适合写在论文“系统实现”章节里。

我一般会这样准备依赖:

npm install npm install --save-dev ganache-cli@6.12.2 npm install --save web3@1.10.0 npm install --save solc@0.8.21

其中ganache-cli是本地以太坊节点,默认监听8545端口;web3.js负责和节点通信;solc是 Solidity 编译器。注意 Node.js 版本不要用太新的 20+,部分旧版solc-js在 Node 18 以上会有 Buffer 接口兼容问题,如果编译报错,优先固定 Node 16。

2.3 目录结构里的关键文件

解压后目录大致如下,和很多 Truffle 脚手架项目基本一致。

Funding-eth/ ├── contracts/ # Solidity 合约文件 ├── src/ # 前端源码 ├── public/ # 静态资源与入口 HTML ├── 01-compile.js # 编译合约,输出 ABI 与 Bytecode ├── 02-deploy.js # 部署到本地链或测试网 ├── package.json # 项目依赖与脚本 ├── README.md.zbak # 说明文档备份 ├── 众筹项目地图-唯一.xmind # 项目设计思维导图 └── .gitignore

对这个结构,我建议重点看三个文件:contracts下的.sol文件是合约源码,整个项目的核心;01-compile.js能帮你理解合约如何从源代码变成 ABI;02-deploy.js则展示了交易如何被打包上链。README.md.zbak里的内容往往是旧版说明,部署参数可能过期,但架构图仍值得参考。

路径/文件作用学习优先级
contracts/众筹业务的核心规则
01-compile.js编译流程,适合写进文档
02-deploy.js部署脚本,可改写为自动部署
src/前端组件与页面
.xmind 文件需求分析与模块划分低(答辩资料用)

3. 众筹合约的 Solidity 设计与部署脚本解读

3.1 融资模型在链上如何表示

众筹业务的核心模型是“目标金额 + 截止时间 + 资金归属”。传统实现需要后端数据库记录项目表、订单表、退款表;而以太坊合约只需要一个结构体和一个映射。

我这里整理出一份最常见的基础模型,和项目中contracts下的实现逻辑基本一致:

字段类型含义
owneraddress payable项目发起人,拥有资金提取权
titlestring项目名称
goaluint256众筹目标,单位是 wei
deadlineuint256截止时间戳,之后不允许出资
amountRaiseduint256当前已筹金额
endedbool是否已结束,防止重复提取
goalReachedbool是否达标,决定资金去向

合约设计中有两个容易忽略的点。第一,出资人记录不能只存总金额,必须用mapping(uint256 => mapping(address => uint256))记录每个用户对每个项目的出资额,否则退款时无法计算。第二,deadline必须用block.timestamp获取链上时间,不能用服务器时间,否则用户改本地时钟就能绕过截止时间。

3.2 核心合约代码实现

下面是一段简化但可运行的众筹合约,结构思路与毕设项目一致,去掉了管理员和分类信息以方便阅读。

// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract FundingEth { struct Campaign { address payable owner; string title; uint256 goal; uint256 deadline; uint256 amountRaised; bool ended; } Campaign[] public campaigns; // campaignId => userId => contribution amount mapping(uint256 => mapping(address => uint256)) public contributions; event CampaignCreated(uint256 indexed id, address indexed owner, string title, uint256 goal); event Contribution(uint256 indexed id, address indexed contributor, uint256 amount); function createCampaign( string memory title, uint256 goal, uint256 durationSeconds ) external { campaigns.push(Campaign({ owner: payable(msg.sender), title: title, goal: goal, deadline: block.timestamp + durationSeconds, amountRaised: 0, ended: false })); emit CampaignCreated(campaigns.length - 1, msg.sender, title, goal); } function contribute(uint256 campaignId) external payable { Campaign storage c = campaigns[campaignId]; require(block.timestamp < c.deadline, "campaign ended"); require(msg.value > 0, "empty contribution"); contributions[campaignId][msg.sender] += msg.value; c.amountRaised += msg.value; emit Contribution(campaignId, msg.sender, msg.value); } function withdraw(uint256 campaignId) external { Campaign storage c = campaigns[campaignId]; require(msg.sender == c.owner, "not owner"); require(block.timestamp >= c.deadline, "not finished"); require(c.amountRaised >= c.goal, "goal not reached"); require(!c.ended, "already withdrawn"); c.ended = true; uint256 amount = c.amountRaised; c.amountRaised = 0; (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "withdraw failed"); } function refund(uint256 campaignId) external { Campaign storage c = campaigns[campaignId]; require(block.timestamp >= c.deadline, "not finished"); require(c.amountRaised < c.goal, "goal reached"); uint256 amount = contributions[campaignId][msg.sender]; require(amount > 0, "no contribution"); contributions[campaignId][msg.sender] = 0; (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "refund failed"); } }

代码里有三个设计细节值得展开。

withdraw中先改c.ended = true,再发送 ETH,是为了防止重入攻击。如果先转账,攻击者可以在 fallback 里再次调用withdraw,导致资金被重复提取。虽然演示项目不一定遇到攻击,但论文评审时这一行能明显加分。

refund把用户出资清零后再转账,这样即使退款失败,用户的额度也已清零,不会出现重复退款。这里没有把amountRaised减掉,因为一旦项目失败,合约里剩余资金就是所有人的退款池。

Campaign storage c = campaigns[campaignId]使用storage指针而不是内存副本,直接修改链上数组中的原始结构体。如果写成Campaign memory c,则只修改了临时副本,状态不会更新。这是新手最常见的 bug。

3.3 编译与部署脚本的用法

项目里的01-compile.js02-deploy.js实际上是在演示一条完整的发布链路。

// 01-compile.js const solc = require('solc'); const fs = require('fs'); const path = require('path'); const source = fs.readFileSync( path.resolve(__dirname, 'contracts', 'FundingEth.sol'), 'utf8' ); const input = { language: 'Solidity', sources: { 'FundingEth.sol': { content: source } }, settings: { outputSelection: { '*': { '*': ['abi', 'evm.bytecode.object'] } } } }; const output = JSON.parse(solc.compile(JSON.stringify(input))); const contract = output.contracts['FundingEth.sol']['FundingEth']; fs.mkdirSync('build', { recursive: true }); fs.writeFileSync( 'build/FundingEth.json', JSON.stringify({ abi: contract.abi, bytecode: contract.evm.bytecode.object }, null, 2) ); console.log('compile done, abi saved to build/FundingEth.json');

这段脚本首先读取.sol文件,然后通过solc.compile返回编译结果。outputSelection配置了需要输出 ABI 和部署字节码;evm.bytecode.object是用于链上部署的十六进制字节码。编译结果写入build目录,后续部署脚本直接读取这个 JSON。

部署脚本的核心片段如下。

// 02-deploy.js const Web3 = require('web3'); const fs = require('fs'); (async () => { const web3 = new Web3('http://127.0.0.1:8545'); const [deployer] = await web3.eth.getAccounts(); const artifact = JSON.parse( fs.readFileSync('./build/FundingEth.json', 'utf8') ); const contract = new web3.eth.Contract(artifact.abi); const deployTx = contract.deploy({ data: '0x' + artifact.bytecode }); const gas = await deployTx.estimateGas({ from: deployer }); const instance = await deployTx.send({ from: deployer, gas }); console.log('deployed at', instance.options.address); })();

estimateGas会估算部署交易所需的 gas,省去手动设置;send完成签名广播。部署地址就是未来前端要使用的合约地址,建议保存到src/config.js或环境变量,而不是在代码里写死。

4. 前端 MetaMask 交互与 Node.js 数据接口实现

4.1 前端通过钱包地址完成身份认证

前端项目一般不会保存用户私钥,而是用 MetaMask 的注入对象window.ethereum与合约交互。项目在 React 组件里通常会封装一个web3实例。

import Web3 from 'web3'; let web3; if (window.ethereum) { web3 = new Web3(window.ethereum); await window.ethereum.request({ method: 'eth_requestAccounts' }); } else { web3 = new Web3('http://127.0.0.1:8545'); } const accounts = await web3.eth.getAccounts(); const contract = new web3.eth.Contract(artifact.abi, contractAddress); // 用户出资 0.1 ETH await contract.methods.contribute(campaignId).send({ from: accounts[0], value: web3.utils.toWei('0.1', 'ether'), gas: 200000 });

这里的eth_requestAccounts会弹出 MetaMask 授权窗口,返回值就是当前钱包地址。用户不需要输入密码,不需要额外注册,链上地址本身就是身份。调用send时,MetaMask 会弹出确认窗口显示 gas 费和数据,确认后才写入链上。

有一个常见坑:前端调用contribute前必须做好表单校验,否则用户输入一个负数或者空字符串,toWei会抛出异常。更重要的是一旦用户点击确认,链上交易无法取消,所以前端要在send之前先通过后端接口查询项目是否已到期,不要完全依赖合约报错。

4.2 Node.js 后端做事件缓存与 REST 接口

智能合约可以通过getPastEvents拉取历史事件,但每次遍历所有区块成本很高。所以项目里用 Node.js + Express 把CampaignCreatedContribution事件同步到本地内存或数据库,前端列表页直接请求后端接口。

const express = require('express'); const Web3 = require('web3'); const app = express(); const web3 = new Web3('http://127.0.0.1:8545'); const contract = new web3.eth.Contract(abi, contractAddress); app.get('/api/campaigns', async (req, res) => { const events = await contract.getPastEvents('CampaignCreated', { fromBlock: 0, toBlock: 'latest' }); const campaigns = await Promise.all( events.map(async (event) => { const id = event.returnValues.id; const data = await contract.methods.campaigns(id).call(); return { id, title: data.title, goal: web3.utils.fromWei(data.goal, 'ether'), amountRaised: web3.utils.fromWei(data.amountRaised, 'ether'), deadline: data.deadline, owner: data.owner }; }) ); res.json({ code: 0, data: campaigns }); }); app.listen(3000, () => console.log('api server running on 3000'));

这里的getPastEvents从 0 区块开始扫描,返回所有创建众筹项目的事件。拿到事件后,再用campaigns(id).call()读取对应项目的最新状态。call()不产生交易,不消耗 gas,适合读操作。

接口路径方法说明前端使用场景
/api/campaignsGET众筹项目列表首页列表
/api/campaigns/:idGET单个项目详情项目详情页
/api/campaigns/:id/eventsGET出资记录项目进度页

实际项目里,后端还可以定时用contract.events.Contribution().on('data', callback)监听新出资事件,把数据写入数据库。这个做法比每次请求都去链上拉取更高效,也更接近生产环境。

4.3 前端展示与错误捕获

链上数据和普通 API 数据混在一起时会有一个问题:deadline是秒级时间戳,而 JavaScript 的Date需要毫秒。合约返回的goal单位是 wei,直接显示会给用户造成很大困扰。我一般会在前端做一个格式化工具,统一处理单位转换。

function formatCampaign(campaign) { return { id: campaign.id, title: campaign.title, goal: window.web3.utils.fromWei(campaign.goal, 'ether'), amountRaised: window.web3.utils.fromWei(campaign.amountRaised, 'ether'), deadline: new Date(Number(campaign.deadline) * 1000).toLocaleString() }; }

如果 MetaMask 没有安装,页面应给出明确提示,并保留只读模式让用户查看项目。调试时最有效的方式是在send后的.on('transactionHash')里打印交易哈希,然后去 Ganache 终端看交易是否被打包,这样能快速区分是合约报错、交易阻塞还是前端回调未触发。

5. 把 95 分项目跑起来:编译、验收和排错清单

5.1 从 0 到演示的命令顺序

拿到完整资料后,我建议按下面顺序验证,不要直接改代码。

npm install npx ganache-cli --port 8545 node 01-compile.js node 02-deploy.js npm run dev

第一条安装依赖;第二条启动本地以太坊节点;第三条编译合约,生成build目录;第四条把合约部署到本地链,输出合约地址;最后启动前端开发服务器。ganache-cli需要保持前台运行,另外开一个终端执行部署命令。

如果02-deploy.jsInvalid response,先检查 Ganache 是否真的在8545端口运行。如果estimateGasalways failing transaction,说明合约构造函数或前置条件有问题,回到代码检查require条件是否为合理范围。

5.2 验收时需要重点检查的功能

检查项操作预期结果失败排查方向
网络连接curl http://localhost:8545返回 JSON 响应Ganache 未启动或端口被占用
合约部署执行 02-deploy.js输出合约地址编译缓存未删除,重新执行
创建项目调用 createCampaign状态显示“已创建”MetaMask 没切到本地网络
出资调用 contribute金额增加from地址不是当前钱包
退款到期后调用 refund金额回退到账户区块时间未到截止日期

MetaMask 需要手动添加 Ganache 的网络配置:http://127.0.0.1:8545,Chain ID 是1337。导入 Ganache 启动时显示的助记词,第一个账户会有 100 ETH 测试额度。项目演示时最好准备两个账户,一个发起项目,另一个出资,这样能把事件列表显示得更清楚。

5.3 给毕设答辩留一个验证技巧

在演示退款功能时,不要真的等截止时间结束。可以把合约里的deadlineblock.timestamp + durationSeconds临时改成block.timestamp + 10,创建项目后等十秒即可测试超时场景。另一个更好用的方式是直接用ganache-clievm_increaseTime命令,让链上时间快进到截止日期之后。

curl -X POST http://127.0.0.1:8545 \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"evm_increaseTime","params":[3600],"id":1}'

执行后,再调用evm_mine产生一个新区块,block.timestamp才会真正更新。这个小技巧在现场演示和期末大作业验收时非常实用,能省去重写合约的时间。

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

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

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

立即咨询