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

1、项目部署

2、创建一个众筹项目,目标4ETH

3、提交完成后,在项目列表里可以显示项目详情。

4、这里只有管理员即合约发布者才有资格暂停合约的新交易,同时可以转移管理员权限

5、捐款

账户1捐赠2ETH,等待交易入块后,可以在我的概览中展现交易明细,并且点击交易哈希可以显示交易详细信息。

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

管理员与暂停机制详解
核心状态变量:
address public admin
:管理员地址,构造函数中初始化为部署者(
msg.sender
)
bool public paused
:暂停状态标志,
false
为运行中,
true
为已暂停
修饰器
whenNotPaused
:
modifier whenNotPaused() {
if (paused) revert Paused();
_;
}
检查
paused
状态,若为
true
则 revert
Paused()
错误,阻止函数执行
应用于所有关键写入操作:
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
等视图函数仍可正常调用
?? 暂停是全局的:
一旦暂停,所有项目的创建/出资/提取/退款都会被阻止
?? 管理员权限集中:
请妥善保管管理员私钥,建议使用多签钱包或 Timelock 合约管理
?? 无法暂停已确认的交易:
暂停只能阻止新的交易提交,已进入 mempool 的交易仍可能被执行
初始化合约状态功能:
创建合约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
是部署合约的账户地址(EOA 或合约地址)
将
admin
状态变量写入存储槽(storage slot)
这是唯一一次设置初始管理员的机会
后续只能通过
setAdmin()
转移权限
_locked = 0;
初始化重入锁标志为 0(未锁定状态)
_locked
是
uint256
类型,占用一个存储槽
0 表示未锁定,1 表示已锁定
虽然
uint256
默认值为 0,但显式赋值更清晰
paused
的初始化
bool public paused;
在声明时默认值为
false
Solidity 中
bool
类型默认值为
false
因此合约部署后默认为运行状态
修饰器
whenNotPaused
执行
在函数体执行前检查
paused
状态
如果
paused == true
,立即 revert
Paused()
错误
如果
paused == false
,继续执行函数体
if (goal == 0) revert InvalidGoal();
检查目标金额是否为 0
goal
是
uint256
类型,无符号整数,最小值为 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.senderpayable addresstitle: title
:从内存参数复制到 storage(字符串复制,gas 耗费较高)
description: description
:同上,字符串复制
goal: goal
:直接赋值,
uint256deadline: deadline
:直接赋值,
uint256raised: 0
:初始化为 0,表示尚未筹集任何资金
claimed: false
:初始化为
falsecampaignId = campaigns.length - 1;
campaigns.length数组索引从 0 开始,所以最后一个元素的索引是
length - 1
例如:第一个项目
length = 11 - 1 = 0
emit CampaignCreated(...)campaignIdmsg.sendertitlegoaldeadline捐款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);
}campaignIduint256msg.valuepayable代码逻辑:
修饰器
whenNotPausedpausedtrueCampaign storage c = _campaign(campaignId);
调用内部函数
_campaign(campaignId)_campaign()campaignId < campaigns.lengthstoragecCampaign storageif (block.timestamp >= c.deadline) revert DeadlinePassed();
block.timestampc.deadlineDeadlinePassed()
注意:使用
>=>require(msg.value > 0, "No ETH sent");
msg.valuerequire()falsemsg.value == 0require()revertc.raised += msg.value;
更新项目的已筹金额
+=c.raised = c.raised + msg.value
这是 storage 写入操作,gas 耗费取决于:
如果
c.raisedc.raised允许超额筹集:
raisedgoal
totalRaised += msg.value;totalRaisedcontributions[campaignId][msg.sender] += msg.value;
更新嵌套映射,记录个人出资额
contributions[campaignId][msg.sender]campaignIdaddressuint256contributions[campaignId][msg.sender] = 1.5 ETHemit Contributed(campaignId, msg.sender, msg.value);
触发事件,记录出资信息
事件参数:
campaignIdindexedmsg.senderindexedmsg.value状态变化:
campaigns[campaignId].raisedmsg.valuetotalRaisedmsg.valuecontributions[campaignId][msg.sender]msg.valuemsg.value注意事项:
允许超额筹集:
raisedgoalmsg.value错误处理:
Paused()whenNotPaused_campaign()DeadlinePassed()提款withdrawfunction 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
,表明函数正在运行中,revert
Reentrancy()
错误
如果
_locked == 0
,设置
_locked = 1
,继续执行
函数完成运行后,设置
_locked = 0
(在装饰器结尾处)
这是预防重入攻击的重要机制
装饰器
whenNotPaused
执行
检查
paused
状态,如果为
true
则 revert
Campaign storage c = _campaign(campaignId);
获取项目的 storage 引用
验证项目存在,否则 revert “无效活动”
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
,说明已提取过,revert
AlreadyClaimed()
防止重复提取
c.claimed = true;
关键:先更新状态(CEI 模式中的效果)
将
claimed
标志设置为
true
这是 storage 写入操作,约 5,000 gas(从
false
更新为
true
)
必须在转账前更新,以防重入攻击
如果转账失败,状态已经更新,但可通过其他机制恢复(本合约中如果转账失败会 revert)
uint256 amount = c.raised;
记录提取金额
将项目的已筹集金额保存到局部变量
使用局部变量可以节省 gas(避免重复读取 storage)
提取的是全部已筹集金额,包括超出部分
(bool ok, ) = c.owner.call{value: amount}("");
关键:执行外部调用(CEI 模式中的交互)
call{value: amount}("")
是低级调用,向
c.owner
发送
amount
wei 的 ETH
""
表示不调用任何函数,仅转账
返回值
ok
表示调用是否成功(
true
或
false
)
第二个返回值(函数选择器)被忽略(使用
_
)
使用
call
而非
transfer
或
send
的原因:
transfer
和
send
有 2,300 gas 限制,可能导致失败
call
转发所有剩余 gas,更灵活
但需手动检查返回值
require(ok, "Transfer failed");
验证转账是否成功
如果
ok == false
,说明转账失败,revert “转账失败”
转账失败的原因可能是:
接收地址是合约且拒绝接收 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 “无效活动”
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(因步骤 8 会清零)
if (amount == 0) revert NothingToRefund();
验证是否有可退款的金额
如果
amount == 0
,说明:
该地址从未出资,或
该地址已经退款过(
contributions
已被清零)
revert
NothingToRefund()
错误
contributions[campaignId][msg.sender] = 0;
(注释:效果)
关键:先更新状态(CEI 模式中的效果)
将个人出资记录清零
这是 storage 写入操作:
如果从非零值写入零值:约 5,000 gas(SSTORE 清零操作,会返还 gas)
实际上,清零操作会退还大约 15,000 gas(EIP-3529) 必须在转账前清零,以防止重入攻击 如果转账失败,状态已更新,但整个交易会回退,状态会恢复(bool ok, ) = payable(msg.sender).call{value: amount}("");
(注释:互动)
payable(msg.sender)
将
msg.sender
转换为
payable address
类型
call{value: amount}("")
向调用者发送
amount
wei 的 ETH
返回值
ok
表示调用是否成功
使用
call
而不是
transfer
的原因与
withdraw
相同
require(ok, "Refund failed");
验证转账是否成功
如果
ok == false
,说明转账失败,回退 “Refund failed”
如果转账失败,整个交易会回退,所有状态更改都会恢复(包括步骤 8 的清零操作)
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,说明函数正在执行中,回退(防止重入)
如果为 0,设置
_locked = 1
,锁定状态
执行函数体(包括外部调用)
函数执行完毕后,设置
_locked = 0
,解锁
为什么需要重入保护?
在
withdraw
中,先更新状态(
claimed = true
)再转账
如果接收地址是恶意合约,在
call
时可能回调
withdraw
如果没有重入保护,第二次调用时
claimed
仍为
false
(因为第一次调用尚未完成)
可能导致重复提取,造成资金损失
常见问题(FAQ)
请妥善保管私钥,仅在测试环境中使用。
出资后页面未更新:事件监听会自动刷新,偶尔因网络原因可手动点击“刷新列表”。
本地链账号余额:使用 Hardhat 内置账户;若切换到测试网,请在水龙头获取测试币后再操作。
四、前后端交互逻辑
github地址:https://github.com/qwerLoL11/firstWeb3.git
开发阶段:
开发者编写 CrowdFund.sol
↓
npx hardhat compile
↓
生成 artifacts/CrowdFund.json
↓
(可选) npm run export-frontend
↓
生成 frontend/abi/CrowdFund.json
部署阶段:
运行 deploy.js
↓
读取 artifacts/CrowdFund.json
↓
部署到区块链网络
↓
生成 frontend/contract-address.json
生成 frontend/abi/CrowdFund.json
运行阶段:
用户打开前端页面
↓
app.js 加载配置文件
├─ contract-address.json (合约地址)
└─ abi/CrowdFund.json (ABI)
↓
创建 Contract 实例
↓
通过 Ethers.js 调用合约函数
↓
与区块链上的合约实例交互
扫码加好友,拉您进群



收藏
