简介:一份基于以太坊的Dapp众筹项目完整毕业设计方案,定位清晰,适合计算机相关专业在校学生、教师及开发者,用于毕业设计、课程设计、期末大作业或项目初期立项演示。项目源码已通过本地编译与运行验证,整体获导师认可并达到答辩评审九十五分以上,难度适中,既能作为区块链Dapp开发的学习范本,也便于在此基础上扩展新功能。
整包共三十一个文件,以JavaScript编译部署脚本、XML与JSON配置、Markdown说明文档为主,同时包含Solidity智能合约源码、PDF设计文档和XMind项目思维导图等,压缩包约34.86MB。目录围绕合约层、部署层与前端展示层组织,从智能合约编写、部署脚本到页面交互形成完整闭环,结构清晰。目前已有113人学习下载。资料提供完整源代码、详细设计文档、答辩演示材料及项目地图,可系统掌握Dapp众筹项目的整体框架与核心实现,直接用于毕业设计、课程设计或二次开发。
1. Dapp众筹项目到底在做什么:一个能落到毕业设计上的以太坊应用
接触这个基于以太坊的Dapp众筹项目,是帮一个准备毕设的学生做技术选型的时候。他的需求很普通:做一个去中心化众筹平台,发起人能创建项目,支持者用 MetaMask 投 ETH,到期后要么拨款给发起人,要么按比例退款。听起来简单,但真上手会发现,合约状态设计、前端和钱包的配合、脚本组织方式,每一项都有不少细节。这套资料里合约源码、compile 和 deploy 脚本、前端代码、设计文档、思维导图都齐了,评审分能到 95,说明“能跑通”这个维度做得比较扎实。
对准备毕设、课设或者想进入 Dapp 开发的同学,它的价值集中在三点:合约逻辑完整,不是只剩两个函数的半成品;脚本可直接执行,不用自己拼装编译流程;前端把发起、参与、查询这几个关键交互都串了起来。接下来我会先带你拆目录和合约,再讲编译部署与前端交互,最后把实际踩过的几类坑列出来,避免你在同样的问题上卡一整天。
2. 拆解项目结构与核心合约:先看清地图再动手
2.1 目录逐个过一遍:合约、脚本、前端各管什么
把压缩包解开之后,你会看到contracts/、src/、public/、01-compile.js、02-deploy.js,还有README.md、众筹项目.pdf和众筹项目地图-唯一.xmind。我拿到这类资源的第一件事,不是急着跑npm install,而是先读 README 和思维导图,搞清楚“这个项目实现了哪几个功能点、代码入口在哪里”。一份标注清楚的思维导图,比直接看代码快得多,答辩时也方便给评委讲整体结构。
| 文件/目录 | 作用 | 要注意的点 |
|---|---|---|
| contracts/ | 存放 Solidity 合约源码 | 编译版本必须和 solc 匹配 |
| 01-compile.js | 用 solc 把合约编译成 ABI 和 bytecode | 输出文件建议统一放到 build/ 目录 |
| 02-deploy.js | 把编译产物部署到链上 | 需要配置 RPC、私钥、gas 参数 |
| src/ | 前端业务逻辑 | 负责加载 ABI、调合约方法、监听事件 |
| public/ | 入口 HTML 与静态资源 | 页面结构在这里改 |
| 众筹项目.pdf | 设计与实现说明文档 | 里面有系统结构图和流程说明 |
| 众筹项目地图-唯一.xmind | 功能模块梳理 | 答辩展示思路时很好用 |
| .idea/ | IDE 配置 | 不影响运行,可以忽略 |
普通 Web 项目会把后端和数据库的流程藏起来,而这类去中心化应用恰恰相反:所有业务规则都写在合约里,前端只是一层和钱包交互的壳。所以判断这个众筹 Dapp 做得好不好,关键看合约写得是否严密,页面只是加分项。你会看到01-compile.js和02-deploy.js是两个独立脚本,这比用 Truffle 或 Hardhat 的好处是过程透明,每一步做了什么都能讲清楚。
2.2 众筹合约的核心:状态、权限与资金流
先看合约。这份资料里的核心合约是Funding.sol,一个典型的众筹流程用结构体、函数修饰符和事件组织起来。下面是一个按常见实现精简但可运行的版本,结构上与这份资料保持一致,面试或答辩时照着讲思路就行:
// contracts/Funding.sol —— 众筹合约核心逻辑 // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; contract Funding { struct Campaign { string title; // 项目名称 string desc; // 项目描述 address payable creator; // 发起人,必须 payable 才能接收 ETH uint256 goal; // 目标金额,单位 wei uint256 deadline; // 截止时间戳,单位秒 uint256 raised; // 当前已筹金额 bool ended; // 是否已结算 uint256 supporterCount; // 参与人数,方便前端展示 mapping(address => uint256) contributions; // 支持者地址 -> 出资额 } Campaign[] public campaigns; // 所有众筹项目 event CampaignCreated(uint256 indexed id, address indexed creator, uint256 goal, uint256 deadline); event Contributed(uint256 indexed id, address indexed supporter, uint256 amount); event CampaignFinalized(uint256 indexed id, bool success); function createCampaign(string memory _title, string memory _desc, uint256 _goal, uint256 _durationSec) public returns (uint256) { require(_goal > 0, "goal must be > 0"); require(_durationSec > 0, "duration must be > 0"); uint256 id = campaigns.length; campaigns.push(Campaign({ title: _title, desc: _desc, creator: payable(msg.sender), goal: _goal, deadline: block.timestamp + _durationSec, raised: 0, ended: false, supporterCount: 0 })); emit CampaignCreated(id, msg.sender, _goal, block.timestamp + _durationSec); return id; } function contribute(uint256 _id) public payable { Campaign storage c = campaigns[_id]; // storage 引用,改的是链上状态 require(block.timestamp <= c.deadline, "campaign ended"); require(!c.ended, "campaign settled"); require(c.raised < c.goal, "goal reached"); require(msg.value > 0, "amount must be > 0"); c.raised += msg.value; if (c.contributions[msg.sender] == 0) { c.supporterCount++; } c.contributions[msg.sender] += msg.value; emit Contributed(_id, msg.sender, msg.value); } function finalize(uint256 _id) public { Campaign storage c = campaigns[_id]; require(!c.ended, "already settled"); require(block.timestamp > c.deadline || c.raised >= c.goal, "not ready"); c.ended = true; if (c.raised >= c.goal) { c.creator.transfer(c.raised); emit CampaignFinalized(_id, true); } else { emit CampaignFinalized(_id, false); } } function refund(uint256 _id) public { Campaign storage c = campaigns[_id]; require(c.ended, "not settled yet"); require(c.raised < c.goal, "campaign succeeded"); uint256 amount = c.contributions[msg.sender]; require(amount > 0, "no contribution"); c.contributions[msg.sender] = 0; // 先归零再转账,防止重入 payable(msg.sender).transfer(amount); } }逻辑说明:Campaign用结构体保存项目属性,mapping(address => uint256) contributions记录每个支持者的出资,campaigns数组是所有项目的存储入口,前端通过campaigns(id)按索引查询。整个合约是一个状态机:创建项目 → 募资中 → 到期结算 → 拨款或退款,每个阶段都有require做前置条件校验,少一个require都有可能被异常调用钻空子。
参数说明:_goal单位是 wei,1 ETH 等于 10¹⁸ wei,前端展示必须换算;_durationSec是募资时长,不是截止日期;deadline在合约内部用block.timestamp + _durationSec算出。如果合约按旧版本 Solidity 编写,注意 0.8 之前用的是now,0.8 之后统一用block.timestamp。contribute里的storage关键字把c变成持久化引用,这样c.raised += msg.value直接写回链上,比memory拷贝高效且修改生效。
2.3 前端拿到的是 ABI 和地址,不是合约文件
合约编译后,前端并不需要合约源码,只需要两个东西:ABI 接口描述和部署后的合约地址。前端通过new web3.eth.Contract(abi, address)生成合约实例,再调contract.methods.xxx().call()做只读查询、用.send()发起交易。地址如果硬编码在前端,以后换链就得改源码;常见做法是部署脚本把地址写到build/contract-address.txt,前端启动时读取一次,重部署不用碰业务代码。
// 从 build 目录加载 ABI 与地址,暴露给整个前端 const fs = require('fs'); const path = require('path'); const buildFile = path.resolve(__dirname, 'build', 'Funding.json'); const contractAddress = fs.readFileSync(path.join(__dirname, 'build', 'contract-address.txt'), 'utf8').trim(); module.exports = { abi: JSON.parse(fs.readFileSync(buildFile, 'utf8')).abi, address: contractAddress };逻辑说明:上面的模块把 ABI 和地址集中管理,订阅经济的消费,页面组件只需要 import,不关心文件是怎么来的。毕设阶段这比每次都从 window 全局变量取要规范,代码量增加很少但效果明显。
这里有个易错点:call()不消耗 gas、只读链上状态;send()消耗 gas、写入状态。刚接触 web3 时最容易犯的错是把只读函数也写成send(),导致查询一个goal也要弹 MetaMask 确认。后面第 4 章会有更具体的调用示例。
3. 本地跑通编译部署流程:01-compile.js 和 02-deploy.js 的用法
3.1 为什么项目自带 compile 脚本,而不是用框架
很多课程项目默认用 Truffle,打开这套资料会发现它把编译和部署拆成两个独立 Node 脚本,直接依赖solc和web3包。这样设计的好处是链路清楚:第一步编译产生可验证的中间产物,第二步部署走一个公开的交易流程,每一步都可以单独运行和排查。答辩时被问“你这个项目怎么编译、怎么部署”,打开脚本当场演示,比敲一个框架命令更有说服力。
基本链路是:
01-compile.js读取contracts/Funding.sol- 构造 solc 标准 JSON 输入,调用
solc.compile - 把 ABI 和 bytecode 写入
build/目录 02-deploy.js读取build/下的 JSON,用 web3 部署到本地链
如果你之前用过 Remix,它会把编译细节隐藏起来,而这个脚本把“标准 JSON 输入”暴露到眼前,是理解 EVM 编译过程的一条捷径。
3.2 01-compile.js 拆解:输入 JSON、输出目录与版本选择
下面是这个项目场景下最常用的编译脚本写法,结构和资料里的脚本保持一致:
// 01-compile.js —— 编译合约并输出 ABI 与 bytecode const fs = require('fs'); const path = require('path'); const solc = require('solc'); const contractPath = path.resolve(__dirname, 'contracts', 'Funding.sol'); const source = fs.readFileSync(contractPath, 'utf8'); // solc 的 standard-json 输入结构 const input = { language: 'Solidity', sources: { 'Funding.sol': { content: source } }, settings: { optimizer: { enabled: true, runs: 200 }, outputSelection: { '*': { '*': ['abi', 'evm.bytecode'] } } } }; const output = JSON.parse(solc.compile(JSON.stringify(input))); if (output.errors) { const hasError = output.errors.some(e => e.severity === 'error'); console.error(output.errors); if (hasError) process.exit(1); } const contract = output.contracts['Funding.sol']['Funding']; const buildDir = path.resolve(__dirname, 'build'); if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir); fs.writeFileSync( path.join(buildDir, 'Funding.json'), JSON.stringify( { abi: contract.abi, bytecode: contract.evm.bytecode.object }, null, 2 ) ); console.log('编译完成,输出到 build/Funding.json');逻辑说明:solc.compile的入参是一整个 JSON 字符串,不是单独的合约文本。outputSelection指定要导出哪些字段:abi是给前端用的接口描述,evm.bytecode是部署时需要的创建代码。如果合约有继承或导入其他文件,需要在sources里把所有源文件都传进去,只传一个主文件会导致Source not found。
参数说明:optimizer.enabled开启后会增大编译耗时但降低 gas 消耗,runs: 200表示编译器按“该函数预期执行 200 次”来优化,数值越大越侧重运行成本。对本地测试网络没有实际区别,若部署到主网或测试网,保持 200 是个平衡值。output.errors里含有severity: "warning"时编译不会中断,只有error才会。写脚本时不要忽略 warning,有些 warning 是版本过时的提示,升级 Solidity 时全靠它们定位。
3.3 02-deploy.js 拆解:RPC 地址、私钥与 gas 设置
编译产物出来后就是部署。以 Ganache 为例,本地链默认跑在http://127.0.0.1:8545,部署前需要在 Ganache 界面复制一个带 ETH 的账户地址和私钥。下面是一个可直接运行的02-deploy.js:
// 02-deploy.js —— 把 build/Funding.json 部署到目标链 const fs = require('fs'); const path = require('path'); const Web3 = require('web3'); const rpcUrl = 'http://127.0.0.1:8545'; const privateKey = '0x...'; // 替换成 Ganache 提供的测试私钥 const web3 = new Web3(rpcUrl); async function main() { const { abi, bytecode } = JSON.parse( fs.readFileSync(path.resolve(__dirname, 'build', 'Funding.json'), 'utf8') ); const account = web3.eth.accounts.privateKeyToAccount(privateKey); web3.eth.accounts.wallet.add(account); web3.eth.defaultAccount = account.address; const contract = new web3.eth.Contract(abi); const deployTx = contract.deploy({ data: '0x' + bytecode, arguments: [] }); // 先用 estimateGas 估算,再留 20% 冗余 const gas = await deployTx.estimateGas({ from: account.address }); const deployed = await deployTx.send({ from: account.address, gas: Math.floor(gas * 1.2), gasPrice: await web3.eth.getGasPrice() }); console.log('部署成功,合约地址:', deployed.options.address); // 把地址写给前端,避免硬编码 fs.writeFileSync( path.resolve(__dirname, 'build', 'contract-address.txt'), deployed.options.address ); } main().catch((err) => { console.error('部署失败:', err.message); process.exit(1); });逻辑说明:privateKeyToAccount把私钥转成可签名的账户对象,wallet.add把它挂到 web3 实例上,之后send会自动签名。estimateGas不真正扣费,它通过eth_estimateGas查询需要的 gas,我习惯再乘 1.2 留足余量,否则复杂合约容易出现gas too low。
参数说明:deploy({ data: '0x' + bytecode, arguments: [] })中bytecode来自编译产物,但data必须加0x前缀,不加直接报invalid bytecode。arguments是构造函数参数,合约没有构造函数就传空数组。部署时from账户必须持有 ETH,否则第一步estimateGas就会失败。测试环境的私钥放在脚本里没问题,但如果以后要部署到真实测试网,建议改用.env文件,不要把私钥提交到代码仓库。
3.4 部署后怎么验证
部署成功的标志不是终端打印一行地址,而是链上真的存在合约字节码。最直接的验证方式是查eth_getCode,返回结果长度大于 2 说明有代码:
# 返回的 result 如果是 0x 开头且长度大于 2,说明合约已部署 curl -X POST http://127.0.0.1:8545 \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"eth_getCode","params":["合约地址", "latest"],"id":1}'再配合 Node 脚本做一次业务验证,调用campaigns(0)看能不能正常读:
node -e " const Web3 = require('web3'); (async () => { const web3 = new Web3('http://127.0.0.1:8545'); const fs = require('fs'); const addr = fs.readFileSync('build/contract-address.txt', 'utf8').trim(); const { abi } = require('./build/Funding.json'); const c = new web3.eth.Contract(abi, addr); console.log('合约可查询,campaigns 数量:', await c.methods.campaigns(0).call().catch(() => '还没有项目')); })(); "逻辑说明:eth_getCode是最底层的“代码存在性”检查,ABI 查询则是业务层面的检查。两条都过,说明编译产物和部署地址是对得上的。如果第二步报code size is zero,基本就是字节码没写入成功,回查ethereumjs版本或 Ganache 链状态。
4. 从前端到钱包:发起与参与众筹的完整链路
4.1 连接 MetaMask:拿到账户与链信息
前端第一步是拿到“谁能调用合约”的凭证,也就是账户地址。MetaMask 在浏览器里注入window.ethereum,eth_requestAccounts会唤起授权窗口。这里有个细节:只有用户点了授权,脚本才能拿到账户;授权过一次后刷新页面不会重复弹窗。
// 连接 MetaMask 并返回当前账户地址 async function connectWallet() { if (!window.ethereum) { throw new Error('请先安装 MetaMask 浏览器插件'); } const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }); if (!accounts || accounts.length === 0) { throw new Error('MetaMask 未授权账户'); } return accounts[0]; }逻辑说明:eth_requestAccounts会强制唤起授权,eth_accounts只返回已授权账户、不弹窗。通常拿来做“检测是否已连接”的接口,两者应用场景不同,别混用。返回的accounts是字符串数组,第一个是当前选中账户。
参数说明:在多账户模式下accounts[0]就是 MetaMask 当前选中的账户。如果需要链 ID,可以再调eth_chainId,返回值是十六进制字符串,例如本地链返回0x539。前端拿到链 ID 后可以做网络校验,避免用户在主网操作测试合约。
4.2 发起众筹:单位换算、交易组装与等待确认
钱包连接后,创建众筹的核心逻辑是调用createCampaign。这里最容易出问题的是单位:合约里goal单位是 wei,durationSec单位是秒,而 UI 上通常输入 ETH 和天数,所以前端必须做两次换算,漏掉一次,创建出来的项目金额就会差 10¹⁸ 倍。
// 发起一个众筹项目 async function createCampaign(title, desc, goalEth, durationDays) { const from = await connectWallet(); const goalWei = web3.utils.toWei(goalEth.toString(), 'ether'); const durationSec = durationDays * 24 * 60 * 60; const result = await contract.methods .createCampaign(title, desc, goalWei, durationSec) .send({ from: from }); console.log('创建成功,交易哈希:', result.transactionHash); return result.events.CampaignCreated.returnValues; }逻辑说明:.send()的流程是:构造交易 → 用from发起 → MetaMask 弹出签名确认 → 交易广播到链上 → 等待确认 → 返回交易收据。result.events.CampaignCreated.returnValues是解析出来的事件内容,不用再回头查链。.send()内部会调用estimateGas做一次估算,所以代码里没有显式写 gas,也是可行的。
参数说明:web3.utils.toWei(goalEth.toString(), 'ether')的第一个参数必须是字符串,直接传数字在部分 web3 版本里可能出现精度问题。durationSec在 JS 里先算好,Solidity 0.8 会自动把整数转成 uint256,但前端必须先做范围校验,拒绝负数、NaN 和过长天数。
4.3 参与众筹和事件监听:前端保持同步的关键
参与众筹的方法是contribute,逻辑最简单,但不该只想着“调用成功就行”。如果另一个支持者投了一笔,当前页面的进度条不会自动更新,必须通过事件监听刷新。合约里已经声明了Contributed事件,前端订阅它即可:
// 监听新捐款事件,更新 UI contract.events.Contributed({ fromBlock: 'latest' }) .on('data', (event) => { const { id, supporter, amount } = event.returnValues; const amountEth = web3.utils.fromWei(amount, 'ether'); console.log(`项目 #${id} 收到来自 ${supporter} 的 ${amountEth} ETH`); refreshCampaign(id); // 重新拉取项目详情并渲染 }) .on('error', (err) => console.error('事件订阅出错', err));逻辑说明:fromBlock: 'latest'表示只监听进入最新区块的新事件,不回溯历史。refreshCampaign(id)内部重新调用campaigns(id).call()查询链上当前状态,并把进度条、已筹金额重新渲染。这里不直接信任event.returnValues里的累计值,因为有可能漏掉部分事件导致总和不准,查合约状态最稳妥。
参数说明:Contributed事件的amount是 wei,展示给用户前必须fromWei转成 ETH。另外,公开节点服务(如 Infura)对getPastEvents有区块范围限制,单次查询范围过大时会报limit exceeded,所以历史事件要分片查,或者只监听新增事件、用合约方法读当前状态。这个在毕设演示时尤其值得注意,避免第一次加载页面白屏。
5. 避坑指南:从编译版本到钱包测试的六条血泪记录
5.1 solc 版本对不上:合约是 0.8 语法,脚本装的却是 0.6
现象:node 01-compile.js直接抛ParserError,提示Expected token,看代码却觉得语法没问题。
原因:solc以 npm 包形式安装,版本由package.json决定。只改了合约里的pragma而没升级 solc 包,低版本编译器自然认不出新语法。
解决:在package.json里固定 solc 版本,例如"solc": "0.8.21";同时把pragma solidity ^0.8.0和实际安装版本保持一致。部署前先跑node -e "console.log(require('solc').version())"确认编译器版本,这一步我已经刻进习惯了。
5.2 solc-js 装包时卡在下载编译器二进制
现象:npm install solc卡在半路,一直停在soljson相关下载,或报ETIMEDOUT。
原因:solc 的 npm 包安装时会从远程拉取对应版本的编译器二进制,网络不稳定时容易失败,和普通依赖安装失败表现不一样。
解决:常见做法是切换镜像源重装,或者手动把编译器二进制放到本地缓存目录。更省事的方案是把package-lock.json固定提交,之后团队成员直接按锁文件安装,减少版本漂移。这个坑虽然不深,但在机房遇到一次就会记住。
5.3 MetaMask 连不上本地链:一直转圈或报 network error
现象:MetaMask 选了“本地主机 8545”,请求始终 loading,控制台报network error或could not fetch chain ID。
原因:本地链节点没启动,或 RPC 端口不是 8545。另一个隐蔽原因是页面地址用了localhost,MetaMask 对localhost和127.0.0.1的处理偶尔不一致。
解决:确认 Ganache 已启动,并在 MetaMask 网络配置里新增一条 RPC URL,统一使用http://127.0.0.1:8545,chainId 填本地链的 ID。页面访问也统一用127.0.0.1,避免混用。演示现场这条如果没提前验证,基本就是翻车现场。
5.4 只读函数用了 send 而不是 call
现象:点击“查询项目详情”,MetaMask 弹出转账确认,用户被迫付 gas 才能看数据,页面响应还特别慢。
原因:把campaigns(id).call()写成了campaigns(id).send({ from })。几乎所有从 Remix 转到 web3 的人都犯过这个错,因为 Remix 里点击查询按钮不会区分 call 和 send。
解决:建立一条硬性规则:view和pure函数一律.call();只有改变状态的createCampaign、contribute、finalize、refund用.send()。排查时看函数声明里有没有view,有就绝对不该是 send。
5.5 getPastEvents 查询范围过大:limit exceeded
现象:页面首次加载想展示历史项目,调用getPastEvents('CampaignCreated', { fromBlock: 0 }),节点返回limit exceeded。
原因:Infura 等公共节点限制了单次日志查询的区块范围,全量查询 0 到最新是不可能的。
解决:按区块范围分片查询,比如每次查 2000 个区块,循环拼接结果。毕设场景下更简单的做法是只订阅新增事件,历史数据通过campaigns(id)读当前状态,不依赖事件还原列表。
5.6 换链之后合约地址没更新
现象:把合约重新部署到另一个测试网,前端还在调用旧地址,所有数据读不出来。
原因:前端从contract-address.txt读取地址失败,或者浏览器缓存了旧配置。
解决:部署脚本每次把地址覆盖写入build/contract-address.txt,前端页面加载时强制读取一次,并在控制台打印当前地址和链 ID。这样演示时先看控制台确认,再判断是缓存还是配置问题。
6. 把项目改造成自己的毕设:加功能、换主题与答辩演示
6.1 三个低成本但能给评审留下印象的改造点
如果拿到源码直接整个交上去,评委见多了类似项目,分数不会太高。改动不用大刀阔斧,三个小方向就能做出辨识度。
第一,把退款规则做成可配置。原合约里refund在众筹失败后触发,你可以加一个提前退出机制:募资期间出资者可以申请退还,同时把raised扣减。这个逻辑考验对状态变更的理解,能讲清楚就是一个加分点。
第二,给项目增加分类字段。例如在Campaign里加category,创建时传入,前端加分类筛选。合约改动很小,但功能完整度明显提升,代码量和复杂度都容易把控。
第三,加一个“我的支持记录”页面。通过事件过滤拿到当前账户的Contributed记录,渲染成列表。这个方案看起来简单,但能顺带回答“为什么用事件而不是用 mapping 存储”,是答辩时最容易铺开的话题。
6.2 答辩演示前必须强制走一遍的验证路径
演示最怕现场翻车,所以每次我拿到 Dapp 项目,答辩前都会强制走一遍下面这个流程:
# 1) 清空旧产物,重新编译 rm -rf build && node 01-compile.js # 2) 启动本地链并部署 node 02-deploy.js # 3) 启动前端,MetaMask 授权账户 # 4) 用主账号创建一笔众筹:标题、目标 0.1 ETH、时长 1 天 # 5) 换一个账号投 0.05 ETH,确认进度条变为 50% # 6) 用主账号查询项目详情,确认 supporterCount 为 2 # 7) 将时长改短为 10 秒,等待到期后执行 finalize,确认收款地址余额增加第 7 步那个“等 10 秒”很关键,它证明合约真的在管钱,而不是页面写死的假数据。演示时如果能展示发起人收款前后的余额变化,比讲十页 PPT 都有说服力。从那以后我每次碰这类项目,都会强制把“看地图、跑部署、分清 call/send、验证事件”这一套走完,再谈其他。希望这份拆解也能帮你在毕设或课设里少走弯路。
本文还有配套的精品资源,点击获取