全部版块 我的主页
论坛 数据科学与人工智能 IT基础
150 0
2025-11-18

HTML 结构:

连接钱包区域

连接钱包按钮:触发 MetaMask 链接

账户地址展示:展示当前链接的账户

合约地址展示:展示已部署的合约地址

我的概览区域

历史出资总额:展示当前账户在所有项目中的累计出资额

交易历史表格:展示出资、退款、提取记录,包含时间、类型、项目ID、金额、交易哈希(带区块浏览器链接)

合约控制面板

管理员地址展示

当前状态展示(运行中/已暂停)

暂停/恢复按钮(仅管理员可用)

创建项目表单

标题输入框

描述输入框

目标金额输入框(单位:ETH)

截止时间选择器(datetime-local)

创建按钮和状态提示

项目列表区域

刷新列表按钮

项目总数展示

筛选下拉框(全部/进行中/已达成/未达成/已提取)

项目列表

一、部署到 Sepolia

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.sender

转换为
payable address

,允许接收 ETH
title: title

:从内存参数复制到 storage(字符串复制,gas 耗费较高)

description: 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”
使用
storage

关键字获取引用,而非复制,节省 gas
c


Campaign storage

类型,直接指向 storage 中的结构体
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

这是 storage 写入操作,gas 耗费取决于:
如果

c.raised

从 0 变为非零:约 20,000 gas
如果
c.raised

从非零更新:约 5,000 gas

允许超额筹集:

raised

可以超过
goal

totalRaised += msg.value;

更新全局累计筹集金额
totalRaised

是所有项目的总和
每次出资都会累加,用于统计整个平台的筹集总额
Gas 耗费同上,取决于是否从零值写入
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.value

totalRaised

:增加
msg.value

contributions[campaignId][msg.sender]

:增加
msg.value

合约余额:增加
msg.value

(ETH 自动存入合约)

注意事项:
允许超额筹集:

raised

可以超过
goal

,不设上限
同一地址可多次出资:金额累加,无次数限制
必须在截止时间之前出资:过期后无法出资,只能退款(如果未达成目标)
ETH 自动存入合约:
msg.value

会直接存入合约地址,由合约持有

错误处理:

Paused()

:合约已暂停(由
whenNotPaused

修饰器抛出)
“Invalid campaign”:项目不存在(由
_campaign()

内部函数抛出)
DeadlinePassed()

:已过截止时间
“No ETH sent”:未发送 ETH 或金额为 0
提款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

,表明函数正在运行中,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 调用合约函数
         ↓
与区块链上的合约实例交互
二维码

扫码加我 拉你入群

请注明:姓名-公司-职位

以便审核进群资格,未注明则拒绝

相关推荐
栏目导航
热门文章
推荐文章

说点什么

分享

扫码加好友,拉您进群
各岗位、行业、专业交流群