HTML 结构:
连接钱包区域
- 连接钱包按钮:触发 MetaMask 连接
- 账户地址显示:显示当前连接的账户
- 合约地址显示:显示已部署的合约地址
我的概览区域
- 历史出资总额:显示当前账户在所有项目中的累计出资额
- 交易历史表格:显示出资、退款、提取记录,包含时间、类型、项目ID、金额、交易哈希(带区块浏览器链接)
合约控制面板
- 管理员地址显示
- 当前状态显示(运行中/已暂停)
- 暂停/恢复按钮(仅管理员可用)
创建项目表单
- 标题输入框
- 描述输入框
- 目标金额输入框(单位:ETH)
- 截止时间选择器
- 创建按钮和状态提示
项目列表区域
- 刷新列表按钮
- 项目总数显示
- 筛选下拉框(全部/进行中/已达成/未达成/已提取)
- 项目列表
一、部署到 Sepolia
npm run compile
npx hardhat run deploy.js --network sepolia
npm run export-frontend
把 contract-address.json 与 CrowdFund.json 一并部署到你的静态站点即可。
部署脚本会把 deploymentBlock 一并写入 contract-address.json,前端将据此从部署区块开始检索事件,提高效率。
二、功能测试
1、项目部署,创建一个众筹项目,目标4ETH
2、提交完成后,在项目列表里可以显示项目详情。

3、这里只有管理员也就是合约发布者才有资格暂停合约的新增交易,同时可以转移管理员权限
4、捐款
账户1捐赠2ETH,等待交易入块后,可以在我的概栏中展现交易明细,并且点击交易哈希可以显示交易详细信息。

5、提款:合约到期后,且达到众筹目标后,合约发布者可以提取合约金额

三、项目注意点
管理员与暂停机制详解
核心状态变量:
address public admin:管理员地址,构造函数中初始化为部署者(msg.sender)bool public paused:暂停状态标志,false为运行中,true为已暂停
修饰器 whenNotPaused:
modifier whenNotPaused() {
if (paused) revert Paused();
_;
}
- 检查
paused状态,若为true则 revertPaused()错误,阻止函数执行 - 应用于所有关键写入操作:
createCampaign、contribute、withdraw、refund
管理员控制函数:
function setPaused(bool value) external {
if (msg.sender != admin) revert NotAdmin();
paused = value;
}
function setAdmin(address newAdmin) external {
if (msg.sender != admin) revert NotAdmin();
require(newAdmin != address(0), "zero admin");
admin = newAdmin;
}
setPaused(bool):仅管理员可调用,用于切换暂停状态setAdmin(address):仅管理员可调用,用于转移管理员权限(不能设置为零地址)
工作流程:
- 部署时:
admin = msg.sender(部署者成为管理员),paused = false(默认运行中) - 紧急情况:管理员调用
setPaused(true),所有写入操作(创建/出资/提取/退款)立即被阻止 - 恢复服务:管理员调用
setPaused(false),所有操作恢复正常
- ⚠️ 暂停不影响读取操作:
getCampaign、totalCampaigns等视图函数仍可正常调用 - ⚠️ 暂停是全局的:一旦暂停,所有项目的创建/出资/提取/退款都会被阻止
- ⚠️ 管理员权限集中:请妥善保管管理员私钥
- ⚠️ 无法暂停已确认的交易:暂停只能阻止新的交易提交
初始化合约状态功能:
创建合约createCampaign
function createCampaign(
string memory title,
string memory description,
uint256 goal,
uint256 deadline
) external whenNotPaused returns (uint256 campaignId) {
if (goal == 0) revert InvalidGoal();
if (deadline <= block.timestamp) revert InvalidDeadline();
campaigns.push(
Campaign({
owner: payable(msg.sender),
title: title,
description: description,
goal: goal,
deadline: deadline,
raised: 0,
claimed: false
})
);
campaignId = campaigns.length - 1;
emit CampaignCreated(campaignId, msg.sender, title, goal, deadline);
}
admin = msg.sender;msg.sender是部署合约的账户地址- 这是唯一一次设置初始管理员的机会
- 后续只能通过
setAdmin()转移权限
_locked = 0;- 初始化重入锁标志为 0(未锁定状态)
_locked是uint256类型,占用一个存储槽- 0 表示未锁定,1 表示已锁定
paused的初始化bool public paused;在声明时默认值为false- Solidity 中
bool类型默认值为false - 因此合约部署后默认为运行状态
修饰器
whenNotPaused执行- 在函数体执行前检查
paused状态 - 如果
paused == true,立即 revertPaused()错误 - 如果
paused == false,继续执行函数体
- 在函数体执行前检查
if (goal == 0) revert InvalidGoal();- 检查目标金额是否为 0
- 如果为 0,使用
revert InvalidGoal()回滚交易 revert会撤销所有状态更改并返还剩余 gas
if (deadline <= block.timestamp) revert InvalidDeadline();block.timestamp是当前区块的时间戳(秒级精度)- 检查截止时间是否小于等于当前时间
- 如果已过期或等于当前时间,revert
InvalidDeadline() - 注意:使用
<=而非<,确保截止时间必须严格大于当前时间
campaigns.push(...)campaigns是Campaign[]动态数组,存储在 storage 中push()操作在数组末尾添加新元素- 创建
Campaign结构体实例:owner: payable(msg.sender):将msg.sender转换为payable address,允许接收 ETHtitle: title:从内存参数复制到 storagedescription: description:同上,字符串复制goal: goal:直接赋值,uint256类型deadline: deadline:直接赋值,uint256类型raised: 0:初始化为 0,表示尚未筹集任何资金claimed: false:初始化为false,表示尚未提取
campaignId = campaigns.length - 1;campaigns.length是数组当前长度(push 后已增加 1)- 数组索引从 0 开始,所以最后一个元素的索引是
length - 1 - 例如:第一个项目
length = 1,索引为1 - 1 = 0
emit CampaignCreated(...)- 触发事件,记录在区块链日志中
- 事件参数:
campaignId:索引参数,可用于过滤msg.sender:索引参数,可用于过滤title、goal、deadline:非索引参数,存储在日志数据中
捐款contribute
function contribute(uint256 campaignId) external payable whenNotPaused {
Campaign storage c = _campaign(campaignId);
if (block.timestamp >= c.deadline) revert DeadlinePassed();
require(msg.value > 0, "No ETH sent");
c.raised += msg.value;
totalRaised += msg.value;
contributions[campaignId][msg.sender] += msg.value;
emit Contributed(campaignId, msg.sender, msg.value);
}
参数:
campaignId:项目 ID(uint256类型)msg.value:出资金额(wei),通过payable修饰符接收,必须 > 0
代码逻辑:
修饰器
whenNotPaused执行- 检查
paused状态,如果为true则 revert
- 检查
Campaign storage c = _campaign(campaignId);- 调用内部函数
_campaign(campaignId)获取项目的 storage 引用 _campaign()内部会检查campaignId < campaigns.length,否则 revert “Invalid campaign”
- 调用内部函数
if (block.timestamp >= c.deadline) revert DeadlinePassed();block.timestamp是当前区块时间戳(秒)c.deadline是项目的截止时间- 如果当前时间 >= 截止时间,说明已过期,revert
DeadlinePassed() - 注意:使用
>=而非>,确保在截止时间那一刻就不能再出资
require(msg.value > 0, "No ETH sent");msg.value是随交易发送的 ETH 金额(wei)require()是 Solidity 内置函数,条件为false时 revert- 如果
msg.value == 0,revert 并显示错误信息 “No ETH sent” - 注意:
require()会消耗所有剩余 gas,revert会返还剩余 gas
c.raised += msg.value;- 更新项目的已筹金额
+=是复合赋值运算符,等价于c.raised = c.raised + msg.value- 允许超额筹集:
raised可以超过goal
totalRaised += msg.value;- 更新全局累计筹集金额
totalRaised是所有项目的总和- 每次出资都会累加,用于统计整个平台的筹集总额
contributions[campaignId][msg.sender] += msg.value;- 更新嵌套映射,记录个人出资额
contributions[campaignId][msg.sender]是两层映射:- 第一层:
campaignId→ 映射 - 第二层:
address→uint256(出资额)
- 第一层:
- 同一地址可多次出资,金额会累加
- 例如:第一次出资 1 ETH,第二次出资 0.5 ETH,则
contributions[campaignId][msg.sender] = 1.5 ETH
emit Contributed(campaignId, msg.sender, msg.value);- 触发事件,记录出资信息
- 事件参数:
campaignId:索引参数(indexed),可用于过滤msg.sender:索引参数(indexed),可用于过滤msg.value:非索引参数,存储在日志数据中
- 前端可以通过事件监听实时更新 UI
状态变化:
campaigns[campaignId].raised:增加msg.valuetotalRaised:增加msg.valuecontributions[campaignId][msg.sender]:增加msg.value- 合约余额:增加
msg.value(ETH 自动存入合约)
注意事项:
- 允许超额筹集:
raised可以超过goal,不设上限 - 同一地址可多次出资:金额累加,无次数限制
- 必须在截止时间之前出资:过期后无法出资,只能退款(如果未达成目标)
- ETH 自动存入合约:
msg.value会直接存入合约地址,由合约持有
提款withdraw
function withdraw(uint256 campaignId) external nonReentrant whenNotPaused {
Campaign storage c = _campaign(campaignId);
if (msg.sender != c.owner) revert NotOwner();
if (block.timestamp < c.deadline) revert DeadlineNotReached();
if (c.raised < c.goal) revert GoalNotReached();
if (c.claimed) revert AlreadyClaimed();
c.claimed = true;
uint256 amount = c.raised;
(bool ok, ) = c.owner.call{value: amount}("");
require(ok, "Transfer failed");
emit Withdrawn(campaignId, c.owner, amount);
}
修饰器
nonReentrant执行- 检查
_locked状态:- 如果
_locked == 1,说明函数正在执行中,revertReentrancy()错误 - 如果
_locked == 0,设置_locked = 1,继续执行
- 如果
- 函数执行完毕后,设置
_locked = 0(在修饰器末尾) - 这是防止重入攻击的关键机制
- 检查
修饰器
whenNotPaused执行- 检查
paused状态,如果为true则 revert
- 检查
Campaign storage c = _campaign(campaignId);- 获取项目的 storage 引用
- 验证项目存在,否则 revert “Invalid campaign”
if (msg.sender != c.owner) revert NotOwner();- 验证调用者是否为项目发起人
msg.sender是当前交易的发起者c.owner是项目创建时的发起人地址- 如果地址不匹配,revert
NotOwner()错误 - 这是权限控制,确保只有发起人可以提取
if (block.timestamp < c.deadline) revert DeadlineNotReached();- 验证是否已到截止时间
- 如果当前时间 < 截止时间,说明尚未到期,revert
DeadlineNotReached() - 必须在截止时间之后才能提取
if (c.raised < c.goal) revert GoalNotReached();- 验证是否达成目标金额
- 如果已筹金额 < 目标金额,说明未达成,revert
GoalNotReached() - 只有达成目标才能提取
if (c.claimed) revert AlreadyClaimed();- 验证是否已经提取过
- 如果
c.claimed == true,说明已经提取过,revertAlreadyClaimed() - 防止重复提取
c.claimed = true;- 关键:先更新状态
- 将
claimed标志设置为true - 必须在转账之前更新,防止重入攻击
uint256 amount = c.raised;- 记录提取金额
- 将项目的已筹金额保存到局部变量
- 使用局部变量可以节省 gas
- 提取的是全部已筹金额,包括超额部分
(bool ok, ) = c.owner.call{value: amount}("");- 关键:执行外部调用
call{value: amount}("")是低级调用,向c.owner发送amountwei 的 ETH""表示不调用任何函数,只是转账- 返回值
ok表示调用是否成功(true或false) - 第二个返回值(函数选择器)被忽略(使用
_)
require(ok, "Transfer failed");- 验证转账是否成功
- 如果
ok == false,说明转账失败,revert “Transfer failed” - 转账失败的原因可能是:
- 接收地址是合约且拒绝接收 ETH(revert)
- Gas 不足(虽然
call转发所有 gas,但可能仍不足) - 其他异常情况
emit Withdrawn(campaignId, c.owner, amount);- 触发事件,记录提取信息
- 事件参数:
campaignId:索引参数c.owner:索引参数amount:非索引参数
退款refund
function refund(uint256 campaignId) external nonReentrant whenNotPaused {
Campaign storage c = _campaign(campaignId);
if (block.timestamp < c.deadline) revert DeadlineNotReached();
if (c.raised >= c.goal) revert GoalNotReached();
uint256 amount = contributions[campaignId][msg.sender];
if (amount == 0) revert NothingToRefund();
// Effects
contributions[campaignId][msg.sender] = 0;
// Interactions
(bool ok, ) = payable(msg.sender).call{value: amount}("");
require(ok, "Refund failed");
emit Refunded(campaignId, msg.sender, amount);
}
修饰器
nonReentrant执行- 检查并设置重入锁,防止重入攻击
- 工作原理与
withdraw函数相同
修饰器
whenNotPaused执行- 检查
paused状态,如果为true则 revert
- 检查
Campaign storage c = _campaign(campaignId);- 获取项目的 storage 引用
- 验证项目存在,否则 revert “Invalid campaign”
if (block.timestamp < c.deadline) revert DeadlineNotReached();- 验证是否已到截止时间
- 如果当前时间 < 截止时间,说明尚未到期,revert
DeadlineNotReached() - 必须在截止时间之后才能退款
if (c.raised >= c.goal) revert GoalNotReached();- 验证项目是否未达成目标
- 如果已筹金额 >= 目标金额,说明已达成目标,revert
GoalNotReached() - 注意:这里使用
>=而非>,确保刚好达成目标时也不能退款 - 只有未达成目标的项目才能退款
uint256 amount = contributions[campaignId][msg.sender];- 读取个人出资额
contributions[campaignId][msg.sender]是嵌套映射,查询该地址在该项目中的出资额- 如果从未出资,值为 0
- 如果已退款,值也为 0
if (amount == 0) revert NothingToRefund();- 验证是否有可退款的金额
- 如果
amount == 0,说明:- 该地址从未出资,或
- 该地址已经退款过(
contributions已被清零)
- revert
NothingToRefund()错误
contributions[campaignId][msg.sender] = 0;- 关键:先更新状态
- 将个人出资记录清零
- 必须在转账之前清零,防止重入攻击
- 如果转账失败,状态已经更新,但整个交易会 revert,状态会回滚
(bool ok, ) = payable(msg.sender).call{value: amount}("");payable(msg.sender)将msg.sender转换为payable address类型call{value: amount}("")向调用者发送amountwei 的 ETH- 返回值
ok表示调用是否成功 - 使用
call而非transfer的原因与withdraw相同
require(ok, "Refund failed");- 验证转账是否成功
- 如果
ok == false,说明转账失败,revert “Refund failed” - 如果转账失败,整个交易会 revert,所有状态更改都会回滚
emit Refunded(campaignId, msg.sender, amount);- 触发事件,记录退款信息
- 事件参数:
campaignId:索引参数msg.sender:索引参数amount:非索引参数
重入保护机制详解:
nonReentrant 修饰器的实现:
modifier nonReentrant() {
if (_locked == 1) revert Reentrancy();
_locked = 1;
_; // 执行函数体
_locked = 0;
}
工作原理:
- 函数调用时,检查
_locked是否为 1 - 如果为 1,说明函数正在执行中,revert(防止重入)
- 如果为 0,设置
_locked = 1,锁定状态 - 执行函数体(包括外部调用)
- 函数执行完毕后,设置
_locked = 0,解锁
为什么需要重入保护?
- 在
withdraw中,先更新状态(claimed = true)再转账 - 如果接收地址是恶意合约,在
call时可能回调withdraw - 如果没有重入保护,第二次调用时
claimed仍为false(因为第一次调用还未完成) - 可能导致重复提取,造成资金损失
常见问题
- 请妥善保管私钥,仅在测试环境使用。
- 出资后页面未更新:事件监听会自动刷新,偶尔网络原因可手动点击“刷新列表”。
- 本地链账号余额:使用 Hardhat 内置账户;若切到测试网,请在水龙头获取测试币后再操作(建议闲鱼)。
四、前后端交互逻辑
github地址:https://github.com/qwerLoL11/firstWeb3.git
开发阶段:
编写 CrowdFund.sol
↓
npx hardhat compile
↓
生成 CrowdFund.json
↓
npm run export-frontend
↓
生成 CrowdFund.json
部署阶段:
运行 deploy.js
↓
读取 CrowdFund.json
↓
部署到区块链网络
↓
生成 contract-address.json
生成 CrowdFund.json
运行阶段:
用户打开前端页面
↓
app.js 加载配置文件
├─ contract-address.json (合约地址)
└─ CrowdFund.json (ABI)
↓
创建 Contract 实例
↓
通过 Ethers.js 调用合约函数
↓
与区块链上的合约实例交互
浙公网安备 33010602011771号