HTML 结构:

  1. 连接钱包区域

    • 连接钱包按钮:触发 MetaMask 连接
    • 账户地址显示:显示当前连接的账户
    • 合约地址显示:显示已部署的合约地址
  2. 我的概览区域

    • 历史出资总额:显示当前账户在所有项目中的累计出资额
    • 交易历史表格:显示出资、退款、提取记录,包含时间、类型、项目ID、金额、交易哈希(带区块浏览器链接)
  3. 合约控制面板

    • 管理员地址显示
    • 当前状态显示(运行中/已暂停)
    • 暂停/恢复按钮(仅管理员可用)
  4. 创建项目表单

    • 标题输入框
    • 描述输入框
    • 目标金额输入框(单位:ETH)
    • 截止时间选择器
    • 创建按钮和状态提示
  5. 项目列表区域

    • 刷新列表按钮
    • 项目总数显示
    • 筛选下拉框(全部/进行中/已达成/未达成/已提取)
    • 项目列表

一、部署到 Sepolia

npm run compile
npx hardhat run deploy.js --network sepolia
npm run export-frontend
contract-address.jsonCrowdFund.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 则 revert Paused() 错误,阻止函数执行
  • 应用于所有关键写入操作:createCampaigncontributewithdrawrefund

管理员控制函数:

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):仅管理员可调用,用于转移管理员权限(不能设置为零地址)

工作流程:

  1. 部署时:admin = msg.sender(部署者成为管理员),paused = false(默认运行中)
  2. 紧急情况:管理员调用 setPaused(true),所有写入操作(创建/出资/提取/退款)立即被阻止
  3. 恢复服务:管理员调用 setPaused(false),所有操作恢复正常
  • ⚠️ 暂停不影响读取操作getCampaigntotalCampaigns 等视图函数仍可正常调用
  • ⚠️ 暂停是全局的:一旦暂停,所有项目的创建/出资/提取/退款都会被阻止
  • ⚠️ 管理员权限集中:请妥善保管管理员私钥
  • ⚠️ 无法暂停已确认的交易:暂停只能阻止新的交易提交

初始化合约状态功能:

创建合约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);
}
  1. admin = msg.sender;

    • msg.sender 是部署合约的账户地址
    • 这是唯一一次设置初始管理员的机会
    • 后续只能通过 setAdmin() 转移权限
  2. _locked = 0;

    • 初始化重入锁标志为 0(未锁定状态)
    • _lockeduint256 类型,占用一个存储槽
    • 0 表示未锁定,1 表示已锁定
  3. paused 的初始化

    • bool public paused; 在声明时默认值为 false
    • Solidity 中 bool 类型默认值为 false
    • 因此合约部署后默认为运行状态
  4. 修饰器 whenNotPaused 执行

    • 在函数体执行前检查 paused 状态
    • 如果 paused == true,立即 revert Paused() 错误
    • 如果 paused == false,继续执行函数体
  5. if (goal == 0) revert InvalidGoal();

    • 检查目标金额是否为 0
    • 如果为 0,使用 revert InvalidGoal() 回滚交易
    • revert 会撤销所有状态更改并返还剩余 gas
  6. if (deadline <= block.timestamp) revert InvalidDeadline();

    • block.timestamp 是当前区块的时间戳(秒级精度)
    • 检查截止时间是否小于等于当前时间
    • 如果已过期或等于当前时间,revert InvalidDeadline()
    • 注意:使用 <= 而非 <,确保截止时间必须严格大于当前时间
  7. campaigns.push(...)

    • campaignsCampaign[] 动态数组,存储在 storage 中
    • push() 操作在数组末尾添加新元素
    • 创建 Campaign 结构体实例:
      • owner: payable(msg.sender):将 msg.sender 转换为 payable address,允许接收 ETH
      • title: title:从内存参数复制到 storage
      • description: description:同上,字符串复制
      • goal: goal:直接赋值,uint256 类型
      • deadline: deadline:直接赋值,uint256 类型
      • raised: 0:初始化为 0,表示尚未筹集任何资金
      • claimed: false:初始化为 false,表示尚未提取
  8. campaignId = campaigns.length - 1;

    • campaigns.length 是数组当前长度(push 后已增加 1)
    • 数组索引从 0 开始,所以最后一个元素的索引是 length - 1
    • 例如:第一个项目 length = 1,索引为 1 - 1 = 0
  9. emit CampaignCreated(...)

    • 触发事件,记录在区块链日志中
    • 事件参数:
      • campaignId:索引参数,可用于过滤
      • msg.sender:索引参数,可用于过滤
      • titlegoaldeadline:非索引参数,存储在日志数据中

捐款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

代码逻辑:

  1. 修饰器 whenNotPaused 执行

    • 检查 paused 状态,如果为 true 则 revert
  2. Campaign storage c = _campaign(campaignId);

    • 调用内部函数 _campaign(campaignId) 获取项目的 storage 引用
    • _campaign() 内部会检查 campaignId < campaigns.length,否则 revert “Invalid campaign”
  3. if (block.timestamp >= c.deadline) revert DeadlinePassed();

    • block.timestamp 是当前区块时间戳(秒)
    • c.deadline 是项目的截止时间
    • 如果当前时间 >= 截止时间,说明已过期,revert DeadlinePassed()
    • 注意:使用 >= 而非 >,确保在截止时间那一刻就不能再出资
  4. 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
  5. c.raised += msg.value;

    • 更新项目的已筹金额
    • += 是复合赋值运算符,等价于 c.raised = c.raised + msg.value
    • 允许超额筹集:raised 可以超过 goal
  6. totalRaised += msg.value;

    • 更新全局累计筹集金额
    • totalRaised 是所有项目的总和
    • 每次出资都会累加,用于统计整个平台的筹集总额
  7. contributions[campaignId][msg.sender] += msg.value;

    • 更新嵌套映射,记录个人出资额
    • contributions[campaignId][msg.sender] 是两层映射:
      • 第一层:campaignId → 映射
      • 第二层:addressuint256(出资额)
    • 同一地址可多次出资,金额会累加
    • 例如:第一次出资 1 ETH,第二次出资 0.5 ETH,则 contributions[campaignId][msg.sender] = 1.5 ETH
  8. 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 会直接存入合约地址,由合约持有

提款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);
}
  1. 修饰器 nonReentrant 执行

    • 检查 _locked 状态:
      • 如果 _locked == 1,说明函数正在执行中,revert Reentrancy() 错误
      • 如果 _locked == 0,设置 _locked = 1,继续执行
    • 函数执行完毕后,设置 _locked = 0(在修饰器末尾)
    • 这是防止重入攻击的关键机制
  2. 修饰器 whenNotPaused 执行

    • 检查 paused 状态,如果为 true 则 revert
  3. Campaign storage c = _campaign(campaignId);

    • 获取项目的 storage 引用
    • 验证项目存在,否则 revert “Invalid campaign”
  4. if (msg.sender != c.owner) revert NotOwner();

    • 验证调用者是否为项目发起人
    • msg.sender 是当前交易的发起者
    • c.owner 是项目创建时的发起人地址
    • 如果地址不匹配,revert NotOwner() 错误
    • 这是权限控制,确保只有发起人可以提取
  5. if (block.timestamp < c.deadline) revert DeadlineNotReached();

    • 验证是否已到截止时间
    • 如果当前时间 < 截止时间,说明尚未到期,revert DeadlineNotReached()
    • 必须在截止时间之后才能提取
  6. if (c.raised < c.goal) revert GoalNotReached();

    • 验证是否达成目标金额
    • 如果已筹金额 < 目标金额,说明未达成,revert GoalNotReached()
    • 只有达成目标才能提取
  7. if (c.claimed) revert AlreadyClaimed();

    • 验证是否已经提取过
    • 如果 c.claimed == true,说明已经提取过,revert AlreadyClaimed()
    • 防止重复提取
  8. c.claimed = true;

    • 关键:先更新状态
    • claimed 标志设置为 true
    • 必须在转账之前更新,防止重入攻击
  9. uint256 amount = c.raised;

    • 记录提取金额
    • 将项目的已筹金额保存到局部变量
    • 使用局部变量可以节省 gas
    • 提取的是全部已筹金额,包括超额部分
  10. (bool ok, ) = c.owner.call{value: amount}("");

    • 关键:执行外部调用
    • call{value: amount}("") 是低级调用,向 c.owner 发送 amount wei 的 ETH
    • "" 表示不调用任何函数,只是转账
    • 返回值 ok 表示调用是否成功(truefalse
    • 第二个返回值(函数选择器)被忽略(使用 _
  11. require(ok, "Transfer failed");

    • 验证转账是否成功
    • 如果 ok == false,说明转账失败,revert “Transfer failed”
    • 转账失败的原因可能是:
      • 接收地址是合约且拒绝接收 ETH(revert)
      • Gas 不足(虽然 call 转发所有 gas,但可能仍不足)
      • 其他异常情况
  12. 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);
}
  1. 修饰器 nonReentrant 执行

    • 检查并设置重入锁,防止重入攻击
    • 工作原理与 withdraw 函数相同
  2. 修饰器 whenNotPaused 执行

    • 检查 paused 状态,如果为 true 则 revert
  3. Campaign storage c = _campaign(campaignId);

    • 获取项目的 storage 引用
    • 验证项目存在,否则 revert “Invalid campaign”
  4. if (block.timestamp < c.deadline) revert DeadlineNotReached();

    • 验证是否已到截止时间
    • 如果当前时间 < 截止时间,说明尚未到期,revert DeadlineNotReached()
    • 必须在截止时间之后才能退款
  5. if (c.raised >= c.goal) revert GoalNotReached();

    • 验证项目是否未达成目标
    • 如果已筹金额 >= 目标金额,说明已达成目标,revert GoalNotReached()
    • 注意:这里使用 >= 而非 >,确保刚好达成目标时也不能退款
    • 只有未达成目标的项目才能退款
  6. uint256 amount = contributions[campaignId][msg.sender];

    • 读取个人出资额
    • contributions[campaignId][msg.sender] 是嵌套映射,查询该地址在该项目中的出资额
    • 如果从未出资,值为 0
    • 如果已退款,值也为 0
  7. if (amount == 0) revert NothingToRefund();

    • 验证是否有可退款的金额
    • 如果 amount == 0,说明:
      • 该地址从未出资,或
      • 该地址已经退款过(contributions 已被清零)
    • revert NothingToRefund() 错误
  8. contributions[campaignId][msg.sender] = 0;

    • 关键:先更新状态
    • 将个人出资记录清零
    • 必须在转账之前清零,防止重入攻击
    • 如果转账失败,状态已经更新,但整个交易会 revert,状态会回滚
  9. (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 相同
  10. require(ok, "Refund failed");

    • 验证转账是否成功
    • 如果 ok == false,说明转账失败,revert “Refund failed”
    • 如果转账失败,整个交易会 revert,所有状态更改都会回滚
  11. emit Refunded(campaignId, msg.sender, amount);

    • 触发事件,记录退款信息
    • 事件参数:
      • campaignId:索引参数
      • msg.sender:索引参数
      • amount:非索引参数

重入保护机制详解:

nonReentrant 修饰器的实现:

modifier nonReentrant() {
    if (_locked == 1) revert Reentrancy();
    _locked = 1;
    _;  // 执行函数体
    _locked = 0;
}

工作原理:

  1. 函数调用时,检查 _locked 是否为 1
  2. 如果为 1,说明函数正在执行中,revert(防止重入)
  3. 如果为 0,设置 _locked = 1,锁定状态
  4. 执行函数体(包括外部调用)
  5. 函数执行完毕后,设置 _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 调用合约函数
         ↓
与区块链上的合约实例交互