目录

3.1 rocketpoollai介绍

3.1.1 核心业务:流动性质押

3.1.2 节点质押与独立验证者迁移

3.1.3 核心优势与理念

3.2 FunMe简介

3.2.1 介绍

3.2.2 概述

3.2.3 资金和提款

3.2.4 结论

3.3 项目设置

3.3.1 介绍

3.3.2 项目设置

3.3.3 资金和取款功能

3.3.4 结论

3.3.5 测试一下自己

3.4 通过函数发送ETH

3.4.1 介绍

3.4.2 价值和应付

3.4.3 撤销交易

3.4.4 结论

3.4.5 测试一下自己

核心作用与功能影响:

3.5 Solidity回退

3.5.1 介绍

3.5.2 恢复

3.5.3 Gas使用量

3.5.4 交易字段

3.5.5 结论

3.5.6 测试一下自己

3.6 预言机简介-获取真实世界价格数据

3.6.1 介绍

3.6.2 美元价值

3.6.3 去中心化预言机

3.6.4 Chainlink 的工作原理

3.6.5 Chainlink 数据源

3.6.6 Chainlink VRF

3.6.7 Chainlink Automation(以前称为“Keepers”)

3.6.8 链环功能

3.6.9 结论

3.6.10 测试一下自己

🔗 Chainlink Data Feeds (数据喂价)

🎲 Chainlink VRF (可验证随机函数)

🌉 Chainlink CCIP (跨链互操作协议)

🤖 Chainlink Automation (自动化)

3.7 小测试

3.8 中期回顾

3.8.1 介绍

3.8.2 将外部数据与 Chainlink 集成

3.8.3 结论

3.8.4 测试一下自己

3.9 solidity 中的接口

3.9.1 介绍

3.9.2 将以太坊兑换成美元

3.9.3 Chainlink 数据源

3.9.4 接口(Interface)

3.9.10 结论

3.9.11 测试一下自己

3.10 利用人工智能提供帮助(第二部分)

3.10.1 介绍

3.10.2 获取版本函数

3.10.3 结论

3.10.4 测试一下自己

实际例子

3.11 从NPM和GitHub导入库

3.11.1 介绍

3.11.2 interfaces

3.11.3 直接从 GitHub 导入

3.11.4 从 NPM 导入

3.11.5 结论

3.11.6 测试一下自己

3.12 从Chainlink获取真实世界价格数据

3.12.1 介绍

3.12.2 创建新的合约实例

3.12.3 latestRoundData函数

3.12.4 类型转换

3.12.5 测试一下自己

数值参考

注意事项

3.13 Solidity数学

3.13.1 介绍

3.13.2 检查最低美元金额

3.13.3 部署到测试网

3.13.4 测试一下自己

3.14 msg.sender的解释

3.14.1 介绍

3.14.2 Tracking Funders

3.14.3 将用户与其发送的资金进行映射

3.14.4 测试一下自己

3.15 本节快速回顾

3.15.1 介绍

3.15.2 与外部合同方交互

3.15.3 Chainlink价格信息

3.15.4 区块和交易属性

3.15.4 测试一下自己

3.16 创建你自己的库

3.16.1 介绍

3.16.2 库(Libraries)

3.16.3 访问自定义库

3.16.4 自我测试

3.17 使用Safemath

3.17.1 介绍

3.17.2 整数溢出

3.17.3 SafeMath

3.17.4 Solidity 0.8.0

3.17.5 测试一下自己

3.18 Solidity 中的for循环

3.18.1 介绍

3.18.2 for循环

3.18.3 在 FundMe 合约中使用 for 循环

3.18.4 快捷方式

3.18.5 自我测试

3.19 小测试

3.20 重置数组

3.20.1 介绍

3.20.2 重置数组

3.20.3 测试一下自己

3.21 从合约发送ETH

3.21.1 介绍

3.21.2 transfer

3.12.3 send

3.12.4 call

3.12.5 测试一下自己

3.22 智能合约构造器

3.22.1 介绍

3.22.2 构造函数

3.22.3 在构造函数中指定owner

3.22.4 修改withdraw 函数

3.22.5 自我测试

3.23 Solidity函数修饰符

3.23.1 介绍

3.23.2 重复的条件判断

3.23.3 修饰符

3.23.4(_下划线)

3.23.5 自我测试

3.24 使用测试网测试智能合约

3.24.1 介绍

3.24.2 部署事务

3.24.3 合约交互

3.24.4 成功交易

3.24.5 提现函数与错误

3.24.6 测试一下自己

3.25 不变性和常量

3.25.1 介绍

3.25.2 优化变量

3.25.3 评估 FundMe 合同

3.25.4 Constant(常量)

3.25.5 Immutable(不可变变量)

3.25.6 自我测试

3.26 创建自定义错误

3.26.1 引言

3.26.2 Require

3.26.3 自定义错误

3.26.4 自我测试

3.27 实现接收回退

3.27.1 介绍

3.27.2 接收和回退功能

3.27.3 向 fundMe 发送以太币

3.27.4 自我测试

3.28 小测试

3.29 恭喜

3.29.1 引言

3.29.2 特殊函数

3.29.3 节省 Gas

3.29.4 发送以太币

3.29.5 自我测试

3.30 ZKsync插件修复

3.30.1 ZKsync Remix 插件的小错误

3.31 部署到ZKsync

3.31.1 介绍

3.31.2 ZKsync 的调整

3.31.3 合同部署


3.1 rocketpoollai介绍

Rocket Pool 是一个为以太坊(ETH)生态系统提供去中心化、非托管流动性质押服务的平台

简单来说,它解决了传统以太坊质押的两个主要难题:高门槛(需要32个ETH)流动性锁死(质押期间资金无法动用)

3.1.1 核心业务:流动性质押

平台最核心的服务是让你无需运行节点即可质押任意数量的ETH。质押后,你会获得代表质押资产和收益的流动性质押代币 rETH

  • rETH Vault:官网重点展示的新功能。你可以将 rETH 一键存入此金库,由专业策略师管理,通过安全的 DeFi 策略(如借贷、流动性挖矿)赚取额外收益,实现“收益叠加”。

  • 灵活性:持有 rETH 意味着你可以随时在支持的 DeFi 交易所进行交易,而无需解除质押,解决了资金流动性问题。

3.1.2 节点质押与独立验证者迁移

除了普通用户,平台也为更专业的参与者提供服务:

  • 运行节点:只需投入 16 个ETH(而非标准的32个),即可成为节点运营商。平台会为你匹配来自其他用户的16个ETH组成验证者节点,你可以赚取质押收益和额外佣金。

  • 迁移服务:如果你已经是一个独立验证者,可以迁移到 Rocket Pool,将你的 32 ETH 验证者转换为多个 4 ETH 的迷你池,并有机会加入“平滑奖池”获得更稳定的奖励。

3.1.3 核心优势与理念

官网强调其去中心化、透明和非托管的特性:

  • 透明可查:所有策略和资金流动都有链上数据支持。

  • 社区驱动:由一个分布广泛的节点运营商和开发者社区维护。

  • 广泛集成:rETH 已被众多主流 DeFi 协议集成,流动性好,用途广泛。

总的来说,Rocket Pool 通过其创新的“流动性质押代币(rETH)”模式,降低了参与以太坊质押的门槛,并释放了质押资产的流动性,是参与以太坊网络并获得收益的一个重要工具。

3.2 FunMe简介

3.2.1 介绍

在本节中,我们将创建一个去中心化的众筹合约。完整的代码库可在Github仓库中找到。

3.2.2 概述

对于这个项目,我们将使用两个合约:FundMe(主要的众筹合约)和 PriceConverter。它们的功能类似于 Kickstarter,允许用户发送任何原生区块链加密货币,同时也允许合约所有者提取所有筹集的资金。之后,我们将把这些合约部署在测试网上。

注意:请谨慎使用测试网。限制测试网交易有助于防止网络拥堵,确保每个人都能获得更流畅的测试体验。

3.2.3 资金和提款

FundMe 合约在 Remix 上部署后,你会注意到一系列函数,其中包括一个标有 fund 的红色新按钮,这表示该函数是 可支付 的。一个可支付函数允许你向合约发送原生区块链货币(例如以太币、Polygon、Avalanche)。我们还将设定一个最低 USD 金额,在调用 fund 函数时发送给合约。要向 FundMe 合约转移资金,你可以导航到 Remix 部署选项卡中的“数值”部分,输入一个数值(例如 0.1 以太币),然后点击 fund。随后会出现一个 MetaMask 交易确认窗口,并且在交易最终确认之前,合约余额将保持为零。一旦交易完成,合约余额将更新以反映已转移的金额。之后,合约所有者可以 withdraw(提取)资金。在这种情况下,由于我们拥有该合约,资金将从合约余额中移除并转移到我们的钱包。

fund函数 -> 转移资金

withdraw -> 提取资金

3.2.4 结论

这25节课程将逐步指导你实现一个众筹合约,该合约支持加密货币捐赠所有者提款功能。

3.3 项目设置

3.3.1 介绍

让我们从编写 FundMe 开始,这是一个允许用户发送资金、之后所有者可以提取的众筹合约。在开始之前,我们先清理一下 Remix IDE 的工作区。

3.3.2 项目设置

请从头开始:打开你的 Remix IDE 并删除所有现有合约。接下来,创建一个名为 FundMe 的新合约。

❗重要提示:在开始编写代码之前,请尝试写下你希望代码实现的目标。这有助于明确目标并构建你的编程思路。

我们希望 FundMe 合约能够执行以下任务:

(1)允许用户向合约存入资金:用户应能向 FundMe 合约存入资金。

(2)允许合约所有者提取资金:拥有 FundMe 合约的账户应具备提取所有存入资金的能力。

(3)设置一个以 USD 计价的最低存款金额:应设定一个可以存入合约的最小金额。

现在,我们来概述一下合约的核心结构:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
contract FundMe {}

3.3.3 资金和取款功能

FundMe 合约将有两个主要功能,作为主要的交互点:

(1)fund允许用户向合约中存入资金;

(2)withdraw赋予合约持有人提取先前已存入资金的能力;

首先,我们来编写这个fund函数,withdraw暂时先将其注释掉。

contract FundMe {
    // send funds into our contract
    function fund() public {}
    // owner can withdraw funds
    /*function withdraw() public {}*/
}

3.3.4 结论

在本课中,我们创建了一个新FundMe合约,并大致定义了将要执行的逻辑。

3.3.5 测试一下自己

(1)为什么开发者在开始编写代码之前一定要先明确自己的编码目标?

在开始编写代码前明确目标,是确保开发方向正确、提升效率的核心习惯,在智能合约这种管理真实资产、部署后难以修改的高风险领域更是如此。它能像蓝图一样,直接指导你构建出功能完整、权限清晰且经济高效的安全合约,避免因盲目开发而出现致命漏洞或成本浪费,是从根本上保障项目成功的首要步骤。

3.4 通过函数发送ETH

3.4.1 介绍

在本部分,我们将探讨如何通过创建函数将以太坊 (ETH) 转入智能合约fund。该函数需要一定数量的 ETH以确保交易的正确处理。

3.4.2 价值和应付

当一笔交易被发送到区块链时,其交易数据中始终包含一个数值字段。该字段表示在该笔特定交易中转移的原生加密货币的数量。为了使 fund 函数能够接收以太币,必须将其声明为 payable(可支付)。在 Remix 的用户界面中,这个关键字将使该函数显示为红色,标志着它可以接收加密货币。钱包地址和智能合约能够持有并管理加密货币资金这些实体可以操作资金、执行交易并维护余额记录,就像钱包一样

function fund() public payable {
    // allow users to send $
    // have a minimum of $ sent
    // How do we send ETH to this contract?
    msg.value;

    //function withdraw() public {}
}

在 Solidity 中,可以通过 msg.value (获取到的是以太币的数量)属性来获取交易的金额。该属性属于全局对象 msg 的一部分。它表示当前交易中转移的 Wei 的数量,其中 Wei 是以太币(ETH)的最小单位。

3.4.3 撤销交易

我们可以使用 require 关键字作为检查器,强制要求我们的函数接收至少一个完整以太币的最小值:

require(msg.value > 1e18); // 1e18 = 1 ETH = 1 * 10 ** 18

require条件确保交易满足最低以太币要求,只有满足此阈值,函数才能执行。如果未满足指定要求,交易将回滚。Solidity 中的 require 语句可以包含自定义错误消息,当条件不满足时,将显示该错误消息,清楚地解释事务失败的原因:

require(msg.value > 1 ether, "Didn't send enough ETH"); //if the condition is false, revert with the error message

像Ethconverter这样的在线工具可以用于执行以太币WeiGwei之间的转换。

👀❗重要提示:1 ETH = 1e^9 Gwei = 1e^18 Wei

🗒️注意:燃气费用通常以 Gwei 表示。

如果用户尝试发送的以太币数量少于所需数量,交易将失败并显示一条消息。例如,如果用户尝试发送 1000 Wei(远小于 1 个以太币),则该功能将回退,不会继续执行。

3.4.4 结论

在本课中,我们探讨了如何使用value交易字段将以太币转移到合约。我们还学习了当用户向合约发送的以太币不足时如何生成错误消息FundMe

3.4.5 测试一下自己

(1)描述 payable 关键字的作用。它如何影响一个函数的功能?

payable 是 Solidity 中的一个关键修饰符,其主要作用是允许一个函数或地址接收区块链的原生加密货币(如Ethereum的 ETH)

核心作用与功能影响:

(1)赋予接收资金的能力

  • 没有 payable 修饰的函数,无法接收 ETH。任何试图向该函数发送 ETH 的交易都会被自动拒绝

  • 拥有 payable 修饰的函数,可以正常处理附带的 ETH 转账。你正在编写的 FundMe 合约中的 fund 函数就必须是 payable 的。

(2)影响函数的行为和资源消耗

  • Gas 成本payable 函数通常涉及状态变更和资金转移,因此执行所需的 Gas(燃料费)会比不处理资金的函数更高

  • 安全检查:开发者必须在 payable 函数内部显式地加入业务逻辑和安全检查(例如,使用你提到的 require 语句来验证 msg.value 是否达到最低金额)。因为一旦涉及真金白银,任何漏洞都可能导致资金损失。

(3)应用场景

  • 接收存款:如你的 fund 函数。

  • 实现提款:允许合约向外部地址发送 ETH 的函数通常也需要 payable

  • 合约构造函数:如果希望合约在部署时就初始存入资金,构造函数可以标记为 payable

简单比喻:你可以把一个没有 payable 的函数想象成一个只接受信息(数据)的邮箱。而一个 payable 函数则像一个配备了保险箱的邮箱,它既能接收信息,也能安全地接收和处理附带的现金。

总结来说,payable 关键字为函数打开了处理原生加密货币的通道,但它同时也要求开发者承担起更严格的安全和责任,确保资金流动符合预期逻辑。 这是智能合约开发中最基础也最重要的概念之一。

理解了 payable 的作用后,你可以更好地设计 fund 函数中的 require 检查逻辑。

(2)解释 require 语句在 Solidity 中是如何工作的,以及它防止什么情况。

require 语句是 Solidity 中用于执行条件检查并在不满足条件时回滚交易的核心安全机制。

工作原理:守卫与回滚

(1)条件检查:它接受一个结果为布尔值(truefalse)的表达式作为第一个参数。

(2)通过/失败处理

  • 如果表达式为 true:程序继续正常运行,执行 require 之后的代码。

  • 如果表达式为 false立即终止当前函数的所有执行回滚本次调用及其引发的所有状态变更(如:已扣除的 ETH 会被返还,已写入的变量会被恢复),并退还剩余未使用的 Gas 费。

(3)错误信息(可选):可以提供一个字符串作为第二个参数,在失败时向调用者解释原因。

主要防止的情况(核心作用)

在像 FundMe 这样的金融合约中,require 是首要的安全检查哨兵,主要用于防止:

(1)无效或恶意的输入

  • 防止:用户存入的资金不足。

  • 示例require(msg.value >= 1 ether, “Must send at least 1 ETH”);

(2)违反业务逻辑的状态

  • 防止:在不符合条件时执行关键操作(如在众筹未结束时提取资金)。

(3)未授权的访问

  • 防止:非合约所有者调用敏感函数(如 withdraw)。

  • 示例require(msg.sender == owner, “Only owner can withdraw”);

revert / assert 的关键区别

  • require:用于验证输入或外部调用返回的条件,是面向用户的、预期的失败。

  • revert:功能类似,但更灵活,可在复杂逻辑中随时调用。

  • assert:用于检查绝不应为假的内部错误(如数学溢出),消耗全部Gas,通常用于验证合约一致性。

总结require 是你为合约设定的规则执行者。它在函数执行之初就建立防线,确保只有满足所有预设安全条件和业务规则的操作才能进行,是保护合约资金安全和逻辑正确的第一道、也是最关键的一道关卡。在你的 FundMe 合约中,无论是验证最低存款金额,还是检查调用者权限,都依赖于它。

(3)Wei、Gwei 和 Ether 之间有什么区别?

Wei、Gwei 和 Ether 是以太坊中用于计量其原生加密货币 ETH三种不同单位。它们之间的核心区别在于数量级,类似于计算机存储中字节(Byte)、千字节(KB)和兆字节(MB)的关系。

单位

与 Ether 的换算关系

与 Wei 的换算关系

主要用途场景

Wei

1 Ether = 10¹⁸ Wei

基础单位

智能合约层面:Solidity 代码中 msg.value、账户余额的默认存储和计算单位。是最精确、无小数的基础单位。

Gwei

1 Ether = 10⁹ Gwei

1 Gwei = 10⁹ Wei

Gas 费用层面:几乎专用于衡量和支付 Gas 价格(即你愿意为每个计算步骤支付多少费用)。

Ether

主单位

1 Ether = 10¹⁸ Wei

用户界面层面:交易所、钱包向用户展示余额和交易金额时最常用的、便于人类理解的单位。

关键要点与代码示例

(1)智能合约的“母语”是 Wei:在 Solidity 中,所有涉及金额的变量(如 msg.valueaddress(this).balance)其底层值都是以 Wei 为单位的整数。

(2)便捷的字面量表示:Solidity 允许你直接使用单位后缀,编译器会自动转换为对应的 Wei 值,这极大提高了代码可读性并防止计算错误。

// 以下三行代码在合约中等效
require(msg.value >= 1000000000000000000); // 难以阅读:1 Ether 的 Wei 数
require(msg.value >= 1 ether);             // 清晰:使用 `ether` 后缀
require(msg.value >= 1e18);                // 清晰:使用科学计数法

你可以这样理解:Wei 是机器(合约)使用的“原子单位”,Gwei 是给网络燃料(Gas)定价的“专用单位”,而 Ether 是人类在交易所和钱包里看到的“标准单位”。在开发智能合约时,心中必须时刻进行单位换算,明确你正在处理的是哪个层面的数字。

(4) 创建一个 tinyTip 函数,要求用户发送不少于 1 Gwei。

function tinyTip(){
    require(msg.value > 1e9,"Didn't send enough ETH")
}

3.5 Solidity回退

3.5.1 介绍

在本课中,我们将深入探讨交易回滚的工作原理,以及gas是什么,它在何处使用。

3.5.2 恢复

我们先来给这个fund函数添加一些逻辑:

 uint256 public myValue = 1;
 function fund() public {
    myValue = myValue + 2;
 }

回退操作(revert)会撤销在此之前执行的所有操作,并将剩余的gas返还给交易的发送者。在这个fund函数中,每次成功执行后myValue会增加2个单位。然而,如果紧接着遇到回退语句(revert),从函数开始执行的所有操作都会被撤销。myValue将重置为其初始状态值,即1。

 uint256 public myValue = 1;
 function fund() public {
    myValue = myValue + 2;
    require(msg.value > 1e18, "didn't send enough ETH");
    // a function revert will undo any actions that have been done.
    // It will send the remaining gas back
 }

3.5.3 Gas使用量

注意:如果交易因回退语句(revert)而失败,交易中已使用的gas将不予退还。这些gas已经被消耗,因为代码已经由计算机执行,尽管交易最终被回退了。

用户可以指定他们愿意为某笔交易分配的gas数量。如果 fund 函数在 require 语句之后包含大量代码行,并且我们确实设定了限额,那么之前已分配但未使用的gas将不会被收取。

🗒️注意:如果交易被撤销,则定义为失败;

3.5.4 交易字段

在一次价值转移中,一笔交易将包含以下字段:

  • Nonce(随机数):账户的交易计数器

  • Gas 价格(wei):发送方愿意为每单位 Gas 支付的最高价格

  • Gas 限额:发送方愿意为这笔交易使用的最大 Gas 数量。一个常见值大约为 21000。

  • To(接收方):接收方地址

  • Value(金额,以 wei 为单位):要转账给接收方的加密货币数量

  • Data(数据)☑️ 空

  • v、r、s:交易签名的组成部分。它们证明该交易已获得发送方授权。

在合约交互交易中,交易字段将填充如下内容:

  • Nonce(随机数):账户的交易计数器

  • Gas 价格(wei):发送方愿意为每单位 Gas 支付的最高价格

  • Gas 限额:发送方愿意为这笔交易使用的最大 Gas 数量。一个常见值大约为 21000。

  • To(接收方):交易发送到的地址 例如智能合约

  • Value(金额,以 wei 为单位):要转账给接收方的加密货币数量

  • Data(数据):☑️ 要发送给 接收方地址 的内容,例如函数及其参数。

  • v、r、s:交易签名的组成部分。它们证明该交易已获得发送方授权。

3.5.5 结论

回滚和Gas 消耗有助于维护区块链状态的完整性。回滚机制会在交易失败时撤销已执行的交易,而Gas 则用于执行交易并运行以太坊虚拟机 (EVM)。当交易失败时,消耗的 Gas 无法恢复。为了管理 Gas 消耗,以太坊允许用户设置每笔交易愿意支付的最大 Gas 金额。

3.5.6 测试一下自己

(1)请描述本课中列出的两种交易类型。

价值转移和合约交互交易

(2)为什么要使用回滚操作?

在智能合约中,回滚操作(如 requirerevertassert)用于在交易执行过程中遇到错误或条件不满足时,撤销所有已执行的状态更改,确保交易的原子性。这可以防止合约状态因部分执行而处于不一致或无效状态。例如:

  • 验证用户输入、权限或余额等前置条件,若失败则回滚,避免错误操作。

  • 在复杂逻辑中手动触发回滚,以处理异常情况。

  • 通过回退,将未使用的 Gas 返还给调用者(除 assert 外),减少用户损失。

总之,回滚操作是保证合约安全性和数据一致性的关键机制。

What is revert?

Answer:Undo any actions that have been done, and send the ramaining gas back.

(3)Bob 将 gas 价格设置为 20 Gwei(每单位的gas的费用是多少),gas 上限设置为 50,000 单位。交易在回滚前消耗了 30,000 单位的 gas。实际会被收取多少 ETH?

3.6 预言机简介-获取真实世界价格数据

3.6.1 介绍

随着区块链技术的快速发展和去中心化金融(DeFi)平台的日益普及,支持多种数字货币的必要性显著增加。允许用户使用他们偏好的数字货币,能够扩大应用的市场覆盖范围,并提升其可用性。本课程将指导你为 fundMe 合约添加货币转换功能,并使用 Chainlink 预言机(一个用于获取外部数据的去中心化网络)来设置价格阈值。

3.6.2 美元价值

目前,我们的合约要求交易价值大于一个以太币(ETH)。如果我们希望让用户能够灵活地支付5美元,就需要更新我们的合约。我们可以先在合约顶部通过一个状态变量 uint256 public minimumUSD = 5 来指定这个新数值。下一步是修改 fund 函数中的条件,以检查发送的 value 是否大于或等于我们的 minimumUSD。然而,我们在这里遇到了阻碍:minimumUSD 的值是以美元为单位,而交易消息中的 value 是以以太币指定的。

3.6.3 去中心化预言机

像以太坊这类资产的美元价格无法仅从区块链技术本身获得,而是由金融市场决定的。为了获取正确的价格信息,需要在链下链上数据之间建立连接,这通过一个去中心化的预言机网络来实现。

这种区块链限制的存在是因为其确定性的性质,确保所有节点都能唯一地达成共识。试图将外部数据引入区块链会破坏这种共识,从而导致所谓的智能合约连接性问题预言机问题

智能合约要有效取代传统协议,必须能够与真实世界数据进行交互。

依赖中心化的预言机进行数据传输是不够的,因为它会重新引入潜在的故障点。中心化数据源可能破坏区块链功能所必需的信任假设。因此,中心化节点不足以满足外部数据或计算需求。Chainlink 通过提供去中心化的预言机网络来应对这些中心化挑战。

3.6.4 Chainlink 的工作原理

Chainlink 是一个模块化、去中心化的预言机网络,它支持将数据和外部计算集成到智能合约中。当智能合约结合链上和链下数据时,就可以将其定义为混合型智能合约,从而创建功能丰富的应用程序。

Chainlink 提供了一些可添加到智能合约中的现成功能。我们将介绍其中的一些功能:

  • 数据源:Chainlink数据源为智能合约提供安全、可靠的外部真实世界数据,比如资产价格或天气信息,确保链上应用能准确获取链下数据。

  • 可验证随机数:Chainlink可验证随机数(VRF)生成防篡改的随机数,保障NFT铸造、游戏抽奖等场景的公平性和不可预测性。

  • 自动化:Chainlink自动化(原“守护者”)允许智能合约在预设条件满足时自动执行任务,如定期清算或定时触发,无需人工干预。

  • 函数:Chainlink函数让智能合约能够连接任意API并执行自定义计算,从而灵活处理链下数据并实现更复杂的逻辑。

Chainlink实现去中心化的原理,可以想象成你在做一道重要的菜,但不知道放多少盐——你不再只问一个“专家”(单一数据源),而是同时请教十个不同的大厨。这十个大厨各自从自己熟悉的渠道获取盐的用量建议,然后分别告诉你。Chainlink的机制就是同时收集这十个大厨的回答,通过一个预设的规则(比如取平均值,或去掉最高最低),自动筛选出一个最可信的答案,再喂给智能合约。这个过程完全公开透明,即使其中一两个大厨给了离谱的建议,也不会影响最终结果。这样,智能合约就不再依赖任何一个单一、易被攻击或出错的信息源,从而实现了数据获取的“去中心化”和安全性。

3.6.5 Chainlink 数据源

Chainlink 数据源为 DeFi 领域超过 500 亿美元的交易提供支持。这个 Chainlink 节点网络聚合来自各个交易所和数据提供商的数据,每个节点独立验证资产价格。

他们将这些数据汇总起来,并通过单笔交易将其传递给参考合约(即价格馈送合约) 。每个合约都会存储特定加密货币的价格详情。

3.6.6 Chainlink VRF

Chainlink VRF(可验证随机函数)提供了一种生成可验证随机数的解决方案,确保 NFT 随机化、彩票和游戏等应用中的真正公平性。这些随机数在链下生成,并且无法被篡改。

3.6.7 Chainlink Automation(以前称为“Keepers”)

Chainlink 的另一个强大功能是Keepers系统。这些节点监听特定事件,并在事件触发时自动执行调用合约中的预期操作。

3.6.8 链环功能

最后,Chainlink Functions 允许在去中心化环境中进行API 调用。此功能非常适合创建创新型应用程序,建议对 Chainlink 生态系统有深入了解的高级用户使用。

3.6.9 结论

Chainlink 数据源将有助于在我们的FundMe合约中集成货币转换功能。Chainlink 的去中心化预言机网络不仅解决了“预言机问题”,还提供了一系列附加功能,以增强每个 DApp 的能力。

ChainLink数据源:https://data.chain.link/feeds

ChainLink案例(Solidity):https://docs.chain.link/data-feeds/using-data-feeds#solidity

水龙头网站:https://faucets.chain.link/sepolia

添加代币:https://docs.chain.link/resources/link-token-contracts

3.6.10 测试一下自己

(1)请描述 Chainlink 的 4 款产品,并说明每款产品解决的问题。

🔗 Chainlink Data Feeds (数据喂价)

这是Chainlink最核心和基础的服务。它通过一个去中心化的预言机网络,将来自众多高等级数据源(如付费API、交易所)的市场数据(如资产价格、储备金余额)聚合起来,并定期发布到区块链上供智能合约使用 。

  • 解决的问题:智能合约本身无法主动获取链外信息。在去中心化金融(DeFi)领域,借贷协议(如Aave)需要准确的价格来评估抵押品价值,永续合约平台(如GMX)需要可靠的价格来进行交易结算。如果依赖单一的中心化数据源,不仅存在单点故障风险,还可能遭遇数据操纵 。Chainlink Data Feeds通过去中心化、多源数据聚合和抗女巫攻击的节点网络,确保了上链数据的高可靠性和抗操纵性,为DeFi生态的安全运行提供了基础保障。

🎲 Chainlink VRF (可验证随机函数)

VRF是一种可证明公平且可验证的随机数生成器。当智能合约请求随机数时,Chainlink VRF会生成一个随机数及其对应的加密证明。这个证明会在链上被验证,以确保生成的随机数没有被任何单一实体(包括节点运营商、开发者、矿工或用户)篡改或操纵 。

  • 解决的问题:在区块链上生成安全的随机数非常困难。简单的方法如使用区块哈希值,可能被矿工操纵 ;而将链下生成的随机数传回链上,又无法验证其生成过程是否公正。对于依赖不可预测性的应用(如区块链游戏抽卡、NFT稀有度分配、协议中法官的随机指派),一个可被操纵的随机数引擎将是毁灭性的 。Chainlink VRF通过其加密证明和链上验证机制,确保了随机性的公平、透明和不可预测,为这些应用提供了信任基础。

🌉 Chainlink CCIP (跨链互操作协议)

CCIP是一个开放的、协议级别的标准,旨在通过一个统一接口,安全地连接众多不同的区块链网络。它允许数据和代币在不同链之间无缝、安全地传输。其核心安全模型包括一个独立的主动风险管理网络(RMN),它会持续监控跨链活动,并在检测到异常或恶意行为时暂停操作 。

  • 解决的问题:随着越来越多的区块链(如以太坊、Polygon、Arbitrum)出现,资产和流动性被分散在各自为政的"孤岛"中。传统的跨链桥方案屡次发生因安全漏洞导致巨额资金被盗的事件 。应用和用户需要一个高度安全、可靠且统一的跨链标准来转移资产和信息,同时不影响流动性和用户体验。CCIP通过其"防御纵深"的安全架构、可编程代币转移(PTT)以及满足机构级合规要求的能力,解决了跨链互信和安全的难题 。

🤖 Chainlink Automation (自动化)

Automation是一个去中心化的服务,它可以可靠地根据预定义的条件(如时间、事件或复杂的链下计算结果)自动触发智能合约的函数执行。开发者只需向Automation网络提交一个" upkeep"任务,网络中的节点就会持续监控条件,并在条件满足时发起交易调用合约 。

  • 解决的问题:智能合约是被动运行的,它们无法自己"醒来"执行操作。过去,项目方不得不依赖中心化的服务器或人工手动触发关键功能,如收益聚合、代币rebase、清算或游戏回合的开启/结束。这不仅耗费宝贵的开发资源,还引入了单点故障风险和中心化操纵的可能 。Chainlink Automation用一个去中心化的、无须信任的 keeper 网络取代了这些脆弱环节,确保了协议关键功能的及时性、可靠性和抗审查性。例如,Beefy Finance用它来自动复投用户的收益,Aave用它来确保及时清算以保障协议偿付能力 。

3.7 小测试

3.8.1 介绍

从第 1 课到第 5 课,我们探讨了关键字的用法payable、全局属性msg.value以及函数回退时会发生什么。

payable, required, msg.value

要使函数能够接收原生区块链代币(例如以太坊),必须将其标记为payable

function deposit() public payable {
    balances[msg.sender] += msg.value;
}

msg.sender 是 Solidity 中的全局变量,代表当前函数调用的直接发起者地址(可以是外部账户或另一个合约),主要用于身份认证(如限制只有合约部署者才能执行某些操作)、接收转账时的资金来源记录,以及触发事件时追踪调用方;它与 tx.origin(整个交易的原始发起者)不同,msg.sender 在合约间调用时会变为中间合约的地址,因此在权限控制中必须使用 msg.sender 而非 tx.origin,同时也要防范因调用外部合约可能引发的重入攻击。

balances[msg.sender] 是 Solidity 中一个常见的映射(mapping(address => uint))用法,表示当前函数调用者(msg.sender)在 balances 这个账本中对应的余额数值,通常用于记录每个地址的代币持有量或存入的资金。通过它,合约可以快速查询或更新调用者的个人资产,实现类似银行账户的功能——例如在转账时减少发送方的 balances[msg.sender] 并增加接收方的余额;由于 msg.sender 直接代表操作者,天然与用户地址绑定,再配合 require 等权限检查,就能安全地让每个用户仅管理自己的余额,这是 ERC20 代币、质押挖矿等去中心化金融合约的核心设计。

如果我们希望某个函数在特定条件下执行失败,可以使用 require 语句。例如,在银行转账的场景中,如果发送方余额不足,我们希望操作失败。示例如下:

function transfer(address recipient, uint amount) public {
    require(balances[msg.sender] >= amount);
    balances[msg.sender] -= amount;
    balances[recipient] += amount;
}

在这段代码中,如果 balances[msg.sender] >= amount 的条件未满足,交易将回退。这意味着操作会撤销之前的所有动作,并且不会消耗用户分配的全部 Gas 限额。

Solidity 的全局属性 msg.value 包含了随交易发送的加密货币数量。

3.8.2 将外部数据与 Chainlink 集成

Chainlink是一项革命性的技术,它能够将外部数据和计算功能集成到智能合约中。它提供了一种去中心化的数据注入方式,这对于处理价值随时间波动的资产尤其有用。例如,如果一个智能合约涉及股票或大宗商品等现实世界资产,获取实时定价信息就至关重要。

注意:Chainlink 使智能合约能够与现实世界的数据和服务进行交互,而不会牺牲区块链技术固有的安全性和可靠性保证。

设想一个处理黄金等大宗商品的智能合约。Chainlink价格信息可以提供实时黄金价格,使智能合约能够反映当前的市场价格。

// SPDX-License-Identifier: MIT
// 声明源代码许可协议,这是Solidity 0.6及以上版本的最佳实践

// 导入Chainlink的AggregatorV3Interface接口,用于与价格预言机合约交互
// 该接口定义了获取最新价格数据的核心方法(如latestRoundData)
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

/**
 * @title GoldPriceContract
 * @author 开发者名称(可自定义)
 * @notice 该合约用于通过Chainlink价格预言机获取最新的黄金价格
 * @dev 依赖Chainlink的AggregatorV3Interface接口,使用指定的黄金价格喂价合约地址
 * @custom:version 1.0.0
 */
contract GoldPriceContract {
    // 声明价格预言机接口实例,用于调用价格喂价合约的方法
    AggregatorV3Interface internal priceFeed;

    /**
     * @notice 合约构造函数,初始化黄金价格喂价合约的接口实例
     * @dev 构造时指定Chainlink黄金价格喂价合约的具体地址(此处为示例地址,需根据部署网络调整)
     *      0x8468b2bDCE073A157E560AA4D9CcF6dB1DB98507 为某网络下的黄金价格喂价合约地址
     */
    constructor() public {
        // 将指定的喂价合约地址绑定到AggregatorV3Interface接口实例
        priceFeed = AggregatorV3Interface(0x8468b2bDCE073A157E560AA4D9CcF6dB1DB98507);
    }

    /**
     * @notice 获取Chainlink预言机提供的最新黄金价格
     * @dev 调用priceFeed的latestRoundData()方法获取价格,仅返回核心的价格数值
     *      latestRoundData()返回值说明:(uint80 roundId, int price, uint startedAt, uint updatedAt, uint80 answeredInRound)
     *      此处忽略了其他返回值(roundId、时间戳等),仅提取价格
     * @return 最新的黄金价格(整数类型,单位由Chainlink喂价合约定义,通常为8位小数,如价格返回190000000000代表1900美元/盎司)
     */
    function getLatestGoldPrice() public view returns (int) {
        // 调用预言机合约的latestRoundData方法,解构返回值,仅保留price字段
        (,int price,,,) = priceFeed.latestRoundData();
        // 返回提取到的最新黄金价格
        return price;
    }
}

在这个例子中,Chainlink Feeds用于查询最新的黄金价格,确保智能合约拥有最新的市场信息。

3.8.3 结论

理解和运用 payablerequiremsg.value 对于在 Solidity 中处理交易至关重要。除此之外,Chainlink 通过提供对现实世界数据的访问来增强智能合约的功能,从而实现更动态、更可靠的去中心化应用。

3.8.4 测试一下自己

(1)第 1 课到第 5 课主要涵盖哪三个主题?

3.9 solidity 中的接口

3.9.1 介绍

在本部分中,我们将学习如何将以太坊 (ETH)兑换成美元 (USD) 以及如何使用接口。

3.9.2 将以太坊兑换成美元

我们首先尝试将以太币(ETH)计价的 msg.value 转换为美元(USD)。 这个过程需要获取以太坊的当前美元市场价格,并利用它将 msg.value 的金额转换为美元。

 // Function to get the price of Ethereum in USD
 function getPrice() public {}
 // Function to convert a value based on the price
 function getConversionRate() public {}

3.9.3 Chainlink 数据源

我们获取以太坊价格的主要来源是 Chainlink 数据源。Chainlink 数据源文档提供了如何与数据源合约交互的示例:

(1)AggregatorV3Interface:一个以数据源地址作为输入的合约。该合约负责维护更新的 ETH/USD 价格。

(2)LatestRoundData:一个返回 answer 的函数,代表最新的以太坊价格。

要使用价格源合约,我们需要它的地址和 ABI。该地址可以在 Chainlink 文档的价格源合约地址部分找到。在我们的场景中,我们将使用 ETH/USD 价格源。

3.9.4 接口(Interface)

要获取 ABI,你可以导入、编译并部署 PriceFeed 合约本身。在上一节中,我们将 SimpleStorage 合约导入到 StorageFactory 合约中,部署了它,然后才能使用其函数。

另一种方法是使用接口(Interface),它只定义方法的签名,而不包含其实现逻辑。如果编译 Price Feed 接口,它将返回 Price Feed 合约本身的 ABI,而该合约此前已部署在区块链上。我们无需了解函数实现的任何细节,只需知道 AggregatorV3Interface 的方法就足够了。这个名为 Aggregator V3 Interface 的 Price Feed 接口可以在 Chainlink 的 GitHub 仓库中找到。

🗒️注意:接口允许不同的合约无缝交互,确保它们共享一组共同的功能。

我们可以通过调用以下函数来测试接口的使用情况version()

function getVersion() public view returns (uint256) {
    return AggregatorV3Interface(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419).version();
}

🗒️注意:最好在部署完成后再对测试网进行操作,因为这可能会耗费时间和资源。

3.9.10 结论

使用接口是与外部合约交互的常用且有效的方式。首先,获取外部合约的接口,编译得到ABI,然后使用已部署合约的地址。这样就可以无缝调用该地址上的任何函数。

3.9.11 测试一下自己

(1)解释接口在 Solidity 中的作用以及它们的优势是什么。

(2)将以太币(ETH)价值变量转换为等值美元(USD)需要哪些步骤?

(3)在 FundMe 合约上实现另一个函数,该函数调用数据源地址的 decimals() 方法。

3.10 利用人工智能提供帮助(第二部分)

3.10.1 介绍

当遇到不太清晰的部分时,人工智能或论坛可以帮助你全面理解当前的主题。在本课中,我们将学习如何提出有效的问题和提示词。

3.10.2 获取版本函数

例如,考虑一下接口构造背后的机制:

How does this solidity function return the value `4` when I didn't define any logic in AggregatorV3Interface?

```
 function getVersion() public view returns (uint256) {
    return AggregatorV3Interface(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419).version();
 }
```

以下是 ChatGPT 3.5 提供的答案:

In the code you provided, the `getVersion()` function is calling the `version()` function of an instance of the `AggregatorV3Interface` contract. It appears that the contract address `0x694AA1769357215DE4FACO81bf1E309aDC325306` is being used to create an instance of the `AggregatorV3Interface`.

The `AggregatorV3Interface` is an interface contract that defines a set of functions that must be implemented by another contract. It serves as a way to interact with the contract at the given address by providing a common interface.

The `version()` function is called in your code is likely defined in the actual contract that implements the `AggregatorV3Interface`. This implementation contract is deployed at the specified address and is expected to have a `version()` function that returns a `uint256` value.

So, the code you provided is creating an instance of the `AggregatorV3Interface` contract at the given address and then calling its `version()` function to retrieve a `uint256` value. If the implementation contract at that address has a `version()` function that returns `4`, then the `getVersion()` function will return `4` as well.

通过将地址强制转换为接口类型,我们向 Solidity 编译器表明version该地址处有一个可用的函数。

提出更多问题

由于人工智能擅长保持上下文,我们可以轻松地对此答案进行后续探讨。

What would happen if that contract address didn't have that function?

正如人工智能所解释的那样:

If the implementation contract at the given address does not have a `version()` function or if it has a different function signature, calling `version()` on that contract will result in a compilation error or a runtime error.

🚧警告:请务必通过查阅论坛来验证人工智能提供的信息。

3.10.3 结论

通过利用人工智能和讨论论坛,你可以更深入地理解复杂话题,并确保你的问题得到充分解答。

3.10.4 测试一下自己

(1)深入探究 getVersion 函数,向 AI 再提出三个相关问题。

在 Solidity 中,getVersion()version() 都是用来获取合约或接口版本信息的函数。它们的作用类似,但命名习惯和使用场景略有不同:

1. version() 函数

  常见于标准化接口:例如 Chainlink 的 AggregatorV3Interface 中就定义了一个 version() 函数,用于返回当前价格数据源的版本号(通常是一个 uintstring)。ERC20 代币标准中并没有强制要求 version(),但很多项目会自发实现它,用于标识合约实现的版本。

  用途:让外部调用者(如其他合约或前端)能够查询合约的版本,以便根据版本执行不同的逻辑(例如处理旧版本的兼容性问题),或者在升级合约时记录变更。

2. getVersion() 函数

  • 自定义命名:这是开发者自己命名的函数,不是标准接口的一部分。通常用于返回合约自身的版本号,可能是一个字符串如 "1.0.0" 或一个整数 1

  • 用途:与 version() 类似,主要用于版本标识,但命名更强调“获取”动作。有的开发者习惯用 getVersion() 来明确这是一个读取函数(view)。

共同点

  • 都是 view 函数(不修改状态),调用不消耗 gas(仅查询)。

  • 返回值可以是 uintstringbytes32,取决于合约设计。

  • 常用于日志记录、前端展示、升级代理合约时的版本校验等场景。

实际例子

// Chainlink 预言机接口中的 version()
interface AggregatorV3Interface {
    function version() external view returns (uint256);
}

// 自定义合约中的 getVersion()
contract MyContract {
    string private constant VERSION = "2.1.0";
    function getVersion() external pure returns (string memory) {
        return VERSION;
    }
}

总结:无论叫 version() 还是 getVersion(),核心目的都是对外公开合约的版本号,方便生态中的其他组件识别和适配。

3.11 从NPM和GitHub导入库

3.11.1 介绍

随着我们深入智能合约开发,与外部智能合约的交互将变得越来越频繁。这可能会涉及多个接口的使用,从而使源代码变得臃肿且难以阅读。

3.11.2 interfaces

我们以 SmartContract 接口为例:

interface SmartContract {
    function someFunction() external view returns(uint, uint){};
}

要引入这个合约,我们只需在文件顶部使用 import "./SimpleStorage.sol" 语句。另外,如果项目没有存储在本地,我们可以使用来自 Github 的导入。

3.11.3 直接从 GitHub 导入

托管在 GitHub 上的智能合约可以直接导入到你的项目中。例如,考虑 Chainlink 的 AggregatorV3Interface 合约,它位于 Chainlink 的 GitHub 代码库中,而不是我们项目的目录中。

与其手动将其所有代码复制到你的项目中,然后像这样导入:

import { AggregatorV3Interface } from "./AggregatorV3Interface.sol";

我们可以按照 Chainlink 文档中的说明,更高效地导入它。

import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";

此导入语句包含GitHub 存储库中 AggregatorV3Interface.sol 文件的路径,允许您直接从 GitHub 或 NPM(Node 包管理器)导入合约。

3.11.4 从 NPM 导入

NPM 上提供的 @chainlink/contracts 包遵循语义化版本控制(SemVer),允许你在合约中下载并使用特定版本(例如:npm install @chainlink/contracts@1.2.3),同时它与 Chainlink 的 GitHub 代码库保持直接同步。导入路径的其余部分则指定了 Remix 应使用的确切文件。

Remix 将 @chainlink/contracts 解释为对 NPM 包的引用,并从中下载所有必要的代码。

 pragma solidity ^0.8.18;
 import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
 contract FundMe {}

因为 npm 是 Node.js 的包管理工具,它需要在命令行环境下工作。通过这种方式,你可以将 Chainlink 的 Solidity 接口和库引入项目,然后在合约中直接使用 import 语句引用它们(例如 import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";)。

如果还没有 package.json 文件,可以先运行 npm init -y 初始化项目,然后再执行安装命令。

3.11.5 结论

高效管理外部智能合约对于智能合约开发至关重要,它能确保源代码的简洁性和可读性。如 Chainlink 的 AggregatorV3Interface 所示,直接从 GitHub 和 NPM 导入可以简化这一过程。

3.11.6 测试一下自己

  1. @chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol📕 Solidity 编译器会将这条语句翻译成什么?

对于 import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; 这条语句,Solidity 编译器并不会把它翻译成某种机器码,而是会将其解析为一个文件系统路径,并从这个路径中找到并导入接口的定义

不过,这里有一个非常关键的变化需要你注意。根据 Chainlink 官方文档和社区的最新讨论,你问题中提供的导入路径 v0.8/interfaces/ 已经不再是最新的正确路径

  • 编译器如何解析这条语句:当 Solidity 编译器(无论是在 Remix、Hardhat 还是 Foundry 中)遇到这个导入语句时,它首先会识别出 @chainlink/contracts/ 这个前缀。

    • Remix 环境中,这个前缀会被自动理解为指向一个 NPM 包的引用。Remix 会从 NPM 仓库自动下载 @chainlink/contracts 包,然后根据后续的 src/v0.8/... 路径,在你的项目虚拟文件系统中找到对应的接口文件 。

    • HardhatFoundry 这类本地开发环境中,@chainlink/contracts/ 前缀通常是通过配置中的“重映射(remapping)”功能,指向你项目本地的 node_modules/@chainlink/contracts 目录 。编译器最终会读取你本地安装的合约包里的文件。

  • 路径变更的重要提示:从 @chainlink/contractsv1.4.0 版本开始,Chainlink 对仓库结构进行了重组。AggregatorV3Interface 已经被移动到了 shared/interfaces/ 目录下 。因此,当前正确且推荐的导入语句应该是

      Chainlink 的官方文档和教程中也已经全面采用了这个新的导入路径 。

    • 如果使用旧路径会发生什么?

      • Remix:可能仍然可以工作,因为它可能缓存了旧版本(如 v1.3.0 或更早)的包,其中旧路径是存在的。

      • 本地项目(Hardhat/Foundry):如果你安装了最新版的 @chainlink/contracts(v1.4.0 或更高),编译器会报错“文件找不到”,因为 interfaces 文件夹下确实没有这个文件了 。你需要更新路径,或者安装一个旧版本的 Chainlink 合约包。

    总结一下,你问题中的语句是旧版的写法。现在应该使用包含 shared 的路径来确保兼容性。

    3.12 从Chainlink获取真实世界价格数据

    3.12.1 介绍

    AggregatorV3Interface 为与数据喂价合约的交互提供了一个简化的 ABI。在本课中,我们将探讨如何从中检索价格信息。

    3.12.2 创建新的合约实例

    AggregatorV3Interface 包含了 LatestRoundData 函数,该函数用于从指定的合约中检索最新的加密货币价格。我们首先声明一个类型为 AggregatorV3Interface 的新变量 priceFeed。这个接口需要部署在 Sepolia 网络上的数据喂价合约的地址。

    AggregatorV3Interface priceFeed = AggregatorV3Interface(0x1b44F3514812d835EB1BDB0acB33d3fA3351Ee43);

    3.12.3 latestRoundData函数

    latestRoundData() 是 Chainlink 价格预言机(AggregatorV3Interface)中的核心函数,用于获取指定资产的最新价格数据及其相关元信息

    1、函数签名

    function latestRoundData() external view
        returns (
            uint80 roundId,         // 本轮次的唯一标识符
            int256 answer,          // 最新价格(注意:根据小数位数调整)
            uint256 startedAt,      // 本轮次开始时间戳
            uint256 updatedAt,      // 本轮次最新更新时间戳(通常用于判断数据是否过时)
            uint80 answeredInRound  // 答案实际被记录的轮次(一般等于 roundId)
        );

    返回值详解:

    • roundId:当前数据轮的 ID,每更新一次价格就会递增。

    • answer:该资产对的最新价格。例如 ETH/USD 返回的可能是 183500000000,实际价格需要除以 10**decimals()(通常为 8 位小数)。

    • startedAt:本轮次开始的时间戳(Unix 时间)。

    • updatedAt:本轮次最新一次更新的时间戳。通常用这个值判断数据是否太旧(避免使用过时价格)。

    • answeredInRound:本轮次中答案被记录的轮次 ID,通常与 roundId 相同。

    在 Chainlink 预言机中,“本轮次”指的是当前最新的一个价格更新周期。可以把每个“轮次”(Round)想象成一次价格数据的“快照”或“版本号”——每当预言机从链下获取最新价格并提交到链上时,就会产生一个新的轮次,轮次 ID(roundId)随之递增。

    • 为什么叫“轮次”?因为 Chainlink 的去中心化节点网络会按照固定的时间间隔(例如每 1 小时)或价格偏离阈值(例如价格波动超过 0.5%)来轮换提交价格,每一轮提交的数据就是一个新的轮次。

    • latestRoundData() 返回的就是最近一轮的价格数据,包括该轮次的 ID、价格、时间戳等。通过比较 roundId 可以知道价格更新到了第几轮,而 updatedAt 则告诉你这一轮数据是何时更新的。

    简单来说,“本轮次”就是预言机当前最新的一期价格报告。

    典型用法:

    // SPDX-License-Identifier: MIT
    // 指定开源许可证为 MIT,允许任何人自由使用、修改和分发代码
    
    pragma solidity ^0.8.0; // 指定编译器版本必须不低于 0.8.0 且不高于 0.9.0
    
    // 导入 Chainlink 的聚合器接口,该接口定义了与价格预言机交互所需的函数
    import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
    
    /**
     * @title PriceConsumer
     * @dev 该合约演示如何使用 Chainlink 价格预言机获取最新价格数据
     * 它通过 AggregatorV3Interface 与指定的价格源合约进行交互
     */
    contract PriceConsumer {
        // AggregatorV3Interface 类型的内部状态变量,用于存储特定价格源的接口实例
        AggregatorV3Interface internal priceFeed;
    
        /**
         * @dev 构造函数,在合约部署时执行
         * @param feedAddress 要使用的 Chainlink 价格源合约的地址(例如 ETH/USD 的代理合约地址)
         * 构造函数将传入的地址转换为 AggregatorV3Interface 类型并赋值给 priceFeed 变量
         */
        constructor(address feedAddress) {
            priceFeed = AggregatorV3Interface(feedAddress);
        }
    
        /**
         * @dev 获取最新的价格数据,并确保数据未过时
         * @return int 返回最新的价格值,注意该价格带有小数位(通常为 8 位小数,需根据 decimals() 函数转换)
         *
         * 该函数调用 priceFeed 的 latestRoundData() 方法,该方法返回多个值:
         * - roundId: 当前轮次 ID
         * - answer: 最新价格
         * - startedAt: 本轮次开始时间戳
         * - updatedAt: 本轮次最新更新时间戳
         * - answeredInRound: 答案被记录的轮次 ID
         * 我们只关心 answer 和 updatedAt,因此用逗号忽略其他返回值。
         *
         * 为了保证价格的新鲜度,函数检查当前时间(block.timestamp)与更新时间戳的差值是否小于 1 小时。
         * 如果数据过时(超过 1 小时未更新),则通过 require 语句回滚交易并提示 "Stale price"。
         */
        function getLatestPrice() public view returns (int) {
            ( , int price, , uint updatedAt, ) = priceFeed.latestRoundData();
            // 要求当前时间与数据更新时间之差小于 1 小时(3600 秒),否则视为过时数据
            require(block.timestamp - updatedAt < 1 hours, "Stale price");
            return price; // 返回价格
        }
    }

    注意事项

    • 小数位数:价格返回值的小数位数通过 decimals() 函数获取,需要根据该值进行转换。

    • 数据新鲜度:必须检查 updatedAt,防止使用已停止更新的价格。

    • 安全性:建议在使用价格前验证 answeredInRound >= roundId 等条件(但多数现代 Feed 可直接信任)。

    该函数是 DeFi 协议获取实时链上价格的最常用方法。

    我们可以调用latestRoundData()此接口上的函数来获取多个值,包括最新价格。

    function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound);
    

    目前,我们将专注于该answer值,并忽略其他返回值,使用逗号作为不需要的变量的占位符。

    function getLatestPrice() public view returns (int) {
        (,int price,,,) = priceFeed.latestRoundData();
        return price;
    }

    我们的 getLatestPrice() 函数现在从数据喂价合约的 latestRoundData() 函数中检索 ETH 兑美元的最新价格。返回的价格是 int256 类型,精度为 1e8,如 decimals 函数所示。

    Decimals

    • msg.value 是一个具有 18 位小数的 uint256 值。

    • answer 是一个具有 8 位小数的 int256 值(基于美元的交易对使用 8 位小数,而基于 ETH 的交易对使用 18 位小数)。

    这意味着从我们的 LatestRoundData 函数返回的 pricemsg.value 不直接兼容。为了匹配小数位数,我们将 price 乘以 1e10:

    return price * 1e10;

    3.12.4 类型转换

    类型转换,涉及将值从一种数据类型转换为另一种数据类型。在 Solidity 中,由于底层表示的差异以及可能存在的数据丢失,并非所有数据类型都能进行转换。但某些转换是被允许的,例如从 int 到 uint的转换。

    return uint(price) * 1e10;

    我们可以将view函数最终实现如下:

    function getLatestPrice() public view returns (uint256) {
        (,int answer,,,) = priceFeed.latestRoundData();
        return uint(answer) * 1e10;
    }

    结论:该完整getLatestPrice函数检索最新价格,调整小数位数,并将值转换为无符号整数,使其可以与其他函数兼容使用。

    3.12.5 测试一下自己

    1、为什么我们需要将最新的 ETH 价格乘以 1e10?

    我们需要将最新的 ETH 价格乘以 1e10,是为了对齐小数位,使价格数据与以太坊的金额单位(Wei)兼容。以下是详细原因:

    1. 数据来源的精度差异

    • msg.value:用户在交易中发送的以太币数量,单位是 Wei(1 ETH = 1e18 Wei)。这是一个 uint256 类型,固定有 18 位小数(即 1 ETH = 10^18 Wei)。

    • Chainlink 价格喂价(ETH/USD):通过 latestRoundData() 返回的 priceint256 类型,精度为 8 位小数(例如价格 3000.12345678 美元)。

    (这是 Chainlink 的约定:基于美元的交易对使用 8 位小数,基于 ETH 的交易对使用 18 位小数。)

    2. 直接使用的问题

    假设当前 ETH/USD 价格喂价返回 3000 * 1e8(即 300000000000,表示 3000.00000000 美元), 而用户发送了 1 ETH,即 1 * 1e18 Wei。 如果直接用 price 计算用户发送的 ETH 价值(美元):

    uint256 usdValue = msg.value * price; // 错误!
    • msg.value = 1e18(Wei)

    • price = 3000 * 1e8 = 3e11(8 位小数)

    • 乘积结果 = 3e29,单位是 Wei * 价格,毫无意义,且无法与真实美元价值对应。

    3. 解决方案:对齐小数位

    为了使价格能与 msg.value 相乘,需要将 price 转换为 18 位小数(与 msg.value 一致)。

    因为价格原本有 8 位小数,所以乘以 1e10(即 10^10)后:

    uint256 adjustedPrice = uint256(price) * 1e10;
    • price = 3000 * 1e8 = 3e11(8 位小数)

    • 调整后 adjustedPrice = 3e11 * 1e10 = 3e21(18 位小数)

    此时 adjustedPrice 的含义是:1 ETH 对应的美元金额,单位是 1e-18 美元(即每 Wei 对应的美元价值)。

    现在计算用户发送的 1 ETH 价值多少美元:

    uint256 usdValue = msg.value * adjustedPrice;
    // 1e18 (Wei) * 3e21 = 3e39

    这个结果 3e39 表示 3000 美元(因为 1e18 Wei * (3000 * 1e18) = 3000 * 1e36,再经过后续除以 1e18 可得到真实美元)。

    4. 为什么是 1e10 而不是其他值?

    • 价格喂价:8 位小数 → 需要补足到 18 位小数,需乘以 10^(18-8) = 10^10

    • 如果交易对本身基于 ETH(如 ETH/ETH),则价格喂价已有 18 位小数,无需调整。

    5. 实际代码中的用途

    这种调整常见于需要将用户发送的 ETH 金额与价格进行比较的场景,例如:

    function fund() public payable {
        uint256 minimumUSD = 50 * 1e18; // 最低 50 美元(用 18 位小数表示)
        uint256 adjustedPrice = uint256(price) * 1e10; // 对齐小数位
        uint256 msgValueInUSD = msg.value * adjustedPrice; // 计算用户发送的 ETH 等值美元
        require(msgValueInUSD >= minimumUSD, "Send more ETH");
    }

    总结:乘以 1e10 是为了将价格从 8 位小数转换为 18 位小数,使其能与 msg.value(18 位小数)进行正确的数学运算,从而得到有意义的美元价值。这是智能合约中处理不同精度数据时的常见操作。

    2、类型转换 uint256(-2) 的结果是什么?

    在 Solidity 中,将负数 -2 显式转换为 uint256 时,结果是 2**256 - 2(即 2^256 - 2)。这是因为:

    • Solidity 采用**二进制补码(two‘s complement)**表示有符号整数。对于 256 位整数,-2 的二进制表示为:所有高位为 1,最低位为 0(即 0xFFFF...FE,共 64 个十六进制 F 后跟 E)。

    • 当将其直接解释为无符号整数时,该位模式对应的数值就是 2^256 - 2

    • 这种转换不会触发溢出检查,仅仅是重新解释底层的位模式。

    数值参考

    2^256 - 2 约等于 1.1579e77,是一个极大的数。在 Solidity 中,这等同于 type(uint256).max - 1(因为 type(uint256).max = 2^256 - 1)。

    注意事项

    • 这种转换在数学上通常没有意义,实际开发中应避免将负数直接转换为无符号类型,除非明确知道位操作的目的。

    • 如果使用 intuint 的隐式转换,编译器会报错,必须显式转换(如 uint256(-2))。

    3.、创建一个包含 getLatestBTCPriceInETH() 函数的合约,该函数用于检索以 ETH 计价的 BTC 最新价格。

    // SPDX-License-Identifier: MIT
    
    pragma solidity ^0.8.18;
    
    import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
    
    contract FundMe {
    
        AggregatorV3Interface public priceFeed = AggregatorV3Interface(0xdeb288F737066589598e9214E782fa5A8eD689e8);
    
        // fund允许用户向合约中存入资金
        function fund() public payable {
    
            // allow users to send $
            // have a minimun of $ sent
            // How do we send ETH to this contract?
            require(msg.value > 1e18,"Didn't send enough ETH");
        }
    
        function getLatestPrice() public view returns (uint256) {
            (,int answer,,,) = priceFeed.latestRoundData();
            return uint(answer) * 1e10;
        }
    }

    3.13 Solidity数学

    你可以从这里观看视频课程。

    3.13.1 介绍

    在这节课中,我们将引导您将以太坊(ETH)的价值转换为美元(USD)。我们将在新定义的 getConversionRate 函数中使用之前定义的 getPrice 函数。

    getPricegetConversionRate 函数

    • getPrice 函数将以 uint256 类型返回以太坊当前的美元价值。

    • getConversionRate 函数则将指定数量的以太坊转换为其等值的美元金额。

    Decimal Places

    在 Solidity 中,由于该语言不支持浮点型数据,因此仅使用整型值。

    function getConversionRate(uint256 ethAmount) internal view returns (uint256) {
        uint256 ethPrice = getPrice();  // 当前 ETH 的美元价格
        uint256 ethAmountInUsd = (ethPrice * ethAmount) / 1e18;
        return ethAmountInUsd;
    }

    🗒️注意:uint256 ethAmountInUsd = (ethPrice * ethAmount) 这行代码计算出的数值精度为 1e18 * 1e18 = 1e36。为了让 ethAmountInUsd 的精度恢复为 1e18,我们需要将结果除以 1e18。

    🔥警告:在除法运算前先进行乘法运算,以保持精度并避免截断误差。例如,在浮点运算中,(5/3)/2 约等于 3.33。但在 Solidity 中,(5/3)/2 等于 1,再乘以 2 得到 2。如果你先进行乘法运算 (5*2)/2 再除以 3,就能获得更好的精度。

    getConversionRate示例

    • ethAmount 设置为 1 ETH,精度为 1e18。

    • ethPrice 设置为 2000 USD(一个以太币价值2000美元),精度为 1e18,结果为 2000e18。

    • ethPrice * ethAmount 的结果为 2000e36。

    • 为了将 ethAmountInUsd 的精度缩小到 1e18,需将 ethPrice * ethAmount 除以 1e18。

    3.13.2 检查最低美元金额

    我们可以验证用户是否向我们的合约发送了至少5美元:

    require(getConversionRate(msg.value) >= MINIMUM_USD, "You need to spend more ETH!");

    由于getConversionRate返回的值有 18 位小数,我们需要乘以51e18结果为5 * 1e18(相当于5 * 10**18)。

    3.13.3 部署到测试网

    在 Remix 中,我们可以将FundMe合约部署到测试网。部署完成后,getPrice可以调用该函数来获取以太坊的当前价值。也可以向该合约发送资金,如果 ETH 金额少于 5 美元,则会触发错误。

    Gas 估算失败。执行出错,已回滚,发送的 ETH 不足。

    结论:在本课中,我们演示了如何使用getConversionRate函数将 ETH 转换为 USD,并通过正确处理小数位来确保计算的精确性。

    3.13.4 测试一下自己

    1、为什么在 Solidity 计算中先乘后除很重要?这种做法如何帮助保持精度?

    在 Solidity 中,所有的算术运算都是基于整数的,除法运算(/)会直接截断小数部分,只保留整数商。这种截断会导致精度损失,尤其是当运算顺序不当时,损失会被放大。

    先乘后除的重要性在于尽可能延迟除法操作,让中间结果保持更大的数值,从而减少相对误差。具体来说:

    1. 避免早期截断:如果先进行除法,可能会丢失被除数的小数部分,后续乘法只能基于这个不精确的结果,最终误差较大。而先做乘法,会得到一个较大的中间值,再做除法时,截断只发生在最后一步,误差相对较小。

    2. 保留中间精度:在金融计算中,例如将以太坊数量乘以价格得到美元价值,价格和数量都有很高的精度(如 1e18)。乘法后精度翻倍(1e36),再除以 1e18 恢复精度,这样在除法前数值很大,截断造成的相对误差非常小。

    示例对比

    • 假设要计算 (5/3)/2 在 Solidity 中的结果(整数运算):

        显然先乘后除保留了更多精度。

      • 先除:5/3 = 1(截断),再 1/2 = 0,最终结果为 0。

      • 如果先乘后除:(5*2)/3 = 10/3 = 3,结果 3 更接近真实值 3.33。

    getConversionRate 函数中,(ethPrice * ethAmount) / 1e18 正是先乘后除,确保了美元等值计算的准确性。如果先除以 1e18 再乘,即 (ethPrice / 1e18) * ethAmount,由于 ethPrice / 1e18 的截断,可能会丢失价格的小数部分,导致最终结果偏差很大。

    因此,在 Solidity 中,为了最大限度地保持精度,应始终遵循 先乘后除 的原则。

    2、getConversionRate 函数的用途是什么?它如何利用 getPrice 函数将 ETH 转换为 USD?

    getConversionRate 函数的用途是将给定数量的以太坊(ETH)转换为等值的美元(USD)金额,以便在智能合约中进行美元计价的操作,例如检查用户发送的 ETH 是否满足最低美元价值要求。

    该函数利用 getPrice 函数获取当前 ETH/USD 的价格,然后通过以下步骤完成转换:

    1. 获取价格:调用 getPrice(),该函数返回当前 1 ETH 的美元价格,返回值为 uint256 类型,并带有 1e18 的精度(例如 2000 USD 表示为 2000e18)。

    2. 相乘:将 ETH 数量 ethAmount(也带有 1e18 精度,1 ETH = 1e18)与价格相乘,得到 (ethPrice * ethAmount)。此时中间结果的精度变为 1e18 × 1e18 = 1e36,表示总美元价值的粗略值(但精度过高)。

    3. 调整精度:将上一步的结果除以 1e18,将精度恢复为 1e18,得到以 1e18 为精度的美元金额(例如 1 ETH 价值 2000 USD 时返回 2000e18)。

    这个计算过程遵循 Solidity 中“先乘后除”的原则,以最大程度地保持精度,避免因整数除法截断而导致的误差。最终返回的 ethAmountInUsd 可以直接用于比较、算术运算等,因为它的精度与价格输入一致。

    3、创建一个函数convertUsdToEth(uint256 usdAmount, uint256 ethPrice) public returns(uint256),将给定数量的美元转换为等值的以太坊。

    function convertUsdToEth(uint256 usdAmount, uint256 ethPrice) public pure returns (uint256) {
        // 确保价格不为零,避免除以零错误
        require(ethPrice > 0, "Price cannot be zero");
        
        // 计算等值的 ETH 数量,结果精度为 1e18
        // 公式:ETH = (usdAmount * 1e18) / ethPrice
        return (usdAmount * 1e18) / ethPrice;
    }

    3.14 msg.sender的解释

    3.14.1 介绍

    在本课中,我们将学习使用msg.sender来追踪向合约提供资金的地址以及它们将发送的金额。

    msg.sender 是 Solidity 中的一个全局变量,代表当前函数调用的直接发起者地址。它的核心功能是身份标识:当外部账户或另一个合约调用当前合约的函数时,msg.sender 就会自动记录下这个调用者的地址,让合约能够“知道是谁在调用我”。

    基于这一特性,它在智能合约开发中主要有三个作用:

    • 一是资金溯源,例如在众筹或存款合约中,合约可以通过记录 msg.sender 来追踪每个地址存入的金额;

    • 二是权限控制,比如只有特定的 msg.sender(如合约所有者)才能执行某些敏感函数(如提取资金);

    • 三是跨合约交互,当一个合约调用另一个合约的函数时,msg.sender 会变为发起调用的合约地址,而非原始交易发起者。

    需要注意的是,msg.sender 很容易被伪造——如果用户通过一个恶意合约来调用你的合约,msg.sender 将是那个恶意合约的地址,因此不能完全依赖它来代表“真实用户”,在涉及身份验证的场景中通常需要结合其他机制(如数字签名)。

    3.14.2 Tracking Funders

    为了追踪向合约发送资金的地址,我们可以创建一个名为 funders 的地址数组:

    address[] public funders;

    每当有人向合约汇款时,我们都会使用以下push函数将他们的地址添加到数组中:

    funders.push(msg.sender);

    全局变量 msg.sender 指的是发起交易的地址。

    3.14.3 将用户与其发送的资金进行映射

    我们可以使用映射将每个资助者的地址与其发送的金额关联起来。让我们在 Solidity 中定义一个映射:

    mapping(address funders => uint256 amountFunded) public addressToAmountFunded;

    addressToAmountFunded 映射将每个资助者的地址与其贡献的总金额关联起来。当有新金额发送时,我们可以将其累加到用户的总贡献中:

    addressToAmountFunded[msg.sender] += msg.value;

    结论:我们已成功实现了一个系统,用于追踪向 fundMe 合约提供资金的用户。该机制记录每个向合约发送 ETH 的地址,并将发送者地址与其贡献的总金额进行映射。

    3.14.4 测试一下自己

    1. 为什么在 fundMe 合约中需要使用 mapping addressToAmountFunded

    fundMe 合约中,我们需要追踪每个地址总共向合约发送了多少 ETH。使用 mapping 有以下几个关键原因:

    • 高效检索:mapping 允许我们通过地址直接查询该地址的累计捐赠金额,时间复杂度为 O(1)。如果使用数组存储所有交易记录,每次查询都需要遍历整个数组,效率极低且消耗大量 Gas。

    • 自动聚合:同一个地址可能多次调用 fund 函数,我们需要将其多次发送的金额累加。mapping 可以轻松实现累加逻辑:addressToAmountFunded[msg.sender] += msg.value

    • 状态持久化:mapping 是合约存储的一部分,数据会永久保存在区块链上,便于后续验证、提取资金或执行其他依赖捐赠历史的操作(例如检查用户是否达到最低捐赠额)。

    • 与数组配合:通常我们还会使用一个 address[] funders 数组来记录所有参与过的地址,而 mapping 则用于快速查询每个地址的总金额。两者结合,既能遍历所有资助者,又能高效获取单个地址的贡献值。

    2、实现函数 contributionCount 监控用户调用 fund 的次数

     
    

    3.15 本节快速回顾

    3.15.1 介绍

    在这次回顾中,我们将复习如何与外部合约交互并利用其函数、理解 Chainlink 价格预言机、执行 Solidity 数学运算,以及探索全局属性。

    3.15.2 与外部合同方交互

    要与任何外部合约交互,你需要该合约的地址和 ABI(应用程序二进制接口)。可以将地址视为门牌号,用于识别区块链上的特定合约,而 ABI 则充当手册,说明如何与该合约交互。

    要获取合约的 ABI,你可以编译目标合约所实现的 Solidity 接口。然后,创建一个指向已部署合约特定地址的接口新实例。

    3.15.3 Chainlink价格信息

    Chainlink 价格预言机提供了一种可靠的方式来访问现实世界的数据(例如定价数据)并将其注入智能合约中。这对于在 Solidity 和以太坊虚拟机(EVM)中执行数学运算尤其有用,因为其中不使用浮点数。

    Solidity 全局属性

    Solidity 文档提供了几个与以太坊区块链交互所必需的全局属性。以下是两个关键属性:

    • msg.sender:该属性指的是发起当前函数调用的账户地址

    • msg.value:该属性表示随函数调用发送的Wei 数量

    function updateValue() public payable {
        require(msg.value >= 1 ether, "Not enough Ether provided.");
    }

    通过理解这些概念,你将能够有效地与外部合约交互,利用 Chainlink 价格预言机获取现实世界数据,并运用 Solidity 的全局属性进行更稳健的智能合约开发。

    3.15.4 区块和交易属性

    • blockhash(uint blockNumber) returns (bytes32):给定区块的哈希值—仅适用于最近的 256 个区块

    • blobhash(uint index) returns (bytes32):与当前交易关联的第 `index` 个 blob 的版本化哈希。版本化哈希由一个表示版本的单字节(当前为 0x01)加上 KZG 承诺的 SHA256 哈希的最后 31 个字节组成(EIP-4844)。如果给定索引的 blob 不存在,则返回零。

    • block.basefee(uint):当前区块的基础费用(EIP-3198 和 EIP-1559)

    • block.blobbasefee(uint):当前区块的 blob 基础费用(EIP-7516 和 EIP-4844)

    • block.chainid(uint):当前链 ID

    • block.coinbase(address payable):当前区块矿工的地址

    • block.difficulty(uint):当前区块难度(EVM < Paris)。对于其他 EVM 版本,它作为 block.prevrandao 的已弃用别名,将在下一个破坏性版本中移除

    • block.gaslimit(uint):当前区块的 gas 限制

    • block.number(uint):当前区块号

    • block.prevrandao(uint):信标链提供的随机数(EVM >= Paris)(参见 EIP-4399)

    • block.timestamp (uint):自 Unix 纪元以来当前区块的时间戳(秒)

    • gasleft() returns (uint256):剩余 gas

    • msg.data (bytes):完整的 calldata

    • msg.sender (address):消息的发送者(当前调用)

    • msg.sig (bytes4):calldata 的前四个字节(即函数标识符)

    • msg.value (uint):随消息发送的 wei 数量

    • tx.gasprice (uint):交易的 gas 价格

    • tx.origin (address):交易的发送者(完整调用链)

    3.15.4 测试一下自己

    1、尝试回答第 1 课到第 12 课中所有的理论问题,然后再回过头来完成所有的编码任务。

    3.16 创建你自己的库

    3.16.1 介绍

    在之前的课程中,我们使用了 getPrice() 函数和 getConversionRate() 函数。这些方法可以被任何使用价格反馈(Price Feeds)的人重复使用多次。当某个功能可以被通用时,我们可以创建一个库(library)来高效地管理重复的代码部分。

    3.16.2 库(Libraries)

    Solidity by example 网站 上可以找到很好的库示例。

    Solidity 库与合约类似,但不允许声明任何状态变量,也不能接收以太币(ETH)。

    ❗重要提示:库中的所有函数必须声明为 internal(内部),这样它们在编译期间会被嵌入到合约中。如果有任何函数未如此标记,则该库无法被直接嵌入,而是必须独立部署,然后再链接到主合约。

    我们可以先创建一个名为 PriceConverter.sol 的新文件,并将 contract 关键字替换为 Library。

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.18;
    library PriceConverter {}

    让我们将 FundMe.sol 合约中的 getPricegetConversionRate getVersion 函数复制到我们新建的库中,记得将 AggregatorV3Interface 导入到 PriceConverter.sol 文件中。最后,我们可以将所有函数标记为 internal(内部函数)。

    // SPDX-License-Identifier: MIT
    // 开源许可证:MIT,允许任何人自由使用、修改和分发代码
    
    pragma solidity ^0.8.18; // 指定编译器版本不低于0.8.18且不高于0.9.0
    
    // 导入 Chainlink 价格预言机接口,用于与特定数据源合约交互
    import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
    
    /**
     * @title PriceConverter
     * @dev 一个库,提供获取 ETH/USD 价格以及将 ETH 数量转换为等值 USD 的功能
     * 该库硬编码了 Sepolia 测试网的 Chainlink 价格源地址,使用时需根据实际网络调整
     */
    library PriceConverter {
        /**
         * @dev 从 Chainlink 预言机获取当前 ETH/USD 价格,并转换为 18 位小数的固定精度
         * @return uint256 ETH/USD 价格,以 USD 为单位,带有 18 位小数(例如 3000.12 USD 表示为 3000120000000000000000)
         *
         * 具体步骤:
         * 1. 通过指定地址创建 AggregatorV3Interface 实例,该地址是 Sepolia 测试网的 ETH/USD 数据源
         * 2. 调用 latestRoundData() 获取最新价格数据,忽略 roundId、startedAt、updatedAt、answeredInRound
         * 3. 返回的 answer 是 int256 类型,且原始价格通常为 8 位小数(如 3000.12345678 USD -> 300012345678)
         * 4. 将 answer 乘以 10^10,转换为 18 位小数,便于与 ETH 的 wei 单位(18 位小数)进行后续换算
         * 5. 将结果转换为 uint256 并返回
         *
         * 注意:该地址硬编码为 Sepolia 测试网,主网或其他测试网需替换为相应地址
         */
        function getPrice() internal view returns (uint256) {
            AggregatorV3Interface priceFeed = AggregatorV3Interface(0x694AA1769357215DE4FAC081bf1f309aDC325306);
            (, int256 answer, , , ) = priceFeed.latestRoundData();
            // 将 8 位小数的价格转换为 18 位小数
            return uint256(answer * 10000000000); // 10^10
        }
    
        /**
         * @dev 将给定的 ETH 数量(以 wei 为单位)按当前价格转换为等值 USD(以 18 位小数表示)
         * @param ethAmount ETH 数量,单位为 wei(即 10^-18 ETH)
         * @return uint256 等值的 USD 金额,同样以 18 位小数表示(例如 1 ETH = 3000 USD 时返回 3000000000000000000000)
         *
         * 计算逻辑:
         * - 调用 getPrice() 获取当前 ETH/USD 价格(18 位小数)
         * - 计算 ethPrice * ethAmount,结果有 36 位小数
         * - 除以 1e18(即 10^18),将结果调整回 18 位小数,代表 USD 金额
         *
         * 例如:ethPrice = 3000.12 * 10^18 = 3000120000000000000000
         *       ethAmount = 2 * 10^18 = 2000000000000000000(2 ETH)
         *       乘积 = 6000240000000000000000000000000000000(36 位小数)
         *       除以 1e18 后 = 6000240000000000000000(18 位小数),即 6000.24 USD
         */
        function getConversionRate(uint256 ethAmount) internal view returns (uint256) {
            uint256 ethPrice = getPrice(); // 获取当前 ETH 价格(18 位小数)
            // 计算 ETH 数量对应的 USD 价值,保持 18 位小数精度
            uint256 ethAmountInUsd = (ethPrice * ethAmount) / 1000000000000000000; // 1e18
            return ethAmountInUsd;
        }
    }

    3.16.3 访问自定义库

    你可以在你的合约中导入该库,并使用 using 关键字将其附加到所需的类型上:

    import {PriceConverter} from "./PriceConverter.sol";
    using PriceConverter for uint256;

    然后,就可以像调用 uint256 类型的原生方法一样调用 PriceConverter 中的函数。例如,调用 getConversionRate() 函数现在将变为:

    require(msg.value.getConversionRate() >= minimumUsd, "didn't send enough ETH");

    在这里,msg.value(它是一个 uint256 类型)被扩展支持调用 getConversionRate() 函数。msg.value 会自动作为第一个参数传递给该函数。如果需要额外的参数,则可以在括号中传入:

    uint256 result = msg.value.getConversionRate(123);

    在这种情况下,123它作为第二个uint256参数传递给函数。

    总结:在本课中,我们探讨了使用 库 来重用代码和添加新功能的好处。我们创建了一个 PriceConverter 库来处理 getPrice、getConversionRate 和 getVersion 函数,演示了如何有效地构建和使用库。

    3.16.4 自我测试

    1. Solidity 的库和合约之间有什么区别?

    在 Solidity 中,库(Library)合约(Contract)有以下主要区别:

    1、设计目的

    • 合约:是区块链上的独立实体,可以拥有状态变量、接收 ETH、继承、创建其他合约等,主要用于实现完整的业务逻辑。

    • :旨在提供可复用的函数集合(类似于工具类),通常不存储状态,也不接收 ETH,主要用于代码复用和降低 gas 成本。

    2、状态变量

    • 合约:可以声明和修改状态变量,拥有自己的存储空间。

    • 不能声明状态变量(但可以读取调用者合约的状态,如果通过 delegatecall 调用)。

    3、接收 ETH

    • 合约:可以通过 payable 函数或 receive()/fallback() 接收 ETH。

    • 无法接收 ETH(即使标记 payable 也会编译错误)。

    4、函数可见性

    • 合约:函数可以是 publicexternalinternalprivate

    • :如果库是内嵌使用(函数标记为 internal),则函数在编译时被复制到调用合约中;如果库需要独立部署(函数标记为 publicexternal),则必须通过 delegatecall 调用,且此时库函数无法访问调用者的 msg.sender 等上下文(除非显式传递)。

    5、部署与链接

    • 合约:每次部署都会产生一个独立的地址。

    • :有两种使用方式:

      • 内嵌库:所有函数为 internal,在编译时直接嵌入调用合约,不会单独部署。

      • 链接库:如果包含 publicexternal 函数,则需要先独立部署库,然后在部署主合约时将其地址链接进去(通过 delegatecall 调用)。

    6、using ... for 指令

    • 合约:不能直接附加到类型上。

    • :可以通过 using A for B 将库函数附加到类型 B 上,从而像调用原生方法一样使用(例如 msg.value.getConversionRate())。

    7、Gas 消耗

    • 合约:普通函数调用通过 CALL 操作码,消耗较多 gas。

    • internal 函数内嵌后无额外调用开销;public 库函数通过 delegatecall 调用,比普通合约调用稍便宜,但仍需考虑链接成本。

    示例说明

    // 库:提供数学工具
    library MathLibrary {
        function sum(uint a, uint b) internal pure returns (uint) {
            return a + b;
        }
    }
    
    // 合约:使用库
    contract MyContract {
        using MathLibrary for uint;  // 附加库到 uint 类型
    
        function calculate(uint x, uint y) public pure returns (uint) {
            return x.sum(y);  // 像调用原生方法一样使用
            // 等价于 MathLibrary.sum(x, y);
        }
    }

    如果仍有疑问,可以告诉我你想深入了解的具体场景,我再进一步解释。

    简单来说:

    • 合约:可以存钱(接收 ETH)、存数据(状态变量)、实现复杂业务逻辑,是独立运行的实体。

    • :不能存钱也不能存数据,只提供一堆函数给别的合约用(类似工具包),目的是节省代码重复和 gas。

    2. 如果库函数未标记为internal,会有什么后果?

    如果库函数未标记为 internal(即它是 publicexternal),会有以下后果:

    (1)库必须独立部署:这样的库无法再被嵌入到调用合约中,而是需要先单独部署到区块链上,获得一个独立的地址。

    (2)需要链接库:在部署主合约时,必须将主合约与已部署的库地址进行链接,主合约通过 delegatecall 调用库函数。

    (3)调用方式不同

    • internal 函数:在编译时直接复制到调用合约中,调用无额外开销。

    • public/external 函数:需要通过 delegatecall 调用,gas 成本略高,且库函数无法直接访问调用合约的状态变量(除非显式传递)。

    (4)无法使用 using ... for 附加到类型:如果库包含 public 函数,仍然可以使用 using ... for,但实际调用时仍是外部调用,不是内嵌。简单说:未标记 internal 的库函数会使库从“代码复制”模式变成“独立部署 + 外部调用”模式,增加了部署复杂性和 gas 成本。

    3. 创建一个名为 MathLibrary 的简单库,其中包含一个将两个 uint256 数字相加的 sum 函数。然后在 fundMe合约中创建一个使用 MathLibrary 库中sum函数的 calculateSum 函数。

    下面是一个完整的示例,包含 MathLibrary 库和一个使用该库的 FundMe 合约。

    1. 创建库 MathLibrary

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
    
    library MathLibrary {
        // 将两个 uint256 相加,并返回结果
        function sum(uint256 x, uint256 y) internal pure returns (uint256) {
            return x + y;
        }
    }

    2. 在 FundMe 合约中使用该库

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.0;
    
    import "./MathLibrary.sol";  // 假设库文件在同一目录
    
    contract FundMe {
        using MathLibrary for uint256;  // 将库附加到 uint256 类型(可选)
    
        // 方式一:通过 using 关键字直接调用(推荐)
        function calculateSum(uint256 a, uint256 b) external pure returns (uint256) {
            return a.sum(b);  // 等价于 MathLibrary.sum(a, b)
        }
    
        // 方式二:传统方式调用库函数
        function calculateSumTraditional(uint256 a, uint256 b) external pure returns (uint256) {
            return MathLibrary.sum(a, b);
        }
    }

    说明:

    • 库函数 sum:标记为 internal pure,因为它只做计算,不访问状态,并且只被内部调用(编译时嵌入)。

    • using MathLibrary for uint256:将库函数附加到 uint256 类型,使得任何 uint256 变量都可以直接调用 sum 方法,第一个参数隐式传递。

    • 两种调用方式均可,推荐使用 using 语法更简洁。

    测试函数:你可以部署 FundMe 合约后调用 calculateSum(3,5),返回 8

    3.17 使用Safemath

    3.17.1 介绍

    在本节课中,我们将探讨 SafeMath,这是一个在 Solidity 0.8.0 版本之前被广泛使用的库,此后其使用频率有所降低。

    补充说明:

    • SafeMath 是 Solidity 早期用于防止整数溢出 / 下溢的安全数学库。

    • Solidity 0.8.0 及之后版本内置了溢出检查,因此 SafeMath 的使用场景大幅减少。

    3.17.2 整数溢出

    SafeMath.sol 曾是 Solidity 0.8 版本之前智能合约中的主流库。在此版本之后,它的使用量显著下降。

    让我们先创建一个名为 SafeMathTester.sol 的新文件,并添加一个 add 函数来递增状态变量。

    // SafeMathTester.sol
    pragma solidity ^0.6.0;
    
    contract SafeMathTester {
        uint8 public bigNumber = 255;
    
        function add() public {
            bigNumber = bigNumber + 1;
        }
    }

    注意,我们使用的是 0.6.0 版本的编译器。bigNumber 是一个 uint8 类型变量,最大值为 255。如果我们调用 add 函数,它将返回 0,而非预期的 256。

    在 Solidity 0.8.0 版本之前,有符号和无符号整数均未做溢出检查,这意味着当它们超出变量类型所能承载的最大值时,会重置为下限值。这种现象被称为整数溢出,而 SafeMath 库正是为了防止这类问题而设计的。

    3.17.3 SafeMath

    SafeMath.sol 提供了一种机制,当某个 uint256 数据类型达到其最大限制时会自动回滚交易。这是合约中一项典型的安全措施,用于避免错误计算和潜在漏洞。

    function add(uint a, uint b) public pure returns (uint) {
        uint c = a + b;
        require(c >= a, "SafeMath: addition overflow");
        return c;
    }

    3.17.4 Solidity 0.8.0

    随着 Solidity 0.8 版本的推出,编译器实现了对整数溢出和下溢的自动检查,这使得 SafeMath 库在这类检查场景中变得不再必要。若使用 Solidity 0.8.0 部署 SafeMathTester.sol 合约,调用 add 函数会导致交易失败;而在更早的版本中,这种情况会使数值重置为零。

    对于那些数学运算结果已知不会超出变量限制的场景,Solidity 引入了 unchecked 代码块来提升代码的gas效率。将加法运算包裹在 unchecked 代码块内,会忽略溢出和下溢检查:一旦 bigNumber 超出限制,其数值就会回绕至零。

    uint8 public bigNumber = 255;
    
    function add() public {
        unchecked {
            bigNumber = bigNumber + 1;
        }
    }

    🔥注意:使用未经检查的代码块时务必谨慎,因为它们会重新引入溢出和下溢的可能性。

    结论:Solidity 与 SafeMath.sol 的演进,凸显了以太坊智能合约开发的持续进步。尽管近期更新让 SafeMath.sol 不再那么不可或缺,但它仍是以太坊历史的重要组成部分。理解它的作用,能为洞察 Solidity 的发展与成熟提供宝贵视角。

    3.17.5 测试一下自己

    1、为什么在 0.8 版本之前,SafeMath 库被广泛使用?

    在 Solidity 0.8 版本之前,编译器不会自动检查整数溢出和下溢,当运算超出变量类型的数值范围时,数值会发生回绕(比如 uint8 类型的 255 + 1 会变成 0),这会导致安全漏洞。SafeMath 库通过封装加减乘除运算,在发生溢出 / 下溢时主动回滚交易,从而避免这类漏洞,因此被广泛使用。

    2、解释整数溢出和整数下溢的含义,并使用 uint16 举一个例子。

    • 整数溢出(Overflow):无符号整数超出其类型最大值时,数值会回绕到最小值。

    • 整数下溢(Underflow):无符号整数小于其类型最小值(0)时,数值会回绕到最大值。

    示例(uint16,取值范围 0~65535)

    • 溢出:65535 + 1 = 0(超出最大值,回绕到 0)

    • 下溢:0 - 1 = 65535(小于最小值,回绕到最大值)

    3、Solidity 0.8 版本之后发生了什么变化?

    Solidity 0.8 版本开始内置了整数溢出 / 下溢的自动检查:当运算超出变量范围时,交易会自动回滚并抛出异常,不再发生数值回绕。这使得 SafeMath 库在溢出防护场景中变得冗余,使用量大幅下降。

    4、什么是 unchecked 结构?

    unchecked 是 Solidity 0.8 引入的语法结构,用于包裹一段代码,临时关闭编译器的溢出 / 下溢检查,让运算恢复到 0.8 之前的回绕行为。它主要用于开发者确定运算不会超出范围的场景,以节省 Gas 消耗

    5、修改 SafeMathTester 合约,使用 SafeMath 库来防止整数溢出。

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.6.0;
    
    import "./SafeMath.sol";
    
    contract SafeMathTester {
        uint8 public bigNumber = 255;
        using SafeMath for uint8;
    
        function add() public {
            // 使用 SafeMath 的 add 方法,溢出时会回滚交易
            bigNumber = bigNumber.add(1);
        }
    }

    说明:通过 using SafeMath for uint8;SafeMath 库方法绑定到 uint8 类型,调用 add() 时会自动进行溢出检查,避免数值回绕。

    3.18 Solidity 中的for循环

    3.18.1 介绍

    在本教程中,我们将编写 withdraw 函数,以实现累计资金的提取功能。随后,我们将使用一种名为 for 循环 的方法,将所有映射中的值重置为零。

    3.18.2 for循环

    for 循环 是许多编程语言中广泛应用的概念,它能够让一段代码块重复执行指定的次数

    举个例子,考虑这样一个列表:

    //[1, 2, 3, 4] elements
    // 0  1  2  3  indexes

    该列表中的元素是数字 1 到 4,索引范围从 0 到 3:数字 1 位于索引 0 处,数字 2 位于索引 1 处,以此类推。要访问列表中的所有元素,我们可以使用它们的索引。

    编程语言中典型的 for 循环结构包含以下步骤:

    1. 从某个起始索引开始初始化;

    2. 迭代至结束索引;

    3. 按特定步长递增;

    例如,如果我们从索引 0 开始循环,到索引 10 结束,且每次递增 1,那么我们将得到:

    0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

    但是,如果我们从索引 3 开始,到索引 12 结束,每次递增 2,则得到:

    3, 5, 7, 9, 11

    3.18.3 在 FundMe 合约中使用 for 循环

    要在 FundMe 合约中实现这一概念:

    uint256 funderIndex;
    for (funderIndex = 0; funderIndex < funders.length; funderIndex++) {
        address funder = funders[funderIndex];
        addressToAmountFunded[funder] = 0;
    }

    循环从索引 0 开始,遍历 funders 数组中的所有元素,直到最后一个元素。在每次迭代中,它执行以下操作:

    • 访问当前索引处的 funder 地址。

    • addressToAmountFunded 映射中对应的资助金额重置为零,清除该 funder 的记录。

    🗒️注意:地址到金额映射将地址与其对应的资助金额关联起来。

    3.18.4 快捷方式

    此外,我们在代码中使用了两种简写形式:

      1. funderIndex++ funderIndex = funderIndex + 1 的简写形式。

      2. += :将一个值加到现有值上。x = x + y 等价于 x += y

    为了说明代码片段,我们从 funderIndex = 0 开始。进入循环时,我们获取 funders 数组中第一个位置的资助者地址,并将其金额设为零。之后,我们重复循环,将 funderIndex 增加 1,并检查它是否仍然小于资助者总数。然后我们获取下一个位置的资助者地址,依此类推。

    总结:在本课中,我们学习了如何实现 `withdraw` 函数来管理累积的资金,并使用 `for` 循环重置映射。这个过程确保了资助者记录的高效处理。

    3.18.5 自我测试

    1. 我们在本课中提到的简写形式有哪些?

    在 Solidity 中,常见的简写形式主要包括复合赋值运算符递增/递减运算符,它们能让代码更简洁:

    1. 递增 / 递减

    • i++ 等价于 i = i + 1(先使用后递增)

    • ++i 等价于 i = i + 1(先递增后使用)

    • i----i 同理(递减)

    2. 复合赋值运算符,对变量进行运算并重新赋值:

    • x += y 等价于 x = x + y

    • x -= y 等价于 x = x - y

    • x *= y 等价于 x = x * y

    • x /= y 等价于 x = x / y

    • x %= y 等价于 x = x % y

    • 位运算:x &= yx |= yx ^= yx <<= yx >>= y

    3. 其他简写(视作语法糖)

    • delete:将变量重置为默认值(如 delete array[i]delete mapping[key]),比手动赋值更简洁。

    • 三元运算符 条件 ? 表达式1 : 表达式2:可替代简单的 if-else 语句。

    • 命名返回值:在函数声明中直接指定返回变量名,函数体内赋值后无需 return 语句。

    这些简写形式有助于减少重复代码,提高可读性。在循环或数学计算中尤其常用。

    2. Solidity 中的 for 循环是如何工作的?

    在 Solidity 中,for 循环的工作方式与大多数编程语言(如 JavaScript、C++)非常相似,但有一些区块链特有的注意事项。它的核心语法如下:

    for (初始化; 条件判断; 迭代语句) {
        // 循环体:要重复执行的代码
    }

    执行流程(分步说明):

    假设我们有一个简单的 for 循环:

    for (uint256 i = 0; i < 5; i++) {
        // 执行某些操作
    }

    它的执行顺序是:

    1. 初始化uint256 i = 0

    1. 在循环开始前执行一次,通常用于声明和初始化循环计数器(如 i)。

    1. 条件判断i < 5

    1. 每次迭代开始前检查条件。如果条件为 true,进入循环体;如果为 false,循环终止。

    1. 循环体执行{ ... }

    1. 执行循环内部的代码(例如读取数组元素、修改状态变量等)。

    1. 迭代语句i++

    1. 循环体执行完毕后,执行迭代语句(通常是递增或递减计数器),然后回到第2步再次判断条件。

    直到条件判断为 false 时,循环结束,程序继续执行循环后面的代码。

    在 Solidity 中的特殊行为:

    1. Gas 消耗for 循环的迭代次数越多,消耗的 Gas 越多。如果循环次数过多(例如遍历一个很大的数组),可能耗尽 Gas 导致交易失败。因此,避免无上限的循环,尽量让循环次数可预测且较小。

    2. 修改状态变量:循环体内可以修改合约的状态变量(如映射、数组),但要注意 Gas 成本。每次修改都会写入区块链,消耗 Gas。

    3. 提前退出:可以使用 break 立即终止整个循环,使用 continue 跳过当前迭代、进入下一次迭代。

    4. 数组遍历示例(结合你之前看到的代码):

      address[] public funders;
      mapping(address addr => uint256 Funded) public addressToAmountFunded;
      
      function resetFunders() public {
          for (uint256 i = 0; i < funders.length; i++) {
              address funder = funders[i];
              addressToAmountFunded[funder] = 0;  // 重置资助金额
          }
          delete funders;  // 清空数组(可选)
      }

        这个循环遍历 funders 数组,将每个资助者的金额重置为 0。

      1. 条件判断的动态性:条件表达式 i < funders.length 在每次迭代前都会重新计算,因此如果循环体内改变了数组长度,会影响循环次数(需谨慎处理,避免意外)。

      总结:

      • for 循环在 Solidity 中用于重复执行代码,特别是遍历数组或执行固定次数的操作。

      • 必须注意 Gas 限制,避免无限循环或超大循环。

      • 循环内部可以读取/修改状态变量,但每次修改都会消耗 Gas。

      • 使用 breakcontinue 可以控制循环的流动。

      3. 实现一个名为 pushNumbers 的函数,用 1 到 10 的值填充一个 numbers 数组。

      // SPDX-License-Identifier: MIT
      // 开源许可证:MIT,允许任何人自由使用、修改和分发代码
      
      pragma solidity ^0.8.19;  // 指定编译器版本必须不低于0.8.19且不高于0.9.0
      
      /**
       * @title ushNumbers
       * @dev 一个简单的合约,演示如何使用动态数组和循环向数组中添加元素
       * 注意:合约名可能是 "ushNumbers" 的笔误,更常见的命名为 "PushNumbers" 或 "NumberFiller"
       */
      contract ushNumbers {
      
          /**
           * @dev 动态数组,用于存储一系列无符号整数
           * public 关键字让 Solidity 自动生成一个同名的 getter 函数,
           * 可以通过传入索引查询对应位置的数值,例如 numbers(0) 返回第一个元素
           */
          uint256[] public numbers;
      
          /**
           * @dev 向 numbers 数组中依次添加 1 到 10 这十个数字
           * 该函数每次被调用都会在数组末尾追加新元素,不会清空原有数据
           * 由于函数是 external,只能从合约外部调用,节省了一点 gas
           *
           * 要求:
           * - 无输入参数
           * - 无返回值
           * - 可能触发数组扩容,消耗相应的 gas
           */
          function pushNumbers() external {
              // 使用 for 循环,从 i = 1 开始,到 i = 10 结束(包含 10)
              for (uint256 i = 1; i <= 10; i++) {
                  // 将当前 i 的值追加到 numbers 数组末尾
                  numbers.push(i);
              }
              // 循环结束后,numbers 数组应包含 [1,2,3,4,5,6,7,8,9,10]
          }
      }

      3.19 小测试

      3.20 重置数组

      3.20.1 介绍

      在本节中,我们将重点关注完成 withdraw 函数的最后步骤之一:有效地重置 funders 数组。

      3.20.2 重置数组

      重置 funders 数组最简单的方法与处理映射(mapping)的方式类似:遍历其所有元素,并将每个元素重置为 0。或者,我们也可以创建一个全新的 funders 数组。

      funders = new address[]();

      注意:你可能还记得在部署合约时使用过 new 关键字。然而在此上下文中,它会将 funders 数组重置为一个大小为 0 的空地址数组。

      结论:在本课中,我们学习了如何通过遍历数组元素或创建一个新的空数组来重置 funders 数组。 这一步对于完成 withdraw 函数并确保合约数据得到妥善管理至关重要。

      3.20.3 测试一下自己

      1、为什么在实现 withdraw 函数时重置 funders 数组很重要?

      在实现 withdraw 函数时重置 funders 数组非常重要,主要有以下三个原因:

      1. 防止重复记录与状态不一致

      • 如果不重置数组,funders 中会一直保留所有历史资助者地址。当新一轮资助开始时,新的地址会被追加到数组末尾,导致数组无限膨胀。

      • 同时,addressToAmountFunded 映射中对应地址的金额已重置为 0,但数组中仍保留这些地址,造成数据不一致(数组显示有人资助过,但金额映射显示为 0)。

      2. 避免 Gas 消耗无限增长

      • withdraw 函数中通常会遍历 funders 数组(例如将每个资助者的金额清零)。如果数组不断累积旧地址而不重置,每次提现时循环遍历的数组长度会越来越长,导致 Gas 消耗持续增加,最终可能超出区块 Gas 限制,导致提现功能无法执行。

      • 重置数组后,funders.length 变为 0,后续提现只需极低 Gas。

      3. 确保提现功能的正确逻辑

      • 提现操作意味着合约资金被取出,所有资助记录应该被清空,合约回到初始状态(就像刚部署时一样)。如果不重置数组,合约状态会“残留”历史数据,与“资金已全部取出”的语义不符。

      • 重置数组配合清空映射,共同实现了完整的状态重置,为下一次资助周期做好准备。

      总结:重置 funders 数组是为了维护数据的整洁性、控制 Gas 成本,并保证合约逻辑的准确性,是完成 withdraw 功能的必要步骤。

      2、创建一个名为 expensiveReset 的方法,使用迭代方式重置数组。

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.19;
      
      /**
       * @title FundMe
       * @dev 一个简单的众筹合约,允许用户发送 ETH(资助),只有合约所有者可以提取资金。
       * 每次资助会记录资助者地址和金额,提现时重置所有记录。
       */
      contract FundMe {
          address public immutable owner;          // 合约所有者(部署者)
          address[] public funders;                // 资助者地址数组
          mapping(address => uint256) public addressToAmountFunded; // 每个地址的资助金额
      
          // 修饰器:限制只有所有者可以调用
          modifier onlyOwner() {
              require(msg.sender == owner, "Only owner can call this function");
              _;
          }
      
          constructor() {
              owner = msg.sender;  // 部署者即为所有者
          }
      
          /**
           * @dev 资助函数:向合约发送 ETH,并记录资助者及其金额
           * 要求:发送的金额必须大于 0
           */
          function fund() external payable {
              require(msg.value > 0, "You need to send some ETH");
      
              // 如果该地址是第一次资助,则添加到 funders 数组
              if (addressToAmountFunded[msg.sender] == 0) {
                  funders.push(msg.sender);
              }
              // 累加资助金额
              addressToAmountFunded[msg.sender] += msg.value;
          }
      
          /**
           * @dev 提现函数:将所有合约余额发送给所有者,并重置资助记录
           * 使用高效方法:直接删除数组(将长度置零)
           */
          function withdraw() external onlyOwner {
              // 1. 将合约余额转账给所有者
              (bool success, ) = owner.call{value: address(this).balance}("");
              require(success, "Transfer failed");
      
              // 2. 重置所有资助者的金额为 0(映射)
              // 遍历 funders 数组,将映射中的金额清零
              for (uint256 i = 0; i < funders.length; i++) {
                  address funder = funders[i];
                  addressToAmountFunded[funder] = 0;
              }
      
              // 3. 重置 funders 数组(高效方式:删除整个数组,长度变为 0)
              delete funders;
          }
      
          /**
           * @dev 昂贵重置方式:通过迭代将数组每个元素设为默认值(address(0)),但数组长度保持不变
           * 这种方法会消耗与数组长度成比例的 Gas,因此称为 "expensiveReset"
           */
          function expensiveReset() external onlyOwner {
              uint256 length = funders.length;
              for (uint256 i = 0; i < length; i++) {
                  funders[i] = address(0);   // 将每个元素重置为零地址
              }
              // 注意:映射 addressToAmountFunded 并没有被重置,如果需要可在此添加
              // 但按照题目要求,此函数仅重置数组本身(通过迭代)
          }
      
          /**
           * @dev 辅助函数:返回 funders 数组的长度
           */
          function getFundersLength() external view returns (uint256) {
              return funders.length;
          }
      }

      3.21 从合约发送ETH

      3.21.1 介绍

      本课将探讨将 ETH 从一个账户发送到另一个账户的三种不同方法:transfersendcall。我们将了解它们的区别、各自的工作原理,以及在什么情况下应该使用哪一种方法。

      3.21.2 transfer

      transfer 函数是将以太币发送到接收方地址的最简单方式:

      payable(msg.sender).transfer(amount); // the current contract sends the Ether amount to the msg.sender

      必须将接收方地址转换为 payable 地址,才能使其接收以太币。这可以通过使用 payable 关键字包装 msg.sender 来实现。然而,transfer 有一个显著的局限性:它最多只能使用 2300 gas,并且任何超过此 gas 限制的交易都会回滚,正如 Solidity 示例所示。

      3.12.3 send

      send 函数与 transfer 类似,但其行为有所不同:

      bool success = payable(msg.sender).send(address(this).balance);
      require(success, "Send failed");

      transfer 类似,send 同样有 2300 gas 的限制。如果达到 gas 限制,它不会回滚交易,而是返回一个布尔值(true 或 false)来指示交易的成功或失败。开发者有责任正确处理失败情况,最佳实践是在 send 返回 false 时触发回滚条件。

      3.12.4 call

      call 函数灵活且强大。它可以用于调用任何函数,无需其 ABI。它没有 gas 限制,并且与 send 类似,它返回一个布尔值,而不是像 transfer 那样回滚。

      (bool success, ) = payable(msg.sender).call{value: address(this).balance}("");
      require(success, "Call failed");

      使用 call 函数发送资金时,我们需要将接收方地址转换为 payable 类型,并在传入参数之前用花括号标注发送金额。

      call 函数返回两个变量:一个表示成功或失败的布尔值,以及一个用于存储返回数据(如果有)的字节对象。

      ❗重要提示:call 是发送和接收以太坊或其他区块链原生通证的推荐方式。

      结论:总之,transfersendcall 是在 Solidity 中转移以太币的三种独特方法。它们在语法、行为和 gas 限制上各不相同,各自具有明显的优缺点。

      3.12.5 测试一下自己

      1、在转移以太币时,transfersendcall 之间的主要区别是什么?

      在 Solidity 中,transfersendcall 是三种向地址发送以太币的方法,它们的主要区别在于 gas 限制、错误处理方式、安全性以及推荐用法。以下是详细对比:

      1. gas 限制

      • transfer:固定 2300 gas,足够接收方执行一次日志记录或空投,但无法执行复杂逻辑(如状态变更)。如果接收方合约的 receivefallback 函数需要更多 gas,交易会失败。

      • send:同样固定 2300 gas,与 transfer 相同。

      • call无 gas 限制(可自定义),默认转发所有可用 gas,也可以通过 {gas: } 选项限制。适合与复杂合约交互。

      2. 错误处理

      • transfer失败时自动回滚(revert)。如果发送失败,整个交易会终止并撤销状态更改,无需手动检查。

      • send返回布尔值true/false),不会自动回滚。开发者必须手动检查返回值并决定是否 revert(例如 require(send(...), "Send failed");)。

      • call返回布尔值和数据 (bool success, bytes data)不会自动回滚。同样需要手动检查 success 并处理失败。

      3. 接收方类型

      • transfersend:要求接收方地址必须是 payable 类型,否则无法通过编译。

      • call:接收方地址无需显式转换为 payable(直接使用 address.call{value: ...}("") 即可),但实际发送时仍会要求接收方可支付。

      4. 安全性

      • transfersend:由于 gas 固定为 2300,可以有效防止重入攻击(因为接收方合约无法执行足够复杂的代码来重新调用发送方)。但这也限制了与高级合约的交互。

      • call存在重入风险,因为默认转发所有 gas,接收方合约可以执行复杂逻辑并重新调用发送方函数。使用时必须遵循“检查-生效-交互”模式(Checks-Effects-Interactions)或使用重入锁防护。

      5. 推荐用法

      • transfer已被弃用(部分版本标记为过时),因为 2300 gas 可能不足以应对未来以太坊升级(如 SSTORE 成本变化)。

      • send不推荐,因其 gas 限制低且需要手动处理失败,容易因疏忽导致资金卡在合约中。

      • call当前官方推荐方式,灵活且安全(只要正确防范重入攻击)。示例:

      (bool success, ) = payable(receiver).call{value: amount}("");
      require(success, "ETH transfer failed");

      总结对比表

      特性transfersendcall
      gas 限制2300(固定)2300(固定)无限制(可自定义)
      失败处理自动回滚返回布尔值返回布尔值 + 数据
      重入风险低(gas 不足)低(gas 不足)高(需防护)
      接收方类型需 payable需 payable无需显式转换(但实际仍需可支付)
      当前推荐度❌ 不推荐❌ 不推荐✅ 推荐(需安全编码)

      自我测试答案提示

      1、主要区别:gas 限制、错误处理方式(自动回滚 vs 返回布尔值)、重入风险、推荐程度。

      2、为什么推荐 call:灵活性高、无 gas 限制、可通过重入锁确保安全,且适应未来以太坊升级。

      3、call 的注意事项:必须检查返回值,并防范重入攻击。

      2、为什么在向地址发送以太币之前,必须将其转换为 payable 类型?

      在 Solidity 中,address 类型有两种变体:普通地址(address) 和 可支付地址(address payable)。两者的主要区别在于:

      • address payable 拥有 transfersend 成员函数,并且可以接收以太币。它明确表示该地址能够接受以太币(无论是外部账户还是实现了 receive/fallback 函数的合约)。

      • 普通 address 没有 transfersend 方法,也不能直接接收以太币(除非使用低级别的 call,但语义上不保证能成功接收)。

      因此,在调用 transfersend 发送以太币时,接收方地址必须显式转换为 payable 类型,否则编译器会报错。这样做有两个目的:

      • 类型安全:强制开发者明确目标地址可以接收以太币,避免意外向无法接收资金的地址(例如未实现支付功能的合约)发送资产。

      • 提供发送函数:只有 payable 地址才拥有 transfersend 方法,转换后代码才能调用这些函数。

      对于更灵活的 call 方法,它可以直接在普通 address 上使用(address.call{value: ...}("")),但通常仍建议将地址先转换为 payable,以增强代码可读性明确意图

      3、实现一个名为 callAmountTo 的函数,使用 call 将合约中的以太币发送到作为参数提供的地址。确保该函数能妥善处理失败情况。

      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.19;
      
      contract EthSender {
          /**
           * @dev 使用 call 将指定数量的以太币发送到目标地址
           * @param _to 接收地址(需为 payable 类型,或使用 payable() 转换)
           * @param _amount 发送金额(单位为 wei)
           */
          function callAmountTo(address payable _to, uint256 _amount) external {
              // 检查合约余额是否充足
              require(address(this).balance >= _amount, "Insufficient contract balance");
      
              // 使用 call 发送以太币,并捕获返回值
              (bool success, ) = _to.call{value: _amount}("");
      
              // 如果发送失败,回滚交易并提示错误
              require(success, "ETH transfer failed");
          }
      }

      说明:

      • 余额检查require(address(this).balance >= _amount, ...) 确保合约有足够余额,避免不必要的调用。

      • call 的使用_to.call{value: _amount}("") 向目标地址发送 _amount wei,不携带任何数据(空 calldata)。

      • 错误处理call 返回一个布尔值 success,通过 require(success, ...) 在失败时回滚交易,确保不会静默失败。

      • 地址类型:参数声明为 address payable 可以直接调用 call,如果使用普通 address,则需在调用时显式转换:payable(_to).call{...}("")

      注意事项:

      • 此函数未添加访问控制,任何外部账户均可调用,可能导致合约资金被耗尽。在实际应用中,通常需要添加权限修饰符(如 onlyOwner)或限制调用者。

      • 使用 call 时需警惕重入攻击,但此处发送后无状态更改,风险较低。若需进一步保护,可遵循“检查-生效-交互”模式。

      3.22 智能合约构造器

      3.22.1 介绍

      在本课中,我们将解决当前 fundMe 合约中存在的一个安全漏洞。

      3.22.2 构造函数

      目前,任何人都可以调用 withdraw 函数并耗尽合约中的所有资金。为了解决这个问题,我们需要限制提现功能仅允许合约所有者调用。一种解决方案是创建一个名为 callMeRightAway 的函数,在部署后立即将合约创建者指定为合约所有者。然而,这需要两笔交易。

      更高效的解决方案是 使用 构造函数:

      constructor() {}

      🗒️注意:构造函数未使用functionandpublic关键字。

      3.22.3 在构造函数中指定owner

      构造函数在合约部署期间,于部署合约的同一笔交易中被自动调用。

      我们可以使用构造函数在部署后立即设置合约的owner:

      address public owner;
      constructor() {
          owner = msg.sender;
      }

      这里,我们使用合约部署者的地址(msg.sender)来初始化状态变量 owner

      3.22.4 修改withdraw 函数

      下一步是更新 withdraw 函数,确保它只能由所有者调用:

      function withdraw() public {
          require(msg.sender == owner, "must be owner");
          // rest of the function here
      }

      在执行任何提现操作之前,我们检查 msg.sender 是否为所有者。如果调用者不是所有者,操作将回滚并显示错误信息 "必须是所有者"。这种访问限制确保了只有预期的账户才能执行该函数。

      结论:通过引入构造函数来分配所有权,并更新提现函数以限制访问,我们显著提高了 fundMe 合约的安全性。这些更改确保了只有合约所有者才能提取资金,防止了未经授权的访问。

      3.22.5 自我测试

      1、构造函数 的作用是什么?它与普通函数有何不同?

      构造函数是在合约部署时自动执行的特殊函数,主要用于初始化合约的状态变量(如设置合约所有者、初始参数等)。它与普通函数的核心区别在于:

      (1)执行时机

      • 构造函数:只在合约部署时执行一次,且在同一笔交易中完成。部署后无法再次调用。

      • 普通函数:合约部署后可被重复调用任意次。

      (2)函数名称

      • 构造函数:在 Solidity 0.4.22 之前,构造函数名必须与合约名相同;0.4.22 之后引入 constructor 关键字,统一使用 constructor() 定义,无需函数名

      • 普通函数:必须有明确的函数名(如 withdraw()fund())。

      (3)可见性

      • 构造函数无需指定可见性(如 public/internal),它仅在部署时由 EVM 内部调用,无法被外部或内部调用。

      • 普通函数:必须指定可见性(publicexternalinternalprivate),决定谁可以调用。

      (4)返回值

      • 构造函数不能有返回值,因为它不是被直接调用的。

      • 普通函数:可以返回值(需在声明中指定 returns 类型)。

      (5)是否可重入

      • 构造函数:执行时合约尚未完全部署,无法被重入攻击(其他合约无法调用未部署完成的合约)。

      • 普通函数:可能面临重入攻击,需使用防护机制(如重入锁)。

      (6)用途示例

      • 构造函数:初始化状态变量,例如:

        address public owner;
        constructor() {
            owner = msg.sender;  // 部署时设置合约所有者为部署者地址
        }
        • 普通函数:实现业务逻辑,例如:

          function withdraw() public onlyOwner {
              // 提现逻辑
          }

          (7) 是否可重写(Override)

          • 构造函数不可被继承的合约重写(但子合约可以定义自己的构造函数,并调用父合约构造函数)。

          • 普通函数:如果声明为 virtual,则可在继承合约中重写(override)。

          (8)是否可接收以太币

            构造函数:可以声明为 payable,允许在部署时向合约存入 ETH。

            普通函数:需显式标记 payable 才能接收 ETH。

          总结对比表

          特性

          构造函数

          普通函数

          执行时机

          部署时自动执行一次

          部署后可随时调用

          函数名

          constructor(固定)

          自定义名称

          可见性

          无(不可见)

          public/external

          返回值

          可以有

          重写

          不可重写

          可重(virtual/override

          主要用途

          初始化状态

          业务逻辑

          自我测试答案提示:

          (1)构造函数作用:初始化合约状态(如设置所有者、参数),确保合约从合法状态开始。

          (2)与普通函数区别:执行时机、名称、可见性、返回值、可重写性等均不同。

          2、为什么必须限制对 withdraw 函数的访问?

          限制对 withdraw 函数的访问是智能合约安全的基础要求,主要原因如下:

          1. 防止资金被盗

          • withdraw 函数通常负责将合约中的全部或部分余额发送给某个地址。如果不加限制,任何外部账户或恶意合约都可以调用它,将资金转移到自己名下,导致项目方和投资者的资金损失。

          2. 维护合约设计逻辑

          • 在许多众筹、收益聚合或资金管理合约中,withdraw 的设计初衷是只允许特定角色(如项目方、所有者或经过验证的用户)提取资金。例如,在 FundMe 合约中,提现功能应仅由部署者(所有者)执行,以符合“只有创建者才能取回资金”的业务规则。

          3. 防止重入攻击和恶意操作

          • 即使合约本身没有重入漏洞,如果任何人都能调用 withdraw,攻击者可能利用复杂调用流程耗尽资金。限制调用者可以缩小攻击面,降低风险。

          4. 符合最小权限原则

          • 安全设计强调“最小权限”,即每个函数只赋予完成其任务所需的最低权限。提现操作涉及资金转移,权限应严格限制,避免不必要的暴露。

          5. 避免意外操作

          • 如果没有限制,普通用户可能误触提现函数,导致合约资金被发送到错误的地址(例如零地址),造成永久损失。

          实现方式:通常使用访问控制,例如:

          • 在构造函数中记录部署者地址(owner = msg.sender;)。

          • 添加 onlyOwner 修饰器,在 withdraw 函数中检查 msg.sender == owner,否则 revert

          这样,只有合约所有者才能成功调用提现,确保资金安全。

          3、编写一个名为 withdrawOnlyFirstAccountRemix 的函数,该函数只允许第一个 Remix 账户从合约中提取所有资金。

          // SPDX-License-Identifier: MIT
          pragma solidity ^0.8.19;
          
          contract FundMe {
              // 第一个 Remix 账户的地址(Remix 默认第一个测试账户)
              address constant FIRST_REMIX_ACCOUNT = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;
          
              address[] public funders;                     // 资助者数组
              mapping(address => uint256) public addressToAmountFunded; // 每个地址的资助金额
          
              // 资助函数(示例)
              function fund() external payable {
                  require(msg.value > 0, "Need some ETH");
                  if (addressToAmountFunded[msg.sender] == 0) {
                      funders.push(msg.sender);
                  }
                  addressToAmountFunded[msg.sender] += msg.value;
              }
          
              /**
               * @dev 仅允许第一个 Remix 账户提取合约中的所有资金
               * 提取后重置资助者记录
               */
              address public owner;
          
              constructor() {
                  owner = msg.sender; // 部署者成为所有者(假设部署者是第一个 Remix 账户)
              }
              
              function withdrawOnlyFirstAccountRemix() external {
                  // 检查调用者是否为第一个 Remix 账户
                  require(msg.sender == FIRST_REMIX_ACCOUNT, "Only first Remix account can withdraw");
          
                  // 转账所有余额给调用者
                  (bool success, ) = msg.sender.call{value: address(this).balance}("");
                  require(success, "Transfer failed");
          
                  // 重置所有资助者的金额为 0
                  for (uint256 i = 0; i < funders.length; i++) {
                      address funder = funders[i];
                      addressToAmountFunded[funder] = 0;
                  }
                  // 清空资助者数组
                  delete funders;
              }
          }

          关键点说明:

          • 硬编码地址:使用 Remix 默认的第一个测试账户地址 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4,确保只有该账户能调用提现函数。

          • 权限检查require(msg.sender == FIRST_REMIX_ACCOUNT, ...) 拒绝其他任何调用者。

          • 资金转移:通过 call 将合约全部余额发送给调用者,并检查返回值确保成功。

          • 状态重置:遍历 funders 数组,将映射中的对应金额清零,最后删除数组,保持合约状态一致。

          此函数严格遵循了“最小权限原则”,仅允许特定账户执行敏感操作,有效防止资金被非法提取。

          3.23 Solidity函数修饰符

          3.23.1 介绍

          在本课中,我们将探讨修饰器以及它们如何简化 Solidity 中的代码编写和管理。修饰器使开发者能够创建可重用的代码片段,并将其应用于多个函数,从而增强代码的可读性、可维护性和安全性。

          3.23.2 重复的条件判断

          如果我们构建一个包含多个管理函数的合约,这些函数应仅由合约所有者执行,那么我们可能会重复检查调用者的身份:

          require(msg.sender == owner, "Sender is not owner");

          然而,在每个函数中重复这一行代码会使合约变得混乱,使其更难阅读、维护和调试。

          3.23.3 修饰符

          Solidity 中的修饰器允许在任何函数中嵌入自定义代码行以修改其行为。

          以下是创建修饰器的方法:

          modifier onlyOwner {
              require(msg.sender == owner, "Sender is not owner");
              _;
          }

          🗒️注意:该修饰符的名称onlyOwner反映了它所检查的条件。

          3.23.4(_下划线)

          放置在修饰器体内的下划线_是修饰函数代码的占位符。当调用带有修饰器的函数时,下划线之前的代码会先运行,如果成功,接着执行函数的代码。例如,onlyOwner 修饰器可以这样应用于 withdraw 函数:

          function withdraw(uint amount) public onlyOwner {
              // Function logic
          }

          当调用 withdraw 时,合约首先执行 onlyOwner 修饰器。如果 require 语句通过,再执行 withdraw 函数的其余部分。

          如果将下划线 _ 放在 require 语句之前,那么函数的逻辑会先执行,然后才执行 require 检查,这不符合预期的使用场景。

          结论:使用像 onlyOwner 这样的修饰器可以通过集中公共条件、减少代码重复、增强合约的可读性和可维护性来简化合约开发。

          完整案例:

          以下是一个完整的 Solidity 合约示例,演示了修饰器(Modifier)的定义与使用,包括权限控制(onlyOwner)和时间限制(onlyAfter):

          // SPDX-License-Identifier: MIT
          pragma solidity ^0.8.19;
          
          /**
           * @title 修饰器示例合约
           * @dev 展示如何使用修饰器进行访问控制和定时限制
           */
          contract ModifierExample {
              address public owner;           // 合约所有者
              uint256 public deploymentTime;  // 部署时间戳
          
              // 构造函数:部署时记录所有者与时间
              constructor() {
                  owner = msg.sender;
                  deploymentTime = block.timestamp;
              }
          
              // 修饰器:限制只有所有者可以调用
              modifier onlyOwner() {
                  require(msg.sender == owner, "Not the contract owner");
                  _; // 占位符,表示被修饰函数的代码在此执行
              }
          
              // 修饰器:限制函数只能在指定时间之后执行
              modifier onlyAfter(uint256 _time) {
                  require(block.timestamp >= _time, "Too early");
                  _;
              }
          
              // 事件:记录提现操作
              event Withdraw(address indexed to, uint256 amount);
          
              /**
               * @dev 仅所有者可调用的提现函数
               */
              function withdraw() external onlyOwner {
                  uint256 balance = address(this).balance;
                  require(balance > 0, "No funds");
          
                  (bool success, ) = owner.call{value: balance}("");
                  require(success, "Transfer failed");
          
                  emit Withdraw(owner, balance);
              }
          
              /**
               * @dev 仅所有者可在部署后至少1小时后调用的特殊函数
               */
              function specialAction() external onlyOwner onlyAfter(deploymentTime + 1 hours) {
                  // 执行某些重要操作(示例中仅触发事件)
                  emit SpecialActionPerformed(msg.sender);
              }
          
              event SpecialActionPerformed(address indexed actor);
          
              /**
               * @dev 接收以太币的函数(使合约可充值)
               */
              receive() external payable {}
          }

          修饰器详解:

          1. onlyOwner

            1. 检查调用者是否为合约所有者,若不是则 revert

            2. withdrawspecialAction 上使用,确保敏感操作安全。

          2. onlyAfter

            1. 接受一个时间参数 _time,要求当前区块时间大于等于该时间才能继续。

            2. specialAction 上与 onlyOwner 组合使用,演示多个修饰器叠加(按声明顺序执行)。

          关键特性

          • 占位符 _:修饰器中的 _ 表示被修饰函数的代码将在该位置执行。onlyOwner 中先检查权限,再执行函数体。

          • 修饰器组合:一个函数可以应用多个修饰器,执行顺序从左到右。

          • Gas 优化:修饰器避免了重复的 require 代码,使合约更简洁。

          测试建议:

          • 部署合约后,尝试用非所有者账户调用 withdraw,交易将失败。

          • 调用 specialAction 时,如果在部署后不足1小时,会因 onlyAfter 而失败。

          • 向合约发送 ETH,然后由所有者提现。

          3.23.5 自我测试

          1. 为什么使用修饰器进行访问控制是有益的?

          2. 实现一个名为 onlyAfter(uint256 _time) 的修饰器,确保函数只能在指定时间之后执行。

          3.24 使用测试网测试智能合约

          3.24.1 介绍

          在本课中,我们将深入探讨 Solidity 合约部署和执行的端到端测试。

          3.24.2 部署事务

          首先,我们需要编译合约以确保代码正确。在 Remix 上,将注入提供者设置为 MetaMask,并确认它已正确同步到测试网。如果你计划在 Sepolia 测试网上部署合约,请确保你的钱包中有一些 Sepolia ETH

          我们将FundMe通过点击部署按钮并在 MetaMask 中确认交易来部署合约,这可能需要一些时间。

          3.24.3 合约交互

          成功部署 FundMe 合约后,你将看到几个与之交互的按钮:

          • 红色按钮:可支付函数(例如 fund

          • 橙色按钮不可支付函数(例如 withdraw

          • 蓝色按钮:viewpure 函数

          fund 函数允许我们向合约发送 ETH(最低 5 美元)。合约的 owner 是我们的 MetaMask 账户,因为构造函数将部署者设置为所有者。

          🗒️注意:如果在调用 fund 函数时没有发送任何金额或者发送金额低于 5 美元,你将遇到 gas 估算错误,提示 ETH 不足,并且会浪费 gas。

          3.24.4 成功交易

          如果你将金额设置为 0.1 ETH 并在 MetaMask 中确认,然后你可以在像 Etherscan 或 Blockscout 这样的区块浏览器上追踪成功交易。在交易日志中,你将看到 fundMe 余额增加了 0.1 ETHfunders 数组将记录你的地址,映射 addressToAmountFunded 将记录发送的 ETH 金额。

          3.24.5 提现函数与错误

          向合约注资后,我们可以发起 withdraw 函数。该函数只能由所有者调用;如果非所有者账户尝试提现,将抛出 gas 估算错误,并且函数会回滚。

          结论:在本课中,我们探讨了使用 Remix 和 MetaMask 部署和交互 Solidity 合约的完整流程。我们讲解了部署事务、合约交互,以及如何处理成功的事务和潜在的错误。

          3.24.6 测试一下自己

          1、FundMe在 Remix 上与合约互动,探索其功能可能导致的所有结果。

          此操作需要测试币

          3.25 不变性和常量

          3.25.1 介绍

          在本课中,我们将探讨针对仅设置一次的变量进行 Gas 优化的工具。

          3.25.2 优化变量

          变量 ownerminimumUSD 只被设置一次,并且它们的值永远不会改变:owner 在合约创建期间被赋值,minimumUSD 在合约开头被初始化。

          3.25.3 评估 FundMe 合同

          我们可以通过部署合约并在终端观察交易来评估创建合约所用的 Gas。在原始合约配置中,我们消耗了近 859,000 Gas。

          3.25.4 Constant(常量)

          为了减少 Gas 消耗,我们可以使用关键字 constantimmutable。这些关键字确保变量值保持不变。更多信息请参考 Solidity 文档

          我们可以将这些关键字应用于仅赋值一次且永不改变的变量。对于在编译时就已知的值,使用 constant 关键字。它可以防止变量占用存储槽,使其读取更便宜、更快速。使用 constant 关键字可以节省大约 19,000 Gas,这接近在两个账户之间发送 ETH 的成本。

          注意:常量的命名约定是全部大写,用下划线代替空格(例如 MINIMUM_USD)。

          警告:将当前的 ETH Gas 成本换算成美元,我们可以看到当 ETH 价格为 3000 美元时,将 MINIMUM_USD 定义为 Constant 需要花费 9 美元,比将其定义为公共变量多花了近 1 美元。

          ~~~此处翻译成多花近1美元,可能英文是错误的~~~

          代码演示:constant vs. public 的 Gas 消耗

          我们可以写两个简单的合约,一个用 constant,一个用普通的 public 状态变量,然后在 Remix 里实际部署一下,看看它们的 Gas 消耗究竟差多少。

          合约 A:使用 public 状态变量

          // SPDX-License-Identifier: MIT
          pragma solidity ^0.8.18;
          
          contract FundMeWithPublic {
              // 这是一个普通的公共状态变量
              uint256 public minimumUSD = 5 * 10 ** 18; // 假设 5 USD,带 18位小数
          }

          合约 B:使用 constant 常量

          // SPDX-License-Identifier: MIT
          pragma solidity ^0.8.18;
          
          contract FundMeWithConstant {
              // 这是一个常量
              uint256 public constant MINIMUM_USD = 5 * 10 ** 18; // 假设 5 USD,带 18位小数
          }

          把这两个合约分别部署到 Remix 虚拟机(或者测试网)里,记下“Deployment Cost”这一项,你会发现:

          1. 部署 FundMeWithPublic:成本大约是 174,049 Gas 。这是因为 public 变量不仅占用存储,还会自动生成一个 minimumUSD() 的 getter 函数,这些都增加了部署时的代码量和工作量。

          2. 部署 FundMeWithConstant:成本大约是 129,154 Gas 。用 constant 后,变量值直接被嵌入到合约的字节码中,不占用存储,也没有额外的 getter 函数,所以部署成本大幅降低。

          从上面的例子看,使用 constant 大约节省了 44,895 Gas

          一句话总结constant 确实比 public 变量部署成本更低。但部署成本本身(无论是 8 美元还是 9 美元)是一个随 Gas 价格和 ETH 价格波动的数值。我们真正应该关注的是,constant 通过避免昂贵的存储读写来优化 Gas 消耗,这一优化效果是确定的。

          3.25.5 Immutable(不可变变量)

          constant 变量适用于编译时已知的值,而 immutable 可用于在部署时设置且不会改变的变量。immutable 变量的命名约定是在变量名之前添加前缀 i_(例如 i_owner)。

          在将 owner 设为 immutable 变量后比较 Gas 消耗,我们观察到与使用 constant 关键字相似的 Gas 节省。

          ⚠️ 提示:在学习的早期阶段,不要过于关注 Gas 优化。

          结论:在本课中,我们探讨了在 Solidity 中使用 constantimmutable 关键字来优化仅设置一次变量的 Gas 使用。理解如何以及何时使用这些关键字可以显著降低 Gas 成本,使你的智能合约更加高效。

          3.25.6 自我测试

          1、为什么开发者会选择对某些特定变量使用 immutable 而不是 constant

          开发者选择使用 immutable 而不是 constant,主要是因为两者在值的来源赋值时机上有本质区别。以下是具体原因和适用场景:

          (1)值只能在部署时确定(无法在编译时预知)

          • constant 要求值必须在编译时就完全确定,不能依赖运行时信息(如 msg.senderblock.timestamp、函数返回值等)。

          • immutable 允许在部署时(构造函数中)赋值,赋值后不可更改,因此可以处理那些在编译时未知、但部署后固定的值。

          典型例子

          contract Example {
              address public immutable owner; // ✅ 部署时确定(msg.sender)
              uint256 public constant MIN_VALUE = 100; // ✅ 编译时确定
          
              constructor() {
                  owner = msg.sender; // 只能在构造函数中赋值
              }
          }

          (2)动态计算的值

          • 如果变量的值需要通过某些逻辑计算得到(例如从外部调用、数学运算),且计算过程依赖部署时的上下文,则必须用 immutable

          • constant 只能赋值为字面常量或编译时可确定的表达式(如 2 * 10**18)。

          示例

          contract Factory {
              address public immutable token; // 部署时动态创建的新合约地址
          
              constructor() {
                  token = address(new ERC20("Token", "TKN")); // 部署时计算
              }
          }

          (3)避免代码硬编码,提高合约灵活性

          • 使用 immutable 可以在部署时通过构造函数传入参数,使同一个合约代码适配不同场景(例如不同链、不同配置)。

          • constant 会导致值被硬编码到字节码中,无法在部署时调整。

          示例

          contract FundMe {
              uint256 public immutable MINIMUM_USD; // 部署时由外部传入
          
              constructor(uint256 _minUSD) {
                  MINIMUM_USD = _minUSD; // 不同部署可设置不同最小值
              }
          }

          (4)安全性考虑

          • 某些关键值(如合约所有者)必须在部署时确定,且不允许后续修改。immutable 确保这些值一旦在构造函数中赋值,就永久不变,同时避免了 constant 无法使用的限制。

          • 如果使用 constant,所有者地址会被硬编码在代码中,缺乏灵活性;如果使用普通变量,则可能被意外修改。

          (5)Gas 优化(与 constant 类似)

          • 虽然 immutable 的值不占用存储槽,但它在部署后也是固定的,因此读取 immutable 变量同样比读取普通状态变量更省 Gas(只需从字节码中加载,无需存储读取)。

          • 当无法使用 constant 时,immutable 是次优选择,兼顾灵活性与效率。

          总结对比

          特性

          constant

          immutable

          赋值时机

          编译时

          部署时(构造函数中)

          值来源

          字面常量、编译时可确定的表达式

          构造函数参数、msg.sender、动态计算

          是否可依赖运行时信息

          ❌ 否

          ✅ 是

          典型用途

          数学常数、硬编码的配置值(如 DECIMALS

          合约所有者、部署时传入的配置参数、工厂创建的子合约地址

          Gas 效率

          极高(直接嵌入字节码)

          高(从字节码加载,但需一次赋值)

          实际决策场景

          • constant:如果你确定这个值在写代码时就能确定,并且永远不会改变(如 uint256 public constant MAX_SUPPLY = 1000000;)。

          • immutable:如果这个值在部署时才知道(如部署者地址、外部传入的配置参数、动态生成的地址),但之后不再变化。

          一句话答案:当变量值无法在编译时确定(例如需要依赖 msg.sender、部署参数或外部调用结果),但部署后永久不变时,必须使用 immutable

          2、为当前版本的 fundMe 合约设计一个 constant 变量和一个 immutable 变量。

          (1)constant 变量:MINIMUM_USD

          uint256 public constant MINIMUM_USD = 5 * 10 ** 18;
          • 含义:代表最小资助金额,固定为 5 美元(使用 18 位小数精度,与 ETH 的 wei 单位对齐)。

          • 为什么用 constant:该值在编写合约时就已经确定,且永不会改变。将其声明为 constant 可以避免占用存储槽,直接嵌入字节码,从而大幅降低部署和读取的 Gas 成本。

          • 命名:遵循全大写加下划线的常量命名规范。

          (2)immutable 变量:i_owner

          address public immutable i_owner;
          
          constructor() {
              i_owner = msg.sender;
          }
          • 含义:记录合约的所有者(部署者),用于权限控制(如 withdraw 函数只能由所有者调用)。

          • 为什么用 immutable:所有者地址只有在部署时才能确定(msg.sender),无法在编译时预知,因此不能使用 constant。使用 immutable 可以在构造函数中一次性赋值,之后永久不变,同时避免了普通状态变量的存储开销,读取时也更省 Gas。

          • 命名:遵循 i_ 前缀的惯例,清晰标识为 immutable 变量。

          整体效果:将这两个变量整合到 FundMe 合约后,既能保证业务逻辑的正确性(最小金额约束、所有者权限),又能显著优化 Gas 消耗,使合约更高效。

          3.26 创建自定义错误

          3.26.1 引言

          在上一课中,我们学习了如何让我们的合约更高效地使用 Gas。在本课中,我们将进一步提升它们的效率。

          3.26.2 Require

          提高 Gas 效率的一种方法是优化我们的 require 语句。目前,require 语句迫使我们存储字符串“sender is not an owner”。该字符串中的每个字符都被单独存储,使得管理它的逻辑既复杂又昂贵。

          3.26.3 自定义错误

          在 Solidity 0.8.4 中引入的自定义错误可用于 revert 语句。这些错误应在代码顶部声明,并在 if 语句中使用。然后调用更廉价的错误代码来代替之前的错误消息字符串,从而降低 Gas 成本。

          我们可以从创建一个自定义错误开始:

          error NotOwner();

          然后,我们可以用 if 语句替换 require 函数,在新创建的错误上使用 revert 函数:

          if (msg.sender != i_owner) {
              revert NotOwner();
          }

          通过实现自定义错误,我们降低了 Gas 成本并简化了智能合约中的错误处理。

          结论:在本课中,我们学习了如何通过使用自定义错误代替传统的带字符串的 require 语句,进一步优化 Solidity 合约的 Gas 效率。

          3.26.4 自我测试

          问题1:声明自定义错误而不是使用 require 关键字有哪些好处?

          在 Solidity 中,声明自定义错误(Custom Errors)替代传统的 require 字符串错误提示,主要有以下 5 个核心好处

          (1)显著降低 Gas 成本

          • require 带字符串:错误字符串(如 "Only owner")需要存储在合约字节码中,字符串越长,消耗的 Gas 越多(尤其是部署成本)。

          • 自定义错误:仅存储错误的签名(4 字节),不存储任何字符串。无论是部署还是运行时触发 revert,Gas 成本都远低于字符串错误。

          • 示例对比

            // 传统 require(耗 Gas)
            require(msg.sender == owner, "Only owner");
            
            // 自定义错误(更省 Gas)
            error NotOwner();
            if (msg.sender != owner) revert NotOwner();

            (2)支持参数传递,调试更精准

            • 自定义错误可以携带参数,返回更丰富的错误信息(如地址、金额、时间戳等),便于调试和前端处理。

            • 示例

              error InsufficientBalance(uint256 requested, uint256 available);
              
              function withdraw(uint256 amount) external {
                  if (amount > address(this).balance) 
                      revert InsufficientBalance(amount, address(this).balance);
                  // ...
              }
              • 前端可以直接解析这些参数,而不需要解析字符串。

              (3)代码更简洁、可读性更高

              • require 语句将条件判断和错误消息耦合在一起,复杂逻辑时可能使代码冗长。

              • 自定义错误将错误定义与逻辑分离,条件判断更清晰:

                // 使用 require
                require(amount > 0, "Amount must be positive");
                require(block.timestamp >= startTime, "Not started");
                
                // 使用自定义错误
                if (amount <= 0) revert InvalidAmount();
                if (block.timestamp < startTime) revert NotStarted();

                (4)便于维护和升级

                • 错误集中定义在文件顶部,修改错误名称或参数只需改动一处,不用在代码中搜索替换字符串。

                • 避免因拼写错误导致的不一致(如 "Not onwer""Not owner" 是两个不同的字符串,但逻辑上本应相同)。

                (5)更好的 ABI 集成

                • 自定义错误是 Solidity ABI 的一部分,可以在合约的 ABI 中直接暴露,工具(如 Etherscan、前端库)可以自动解析错误参数。

                • 字符串错误不会出现在 ABI 中,前端只能通过字符串匹配处理,容易出错。

                总结对比:

                特性

                require + 字符串

                自定义错误

                Gas 成本

                高(存储字符串)

                低(仅 4 字节签名)

                参数传递

                不支持

                ✅ 支持

                ABI 暴露

                ❌ 无

                ✅ 有

                可维护性

                低(字符串散落各处)

                高(集中定义)

                适用 Solidity 版本

                所有版本

                0.8.4 及以上

                何时仍然使用 require

                • 快速编写简单原型时。

                • 条件简单且无需额外参数时(但出于 Gas 考虑,仍推荐自定义错误)。

                结论:在正式项目或 Gas 敏感场景中,优先使用自定义错误,它更高效、更灵活、更易维护。

                问题2:创建一个当 msg.senderaddress(0) 时触发的自定义错误,然后将其转换为等效的带有 revert 函数的 if 语句。

                // SPDX-License-Identifier: MIT
                pragma solidity ^0.8.4;
                
                contract Example {
                    // 1. 声明自定义错误(不带参数)
                    error ZeroAddress();
                
                    function restrictedFunction() external {
                        // 2. 使用 if 语句检查条件,触发 revert 并传递自定义错误
                        if (msg.sender == address(0)) {
                            revert ZeroAddress();
                        }
                
                        // 正常逻辑...
                    }
                }

                说明:

                • 自定义错误error ZeroAddress(); 在合约顶部声明,不占用存储,Gas 成本极低。

                • 等效的 if + revert:用 if 条件判断发送者是否为零地址,若是则调用 revert ZeroAddress(); 回滚交易。

                • 这种方式比传统的 require(msg.sender != address(0), "Zero address"); 更省 Gas,且错误信息可通过 ABI 传递。

                3.27 实现接收回退

                3.27.1 介绍

                在 Solidity 中,如果将以太币发送到一个没有 receivefallback 函数的合约,交易将被拒绝,以太币将不会转移。在本课中,我们将探讨如何有效处理这种情况。

                3.27.2 接收和回退功能

                receivefallback 是在用户直接向合约发送以太币或调用不存在的函数时触发的特殊函数。 这些函数不返回任何内容,并且必须声明为 external

                为了说明,让我们创建一个简单的合约:

                //SPDX-License-Identifier: MIT
                pragma solidity ^0.8.7;
                
                contract FallbackExample {
                    uint256 public result;
                
                    receive() external payable {
                        result = 1;
                    }
                
                    fallback() external payable {
                        result = 2;
                    }
                }

                在此合约中,result 被初始化为零。当向合约发送以太币时,receive 函数被触发,将 result 设置为 1。如果一笔交易包含数据但指定的函数不存在,则会触发 fallback 函数,将 result 设置为 2。如需全面解释,请参考 SolidityByExample

                // Ether is sent to contract
                //      is msg.data empty?
                //          /   \
                //         yes  no
                //         /     \
                //    receive()?  fallback()
                //     /   \
                //   yes   no
                //  /        \
                //receive()  fallback()

                3.27.3 向 fundMe 发送以太币

                当用户直接向 fundMe 合约发送以太币而未调用 fund 函数时,可以使用 receive 函数将交易重定向到 fund 函数:

                receive() external payable {
                    fund();
                }
                
                fallback() external payable {
                    fund();
                }

                为了测试此功能,使用 MetaMask 向 fundMe 合约发送一些 Sepolia ETH。这不会直接调用 fund 函数,但 receive 函数会触发它。确认交易后,你可以检查 funders 数组,会发现它已被更新,这反映了 receive 函数成功调用了 fund 函数。

                这种方法确保了所有交易都能按预期处理。虽然直接调用 fund 函数消耗的 Gas 更少,但此方法能确保用户的贡献得到正确确认和记录。

                结论:通过实现 receivefallback 函数,合约可以有效处理直接以太币转账和不存在的函数调用,确保交易按预期处理,并正确追踪用户的贡献。

                3.27.4 自我测试

                1、fallback 函数与 receive 函数有何不同?

                在 Solidity 中,receivefallback 都是特殊函数,用于处理向合约发送以太币或调用不存在函数的情况。它们的主要区别在于触发条件用途

                (1)触发条件不同

                场景

                触发的函数

                只发送 ETH(calldata 为空)

                receive()

                发送 ETH 且附带 calldata(但函数不存在)

                fallback()

                没有 receive 函数,只发送 ETH

                fallback()(回退)

                发送 ETH 且 receivefallback 都不存在

                交易失败(revert)

                (2)函数签名不同

                • receive():必须声明为 receive() external payable { ... }不能有任何参数,也不能返回数据。

                • fallback():声明为 fallback() external payable { ... }fallback(bytes calldata) external payable returns (bytes memory)(带参数和返回值的版本)。它可以接收 calldata 并返回数据。

                (3)用途不同

                • receive:专门用于处理纯 ETH 转账(如通过 sendtransfer 或直接向合约地址转账)。通常用于记录存款、触发事件或重定向到其他函数(如调用 fund)。

                • fallback:更通用,可以处理:

                  • 向合约转账 ETH 但 receive 不存在。

                  • 调用不存在的函数(附带数据)。

                  • 实现代理模式(如将调用转发到其他合约)。

                (4)Gas 消耗

                • receive:通常更轻量,因为它不处理数据。

                • fallback:可能消耗更多 Gas,尤其是当它需要解析 calldata 或执行复杂逻辑时。

                (5)是否必须存在

                • 如果合约预期接收 ETH,必须至少定义 receivefallback,否则转账会失败。

                • 如果只定义 receive,则纯 ETH 转账会成功,但附带数据的调用会失败(除非另有 fallback)。

                • 如果只定义 fallback,则纯 ETH 转账和附带数据的调用都会由 fallback 处理。

                代码示例对比

                contract Example {
                    event Received(address sender, uint amount);
                    event FallbackCalled(address sender, uint amount, bytes data);
                
                    // 仅处理纯 ETH 转账
                    receive() external payable {
                        emit Received(msg.sender, msg.value);
                    }
                
                    // 处理附带数据的调用,或者当 receive 不存在时的 ETH 转账
                    fallback() external payable {
                        emit FallbackCalled(msg.sender, msg.value, msg.data);
                    }
                }

                总结:

                • receive:专为纯 ETH 转账设计,简洁高效。

                • fallback:通用后备函数,处理所有其他情况(附带数据的调用或当 receive 缺失时的转账)。

                在实际开发中,通常建议同时实现两者,以覆盖所有可能场景。

                2、当发送以太币时附带了 data,但合约中只存在 receive 函数,会发生什么情况?

                当发送以太币时附带了 data(即调用数据不为空),但合约中只存在 receive 函数,而没有 fallback 函数,会发生以下情况:

                • 如果 data 不匹配任何现有函数(包括 receive 本身,因为 receive 不处理数据),则交易会失败并回滚(revert),以太币不会被接收。

                • 因为 receive 仅在 msg.data 为空时触发,带数据的调用会尝试寻找匹配的函数,若找不到且无 fallback,则合约无法处理该调用。

                简单来说:只有 receive 的合约无法处理带数据的交易,这类交易会直接失败。

                3.28 小测试

                3.29 恭喜

                3.29.1 引言

                FundMe 课程中,我们涵盖了 Solidity 的大部分基础知识,包括特殊函数、自定义错误、不可变变量、修饰器、构造函数、数组、for 循环、库等等。

                3.29.2 特殊函数

                我们遇到了 receivefallbackconstructor 这些特殊函数。这些函数在名称前不需要 function 关键字。当以太币被发送到合约且 data 字段为空时,receive 函数会被触发。当交易附带数据发送,但未找到匹配的函数时,fallback 函数会被触发。

                3.29.3 节省 Gas

                为了节省 Gas,Solidity 为那些只能设置一次的变量提供了 constantimmutable 等关键字:

                uint constant minimumUSD = 50 * 1e18;

                在此示例中,minimumUSD 是一个常量,无法更改,从而节省 Gas。与在编译时设置的 constant 不同,immutable 允许变量在部署期间被赋值一次。尝试更改 constantimmutable 变量都将导致编译错误。

                3.29.4 发送以太币

                Remix 提供了一种向合约发送以太币的简便方法。部署合约后,你可以按下 transact 按钮,设置交易金额,并省略调用数据。如果不包含调用数据,则会触发 receive 函数(如果存在),否则将执行 fallback 函数。

                结论:在下一节中,我们将从 Remix 转向代码编辑器,尝试更高级的 Solidity 功能。我们将探索枚举、事件、try-catch、函数选择器、abi.encode、哈希、Yul 和内联汇编。

                3.29.5 自我测试

                1、尝试回答第 13 课到第 25 课中的所有理论问题,然后再回头完成所有的编码任务。

                3.30 ZKsync插件修复

                3.30.1 ZKsync Remix 插件的小错误

                正如我们在“简单存储”部分看到的,Remix ZKsync 模块存在一个小 bug。编译成功后,部署选项卡仍然会显示该消息no smart contracts ready for deployment

                这个问题是由于插件的一个小 bug 导致的,该 bug 要求您的智能合约必须位于一个contracts文件夹内。要解决此问题,您可以创建一个名为“contracts”的新文件夹,并将您的智能合约移动到该文件夹中。然后,您可以重新编译合约,应该就可以顺利部署了。

                3.31 部署到ZKsync

                3.31.1 介绍

                在本课中,我们将逐步讲解如何将FundMe合约部署到 ZKsync 测试网。

                3.31.2 ZKsync 的调整

                首先,我们需要ZKsync Sepolia 链的正确价格信息源地址,您可以在Chainlink 文档中找到它。每条链都有其唯一的地址,ZKsync Sepolia 测试网的 ETH/USD 地址与 Sepolia 主网上的地址不同。

                FundMe::getVersion然后,复制 ZKsync Sepolia 测试网 ETH/USD 地址,并替换`and`函数中的现有地址PriceConverter::getPrice

                AggregatorV3Interface priceFeed = AggregatorV3Interface( 0xfEefF7c3fB57d18C5C6Cdd71e45D2D0b4F9377bF ); // 在此处添加 ETH/USD ZKsync Sepolia 地址

                由于 ZKsync 插件可能还不能很好地处理库,您可以将库代码直接复制到您的合约中,而不是导入它。

                建议使用正确的 Solidity 编译器版本,将其更新到0.8.24与 ZKsync 兼容的版本。

                3.31.3 合同部署

                在“环境”选项卡中,您可以连接 MetaMask 钱包,然后部署并验证合约FundMe。部署完成后,您可以通过检查诸如 get_context() 之类的函数与合约交互FundMe::MINIMUM_USDFundMe::getVersion以确保它们返回预期值。

                结论:将合约部署FundMe到 ZKsync 测试网需要几个关键步骤。首先,调整价格馈送地址并正确处理库代码,并确保使用正确版本的 Solidity 编译器。然后,即可将 MetaMask 钱包连接到 Remix ZKsync 模块并部署合约。最后,您可以通过在 ZKsync区块浏览器中调用合约函数来验证一切是否按预期运行。

                更多推荐