Solidity Mastery: Building Decentralized Applications with Confidence

米霖 2024

solidity 官方文档 : https://learnblockchain.cn/docs/solidity/

以太坊(Ethereum)核心概念

以太坊(Ethereum)是一个建立在区块链技术之上的去中心化应用平台。它允许任何人在其平台上创建和使用通过区块链技术运行的去中心化应用(DApp)。

传统的互联网客户端/服务端架构(C/S架构)通常如下所示:

而去中心化应用(DApp)则有所不同,其后端由一组多个节点计算机(矿工)组成的网络支持,如下图所示:

通常情况下,我们使用的应用程序的内容由后端服务器提供,并将请求发送到后端服务器进行处理。例如,支付宝、京东等应用程序中的所有数据都由公司拥有。

然而,在去中心化应用中,前端用户通过自己的钱包管理自己的数据,而后端核心逻辑则通过智能合约在区块链上运行,实现了去中心化的信任机制。DApp与客户端连接的节点只是网络中的一部分,它不会单独处理来自用户的请求(通常称为“交易”),而是需要将用户的请求广播到整个网络。在整个网络达成共识后,该请求才被视为已经处理完成。

智能合约

智能合约是以太坊上运行的程序。就像其他计算机程序一样,它由代码和数据组成。智能合约中的数据通常被称为“状态”,因为整个区块链可以看作是所有数据状态的一个确定的记录。

以太坊的智能合约是“图灵完备”的,这意味着理论上我们可以使用它来编写执行任何任务的程序。目前,智能合约的两个主要编程语言是Solidity和Vyper,其中Solidity更为成熟。

以下是一个简单的计数器合约示例:

pragma solidity ^0.8.0;
contract Counter {
    uint counter;

    constructor() {
        counter = 0;
    }

    function count() public {
        counter = counter + 1;
    }
}

这段代码有一个类型为uint(无符号整数)名为counter的变量。counter变量的内容(值)就是该合约的状态。每当我们调用count()函数时,此智能合约的区块链状态将增加1。

账户

当我们将counter合约部署到链上之后,它会用一个地址来表示(称为合约地址),这是一个以太坊网络中的一种帐户:合约账户。

账户在以太坊中是非常重要的概念,任何事情都离不开它。以太坊中有两类账户:

外部用户账户和合约账户,它们都使用相同的地址格式来表示,在EVM层面是一样的,地址格式为:0xea674fdde714fd979de3edf0f56aa9716b898ec8,是一个20字节的16进制数。

它们之间还有一个不可忽视的区别:只有外部用户账户可以发起交易(主动行为),而合约账户只能被动地响应操作,并且所有的手续费(Gas)必须由外部账户支付。

账户状态

无论账户类型如何,账户状态都由4个基本组成部分组成:

提示:以太坊中有两种nonce , 一种是账号nonce——表示一个账号的交易数量;一种是工作量证明nonce——一个用于计算满足工作量证明的随机数。

下面通过一个合约账户的可视化示例来总结上述内容:

以太坊的全局共享状态由所有账户状态组成,这些状态以账户地址和账户状态之间的映射形式存储在区块的状态树中,如下图所示:

以太币

以太币是一种货币,不同单位的货币就像法币中的不同面额,对于用户来说,最常用的单位是ether,1个ether通常也简称为以太。而对于开发者来说,可能更常使用wei,它是以太币的最小单位,其他单位包括finney和szabo,此外,wei还有几个衍生单位,包括Kwei、Mwei和Gwei。它们之间的换算关系如下:

1 ether = 10^3 finney(即1000 finney)
1 ether = 10^6 szabo
1 ether = 10^18 wei
1 Gwei = 10^9 wei
1 Mwei = 10^6 wei

以太币的单位命名方式非常有趣,以太坊社区为了纪念密码学家的贡献,使用了密码学家的名字作为货币单位,类似于许多国家的货币上印有国家的杰出人物头像一样。

以太坊虚拟机

EVM(以太坊虚拟机)是一种虚拟计算机,用于执行以太坊区块链上的智能合约和去中心化应用程序。EVM是以太坊的核心组件之一,它负责处理和执行智能合约的代码。

EVM的工作原理与传统的计算机虚拟机类似,但它是专门为区块链和智能合约而设计的。智能合约是以太坊上的自动化合同,其代码在EVM上运行,并且可以执行各种任务,例如转移加密货币资金、管理数字资产、实施投票机制等。

EVM执行智能合约时,会将智能合约的字节码加载到内存中,然后按照预定的规则执行该字节码。EVM提供了一种安全的执行环境,以确保合约的正确执行,同时还实施了燃气(Gas)的概念,以防止恶意代码无限循环或耗尽计算资源。

以太坊客户端

以太坊客户端是连接到以太坊网络的节点程序,其中以太坊虚拟机(EVM)是客户端的重要组成部分。通过运行节点程序,您可以成为以太坊网络中的一个节点,参与区块链网络的操作和维护。

以太坊网络分为两个主要层次:

以太坊节点程序可以执行多种任务,包括创建账户、发起交易、部署智能合约、执行智能合约、挖矿出块等。常见的以太坊客户端包括 Geth 和 Parity。Geth 是以太坊官方社区开发的客户端,使用 Go 语言编写。Parity 也是一种以太坊客户端,使用 Rust 语言编写。开发者通常使用 Geth,因为它是官方维护的客户端,并且提供了广泛的文档和支持。

以太坊虚拟机(EVM)是以太坊节点客户端的核心组件,它执行智能合约的代码,并处理交易。EVM与节点客户端紧密结合,使得以太坊网络能够运行智能合约并处理交易。它是以太坊的计算引擎,负责执行智能合约代码并维护全网的状态。

所有用户都通过节点与区块链网络进行交互,一般用户无需设置节点,因为运行节点需要大量资源。目前,有许多专业的节点服务提供商,例如 Infura(https://www.infura.io/zh)和 Alchemy(https://www.alchemy.com/),它们提供了便捷的方式来连接到区块链网络。

钱包

钱包是管理账户的重要工具,用户可以使用钱包创建账户、进行交易签名,并在需要时连接到区块链节点来执行交易。需要注意的是,钱包本身并不存储用户的资产,而是管理访问这些资产的密钥和签名功能。

常见的移动端钱包包括 ImToken、Trust Wallet 等,它们适用于一般用户,提供了便捷的方式来管理加密资产。

MetaMask 是一个浏览器插件,支持多种主流浏览器,如 Chrome、Firefox 和 Opera。它不仅可以用于管理账户,还可以用于部署和执行智能合约。开发者通常使用 MetaMask 与 Remix IDE 等工具结合使用,以便更轻松地开发和测试智能合约。

您可以在 MetaMask 官方网站 上找到相应的插件,并按照指南进行安装。安装完成后,您可以导入现有账户或创建新账户,并在 MetaMask 的界面中查看账户信息,如您所示的截图所示。这将为您提供一个方便的方式来管理您的以太坊账户和进行区块链操作。

gas 机制

在以太坊上,智能合约的“图灵完备性”允许编写执行各种任务的程序。然而,为了防止恶意行为,以太坊引入了Gas机制,它是一种衡量执行操作所需工作量的单位。

Gas价格(Gas price)是Gas机制中的一个关键概念。每笔交易需要指定Gas预算(Gas limit)和愿意支付的Gas价格。Gas预算乘以Gas价格等于交易费用。如果Gas预算不足以覆盖实际Gas消耗,交易将失败,状态更改会被回滚。

另一个要注意的是,如果交易执行结束后还有剩余Gas,剩余Gas会退还给发起交易的账户。这使得Gas机制更加灵活,用户可以根据需求调整Gas价格和Gas预算。

Gas机制是以太坊的重要特性,它确保了网络的稳定性和安全性,同时也为矿工提供了适当的激励来执行交易和合约。

以太坊交易

以太坊交易可以分为以下三种类型:

  1. 普通交易:用于向其他地址转移以太币。
  2. 创建合约:用于在区块链上创建智能合约。这种交易类型的to字段为空,data字段包含智能合约的字节码。
  3. 调用合约函数:用于调用已部署的智能合约的函数。这种交易类型的to字段包含目标合约地址,data字段包含函数名称和参数。

虽然实际交易包含更多细节,但以上是交易的核心概念。接下来,让我们看一些具体的交易示例,特别是通过data字段来理解合约调用。

普通交易示例:

{
  "to": "0x687422eEA2cB73B5d3e242bA5456b782919AFc85",
  "value": 0.0005,
  "data": "0x" // 可以包含消息或留言
}

这是一个非常简单的普通交易,它将一定数量的以太币转移到指定地址,如果愿意,还可以在交易中包含一条消息。

创建智能合约示例:

{
  "to": "",
  "value": 0.0,
  "data": "0x6060604052341561000c57xlb60405160c0806……………"
}

在创建智能合约的交易中,TO字段留空表示创建智能合约,而DATA字段包含了智能合约的字节码。

调用智能合约函数示例:

{
  "to": "0x687422eEA2cB73B5d3e242bA5456b782919AFc85", // 合约地址
  "value": 0.0,
  "data": "0x06661abd"
}

调用智能合约函数的信息封装在DATA字段中,将此交易信息发送到要要调用的智能合约的地址。假设我们要调用前面的count()函数,传递的是 count()函数的选择器。

EVM兼容链及网络

在以太坊生态系统中,出现了一系列与以太坊虚拟机(EVM)兼容的区块链,以及不同的网络,这些都对区块链开发和应用产生了重要影响。为了更好地理解这些概念,让我们对它们进行详细解释.

兼容链

EVM兼容链是那些设计和实现与以太坊虚拟机(EVM)兼容的区块链。这意味着它们可以运行与以太坊相同的智能合约,使用相同的编程语言和工具。这一兼容性使得开发者可以轻松将他们的以太坊应用迁移到这些链上,而无需进行大规模修改。目前,有许多流行的EVM兼容链,其中包括Polygon链、BNB链(BSC)、OK链、Avalanche C链、Fantom等。

每个EVM兼容链都有自己的共识机制和原生代币,这些代币用于支付交易手续费(Gas)。由于竞争和不同的设计选择,这些链通常具有较低的交易费用,这对于用户和开发者来说是一个吸引人的特点。

此外,还存在一种称为Layer2解决方案的技术,如Arbitrum和Optimism,它们也兼容EVM,但在第二层网络上运行,与主要区块链有所不同。这些解决方案通常用于扩展区块链的性能和吞吐量。

一些最初并不支持EVM的区块链项目也开始了EVM兼容性的开发,如:

这些EVM生态链的存在进一步丰富了以太坊的生态系统。

每个链都有自己的chainId和相应的节点RPC,您可以在MetaMask等钱包中通过添加自定义网络RPC来添加它们,如下图所示:

Chainlist列出了大多数EVM兼容链,您可以在那里找到chainID和节点RPC地址,并可以轻松地将它们添加到MetaMask等钱包中。

区块链网络

在每个区块链上,通常都存在多个网络,其中最重要的是主网(Mainnet)和测试网(Testnet)。

主网网络是真正的生产环境,其中的交易使用真实的代币进行结算。这是真正的价值交换发生的地方,因此需要小心谨慎。

在主网上进行开发和测试可能会非常昂贵,因为每个操作都需要使用真实代币支付Gas费用。为了解决这个问题,每个区块链都提供了测试网络,这些网络上的代币没有实际价值,而且通常可以通过水龙头免费获得。开发者可以在测试网络上构建和测试智能合约,以确保它们在主网上能够正确运行。

开发者网络或节点

为了在本地进行区块链开发和测试,开发者通常会使用开发者网络或本地节点。这些网络是虚拟的区块链环境,可以在本地计算机上轻松运行。它们通常具有以下特点:

一些流行的开发者网络包括Ganache和Hardhat。这些工具使开发者能够在本地模拟区块链环境,以便更轻松地开发、调试和测试智能合约。需要注意的是,这些本地模拟的区块链数据通常存储在内存中,因此在重启节点后数据将会丢失,这是开发过程中需要考虑的重要因素。

以下是由Hardhat提供的开发节点示例:

/home/decert > npx hardhat node
Started HTTP and WebSocket JSON-RPC server at http://127.0.0.1:8545/

Accounts
========

WARNING: These accounts, and their private keys, are publicly known.
Any funds sent to them on Mainnet or any other live network WILL BE LOST.

Account #0: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000 ETH)
Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
...

以太网历史与展望

奥林匹克(Olympic)

以太坊区块链在2015年5月开始向用户(主要是开发者)开放使用。版本称为“奥林匹克”,这是一个测试版本。主要供开发人员提前探索以太坊区块链开放以后的运作方式,比如测试交易活动、虚拟机使用、挖矿方式和惩罚机制,同时尝试使网络过载,并对网络状态进行极限测试,以了解协议如何处理大量流量。

边疆(Frontier)

经过几个月对奥林匹克的压力测试,以太坊在2015年7月30日发布官方公共主网,第一个以太坊创世区块产生。边疆依旧是一个很初级的版本,交易都是通过命令行来完成。

不过,边疆版本已经具备以太坊的一系列关键特征:

家园(Homestead)

家园是以太坊网络的首次硬分叉升级计划,在2016年3月14日发生在第1,150,000个区块上。家园版本主要为以太坊带来了主要更新有:

家园升级是第一个通过以太坊改进提案(EIP)实施的分叉升级。

EIP(Ethereum Improvement Proposal)即以太坊改进提案,是以太坊去中心化治理的一部分,所有人都可以提出治理的改进方案,当社区讨论通过后,就会囊括在网络升级版本中(https://eips.ethereum.org/)。

家园升级主要包括三个EIP提案:EIP2、EIP7、EIP8。这些提案具体包含的内容可以通过 EIP 文档查看。

DAO分叉

这是一个计划外的分叉,并非为了功能升级,而是以太坊社区为了对黑客攻击防止损失,而采取的硬分叉回滚了黑客的交易。事情是这样的: 2016年,去中心化自治组织 “The DAO” 通过发售DAO 代币募集了1.5亿美元的资金,作为 DAO 代币持有人可以投票及审查投资项目,并获得一定比例的项目收益,所有的资金均由智能合约管理。然后在2016年6月,the DAO合约遭到黑客攻击,黑客可以利用漏洞源源不断的从合约中盗取以太币。

最终在以太坊社区投票后实行了硬分叉(在1920000块高度时发生),将资金返还到原钱包并修复漏洞。不过这次硬分叉仍旧引来很大的争议,以太坊社区的一些成员认为这种硬分叉方式违背了“Code Is Law” 原则,他们选择继续在原链上进行挖矿和交易。未返还被盗资金的原链则演变成以太坊经典(ETC)。

The DAO事件的分析,可以阅读上海对外经贸大学区块链研究中心主任乐扣老师的文章:https://learnblockchain.cn/article/644

拜占庭(Byzantium)

拜占庭(Byzantium)和君士坦丁堡(Constantinople) 是以太坊称为“大都会”(Metropolis)升级的两个阶段。拜占庭在2017年10 月第4,370,000个区块上激活,拜占庭分叉更新有:增加“REVERT”操作符、增加一些加密方法、调整难度计算、推迟难度炸弹、调整区块奖励(5个减为3个)。

“难度炸弹”(Difficulty Bomb)是这样一种机制:一旦被激活,将增加挖掘新区块所耗费的成本(即“难度”),直到难度系数变为不可能或者没有新区块等待挖掘。这在以太坊中称为进入冰河时代,“难度炸弹”机制在2015年9月就被引入以太坊网络。它的目的是促使以太坊最终从工作量证明(PoW)转向权益证明(PoS)。因为从理论上来说,未来在PoS机制下,矿工仍然可以选择在旧的PoW链上作业,而这种行为将导致社区分裂,从而形成两条独立的链。为了预防这种情况的发生,通过“难度炸弹”增加难度,将最终淘汰PoW挖矿,促使网络完全过渡到PoS机制。

这次分叉包括9个EIP:EIP100 、EIP658、EIP649、EIP140、EIP196、EIP197、EIP198、EIP211、EIP214 , 详细的变更可以参考github。

君士坦丁堡(Constantinople)

“大都会”升级的第二阶段被称作“君士坦丁堡”,原计划于2019年1月中旬在第7,080,000个区块上执行。不过由于潜在的安全问题,以太坊核心开发者和社区其他成员投票决定推迟升级,直到该安全漏洞得以修复。最终在在2019 年2月28日区块高度7,280,000上得到执行。

其中主要的EIPs包括:EIP145——增加按位移动指令;EIP1052——允许智能合约只需通过检查另一个智能合约的哈希值来验证彼此;EIP1014——添加了新的创建合约的指令CREATE2;EIP1234——区块奖励从每块3 ETH减少到2 ETH,难度炸弹推迟12个月。

伊斯坦布尔(Istanbul)

伊斯坦布尔是在9069000在块高执行的,执行时间是在2019 年12月8日,伊斯坦布尔分叉有以下几个重要改进:

  1. 降低calldata(是一个存储数据的位置,将在第 6 章介绍)参数的 gas 消耗(EIP2028);
  2. 降低 alt_bn128(椭圆曲线) 预编译函数的 gas 消耗(EIP1108);
  3. 增加了 chainid 操作码,让智能合约可以识别自己在主链还是分叉链或二层网络扩容链上(EIP-1344);
  4. 添加 BLAKE2 预编译函数,让以太坊可以和专注隐私功能的 Zcash 链交互,提高以太坊的隐私能力。

其中 1 2 3 点对以太坊的二层网络扩容方案是重大利好,因为很多二层网络方案会把很多交易打包在一起传递给智能合约验证(通过alt_bn128函数验证)。

伊斯坦布尔分叉另外还有两个重新调整 gas 费用的改进:EIP-1884 EIP-2200 , 这里不详细介绍,有兴趣可以通过链接阅读。

信标链创世块

2020 年 12 月 1 日,信标链正式启动,是以太坊迈向 POS 共识的重要一步。

信标链启动后,以太坊有两条独立的链,但此时的信标链仅可以进行共识,无法进行任何交易。

柏林(Berlin)

柏林升级在12244000进行,优化了某些以太坊虚拟机操作的燃料成本,并增加了对多种交易类型的支持,柏林升级的修改有:

伦敦(London)

伦敦升级在 12965000 进行(2021/08/05 日)。引入了 EIP-1559,对交易费进行了修改,同时还对交易费用的退款处理进行了修改,修改有:

TheMerge 合并

2022年9月15日,信标链与以太坊 POW 链合并,这是一个重要的里程碑,合并之后不再使用 POW 共识,合并之后,两条链使用新名字:共识层与执行层。

执行层负责交易执行(EVM),共识层负责共识出块。

未来:以 rollup 为中心的开发路线

Vitalik 在 2022年 11 月 5 日,发表了以太坊的最新发展路线图:

新的路线图,包含:The Merge、The Surge、The Scourge、The Verge、The Purge、The Splurge 六大阶段, 六个阶段在同时推进。

  1. The Merge 阶段:已经大部分完成,预计 4 月份进行上海升级,激活取款功能。

  2. The Surge阶段:推动以 Rollup 为中心的扩容,将使得 rollup 的开销降低。

  3. The Verge阶段:引入 Verkle 树,优化数据存储及验证。

  4. The Purge阶段:清理数据、简化存储,降低验证者硬盘空间性能要求。

  5. The Splurge阶段:进行EVM 改进及全面引入零知识证明

合约开发工具

介绍合约开发需要的工具,为开发 Solidity 智能合约打下坚实的基础。 通常不需要我们会使用所有的工具,初学者可以选择从 MetaMask 和 Remix 开始。

Meta mask

MetaMask 钱包是 EVM 链开发者及用户最常使用的钱包,MetaMask 有移动端版本和浏览器插件(也称为扩展程序)版本, 本文介绍的浏览器版本。

MetaMask 插件下载

MetaMask 在 Chrome谷歌浏览器(同Microsoft Edge浏览器)、FireFox火狐浏览器 均提供了插件。

我们打开MetaMask钱包的官网首页 https://metamask.io/ 后,可以点接跳转到插件市场安装。

在下载时,请一定要仔细查看URL 链接, 确保是 metamask.io, 谨防钓鱼,调转到插件市场的界面如下:

然后,直接点击添加到 Chrome ( 由于我已经添加过,上图显示的从 Chrome 移除), 这是最简单的安装方法。

在中国大陆会有部分用户无法打开插件市场, 如果你也无法打开,可以选择去 GitHub 下载 Zip 安装。

Metamask 的 GitHub 插件地址是: https://github.com/MetaMask/metamask-extension/releases/, 进入之后,可以看到如下下载包:

根据自己的浏览器,需要对应的zip包,下载解压。

然后进入到浏览器的扩展程序界面, 进入方法为:点击功能图标-> 选更多工具 -> 扩展程序, 如下图:

进入扩展程序界面后,点“加载已解压的扩展程序”:

选择之前的解压包即可。

安装完成之后, 会在浏览器地址栏的右侧出现一个“小狐狸”的图标,点击这个图标就可以进入Metamask 界面。

创建钱包账号

单击浏览器中的MetaMask图标,如果是第一次使用, 我们需要创建钱包:

然后一步步按找界面提示,输入密码,备份助记词,生成钱包后,点击右侧“小狐狸”图标, 界面如下:

此时你就创建好了一个钱包, 如上图 Account1 下方就是钱包的地址,这里为:0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 。

地址类似于银行卡账号,钱包之间转账就是使用该地址。

导入钱包账号

如果你之前在其他钱包创建过账号,或者要导入Hardhat 或 Forge Anvil 模拟节点生成的钱包, 可以使用 MetaMask 的导入功能:

MetaMask 导入账号需要填入私钥,如果你之前的备份的是助记词,这需要使用助记词推导出私钥在填入,推荐使用 Chaintool(https://chaintool.tech/generateWallet)工具的助记词推导功能,如下图

另外一个建议是,开发不同的项目尽量使用不同的钱包,从而有更好的隐私效果。

给钱包账户充值

创建好账户后,在体验转账或交易之前, 我们需要先给账号充值,我们可以先去测试网的水龙头(Faucet)获取一些测试币。

这里使用 Goerli 测试网的水龙头:https://goerlifaucet.com/

填入自己的地址, 点击”Send Me ETH” 即可,若水龙头网站不可用,这里(https://github.com/ChainToolDao/chaintool-frontend/issues/3)收集了一些水龙头网站地址

获取到测试币之后,然后把网络切换Goerli网络就可以体验转账了。

连接不同的 EVM 区块链

MetaMask 可以连接很多个不同的网络, 点击如下图切换到不同的网络:

以太坊测试网 Goerli , Sepolia 是 MetaMask 默认支持的网络,现在 EVM 有众多的兼容链, 如果我们要添加其他的网络,可以上 Chainlist 一键添加。

添加本地网络

在开发的时候,经常要让 MetaMask 链接本地的网络,例如 Hardhat,在“网络选择”列表的最下方有一个“添加网络”, 手动输入 RPC URL 及链 ID, 这里以 Hardhat node 网络为例,输入信息如下:

Remix IDE

Remix 对初学者来说,是开发智能合约的最佳开发集成环境(IDE),它无需安装,可以直接快速上手。 Remix 是在以太坊上构建的最简单的开发工具,并且拥有大量插件来扩展其体验。

Remix 可帮助我们直接在浏览器中编写 Solidity 代码,并提供用于测试、调试和将智能合约部署到区块链的工具,除此之外,Remix 还提供:

  1. 代码提示补全,代码高亮

  2. 代码警告、错误提示

  3. 运行日志输出

  4. 代码调试

Remix 开箱即用,你可以打开 Remix 网站:https://remix.ethereum.org/ , 进入到 Remix IDE:

Remix 包含 4 个区域,上图用 4 个框分别标记了

  1. 最左侧功能切换:不同图标对应不同的功能,选中不同的功能,功能操作 也会跟随变化
  2. 功能操作:各种功能展示与使用
  3. 文件编辑:代码编辑的地方
  4. 控制台/日志区:显示与合约交互的结果,也可以输入命令。

Solidity 是一门编译型高级语言,需要经过编译、部署才能运行。

下面我们使用 Remix 从无到有探索新建合约、合约代码编写、编译、部署,调用合约的完整过程。

新建合约

新建合约文件可以按如下界面所示操作:

既可以新建文件、也可以从本机或 GitHub 加载文件,这里我们新建一个 counter.sol 合约。

合约代码编写

当处于文件编辑功能时,功能操作区域显示的是文件浏览器,我们选中 counter.sol 文件,在右侧文件编辑区域输入在上一节认识以太坊 的 Counter 合约代码

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Counter {
    uint counter;

    constructor() {
        counter = 0;
    }

    function count() public {
        counter = counter + 1;
    }

    function get() public view returns (uint) {
        return counter;
    }
}

这是一个简单的计数器合约,这个智能合约的作用是在区块链上存储一个计数器变量 counter, counter 值将会被永久保存在区块链上。

count()函数让计数器加1,get()函数用来获取计数器值。

智能合约不需要编写入口方法(如main方法),每一个函数都可以被单独调用。

其实编译器会帮助合约生成main入口函数,EVM 在入口函数里用函数选择器去匹配调用的函数。

输入完代码后,你应该看到如下图:

Solidity 是一门编译型语言,代码编写之后,需要对代码进行编译。

合约编译

切换到编译功能, 选择编译器版本,进行编译。

我们也可以勾选上自动编译,这样代码编辑时,会自动编译,合约编译成功后,会输出两个重要的内容: ABI (合约接口描述) 和 Bytecode 字节码。

合约部署

接下来,就可以把合约部署到链上了,编译之后, 如果代码没有错误,就可以部署到区块链网络上,之前在 区块链网络 介绍过不同的网络。 一个正式的产品推荐的部署流程是:

  1. 在本地的开发者网络(模拟网络)进行部署,测试及验证代码逻辑的正确性
  2. 在测试网络进行灰度发布
  3. 一切 OK 后部署在主网

Remix 提供模拟网络环境, 也可以通过 Metamask 连接到真实的区块链网络进行部署,可以通过如下图方式选择不同的环境:

在这里,我们也先部署到模拟环境,然后部署到测试网络。

部署到 VM

环境(ENVIRONMENT)一栏选择 Remix VM(Merge) ,它与当前以太坊主网(以太坊合并之后)运行的虚拟机功能一样,然后点击“Deploy” 部署:

在部署功能操作区,还有一些设置:如选择使用账号、设置交易 GasLimit、选择发送到合约金额、选择要部署的合约(默认选择当前编辑的合约文件)。

通常这些都有默认值,初学者使用默认值即可。

点击部署时,会发起一笔 创建合约交易, 交易完成后,会在链上生成一个合约地址, 同时在右下方控制台/日志区看到交易详情。

由于这个部署交易是在模拟环境下进行的,因此这个交易是即时完成的,同时使用的账号和消耗的 Gas 均是模拟的,下面我们部署到以太坊测试网 Goerli

部署到真实网络

部署到真实网络,不管是测试网还是主网,在 Remix 的环境里选择Injected Provider - MetaMask, 如下图:

Remix 会加载我们在 MetaMask 中选择的网络,如上图显示的是Goerli, 是因为当前 MetaMask 选择的是 Goerli 网络:

在部署到真实的网络时,需要一个有余额的账号,否则就没办法发起交易。 如果是测试网络,则可以通过水龙头获取测试币。若是主网,就需要购买代币了。

再次点击“Deploy” 部署合约,这次把合约部署到 Goerli 测试网络上, MetaMask 会弹出一个交易确认对话框,如下图:

让我们确认交易费用,点击“确认”时,同时会对这笔交易签名,并发送到 Goerli 网络中。

待交易完成后,同样会在功能操作区域的下方列出合约地址及对应的函数。

调用合约函数

合约部署后,在功能区的下方会出现智能合约部署后的地址以及合约所有可以调用的函数,如下图:

Remix里用橙色按钮来这个动作会修改区块链的状态,蓝色按钮则表示调用仅仅是读取状态。点击上方的count和get两个按钮,就可以调用对应的合约函数。

点击count时,会发起一笔交易,交易打包后,计数器变量加1:

Remix 插件

Remix 还提供了很多插件,作为 Remix 功能的补充:

例如:ETHERSCAN 插件,可以用来做代码开源验证。

REMIXD 是一个很实用的插件,可以用来加载本地文件,使用REMIXD时, 本地电脑上也需要安装:

npm install -g @remix-project/remixd

然后使用命令remixd -s shared-folder -u https://remix.ethereum.org/ 共享本地目录给 remix 网站。

之后,回到remix网站上,启动REMIXD插件,就可以看到remix 加载了本地的合约文件。

Truffle 开发框架

Truffle是一个基于以太坊的区块链应用程序开发框架,它提供了一套开发工具和开发环境,方便开发者快速构建和部署智能合约。本文将介绍 Truffle 的一些特点和使用方法。

Truffle 团队还开发了 Ganache, Ganache 是一个用于以太坊开发和测试的个人区块链网络,它可以让开发者在本地运行以太坊节点,从而无需连接到公共测试网络或主网进行开发和测试。Ganache还提供了许多有用的功能,如快速挖矿、预设的账户和私钥、以太坊虚拟机调试器等,这些功能可以大大提高开发和测试的效率。同时,Ganache还支持与Truffle框架无缝集成,使得开发者可以更加方便地进行智能合约的开发和测试。

以下按使用Truffle框架进行区块链应用程序开发的基本步骤进行介绍:

  1. Truffle & Ganache 安装
  2. 创建Truffle项目
  3. 编写智能合约
  4. 编译
  5. 部署
  6. 测试

Truffle & Ganache 安装

在计算机上安装Truffle。可以通过npm安装,使用以下命令:

npm install -g truffle

Truffle 的安装依赖 Node.js 开发包(使用v14 - v18),Node.js 的安装参考这里。

安装完成之后,可以使用 truffle version 检查一下。

安装 Ganache,作为开发节点。

前往 Ganache 网站 https://trufflesuite.com/ganache/ 下载, Ganache 提供了多个平台的版本,还有控制台命令行版本。

Ganache 安装好,截图如下:

创建Truffle项目

使用以下命令创建一个新的Truffle项目:

> truffle init
Starting init...
================

> Copying project files to /Users/emmett/course/training_camp_2/th

Init successful, sweet!

Try our scaffold commands to get started:
  $ truffle create contract YourContractName # scaffold a contract
  $ truffle create test YourTestName         # scaffold a test

http://trufflesuite.com/docs

Truffle 也提供了模板项目,他们称为 Boxes,在Truffle Boxes 页面提供所有的模板,模板会提供很多基础代码,如果想基于模板项目开发,可以通过以下命令创建项目:

 truffle unbox  aboxname

Truffle项目默认包含以下文件及目录:

  1. contracts:存放智能合约文件目录
  2. migrations:迁移文件、用来指示如何部署智能合约
  3. test:智能合约测试用例文件夹。
  4. truffle-config.js:配置文件,配置truffle连接的网络及编译选项。

合约编写

合约开发推荐使用 VSCode 编辑器 + solidity 插件:

配置好了编辑器, 就可以开始写合约了,使用 truffle create 创建一个名为 Counter 的合约:

truffle create contract Counter

在contracts/ 会新建一个Counter.sol 文件,并且有默认的合约代码:

// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;

contract Counter {
  constructor() public {
  }
}

我们可以在VSCode 编辑器把代码修改为:

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Counter {
    uint counter;

    constructor() {
        counter = 0;
    }

    function count() public {
        counter = counter + 1;
    }

    function get() public view returns (uint) {
        return counter;
    }
}

接下来就可以编译这个合约了。

Truffle 编译

我们需要先告诉 Truffle 使用哪一个版本的编译器来编译合约,truffle-config.js 是 Truffle 框架用于项目配置,在这个文件中,你可以指定编译器、网络、账户和合约的路径等各种配置。

使用solc: 这个选项用来配置 Solidity 编译器的参数,也可以通过这个选项指定编译器的优化等级、版本等参数,这里使用如下配置:

  module.exports = {
      compilers: {
      solc: {
        version: "0.8.9"
      }
    }
  }

文档: https://learnblockchain.cn/docs/truffle/reference/configuration.html

接下来就可以进行编译了, 编译使用 truffle compile :

➜ > truffle compile

Compiling your contracts...

===========================

> Compiling ./contracts/Counter.sol
> Artifacts written to …/build/contracts
> Compiled successfully using:
- solc: 0.8.9+commit.e5eed63a.Emscripten.clang

成功编译后,会在 build/contracts/ 目录下生成Counter.json, Counter.json包含了智能合约的 ABI 、字节码(Bytecode)以及合约元数据等。

Counter.json 较大,这里不贴内容,可以在这里查看到完整的内容。

部署合约

在部署合约前,还需要确定:

  1. 确定部署到哪一个网络, 这可以使用 truffle-config.js 来进行配置
  2. 确定如何部署合约,例如传递什么参数给合约,这需要我们编写部署脚本 之后就可以运行 truffle migrate 执行部署。

配置部署到哪一个网络

推荐的部署流程是:

  1. 在本地的开发者网络(如:Ganache)进行部署,测试及验证代码逻辑的正确性
  2. 在测试网络(如:Goerli)进行灰度发布
  3. 一切 OK 后部署在主网(如: 以太坊主网)

truffle-config.js 中,使用 networks: 选项用来配置不同的网络。你可以通过指定不同的网络配置,来连接不同的EVM网络, 如下配置了两个网络:

module.exports = {
  networks: {
    development: {
      host: "127.0.0.1",
      port: 7545,
      gas: 5500000           //  gas limit
      gasPrice: 10000000000,  // 10 Gwei 
    },
    
    goerli: {
      provider: () => new HDWalletProvider(MNEMONIC,  NODE_RPC_URL),
      network_id: 5,       // Goerli's chain id
      confirmations: 2,    // # of confirmations to wait between deployments. (default: 0)
      timeoutBlocks: 200,  // # of blocks before a deployment times out  (minimum/default: 50)
      skipDryRun: true     // Skip dry run before migrations? (default: false for public nets )
    },
  }
};

development 网络连接了启动的 Ganache ,通过 IP 及 port 指定。

如果是真实的网络,如上的 goerli 网络,则需要提供提交交易账号的助记词 与 节点RPC URL (节点 URL 可以在https://chainlist.org/ 获取)。

注意要在 Goerli 上进行部署,你需要将Goerli-ETH发送到将要进行部署的地址中。 可以从水龙头免费或一些测试币,这是Goerli的一个水龙头:

编写部署脚本

编写部署脚本(也称迁移文件),放在 migrations 目录下,添加一个文件 1_counter.js:

const Counter = artifacts.require("Counter");

module.exports = function (deployer) {

 deployer.deploy(Counter);

};

部署脚本前面有一个序号,是因为Truffle 按序号(从小到大)依次执行部署脚本。

执行部署

使用 truffle migrate 就可以部署合约:

truffle migrate [ -f 序号 --network 网络名称]

可以通过 -f 指定部署哪一个需要的部署脚本, 使用 –network 指定部署到哪一个网络。

在进行部署时,会发起一笔 创建合约交易, 交易完成后,会在链上生成一个合约地址, 如下图就是创建合约交易的详情:

若部署到 Ganache 上,在 Ganache 中,可以出块记录,以及相关的交易详情。

部署信息如合约地址也会写入之前编译生成的构建文件build/contracts/Counter.json中。

Truffle 测试

软件开发中,测试是重要的一环,在智能合约开发中,由于区块链不可篡改特性,测试尤其重要。

打开终端,在 Truffle 项目目录,并输入 truffle create test Counter 命令来创建一个测试文件,它会在 ./test/ 目录下创建一个名为 counter.js 的测试文件:

const Counter = artifacts.require("Counter");

contract("Counter", function (/* accounts */) {
  it("should assert true", async function () {
    await Counter.deployed();
    return assert.isTrue(true);
  });
});

在测试文件中,我们加入对get 及 count 方法的检查, 以下是一个简单的示例。

var Counter = artifacts.require("Counter");

contract("Counter", function(accounts) {
  
  // it定义一个测试用例
  it("Counter", async function() {  
    let counter = await Counter.deployed()
    let num = await counter.get();
    // 满足断言则测试用例通过
    assert.equal(num, 0);  
  });
});

在测试文件中,通过导入需要测试的合约文件,创建一个合约实例,并在测试函数中调用它的方法,然后断言方法返回的结果是否符合预期。

在上面的代码中,我们创建了一个测试套件,并在其中定义了以个测试函数。测试函数测试合约的 get 方法是否返回正确的值。

在每个测试函数中,我们首先通过 await MyContract.deployed() 创建了一个合约实例,然后调用相应的方法,并使用 assert 断言方法返回的结果是否符合预期。最后,我们可以使用 truffle test 命令来运行所有测试:

$ truffle test

$ truffle test ./path/to/test/file.js

总之,编写测试时需要考虑合约的各种情况和边界条件,并断言实际结果是否与预期结果一致。通过编写测试可以帮助我们确保合约的正确性和健壮性,同时也是良好的编程习惯。

小结

本文介绍了Truffle开发框架的一些基本概念和使用方法,包括Truffle框架的基本结构、配置文件和命令、智能合约的编译和测试、Truffle console的使用,以及Truffle-flattener的简介。这些内容涵盖了Truffle框架的基本使用,有助于初学者快速入门Truffle开发。

这里有两个参考文档:

  1. 文档:https://trufflesuite.com/docs/truffle/
  2. 中文文档:https://learnblockchain.cn/docs/truffle/ 不过,目前合约开发使用 Truffle 工具的项目越来越少, 有更多的人开始使用 Hardhat 和 Foundry。

Hardhat 开发框架

Hardhat 提供了一个灵活且易于使用的开发环境,可以轻松地编写、测试和部署智能合约。Hardhat 使用 Node 进行包管理,如果你熟悉 Node 及 Javascript, Hardhat 将非常容易上手。

Hardhat还内置了Hardhat 网络(Hardhat Node),它是为开发而设计的本地以太坊网络。 用来部署合约,运行测试和调试代码。

创建及配置Hardhat项目

Hardhat 构建在 Node.js 之上, 使用 Hardhat 要求我们在电脑先安装好Node.js (>= 16.0), 环境准备可以参考这里。

先创建项目目录:

mkdir hardhat-tutorial
cd hardhat-tutorial

初始化 Node 项目:

npm init

安装 Hardhat :

npm install --save-dev hardhat

在安装Hardhat的目录下运行:

npx hardhat

使用键盘选择”创建一个新的hardhat.config.js(Create a JavaScript project)” ,然后回车。

$ npx hardhat
888    888                      888 888               888
888    888                      888 888               888
888    888                      888 888               888
8888888888  8888b.  888d888 .d88888 88888b.   8888b.  888888
888    888     "88b 888P"  d88" 888 888 "88b     "88b 888
888    888 .d888888 888    888  888 888  888 .d888888 888
888    888 888  888 888    Y88b 888 888  888 888  888 Y88b.
888    888 "Y888888 888     "Y88888 888  888 "Y888888  "Y888

👷 Welcome to Hardhat v2.13.0 👷‍

? What do you want to do? …
❯ Create a JavaScript project
  Create a TypeScript project
  Create an empty hardhat.config.js
  Quit

这个 JavaScript Hardhat 工程会默认下载 hardhat-toolbox 插件及一些常规设置:

创建好的 Hardhat 工程包含文件有:

编写合约

合约开发推荐使用 VSCode 编辑器 + solidity 插件,在contracts 下新建一个合约文件 Counter.sol (*.sol 是 Solidity 合约文件的后缀名), 复制如下代码:

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Counter {
    uint counter;

    constructor() {
        counter = 0;
    }

    function count() public {
        counter = counter + 1;
    }

    function get() public view returns (uint) {
        return counter;
    }
}

接下来就可以编译这个合约了。

使用OpenZepplin 等第三方库

在编写合约时,尽量不要重复造轮子,基于优质开源的第三方库,不仅可以提高效率,还可以让我们的合约代码更安全,例如要开发一个 Token,可以用npm 安装OpenZepplin 库:

npm install @openzeppelin/contracts --save-dev

然后在合约中 import 相应库中的合约文件及可。

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Token is ERC20 {
  constructor(uint256 initialSupply) ERC20("Token Name", "Token Symbol") {
    _mint(msg.sender, initialSupply);
  }
}

编译合约

hardhat.config.js 有默认的Solidity 编译器配置:

require("@nomicfoundation/hardhat-toolbox");

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: "0.8.18",
};

因此我们直接编译合约即可,在终端中运行 npx hardhat compile 。 compile任务是内置任务之一。

$ npx hardhat compile
Compiling 1 file with 0.8.18
Compilation finished successfully

合约已成功编译了。

成功编译后,会在 artifacts/contracts/ 目录下生成Counter.json 和 build-info, Counter.json包含了智能合约的 ABI 、字节码(Bytecode)等。

编写测试用例

为智能合约编写自动化测试至关重要,因为事关用户资金。

在我们的测试中,使用 Harhdat 内置的网络,使用ethers.js与前面的合约进行交互,并使用 Mocha 作为测试运行器。

在项目 test下,并创建一个名为Counter.js的新文件:

const { ethers } = require("hardhat");
const { expect } = require("chai");

let counter;

describe("Counter", function () {
  async function init() {
    const [owner, otherAccount] = await ethers.getSigners();
    const Counter = await ethers.getContractFactory("Counter");
    counter = await Counter.deploy();
    await counter.deployed();
    console.log("counter:" + counter.address);
  }

  before(async function () {
    await init();
  });

  // 
  it("init equal 0", async function () {
    expect(await counter.get()).to.equal(0);
  });

  it("add 1 equal 1", async function () {
    let tx = await counter.count();
    await tx.wait();
    expect(await counter.get()).to.equal(1);
  });

});

在终端上运行npx hardhat test。 你应该看到以下输出:

> npx hardhat test


  Counter
counter:0x5FbDB2315678afecb367f032d93F642f64180aa3
    ✔ init equal 0
    ✔ add 1 equal 1

  2 passing (1s)

这意味着测试通过了。 现在我们解释下主要代码:

  const Counter = await ethers.getContractFactory("Counter");

ethers.js中的ContractFactory是用于部署新智能合约的抽象,因此此处的Counter是用来实例合约的工厂。

counter = await Counter.deploy();

在ContractFactory上调用deploy()将启动部署,并返回解析为Contract的Promise。 该对象包含了智能合约所有函数的方法。

let tx = await counter.count();
await tx.wait();

在counter 上调用合约方法, 并等待交易执行完毕。

注意,默认情况下, ContractFactory和Contract实例连接到第一个签名者(Singer)。

若需要使用其他的签名这, 可以使用合约实例connect 到另一个签名者, 如 counter.connect(otherAccount)

expect(await counter.get()).to.equal(0);

判断相等,我们使用Chai,这是一个断言库。

使用 Console.log 调试合约

在Hardhat Node 节点上运行合约和测试时,你可以在Solidity代码中调用console.log()打印日志信息和合约变量,可以方便我们调试代码。

在合约代码中导入Hardhat 的console.log就可以使用它。

pragma solidity ^0.8.0;

import "hardhat/console.sol";

contract Counter {
    uint public counter;

    constructor(uint x) {
        counter = x;
    }

    function count() public {
        counter = counter + 1;
        console.log("counter is %s ", counter);
    }

}

就像在JavaScript中使用一样, 将一些console.log添加函数中,运行测试时,将输出日志记录:

> npx hardhat test

  Counter
counter:0x5FbDB2315678afecb367f032d93F642f64180aa3
    ✔ init equal 0
counter is 1
    ✔ add 1 equal 1 (38ms)


  2 passing (1s)

部署合约

其实我们在测试时, 合约已经部署到了Hardhat 内置的网络上,部署合约我们需要编写一个部署脚本。

在scripts文件夹,新建一个deploy.js 用来写部署脚本,部署脚本其实和前面测试时 init 函数类似:

const { ethers } = require("hardhat");

async function main() {

     const Counter = await ethers.getContractFactory("Counter");
   const counter = await Counter.deploy();
   await counter.deployed();

  console.log("Counter address:", counter.address);
}

main();

运行 npx hardhat run scripts/deploy.js 时,合约会部署到 Hardhat 内置网络上。

> npx hardhat run scripts/deploy.js
Counter address: 0x5FbDB2315678afecb367f032d93F642f64180aa3

为了在运行任何任务时指示Hardhat连接到特定的EVM网络,可以使用–network参数。 像这样:

npx hardhat run scripts/deploy.js --network <network-name>

network-name 需要在 hardhat.config.js 文件中进行配置:

require("@nomicfoundation/hardhat-toolbox");

// 填入自己的私钥或助记词,
const PRIVATE_KEY1 = "0x.... YOUR PRIVATE KEY1";
const PRIVATE_KEY2 = "0x....  YOUR PRIVATE KEY1";
const Mnemonic = "YOUR Mnemonic";


module.exports = {
  solidity: "0.8.9", // solidity的编译版本
  networks: {
    goerli: {
      url: "https://eth-goerli.api.onfinality.io/public",
      accounts: [PRIVATE_KEY1,PRIVATE_KEY2],
      chainId: 5,
    },
    
     mumbai: {
      url: "https://endpoints.omniatech.io/v1/matic/mumbai/public",
      accounts: {
        mnemonic: Mnemonic,
      },
      chainId: 80001,
    },
  }
};

以上配置了两个网络,一个是以太坊测试网 goerli, 一个是 Polygon 测试网mumbai, 我们可以在 https://chainlist.org 找到每个网络的节点 URL 及 chainID。

在网络配置中,需要提供提交交易账号, 可以通过私钥或助记词 进行配置,这里配置的账号(需要提前充币进入到账号中),在hardhat 脚本中(测试及部署脚本)调用getSigners 即可获得:

const [owner, otherAccount] = await ethers.getSigners();

一个私钥对应一个Singer,助记词则对应无数个 Singer , 为每个项目生成一个独立的账号是比较推荐的做法,使用 ChainTool 开源工具 可以生成账号。

另外要注意, 在 Goerli 上进行部署,需要将Goerli-ETH发送到将要进行部署的地址中。 可以从水龙头免费获取一些测试币,这是Goerli的一个水龙头: https://goerlifaucet.com/

最后运行:

npx hardhat run scripts/deploy.js --network goerli

代码开源验证

智能代码开源会增加了合约的透明度和可靠性,是项目建立信任很重要的一个步骤。

在 hardhat-toolbox 工具箱里,包含了 hardhat-etherscan 插件用于验证已经部署到区块链网络上的智能合约代码与源代码是否匹配,在完成验证后在区块链浏览器中合约标签上会出现✅, 如图:

在部署智能合约时,合约字节码会被写入到区块链中,这意味着其他人无法检查合约的源代码。代码验证的过程是将已部署合约的字节码与原始Solidity代码再次编译后与部署的字节码进行比较,确保它们是一致的。

相比在区块链浏览器上上传代码验证, hardhat-etherscan 有很多优点,它会自动使用 hardhat.config.js 中设置的编译器选项,并且当代码中引用了第三方库或合约, hardhat-etherscan 能自动探测并处理。

开源验证的步骤是:

  1. 安装 hardhat-toolbox 或 hardhat-etherscan , 这一步我们这里已经完成,因为在初始化项目的时候安装了 hardhat-toolbox , 如果没有安装,可以使用以下命令安装
npm install --save-dev @nomiclabs/hardhat-etherscan
  1. 在 hardhat.config.js 中配置您的 Etherscan API 密钥和网络设置,例如:
  require("@nomicfoundation/hardhat-toolbox");
  或
  // require("@nomiclabs/hardhat-etherscan");
  
  etherscan: {
    apiKey: ""
  },

如何获取 Etherscan API 密钥?

  1. 访问部署网络对应主网的 Etherscan 网站,并注册一个账号(如果还没有账号的话)。

  2. 登录你的账号并进入 Etherscan 的「我的帐户」页面。

  3. 点击页面左侧的「API-KEYs」标签页。

  4. 在页面上方的「Create New API KEY」部分,输入 API 密钥的名称和描述,然后选择需要访问的 API 权限。

  5. 点击「Generate」按钮来生成 API 密钥。

  6. 执行验证命令:

npx hardhat verify <deployed-contract-address> "参数(若有)" --network <network-name> 

例如,要在 goerli 网络上验证合约,可以运行以下命令:

npx hardhat verify 0x..... --network goerli

该命令会为我们上传合约代码并验证其源代码。如果一切顺利(网络顺畅的话),在 Etherscan 上看到的合约被成功验证。

参考文档

示例非常简单, 更多使用方法,可参考文档:

Foundry 开发框架

Foundry 是一个Solidity框架,用于构建、测试、模糊、调试和部署Solidity智能合约, Foundry 的优势是以Solidity 作为第一公民,完全使用 Solidity 进行开发与测试,如果你不太熟悉 JavaScript , 使用 Foundry 是一个非常好的选择,而且Foundry 构建、测试的执行速度非常快。

Foundry 的测试功能非常强大,通过 作弊码 来操纵区块链的状态, 可以方便我们模拟各种情况, 还支持基于属性的模糊测试。

Foundry 有非常详细的文档,并且登链社区进行的详尽的翻译,见Foundry 中文文档(https://learnblockchain.cn/docs/foundry/i18n/zh/),对中文用户非常友好,

Foundry 安装

终端并输入以下命令:

curl -L https://foundry.paradigm.xyz | bash

这会下载foundryup。 然后通过运行它安装 Foundry:

foundryup

安装安装后,有三个命令行工具 forge, cast, anvil 组成

初始化Foundry项目

通过 forge 的 forge init 初始化项目:

> forge init hello_decert
Installing forge-std in "/Users/emmett/course/hello_decert/lib/forge-std" (url: Some("https://github.com/foundry-rs/forge-std"), tag: None)
    Installed forge-std v1.5.1
    Initialized forge project.

init 命令会创建一个项目目录,并安装好forge-std 库。

如需手动安装依赖库使用: forge install forge/forge-std

创建好的 Foundry 工程结构为:

> tree -L 2
.
├── foundry.toml
├── lib
│   └── forge-std
├── script
│   └── Counter.s.sol
├── src
│   └── Counter.sol
└── test
    └── Counter.t.sol

5 directories, 4 files
[submodule "lib/forge-std"]
    path = lib/forge-std
    url = https://github.com/foundry-rs/forge-std
    branch = v1.5.0

了解 git submodule

  1. Git submodule 是 Git 中用于管理子模块的工具。允许在一个 Git 仓库中把另一个 Git 仓库作为子目录,实现代码共享和重用(而不是拷贝代码)。
  2. 将子仓库添加到当前库使用:git submodule add url_to_repository path_to_submodule , 在当前库下会生成 .gitmodules 文件用来跟踪子库。
  3. 如果我们 clone 的库包含子库,需要使用 git submodule init 及 git submodule update 来获取子库的代码。

合约开发及编译

合约开发推荐使用 VSCode 编辑器 + solidity 插件,在contracts 下新建一个合约文件 Counter.sol (*.sol 是 Solidity 合约文件的后缀名), 复制如下代码:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

contract Counter {
    uint256 public counter;

    function setNumber(uint256 newNumber) public {
        counter = newNumber;
    }

    function increment() public {
        counter++;
    }

    function count() public {
        counter = counter + 1;
    }
}

在foundry.toml 中使用solc配置编译器版本:

[profile.default]
src = 'src'
out = 'out'
libs = ['lib']

solc = "0.8.18" 

更多的配置项请参考 foundry.toml 配置

之后就使用forge build编译合约了:

> forge build
[⠒] Compiling...
[⠔] Compiling 1 files with 0.8.18
[⠒] Solc 0.8.18 finished in 362.64ms
Compiler run successful

编写自动化测试

测试是用 Solidity 编写的。 如果测试功能 revert,则测试失败,否则通过。

测试 Case 编写 在测试目录下test 添加自己的测试用例,添加文件 Counter.t.sol ,foundry 测试用例使用 .t.sol 后缀,约定具有以test开头的函数的合约都被认为是一个测试, 以下是测试代码:

pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/Counter.sol";

contract CounterTest is Test {
    Counter public counter;

    function setUp() public {
        counter = new Counter();
        counter.setNumber(0);
    }

    function testIncrement() public {
        counter.increment();
        assertEq(counter.counter(), 1);
    }

    function testSetNumber(uint256 x) public {
        counter.setNumber(x);
        assertEq(counter.counter(), x);
    }
}

我们来分析一下测试代码:

import "forge-std/Test.sol";

引入 Forge 标准库 的 Test 合约,并让测试合约继承 Test 合约, 这是使用 Forge 编写测试的首选方式。

第 9 行 setUp() 函数用来进行一些初始化,它是每个测试用例运行之前调用的可选函数

第 14、19 行是以 test 为前缀的函数的两个测试用例,测试用例中使用 assertEq 断言判断相等。

testSetNumber 带有一个参数 x, 它使用了基于属性的模糊测试, forge 模糊器默认会随机指定256 个值运行测试。

运行测试

Forge 使用 forge test 命令运行测试用例(请先启动anvil):

> forge test
[⠒] Compiling...
No files changed, compilation skipped

Running 2 tests for test/Counter.t.sol:CounterTest
[PASS] testIncrement() (gas: 28390)
[PASS] testSetNumber(uint256) (runs: 256, μ: 28064, ~: 28453)
Test result: ok. 2 passed; 0 failed; finished in 9.33ms

结果中的两个 PASS 表示测试通过了,并且列出了测试所消耗的 gas,

在 testSetNumber(uint256) 模糊测试中的(runs: 256, μ: 28064, ~: 28453),含义是:

我们还可以在测试用例用 console2.sol 打印值的结果,修改一下 testIncrement 加入 console2.log, 修改后的代码为:

    function testIncrement() public {
        counter.increment();
        uint x = counter.counter();
        console2.log("x= %d", x);
        assertEq(x, 1);
    }

console2.sol 包含 console.sol 的补丁,允许Forge 解码对控制台的调用追踪

forge test 的默认行为是只显示通过和失败测试的摘要。 可以使用-vv标志通过增加日志详细程度:

> forge test -vv
[⠒] Compiling...
No files changed, compilation skipped

Running 2 tests for test/Counter.t.sol:CounterTest
[PASS] testIncrement() (gas: 31626)
Logs:
  x= 1

[PASS] testSetNumber(uint256) (runs: 256, μ: 27597, ~: 28453)
Test result: ok. 2 passed; 0 failed; finished in 9.94ms

可以看到 Logs 下显示了测试用例中的打印的日志。

部署合约

部署合约到区块链,需要先准备有币的账号及区块链节点的 RPC URL。

Forge 提供 create 命令部署合约, 如:

forge create  src/Counter.sol:Counter  --rpc-url <RPC_URL>  --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

create 命令需要输入的参数较多,使用部署脚本是更推荐的做法是使用 solidity-scripting 部署。

为此我们需要稍微配置 Foundry 。

通常我们会创建一个 .env 保存私密信息(如:私钥),.env 文件应遵循以下格式:

GOERLI_RPC_URL=
MNEMONIC=

.env 中记录自己的助记词及RPC URL。

编辑 foundry.toml 文件:

[rpc_endpoints]
goerli = "${GOERLI_RPC_URL}"
local = "http://127.0.0.1:8545"

然后在 script 目录下创建一个脚本,Counter.s.sol:

pragma solidity ^0.8.13;

import "forge-std/Script.sol";
import "../src/Counter.sol";

contract CounterScript is Script {
        
    function run() external {
        string memory mnemonic = vm.envString("MNEMONIC");
                (address deployer, ) = deriveRememberKey(mnemonic, 0);
                
        vm.startBroadcast(deployer);
        Counter c = new Counter();
        console2.log("Counter deployed on %s", address(c));
        vm.stopBroadcast();
    }
}

我们来分析一下脚本代码:

contract CounterScript is Script {

创建一个名为 CounterScript 的合约,它从 Forge Std 继承了 Script。

function run() external {

默认情况下,脚本是通过调用名为 run 的函数(入口点)来执行的部署。

string memory mnemonic = vm.envString("MNEMONIC");
(address deployer, ) = deriveRememberKey(mnemonic, 0);

从 .env 文件中加载助记词,并推导出部署账号,如果 .env 配置的是私钥,这使用uint256 deployer = vm.envUint(“PRIVATE_KEY”); 加载账号

vm.startBroadcast(deployerPrivateKey);

这是一个作弊码,表示使用该密钥来签署交易并广播。

Counter c = new Counter();

创建Counter 合约。

脚本代码编写好了, 让我们运行它, 在项目的根目录运行:

> source .env

> forge script script/Counter.s.sol --rpc-url goerli --broadcast 
[⠒] Compiling...
[⠊] Compiling 1 files with 0.8.18
[⠒] Solc 0.8.18 finished in 738.87ms
Compiler run successful
Script ran successfully.
Gas used: 127361

== Logs ==
  Counter deployed on 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0
...

部署成功打印出合约的地址。

goerli 是我们之前在foundry.toml 文件中配置的端点。 如果我们不想在命令中输入–rpc-url, 可以在foundry.toml配置一个默认的 URL:

eth-rpc-url = "${GOERLI_RPC_URL}"  // 本地 RPC 为 http://127.0.0.1:8545

forge script 支持在部署时进行代码验证,在 foundry.toml 文件中配置了 etherscan的 API KEY:

[etherscan]
goerli = { key = "${ETHERSCAN_API_KEY}" }

然后需要在 script 命令中加入 –verify 就可以执行代码开源验证。

solidity 基础

开始编写

//SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

// 定义一个合约
contract Counter {
    uint public counter;
    
    constructor() {
        counter = 0;
    }
    
    function count() public {
        counter = counter + 1;
    }
    
    function get() public view returns (uint) {
        return counter;
    }
}

合约是可部署到区块链的最小单元, 一个合约通常由状态变量(合约数据)和合约函数组成。

在学习智能合约时,通常以 Counter 计数器作为入门合约,而不是通常打印 HelloWorld, 这个因为合约主要是用来处理状态的转换,另外,合约程序实际上是在节点上运行,因此是看不到打印输出的。

声明编译器版本

编写合约首先要做的是声明编译器版本, 告诉编译器如何编译当前的合约代码,适合使用什么版本的编译器来编译。

编译器版本声明的语法如下:

pragma solidity >=0.8.0;

它的含义是使用大于等于0.8.0 版本的编译编译 Counter 合约。类似的表示还有:

pragma solidity >=0.8.0 <0.9.0;

pragma solidity ^0.8.0;

版本表达式遵循npm版本语义,可以参考 https://docs.npmjs.com/misc/semver

定义合约

Solidity 使用 contract 定义合约,这里定义了一个名为 Counter 的合约。

contract Counter {
}

合约和其他语言的类(class)很类似。在Solidity中,合约本身也是一个数据类型, 称为合约类型。

合约部署到链上后,使用地址来表示一个合约。

合约由状态变量(合约数据)和合约函数组成。

合约构造函数

构造函数是在创建合约时执行的一个特殊函数,其作用主要是用来初始化合约, constructor 关键字声明的一个构造函数。

如果没有初始化代码也可以省略构造函数(此时,编译器会添加一个默认的构造函数constructor() public {})。

状态变量的初始化,也可以在声明时进行指定,未指定时,默认为0。

下面是一个构造函数的示例代码:

pragma solidity >=0.7.0;

contract Base {
    uint x;
    address owner;
    constructor(uint _x) public {
       x = _x;
       owner = msg.sender;
    }
}

变量与函数的可见性

合约(contract)和其他语言的类(class)很类似,合约添加的变量与函数,也是使用public private等关键字来控制变量和函数是否可以被外部使用。

如Counter合约的如下定义:

 uint public counter;

使用了 public 关键字, 表示 counter 是可以被公开访问的。

除 public 之外,还有几个关键字,来修饰属性与函数的可见性。

Solidity对函数和状态变量提供了4种可见性:external、public、internal、private。

public

声明为 public 的函数或变量,他们既可以在合约内部访问,也以合约接口形式暴露合约外部(其他合约或链下)调用。

另外,public 类型的状态变量,会自动创建一个同名的外部函数(称为访问器),来获取状态变量的值。

external

external 不可以修饰状态变量,声明为 external 的函数只能在外部调用,因此称为外部函数。

如何想在合约内部调用外部函数,需要使用this.func() (而不是 func())。

下面是一个例子:

contract Counter {
    uint a;
    function add(uint x) external {
        a = a+x;
  }
  
  function increase() public {
    // add(1);   // 错误,无法调用
    this.add(1);   // 正确
  } 
  
}

前面有合约地址来调用函数, 即 addr.fun() 形式,这个方式称为外部调用。而 func()形式为内部调用。

外部调用也称为消息调用,会切换上下文。内部调用则是在当前上下文里跳转。

所有暴露给外部的函数 (声明为 external 和 public),构成了合约的对外接口。

internal

声明为 internal 函数和状态变量只能在当前合约中调用或者在派生合约(子合约)里访问。

private

声明为 private 函数和状态变量仅可在当前定义它们的合约中使用,并且不能被派生合约使用。

定义变量

Solidity 是一个静态类型语言,在定义每个变量时,需要在声明该变量的类型。

uint public counter;

这行代码声明了一个变量,变量名为 counter,类型为 uint(一个256位的无符号整数),它是可以被公开访问的。

定义变量按格式: 变量类型 变量可见性 变量名。变量可见性是可选的,没有显示申明可见性时,会使用缺省值 internal。

合约中的变量会在区块链上分配一个存储单元。在以太坊中,所有的变量构成了整个区块链网络的状态,所以合约中变量通常称为状态变量。

有两个特殊的“变量“:常量和不可变量, 他们不在链上分配存储单元。

常量

在合约里可以定义常量,使用 constant 来声明一个常量,常量不占用合约的存储空间,而是在编译时使用对应的表达式值替换常量名。

pragma solidity >=0.8.0;

contract C {
    uint constant x = 32**22 + 8;
    string constant text = "abc";
}

使用constant修饰的状态变量,只能使用在编译时有确定值的表达式来给变量赋值。

不可变量

不可变量的性质和常量很类似,同样在变量赋值之后,就无法修改。不可变量在构造函数中进行赋值,构造函数是在部署的时候执行,因此这是运行时赋值。

Solidity 中使用 immutable 来定义一个不可变量,immutable不可变量同样不会占用状态变量存储空间,在部署时,变量的值会被追加的运行时字节码中,因此它比使用状态变量便宜的多,同样带来了更多的安全性(确保了这个值无法再修改)。

不可变量特性在很多时候非常有用,最常见的如ERC20代币用来指示小数位置的decimals变量,它应该是一个不能修改的变量,很多时候我们需要在创建合约的时候指定它的值,这时immutable就大有用武之地,类似的还有保存创建者地址、关联合约地址等。

以下是immutable的使用举例:

contract Example {    
    uint immutable decimals;
    uint immutable maxBalance;
    
    constructor(uint _decimals, address _reference) public {
       decimals = _decimals;
       maxBalance = _reference.balance; 
    }
}

定义函数

还记得么,合约由状态变量(合约数据)和合约函数组成,刚才介绍了定义变量,现在来看看定义函数:

   function count() public {
        counter = counter + 1;
    }

使用 function 关键字定义函数,这行代码声明了一个名为 count() 函数,public 表示这个函数可以被公开访问。

count() 函数的作用是对counter状态变量加 1 ,因此调用这个函数会修改区块链状态,这时我们就需要通过一个交易来调用该函数,调用者为交易提供 Gas,验证者(矿工)收取 Gas 打包交易,经过区块链共识后,counter变量才真正算完成加1 。

这里的 count() 函数非常简单,我们还可以根据需要定义函数的参数与返回值以及指定该函数是否要修改状态,一个函数定义形式可以这样表示:

function 函数名(<参数类型> <参数名>) <可见性> <状态可变性> [returns(<返回类型>)]{ 

}

函数参数

Solidity 中参数的声明方式与变量声明类似,如:

   function addAB(uint a, uint b) public {
        counter = counter + a + b;
    }

addAB 函数接受两个整数参数。

函数返回值

以下函数定义了返回值:

    function addAB(uint a, uint b) public returns (uint result) {
        counter = counter + a + b;
                result = counter; // return counter;
    }

其实在Solidity 中,返回值与参数的处理方式是一样的,代码中 返回值 result 也称为输出参数,我们可以在函数体里直接为它赋值,或直接在 return 语句中提供返回值。

返回值可以仅指定其类型,省略名称,例如:

function addAB(uint a, uint b) public returns (uint) {
      ....
    return counter + a + b;
}

Solidity 支持函数有多个返回值,例如:

pragma solidity >0.5.0;
contract C {
    function f() public pure returns (uint, bool, uint) {
        return (7, true, 2);
    }
     function g() public {
        // 获取返回值
        (uint x, bool b, uint y) = f();
     }    
}

状态可变性(mutability)

有些函数还还会有一个关键字来描述该函数,会怎样修改区块链状态,形容函数的可变性有 3 个关键字:

view:用 view 修饰的函数,称为视图函数,它只能读取状态,而不能修改状态。 pure:用 pure 修饰的函数,称为纯函数,它既不能读取也不能修改状态。 payable:用 payable 修饰的函数表示可以接受以太币,如果未指定,该函数将自动拒绝所有发送给它的以太币。 view , pure , payable 通常被称为修饰符

  1. 视图函数

这是一个视图函数:

    function cal(uint a, uint b) public view returns (uint) {
        return a * (b + 42) + now;
    }

cal() 函数不修改状态,它不需要提交交易,也不需要花费交易费,调用视图函数时,只需要当前链接的节点执行,就可返回结果。

而交易需要全网节点共识之后才会真正确认,状态修改才会生效。

如果视图函数在一个会修改状态的函数中调用,那么视图函数会消耗 Gas 的。例如在以下代码的set函数调用了 cal函数:

    function set(uint a, uint b) public returns (uint) {
            return cal(a, b);
    }

此时 set 函数 的 gas 包含了 cal函数的 gas。

我们可以这样理解:外部调用试图函数时 Gas 价格为0, 而在修改状态的函数中,Gas 价格随交易设定。

如果在声明为view的函数中修改了状态,则编译器会报错误,除直接修改状态变量外,其他如:触发事件,发送代币等都会视为修改状态。详细可参考Solidity文档。

前面提到 public 类型的状态变量,编译器会自动创建一个同名的外部视图函数(称为访问器),来获取状态变量的值。

如果状态变量的类型是值类型,自动的访问器没有参数,直接返回状态变量的值, 例如:

pragma solidity >=0.8.0;

contract C {
    uint public data = 42;
}

会生成函数:

function data() external view returns (uint) {
    return data;
}

因此,我们可以直接在外部调用合约的data()方法。

  1. 纯函数 纯函数表示函数不读取也不修改状态, 函数声明为pure 表示函数是纯函数,纯函数仅做计算, 例如:
pragma solidity >=0.5.0 <0.7.0;

contract C {
    function f(uint a, uint b) public pure returns (uint) {
        return a * (b + 42);
    }
}

变量类型

对以太坊上智能合约开发有了一些宏观的了解,现在开始探索Solidity 语言特性,我们知道Solidity 是一门静态类型语言,和常见的静态类型语言有C、C++、Java类似,需要在编码时为每个变量(本地或状态变量)指定类型。

Solidity 提供了几种基本类型,用户也可以使用基本类型组合出新的类型,如结构体。

类型分类

Solidity 类型分:

值类型

值类型变量用表示可以用32个字节表示的数据,在赋值或传参时,总是进行拷贝。

值类型包含:

引用类型

引用类型用来表示复杂类型,占用的空间超过32字节,拷贝时开销很大,因此可以使用引用的方式,通过多个不同名称的变量指向一个值。引用类型包括数组 和结构体。

在定义引用类型时,有一个额外属性来标识数据的存储位置,这个属性有:

记住一个规则:不同引用类型在进行赋值的时候,只有在不同的数据位置赋值时会进行一份拷贝,而在同一数据位置内通常是增加一个引用。

pragma solidity >=0.4.0 <0.7.0;

contract Tiny {
    uint[] x; // 状态变量 x 的数据存储位置是 storage

    function f(uint[] memory memoryArray) public {
        x = memoryArray; // 数组拷贝到storage中, 因为 memory 变量赋值给 storage。
        uint[] storage y = x;  // 仅分配一个指针(x y 指向同一个位置),
    }

}

不同的数据位置的gas消耗时不一样的:

映射类型

映射类型和Java的Map、Python的Dict在功能上差不多,它是一种键值对的映射关系存储结构,定义方式为mapping(KT => KV)。

整型

和大多数语言一样,但我们要表达一个数值时,通常用整型(这种数据类型)来表达。

uint/int

用 int/uint 表示有符号和无符号不同位数整数。支持关键字uint8到uint256 ,uint和int默认对应的是uint256 和int256。

关键字末尾的数字以8步进,表示变量所占空间大小,整数取值范围跟空间有关, 比如uint32类型的取值范围是 0 到 2^32-1(2的32次方减1)。

之前我们的Counter 合约里就定义了一个 uint 变量counter:

pragma solidity ^0.8.0;

contract Counter {
    uint public counter;
}

当没有为整型变量赋值时,会使用默认值 0。

整型运算符

整型支持的运算符包括以下几种:

几点说明:

整型变量除法总是会截断取整,但是整型常量不会截断。

整数除 0 会抛出异常。

还可以通过变量的的类型,获取的取值范围,例如:对于整形 X,可以使用 type(X).min 和 type(X).max 去获取这个类型的最小值与最大值。

提示

在 Solidity 0.8版本之前, 如果整数运算结果不在取值范围内,则会被溢出截断。

从 0.8.0 开始,算术运算有两个计算模式:一个是 unchecked(不检查)模式,一个是”checked” (检查)模式。

默认情况下,算术运算在 “checked” 模式下,即都会进行溢出检查,如果结果落在取值范围之外,调用会通过 失败异常 回退。 你也可以通过 unchecked { … } 切换到 “unchecked”模式,更多可参考文档 unchecked 。

关注Gas

pragma solidity ^0.8.0;

contract testUintGas {
    uint z1;  
    function add_high_gas(uint x, uint y) public  {
        z1 = x + y;
    }

    uint z2;
    function add_less_gas(uint x, uint y) public  {
        unchecked {
            z2 = x + y;
        }
    }

}

前面提到,当我们确定一个运算不会发生溢出时,使用 unchecked 模式,有更高的 GAS 效率。

整型是合约开发中使用最多的类型,使用也简单,当很多人容易忽视整型运算的安全问题(溢出,截断等),同时要注意在确定安全的情况下,使用 unchecked 来优化 GAS 消耗。

地址类型

账户与地址

Solidity 合约程序里,使用地址类型来表示我们的账号,如下在合约中,获取了用户地址,保存在地址类型(address)中:

contract testAddr { 
  address public user;
    function getUserAddress() public {
        user = msg.sender;
    }
}

地址类型有两种:

需要注意的是:

那为什么要使用 address 和 address payable 两种类型呢?

如果不做区分,当我们把 ETH 转到一个地址上时,恰巧如果后者是一个合约地址(即合约账户)又没有处理ETH的逻辑,那么 ETH 将永远锁死在该合约地址上,任何人都无法提取和使用它。

因此,需要做此区分,显示的表达,一个地址可以接受ETH, 表示其有处理ETH的逻辑(EOA 账户本身可转账ETH)。

address 和 address payable 两种类型尽管格式一样,但address payable拥有的两个成员函数transfer和send (address 没有这两个方法),transfer和send 的作用是向该地址转账,下文会进一步介绍。

在编写合约时,大部分时候,使用address就好,当需要向地址转账时,可以使用以下代码把address 转换为address payable

address payable ap = payable(addr);

若被转换的地址是一个是合约地址时,则合约需要实现了接收(receive)函数或payable回退函数.如果转换的合约地址上没有接收或 payable 回退函数(https://decert.me/tutorial/solidity/solidity-basic/receive),可以使用这个魔法payable(address(addr)) , 即先转为普通地址类型,在转换为address payable类型 。

地址类型上支持哪些操作

地址比较

地址类型支持的类似整型的比较运算:==(两个地址相同)、!=(两个地址不相同), 例如:

    function _onlyOwner() internal view {
        require(owner() == msg.sender, "调用者不是 Owner");
        _;
    }

    function transferOwnership(address newOwner) public onlyOwner {
        require(newOwner != address(0), "新的 Owner 不可以是 零地址");
          /// ....
    }

地址类型还支持其他介个运算: <=、<、>= 以及 > 。

对地址转账及获取地址余额

地址类型还有一些成员函数属性及函数,因此地址类型在表现上还类似面向对象语言的中的类(内置类), 最常使用的是余额属性与转账函数:

  1. addr.balance 属性 : 返回地址的余额, 余额以wei为单位 (uint256)。

  2. addr_payable.transfer(uint256 amount) : 用来向地址发送amount数量以太币(wei),transfer 函数只使用固定的 2300 gas , 发送失败时抛出异常。

  3. addr_payable.send(uint256 amount) returns (bool): send 功能上和transfer 函数一样,同样使用固定的 2300 gas , 但是在发送失败时不抛出异常,而是返回false。

pragma solidity ^0.8.0;

contract testAddr {
   
   // 如果合约的余额大于等于10,而x小于10,则给x转10 wei
    function testTrasfer(address payable x) public {
       address myAddress = address(this);
       if (x.balance < 10 && myAddress.balance >= 10) {
           x.transfer(10);
       }
    }
}

上面代码的 address myAddress = address(this); 就是把合约转换为地址类型,然后用.balance获取余额, 再使用 .transfer 向 x 转账。

send 和transfer 函数只使用 2300 gas,在对合约地址转账时,会调用合约上的函数,很容易因 gas 不足而失败,一个推荐的转账方法是:

function safeTransferETH(address to, uint256 value) internal {
    (bool success, ) = to.call{value: value}(new bytes(0));
    require(success, 'TransferHelper::safeTransferETH: ETH transfer failed');
}

提炼本节的重点:Solidity 合约程序里,使用地址类型address来表示的账号, 合约和普通地址,都可以用address 类型表示。

在地址类型上用.balance获取该地址的余额, 使用 .transfer / .send向该地址转账。

合约类型

每一个合约,合约本身也是一个数据类型, 称为合约类型,如下代码定义了一个Hello合约类型:

pragma solidity ^0.8.0;

contract Hello {
  function sayHi() public view returns  (uint) {
      return 10;
  }
}

使用合约类型

我们要如何使用合约类型呢,我们可以通过合约类型创建出一个合约事例(即部署一个合约)。

这里是一个例子:

pragma solidity ^0.8.0;

contract Hello {
  function sayHi() public view returns  (uint) {
      return 10;
  }
}

contract HelloCreator {
    uint public x;
    Hello public h;

    function createHello() public returns (address) {
        h = new Hello();
        return address(h);
  }
}

上面的代码,调用 HelloCreator 合约的 createHello 函数可以创建一个合约(new Hello())。

我们在 Remix 演练一下,先部署HelloCreator 合约(注意不是部署Hello):

然后调用createHello 在链上创建一个Hello合约:

右下角的日志中,可以看到创建的合约地址0x93Ff8fe9BF4005…。让我们在Remix 加载该合约,并调用 sayHi 来验证该合约确实部署成功了。

在 Remix 使用 Hello的地址加载Hello, 选择Hello合约, 在At Address 处填入合约地址,如图:

然后调用sayHi() :

createHello 函数中,创建的合约赋值给了状态变量 h , 在 HelloCreator 合约,也可以利用h来调用sayHi 函数, 例如,可以在HelloCreator 合约中,添加如下函数:

function callHi() public returns (uint) {
    x = h.sayHi();
    return x;
}

合约类型元数据成员

Solidity 从 0.6 版本开始,Solidity 增加了一些属性来获取合约类型类似的元信息。

如:对于合约C,可以通过type(C)来获得合约的类型信息,这些信息包含以下内容:

额外知识点:如何区分合约及外部地址

经常需要区分一个地址是合约地址还是外部账号地址,区分的关键是看这个地址有没有与之相关联的代码。EVM提供了一个操作码EXTCODESIZE,用来获取地址相关联的代码大小(长度),如果是外部账号地址,则没有代码返回。因此我们可以使用以下方法判断合约地址及外部账号地址。

function isContract(address addr) internal view returns (bool) {
  uint256 size;
  assembly { size := extcodesize(addr) }
  return size > 0;
  }

如果是在合约外部判断,则可以使用web3.eth.getCode()(一个Web3的API),或者是对应的JSON-RPC方法——eth_getcode。getCode()用来获取参数地址所对应合约的代码,如果参数是一个外部账号地址,则返回“0x”;如果参数是合约,则返回对应的字节码,下面两行代码分别对应无代码和有代码的输出。

>web3.eth.getCode(“0xa5Acc472597C1e1651270da9081Cc5a0b38258E3”) 
“0x”
>web3.eth.getCode(“0xd5677cf67b5aa051bb40496e68ad359eb97cfbf8”) “0x600160008035811a818181146012578301005b601b6001356025565b8060005260206000f25b600060078202905091905056” 

这时候,通过对比getCode()的输出内容,就可以很容易判断出是哪一种地址。

数组类型

和大多数语言一样, 在一个类型后面加上一个[],就构成一个数组类型,表示可以存储一组该类型的值。

数组类型是一个引用类型,在申明一个引用类型的变量,需要指定该变量的位置。

定义数组类型变量

数组类型有两种:固定长度的数组和动态长度的数组, 如:

contract testArray {
    // 状态变量缺省位置为 storage 
    uint [10] tens; // 固定长度的数组
    uint [] numbers;  // 动态长度的数组
    
    // 作为参数,使用 calldata 
    function copy(uint[] calldata arrs) public {
        numbers = arrs;  //  赋值时,不同的数据位置的变量会进行拷贝。 
    }
    
    // 作为参数,使用 memory 
    function handle(uint[] memory arrs) internal {
    }
}

若元素类型为T,声明为T [k], 表示固定长度为k的数组,类似的还可以有:address [10] admins, 此时 admins 最多有10个地址。 若元素类型为T,声明为T [], 表示动态长度的数组,类似的还可以有: address [] admins。

数组类型初始化

可以在数组声明时进行初始化:

contract testArray {
    uint [] public u = [1, 2, 3];
    string[4] adaArr = ["This", "is", "an", "array"];
}

数组还可以用new关键字进行声明,创建基于运行时长度的内存数组,实例如下:

contract testArray {
    uint[] arr1 = new uint[](1);

  // 函数内
    function test(uint len) public {
        // 在内存中,
        uint[] memory c = new uint[](len);      
        string[4] memory adaArr = ["This", "is", "an", "array"];
    }

}

使用 new 创建内存数组时,会根据长度在内存中分配相应的空间。

但是如果变量是在存储中(如 arr1),则表示分配一个起始空间,在之后运行过程中可以扩展该空间。

数组访问

数组通过下标进行访问,序号是从0开始的。例如,访问第1个元素时使用tens[0],对某元素赋值,即tens[0] = 1, 固定长度的数组只能通过下标访问方式赋值。

Solidity 也支持多维数组。例如,声明一个类型为uint、长度为5的变长数组(5个元素都是变长数组),则可以声明为uint[][5]。要访问第3个动态数组的第2个元素,使用x[2][1]即可。访问第三个动态数组使用x[2],数组的序号是从0开始的,序号顺序与定义相反。

注意,定义多维组和很多语言里顺序不一样,如在Java中,声明一个包含5个元素、每个元素都是数组的方式为int[5][]。

数组访问器

public 状态变量,编译器会帮我们生成访问器函数, 如果是public的数组变量,生成访问器函数有一个参数,参数是访问数组的下标索引。

编译器会生成类似的函数:

  function arr(uint i) external view returns (uint) {
      return arr[i];
  }

我们可以调用 arr(uint i) 函数获得某个元素的值。

一维数组的访问器函数有一个参数, 如果是多维数组,会有多个参数。 并且返回数组的一个元素。

如果我们要返回整个数据, 需要额外添加函数,如:

  // 返回整个数组
  function getArray() external view returns  (uint[] memory) {
      return arr;
  }

数组成员

数组类型可以通过成员属性内获取数组状态以及可以通过成员函数来修改数组的状态,这些成员有:

数组切片

如果数组是在calldata 数据位置,可以使用数组切片来获取数组的连续的一个部分。

用法是:x[start:end] , start 和 end是uint256类型(或结果为uint256的表达式),x[start:end] 的第一个元素是x[start], 最后一个元素是x[end - 1]。start和end都可以是可选的:start默认是0,而end默认是数组长度。 如果start比end大或者end比数组长度还大,将会抛出异常。

如使用以下方法获得了函数选择器。

contract testArr {
    function forward(bytes calldata payload) external {
        bytes4 sig = bytes4(payload[:4]);  // 获得函数选择器
    }
}

关注数组 Gas 消耗

使用数组看起来很简单,大多数语言用法几乎,在其他语言中,我们不太关注执行的效率,但在智能合约中效率问题会突出,你能看出下面代码有什么问题吗?

contract testArray {
    uint [] numbers;
    uint total;

    function addItem(uint x) public {
        numbers.push(x);
      }

    function sum() public {
        uint len = numbers.length;
        for (uint i = 0; i < len; i++) {
            total += numbers[i];
        }
    }
}

分析问题:sum() 函数的gas消耗是随着numbers 元素线性增长的,如果numbers 元素非常多,sum() 消耗 gas 会超过区块 gas 限制而无法执行。

常见的解决方法有:

contract testArray {
    uint [] numbers;
    uint total;
    uint calced;  // 保存计算的到哪个位置了
    
    function sum(uint end) public {
        if (end > calced) {
            for (uint i = calced; i < end; i++) {
                total += numbers[i];
            }
            calced = end;
        }
    }
}

再次提醒, 在使用数组时,一定要避免数组遍历出现 gas 问题。

登链社区的 Solidity 专栏 有更多关于列表、数组的 gas 使用技巧。

如何高效移除数组元素

首先,如非必要,不建议删除数组的元素。

如果一定要删除元素,那么要避免元素的移动, 而是把最后一个元素移动到删除元素那个位置, 例如:

    // 移除元素推荐操作
    function remove(uint index) public {
        uint len = numbers.length;
        if (index == len - 1) {
            numbers.pop();
        } else {
            numbers[index] = numbers[len - 1];
            numbers.pop();
        }
    }

string 和 bytes

还有两个特殊的数组类型:string 和 bytes 。

string 是一个字符串,可以认为是一个字符数组, string 不支持数组的 push pop 方法。

bytes 是动态分配大小字节的数组,类似于byte[],但是bytes的gas费用更低。bytes 也可以用来表达字符串, 但通常用于原始字节数据。bytes 支持数组的 push pop 方法。

string 和 bytes 的声明几乎是一样的,形式如下:

contract testStringBytes {
    bytes bs;
    bytes bs0 = "12abcd";
    bytes bs1 = "abc\x22\x22";   // 十六进制数
    bytes bs2 = "Tiny\u718A";   // 718A为汉字“熊”的Unicode编码值

    string str1 = "TinyXiong";

    string name;
    function setName(string calldata _name) public {
        name = _name;
    }
}

注意:bytes和string 都不支持用下标索引进行访问某个元素。

字符串s通过bytes(s)转为一个bytes,通过下标访问bytes(s)[i]获取到的不是对应字符,而是获取对应的UTF-8编码。比如中文的编码是变长的多字节,因此通过下标访问中文字符串得到的只是其中的一个编码。

如果使用一个长度有限制的字节数组,应该使用一个bytes1到bytes32的具体类型,因为它们占用空间更少,消耗的gas更低。

Solidity 语言本身提供的string功能比较弱,并没有提供一些实用函数,如获取字符串长度、获得子字符串、大小写转换、字符串拼接等函数。这些功能有第三方的库实现,在使用时,我们要心理有数:Solidity 处理字符串是gas不够高效的。

结构体

结构体定义

Solidity 使用 struct 关键字来定义一个自定义组合类型, 例如我们定义一个Person 结构体:

struct Person {
    address account;
    bool gender;
    uint8 age;
}

Person 包含了3个成员,同时需要为每个成员定义其类型。 除可以使用基本类型作为成员以外,还可以使用数组、结构体、映射作为成员, 下面是一个更复杂的定义:

struct School {
    Person[] cts;
    mapping(uint=>Person) indexs;
}


struct Student {
    string name;
    mapping(string=>uint) score;
    int age;
}

当时,不能在声明一个结构体的同时将自身结构体作为成员,如以下代码无法通过编译:

struct Person {
    address account;
    bool gender;
    uint8 age;
    Person child;  //  错误
}

原因是这样的:EVM 会为结构体的成员会分配在一个连续的存储空间,如果结构体包含了自身, EVM 就无法确定存储空间的大小。

但是如果结构体有数组成员是结构体自身或 映射的值类型是结构体自身,是合法的定义(尽管编写程序时强烈不推荐这么做),如以下定义是合法的:

struct Person {
        address account;
        bool gender;
        uint8 age;
        mapping(string=>Person) childs;  // 或  Person[]  manyChilds; 
    }

这个是为什么呢?这个是因为变长的数据会单独分配存储槽(而不是连续的方式存储), 在结构体中变长的数据只会有一个固定的存储槽来保存数据指向位置。因此当结构体用有一个变长的数据(即使包含自身)也不会影响 EVM 为结构体分配存储空间。

结构体变量声明与赋值

结构体是一个引用类型, 因此我们在声明变量的时候,需要标识变量的存储位置。

结构体变量声明及赋值有以下几个方式。

pragma solidity ^0.8.0;
contract testStruct {
  struct Person {
    address account;
    bool gender;
    uint8 age;
  }
  
  // 声明变量而不初始化
  Person person;   // 默认为storage
}

来源链接

学习 SoliditySolidity 基础结构体 结构体 结构体定义 Solidity 使用 struct 关键字来定义一个自定义组合类型, 例如我们定义一个Person 结构体:

struct Person { address account; bool gender; uint8 age; }

Person 包含了3个成员,同时需要为每个成员定义其类型。 除可以使用基本类型作为成员以外,还可以使用数组、结构体、映射作为成员, 下面是一个更复杂的定义:

struct School { Person[] cts; mapping(uint=>Person) indexs; }

struct Student { string name; mapping(string=>uint) score; int age; }

当时,不能在声明一个结构体的同时将自身结构体作为成员,如以下代码无法通过编译:

struct Person { address account; bool gender; uint8 age; Person child; // 错误 }

原因是这样的:EVM 会为结构体的成员会分配在一个连续的存储空间,如果结构体包含了自身, EVM 就无法确定存储空间的大小。

但是如果结构体有数组成员是结构体自身或 映射的值类型是结构体自身,是合法的定义(尽管编写程序时强烈不推荐这么做),如以下定义是合法的:

struct Person { address account; bool gender; uint8 age; mapping(string=>Person) childs; // 或 Person[] manyChilds; }

这个是为什么呢?这个是因为变长的数据会单独分配存储槽(而不是连续的方式存储), 在结构体中变长的数据只会有一个固定的存储槽来保存数据指向位置。因此当结构体用有一个变长的数据(即使包含自身)也不会影响 EVM 为结构体分配存储空间。

结构体变量声明与赋值 结构体是一个引用类型, 因此我们在声明变量的时候,需要标识变量的存储位置。

结构体变量声明及赋值有以下几个方式。

(1)仅声明变量而不赋值,此时会使用默认值创建结构体变量,例如:

pragma solidity ^0.8.0;
contract testStruct {
  struct Person {
    address account;
    bool gender;
    uint8 age;
  }
  
  // 声明变量而不初始化
  Person person;   // 默认为storage
}
// 只能作为状态变量这样使用
Person person = Person(address(0x0), false, 18) ;
// 在函数内声明
Person memory person = Person(address(0x0), false, 18) ;

赋值时需要注意参数的类型、顺序的匹配。

使用具名方式可以不按成员定义的顺序赋值:

// 使用具名变量初始化
Person person = Person({account: address(0x0), gender: false, age: 18}) ;

//在函数内声明
Person memory person =  Person({account: address(0x0), gender: false, age: 18}) ;
    Person person;
    // 在函数内
    function updatePersion() public {
        person.account = msg.sender;
        person.gender = true;
        person.age = 12;
    }

映射

映射类型是一种键值对的映射关系存储结构, 在功能上和Java的Map、Python的Dict差不多。

映射是一种使用非常广泛的类型,经常在合约中充当一个类似数据库的角色,比如在代币合约中用映射来存储账户的余额,在游戏合约里可以用映射来存储每个账号的级别,如:

mapping(address => uint) public balances;
mapping(address => uint) public userLevel;

映射的定义为mapping(KeyType => ValueType), KeyType 表示键的类型,ValueType 表示值的类型。

我们可以通过键来获取到对应的值,例如:balances[userAddr] 用来获取某个地址的余额,访问形式很类似于通过下标来获取某个数组元素的值。

类似的,给某个键赋值也是一样,下面是一段示例代码:

pragma solidity >=0.8.0;

contract testMapping {
    mapping(address => uint) public balances;

    function update(uint newBalance) public {
        balances[msg.sender] = newBalance;
    }
    
    function get(address key) public view returns(uint) {
        return balances[key];
    }
}

映射特性与限制

  1. 映射变量只能保存在存储中(storage),通常作为状态变量。
pragma solidity >=0.8.0;

contract testMapping {
    mapping(address => uint)  balances;   // 正确, 默认为 storage

    function init(uint newBalance) public {
            mapping(address => uint) memory balances;   // 错误, 不可以为 memory
    }
}
  1. 键类型有一些限制,仅支持Solidity内置值类型、bytes、string 、合约或枚举,不可以是复杂类型, 如:映射、变长数组、结构体。值的类型是没有任何限制,可以为任何类型。
pragma solidity >=0.8.0;
contract MappingExample {
      struct Funder {
        address addr;
        uint amount;
    } 

    mapping (uint => Funder) idFunders;
    mapping (Funder => uint) funderIds;     // 错误, Key 不可以是结构体
}
  1. Solidity 里的映射是没有长度的,也没有键集合或值集合的概念,因此是没法对映射进行遍历。
pragma solidity >=0.8.0;
contract MappingExample {
    mapping(address => uint)  balances; 

      function length() public view returns(uint) {
        return balances.length;  // 错误
    }
}
  1. 映射是可以嵌套的, 嵌套映射是指映射的 Value 是另一个映射, 例如:
contract testMapping {
    mapping(address => mapping(address => uint)) tokenBalances; 
}

例如,我们一个合约里存了多种 Token, 我们可能就需要使用如上 tokenBalances 来保存每个用户在每个 token 上的余额。

映射访问器

对于状态变量标记为public的映射类型,编译器生成的访问器和数组一致,参数是键类型,返回值类型。

mapping (uint => uint) public idScore;

会类似这样的访问器函数:

function idScore(uint i) external returns (uint) {
   return idScore[i];
}

一个稍微复杂一些的例子,以下是一个嵌套映射 :

pragma solidity >0.8.0;
contract Complex {
    struct Data {
        uint a;
        bytes3 b;
        mapping (uint => uint) map;
    }
    mapping (uint => mapping(bool => Data[])) public data;
}

public 的 data 变量会生成以下访问器函数:

function data(uint arg1, bool arg2, uint arg3) external returns (uint a, bytes3 b) {
    a = data[arg1][arg2][arg3].a;
    b = data[arg1][arg2][arg3].b;
}

Solidity 数组 vs 映射

有时候,我们既可以使用数组存数据,有可以使用映射。

Solidity 数组更适合数据迭代(例如,使用 for 循环),而基于一个已知的键来获取值时,映射更适合(即不需要迭代获得数据)。

与从映射中获取数据相比,在 Solidity 中对数组进行迭代相对来说Gas消耗更大,而且尽量不要让数组太大。

可迭代映射

有时候,可能希望在智能合约中对映射进行迭代或者计算映射长度,这时可以可以创建一个键的数组,例如:

pragma solidity >=0.8.0;

contract IterableMapping {
    mapping(address => uint) public balances; 
    address[] users;
    

      function length() public view returns(uint) {
        return users.length; 
    }

    function insert(address key, uint value) public {
        balances[key] = value;
        users.push(key);
    }

}

Solidity 中有一个更复杂的可迭代的映射的例子。

不过这种实现的可迭代映射, Gas 成本较高,还有另一个方式是使用 mapping 来实现一个链表,用链表来保存下一个元素来进行迭代(我比较推荐的实现)。

pragma solidity >=0.8.0;

contract IterableMapping {
    mapping(address => uint) public balances; 
    mapping(address => address) public nextUser; 
    
    address constant GUARD = address(1);
    
    // 如果需要长度的话
    uint public listSize;
    
    function insert(address key, uint value) public {
        balances[key] = value;
        
        // 元素插入链表
        nextUser[key] = nextUser[GUARD];
        nextUser[GUARD] = key;
        listSize ++;
    }
    
}

合约如何接受

我们要把合约的ETH 转出是很容易的,我们在地址一节已经介绍过。

而当我们要向合约里转入ETH时,情况比我们想象的复杂一些,被转入的合约需要明确表达其可以接收 ETH,以反正因合约没有处理的ETH的,导致ETH永远锁死在合约中。

提示: ERC20 代币向合约转账时,并没有对合约经常类似的检查,因此也时不时会发生 ERC20 因误转入合约而锁死。

可以在合约中明确声明两个函数来表示合约时可以接收 ETH, 他们是 receive 函数和 fallback函数。

receive 函数和 fallback函数都是在转账时被动调用的,通常称为:回调函数,表示有转账了,告诉我(合约)一下。

receive函数(接收函数)

合约的 receive(接收)函数是一种特殊的函数,专门用来表示合约可以接收以太币,接收函数的声明为:

receive() external payable { 

}

函数名只有一个receive关键字,而不需要function关键字,也没有参数和返回值,并且必须是 external可见性和payable修饰。

一个合约最多有一个接收函数。

在Remix验证一下, 部署以下合约:

contract testPayable {
    event Received(address, uint);
    receive() external payable {
            emit Received(msg.sender, msg.value);
    }  
}

请注意,这个合约仅验证接收以太币,他们没有转出的逻辑,因此,所有发送给它的以太币,都没有办法取回。

部署后,testPayable 合约的余额为0 , 在 Remix 如何给合约地址转账呢?

介绍一个技巧,在以太坊核心概念中,转账交易与调用合约函数的差别在于有没有附加data数据,若data为空即是转账。在Remix 中,提供了一个底层交易方法,可以输入任意的附加data数据, 因此我们只要在这个底层交易不填入附加data数据就可以实现向合约转账, 因此只要如下图操作,就可以实现转账:

执行后,合约的余额,将变更为1 ETH:

大家可以做一个对比验证,如果testPayable 合约没有实现receive函数,此时转账交易会抛出异常,合约无法接收ETH。

当对合约进行转账时(不是使用MetaMask钱包转账,还是合约中使用addr.send()或者addr.transfer()对合约转账),合约在收到ETH时会执行receive函数。

若是使用addr.send()或者addr.transfer()对合约转账,EVM在执行 transfer 和 send 函数只使用固定的 2300 gas, 这个gas 基本上只够receive函数输出一条日志,如果receive函数有更多逻辑,就需要使用底层调用call对合约转账:

function safeTransferETH(address to, uint256 value) internal {
    (bool success, ) = to.call{value: value}(new bytes(0));
    require(success, 'TransferHelper::safeTransferETH: ETH transfer failed');
}

特别要说明的是,以下操作的消耗会大于2300 gas。

-(3)执行一个外部函数调用,会花费比较多的gas;

-(4)发送以太币。

合约需要定义 receive 函数才能接收以太币,是在通常我们处理的转账情况。

有一些例外,即便合约没有定义 receive 函数, 验证者的出块和交易奖励依旧可以打入到该合约。另外在销毁合约时(selfdestruct)被销毁合约的ETH需要转到另一个地址,或后者是合约,也不要求定义 receive 函数。

fallback函数 (回退函数)

和接收函数类似,fallback函数也是一个特殊的函数,中文一般称为“回退函数”。

如果用户对合约进行调用时,合约中没有找到用户要调用的函数, fallback 函数就会被调用(可以理解为最终回退到这个函数)。

同样的,若是对合约进行 ETH 转账,而合约又没有实现receive函数,也会回退到 fallback 函数(不过此时要求fallback函数需要能接收ETH, 有 payable 修饰)。

fallback函数的声明如下:

fallback() external payable { ... }

注意,在solidity 0.6里,回退函数是一个无名函数(没有函数名的函数),如果你看到一些老合约代码出现没有名字的函数,不用感到奇怪,它就是回退函数。

和接收函数类似,一个合约最多有一个fallback函数,这个函数无参数,也无返回值,也没有function关键字, 必须是external可见性。

下面的这段代码可以帮助我们进一步理解receive函数与fallback函数。

pragma solidity >= 0.8.0;
contract Test {
    // 发送到这个合约的所有消息都会调用此函数(因为该合约没有其它函数)
    // 向这个合约发送以太币会导致异常,因为fallback函数没有 payable 修饰符
    fallback() external { x = 1; }
    uint x;
}


// 这个合约会保留所有发送给它的以太币,没有办法返还
contract TestPayable {
    // 除了纯转账外,所有的调用都会调用这个函数
    // 因为除了receive函数外,没有其他的函数
    // 任何对合约非空calldata调用会执行回退函数(即使是调用函数附加以太)
    fallback() external payable { x = 1; y = msg.value; }
    // 纯转账调用这个函数,例如对每个空empty calldata的调用
    receive() external payable { x = 2; y = msg.value; }
    uint x;
    uint y;
}

contract Caller {

    function callTest(Test test) public returns (bool) {
        (bool success,) = address(test).call(abi.encodeWithSignature("nonExistingFunction()"));
        require(success);
        //  test.x结果变成 == 1
        // address(test)不允许直接调用send, 因为test没有payable回退函数
        //  转化为address payable类型 , 然后才可以调用send
        address payable testPayable = payable(address(test));
        // 以下这句将不会编译,但如果有人向该合约发送以太币,交易将失败并拒绝以太币
        // test.send(2 ether);
    }
    function callTestPayable(TestPayable test) public returns (bool) {
        (bool success,) = address(test).call(abi.encodeWithSignature("nonExistingFunction()"));
        require(success);
        // test.x结果为 1,test.y结果为0
        (success,) = address(test).call{value: 1}(abi.encodeWithSignature("nonExistingFunction()"));
        require(success);
        // test.x结果为1,test.y结果为1
        // 发送以太币,TestPayable的receive函数被调用
        require(address(test).send(2 ether));
        // test.x结果为2,test.y结果为2 ether
    }
}

以上代码,使用了地址上的底层调用,来模拟调用不存在的函数,这部分内容将在 地址高阶用法 进一步介绍。

再次提醒,当使用合约中使用send和transfer向合约转账时,EVM 仅会提供 2300 gas来执行, 如果receive或fallback函数的实现需要较多的运算量,会导致转账失败。

合约函数接受以太币(payable)

有时我们希望调用某个合约函数时,把以太币转给合约,这个时候我们只需要在合约函数上添加一个 payable 修饰符。

例如,我们要实现一个Bank, 用户调用deposit() 把 ETH 存入合约Bank, 调用withdraw 从合约取出自己的 ETH。

contract Bank {
    mapping(address => uint) public deposits;
    
    function deposit() public payable {
        deposits[msg.sender] += msg.value;
    }
    
    
    // 从合约取款
    function withdraw() public {
        uint d = deposits[msg.sender];
        deposits[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value:  d}("");
        require(success, "Failed to send Ether");
    }
}

此时我们可以不需要要合约中实现 receive 或 fallback 函数

总结

提炼本节的重点:这一节,我们介绍了合约如何接收 ETH,理解了 receive 和 fallback 函数的作用,以及payable 修饰符的作用,可以总结为一下几句话:

函数修改器

修改器作用

函数修改器可以用来改变一个函数的行为,比如用于在函数执行前检查某种前置条件。

函数修改器使用关键字 modifier , 以下代码定义了一个 onlyOwner 函数修改器, 然后使用修改器 onlyOwner 修饰 transferOwner() 函数:

pragma solidity >=0.8.0;


contract owned {
    function owned() public { owner = msg.sender; }
    address owner;

    modifier onlyOwner {
        require(msg.sender == owner, "Only owner can call this function.");
        _;
    }


   function transferOwner(address _newO) public onlyOwner {
        owner = _newO;
    }
}

查看 onlyOwner 修改器的代码,很容易理解其作用是限定交易的发送者只能是owner 。

但我们用 onlyOwner 去修饰其他的函数时,后者也需要修改器的条件,因此对于 transferOwner() 函数来说,只有 owner 才能成功调用transferOwner()。

函数修改器的工作原理是这样的:

函数修改器一般是带有一个特殊符号 ; ; 修改器所修饰的函数的函数体会被插入到;的位置。

因此函数 transferOwner扩展开后,就是:

function transferOwner(address _newO) public {
    require(
        msg.sender == owner,
        "Only owner can call this function."
    );
    owner = _newO;
}

修改器可带参数

修改器可以接收参数,例如:

contract testModifty {

    modifier over22(uint age) {
        require (age >= 22, "too small age");
        _;
    }


    function marry(uint age) public over22(age) {
       // do something
    }
}

以上marry()函数只有满足age >= 22才可以成功调用。

多修改器一起使用

多个修改器可以一起修饰某个函数,此时会根据定义函数修改器的顺序嵌套执行。

修改器可继承

修改器也是可被继承的,同时还可被继承合约重写(Override)。例如:

contract mortal is owned {


    // 只有在合约里保存的owner调用close函数,才会生效
    function close() public onlyOwner {
        selfdestruct(owner);
    }
}

mortal合约从上面的owned继承了onlyOwner修饰符,并将其应用于close函数。

小结

函数修改器是一个语法糖,同来给修饰的函数添加一些额外的功能或检查。

常用于如:检查输入条件、权限控制、重入控制、防止重复初始化等场景。

事件

为什么需要事件

事件是以太坊上一个比较特殊的机制,以太坊虚拟机是一个封闭的沙盒环境,我们在EVM内部通过调用外部世界的接口,把信息转递给外部或从外部获得信息,因为以太坊没法对外部的信息达成共识。想象一下,你有一个智能合约,向一个网络API发出API请求,以获得一对资产的最新价格。当节点A处理一个触发这个API调用的交易时,得到响应42,并相应地更新合约状态。然后当节点B处理同样的交易时,价格发生了变化,响应是40,并相应地合约状态。然后节点C发出请求时,收到一个404的HTTP响应。当网络中的每个节点都可能对最新状态有不同的看法时,以太坊世界计算机就无法对最新状态达成共识。

那如何解决以太坊和外部世界的通信问题呢,答案是通过事件,在合约触发事件,将在链上生成日志,链下通过监听日志,获取沙盒环境内状态的变化。

因此事件(Event)是合约与外部一个很重要的接口。

使用事件

事件是通过关键字event来声明的,event 不需要实现,只需要定义其事件名和参数。

我们也可以认为事件是一个用来被监听的接口(接口同样也不需要实现)。

通过 emit 关键字可以触发事件,此时会在链上生成一个日志条目。

以下定义了一个Deposit 事件并在 deposit() 函数中触发了该事件:

pragma solidity >0.8.0;

contract testEvent {
    constructor() public {
    }
        
    event Deposit(address _from, uint _value);  // 定义事件

    function deposit(uint value) public {
        // 忽略其他的代码
        emit Deposit(msg.sender, value);  // 触发事件
    }
  }
}

获取事件

上面我们知道如何生成一个事件,接下来我们看看从外部如何获取到事件信息,通常我们有三个方法:

通过交易收据获取事件

在交易收据中,会记录交易完整的日志,如果我们知道交易的Hash,就可以通过交易收据获取事件。

JSON-RPC 提供eth_gettransactionreceipt 获取交易收集,也可以直接使用 JSON-RPC的包装库如 Web3.js 、 ethers.js 等库,Remix 已经嵌入了 Web3.js 和 ethers.js 库, 因此可以直接在Remix 控制台通过输入 web3.eth.getTransactionReceipt(hash) 获取收据,如下图:

获取到的收据信息如下:

{
    "transactionHash":"0x5bc2d1fe7d696191ab70bc14a65e90b3c5fc4156c4a1bee979d0d4c5a0a5bc36",
    "transactionIndex":0,
    "blockHash":"0x52fc5f1b701d844cc7befcddbdb6615c6dee2b37c7c3fa480bf20aef73de4213",
    "blockNumber":2,
    "gasUsed":22750,
    "cumulativeGasUsed":22750,
    "logs":[
        {
            "logIndex":1,
            "blockNumber":2,
            "blockHash":"0x52fc5f1b701d844cc7befcddbdb6615c6dee2b37c7c3fa480bf20aef73de4213",
            "transactionHash":"0x5bc2d1fe7d696191ab70bc14a65e90b3c5fc4156c4a1bee979d0d4c5a0a5bc36",
            "transactionIndex":0,
            "address":"0xd9145CCE52D386f254917e481eB44e9943F39138",
            "data":"0x0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc400000000000000000000000000000000000000000000000000000000000003e8",
            "topics":[
                "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c"
            ],
            "id":"log_456a547b"
        }
    ],
    "status":true,
    "to":"0xd9145CCE52D386f254917e481eB44e9943F39138"
}

事件触发的日志,保存记录在 logs 字段下:

[
        {
            "logIndex":1,
            "blockNumber":2,
            "blockHash":"0x52fc5f1b701d844cc7befcddbdb6615c6dee2b37c7c3fa480bf20aef73de4213",
            "transactionHash":"0x5bc2d1fe7d696191ab70bc14a65e90b3c5fc4156c4a1bee979d0d4c5a0a5bc36",
            "transactionIndex":0,
            "address":"0xd9145CCE52D386f254917e481eB44e9943F39138",
            "data":"0x0000000000000000000000005b38da6a701c568545dcfcb03fcb875f56beddc400000000000000000000000000000000000000000000000000000000000003e8",
            "topics":[
                "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c"
            ],
            "id":"log_456a547b"
        }
    ]

Logs 是一个数组,当函数触发多个事件时,Logs 就会有多条记录,每一个事件记录包含 address , topics, data 和前面浏览器中看到信息是对应的。

使用过滤器获取事件

很多时候,我们其实并不知道交易的Hash, JSON-RPC 提供了 eth_getLogs 来根据条件获取过去发生的事件。

Web3.js 对应的接口为 getpastlogs, Ethers.js 对应的接口为 getLogs

web3.eth.getPastLogs({
    address: "0xd9145CCE52D386f254917e481eB44e9943F39138",
    topics: ["0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c"]
})
.then(console.log);

获取到的日志数据和收据中Logs 字段下的数据一致。

getLogs 的参数就是要制定的过滤条件,可以按需设置如:获取某个区块高度区间里某合约地址的所有事件,获取任意合约来自某个主题事件等。

使用过滤器获取实时事件

如果实时获取当前发生的事件,可以使用 JSON-RPC 提供的 eth_subscribe 订阅方法,Web3.js 对应的接口 web3.eth.subscribe, Ethers.js 在 Provider 使用 on 进行监听。需要注意的是, 要订阅需要和节点建立Web Socket 长连接。

const web3 = new Web3("ws://localhost:8545");  

var subscription = web3.eth.subscribe('logs', {
    address: '0x123456..',
    topics: ['0x12345...']
}, function(error, result){
    if (!error)
        console.log(result);
});

Ethers.js 示例:

let provider = new ethers.providers.WebSocketProvider('ws://127.0.0.1:8545/')

filter = {
    address: "0x123456",
    topics: [
        '0x12345...' // utils.id("Deposit(address,uint256)")
    ]
}
provider.on(filter, (log, event) => {
    //  
})

JSON-RPC 的包装库也提供更高层的方法来监听事件,使用 Web3.js ,可以用合约 abi 创建合约兑现来监听 Deposit 事件方法如下:

var abi = /* 编译器生成的abi */;
var addr = "0x1234...ab67"; /* 合约地址 */
var contractInstance = new web3.eth.contract(abi, addr);


// 通过传一个回调函数来监听 Deposit
contractInstance.event.Deposit(function(error, result){
    // result会包含除参数之外的一些其他信息
    if (!error)
        console.log(result);
});

若要过滤 indexed 字段建立索引,给事件提供一个额外的过滤参数即可:

contractInstance.events.Deposit({
    filter: {_from: ["0x.....", "0x..."]}, // 过滤某些地址
    fromBlock: 0
}, function(error, event){
    console.log(event);
})

什么时候用事件

  1. 如果合约中没有使用该变量,应该考虑用事件存储数据
  2. 如果需要完整的交易历史,请使用事件

用事件存储数据

有不少刚转入Web3 的工程师,把智能合约当成数据库使用,习惯把需要用到的数据都保存在智能合约中,但最佳的实践是:如无必要,勿加存储。

倘若在合约中,没有任何函数读取该变量,我们应该使用事件来存储数据,Gas 成本降低很多。

使用事件版本的deposit() 的Gas 消耗是 22750 。

    // gas: 22750
    function deposit(uint value) public {
        emit Deposit(msg.sender, value);  // 触发事件
    }

对比看一下用映射来存储数据的版本:

contract testDeposit {

    mapping(address => uint) public deposits;
    
    // Gas: 43577
    function deposit(uint value) public {
        deposits[msg.sender] = value;
    }
}

deposit() 的Gas 消耗是 43577 。

可以看出两个版本的差别非常大。

如果仅需要在外部展示存款数据(合约中不需要读取数据),使用事件的版本和使用映射的版本可以达到相同的效果,只是前者是通过解析事件获取存款数据,后者是读取变量获取数据。

事件是“只写的数据库“

每次我们在触发事件时,这个事件的日志就会记录在区块链上,每次事件追加一条记录,因此事件实际上就是一个只写的数据库(只添加数据)。我们可以按照自己想要的方式在关系型数据库中重建所有的记录。

当然要实现这一点,所有的状态变化必须触发事件才行。

而存储状态则不同,状态变量是一个可修改的”数据库“, 读取变量获取的是当前值。

如果需要完整的交易历史,就需要使用事件。

小结

事件是外部事件获取EVM内部状态变化的一个手段。在合约内触发事件后,在外部就可以获取或监听到该事件。

使用 event 关键字定义事件,使用 emit 来触发定义的事件。在外部有三种可以获取到合约内部的事件:

  1. 通过交易收据获取事件
  2. 使用过滤器获取过去事件
  3. 使用过滤器获取实时事件

事件是很便宜的存储数据的方式,没有任何函数读取该数据,应该使用选择事件来存储,如何需要交易历史(通常是刚需),也需要使用事件把每一次状态变化记录下来。

错误处理机制

错误处理是指在程序发生错误时的处理方式,在EVM中和在程序中提到错误处理时,他们的含义并不完全相同。

EVM 中错误处理

EVM 处理错误和我们常见的语言(如Java、JavaScript等)不一样,当 EVM 在执行时遇到错误,例如:访问越界的数组,除0等,EVM 会回退(revert)整个交易,当前交易所有调用(包含子调用)所改变的状态都会被撤销,因此不是出现部分状态被修改的情况。

在以太坊上,每个交易都是原子操作,在数据库里事务(transcation)一样,要么保证状态的修改要么全部成功,要么全部失败。

程序中错误处理

在合约代码中进行错误处理,主要指的是通过各种条件的检查,针对不符合预期的情况,进行错误捕获或抛出错误。

如果在程序中抛出了错误,不论是我们程序抛出的错误,或者是出现程序未处理的情况,EVM 都会回滚交易。

如何抛出异常

Solidity 有 3 个方法来抛出异常:require() 、assert()、revert(), 我们来逐个介绍。

  1. require() require函数通常用来在执行逻辑前检查输入或合约状态变量是否满足条件,以及验证外部调用的返回值时候满足条件,在条件不满足时抛出异常。

require函数有两个形式:

  1. require(bool condition):如果条件不满足,则撤销状态更改;
  2. require(bool condition, string memory message):如果条件不满足,则撤销状态更改,可以提供一个错误消息。 以下是require 使用例子:
pragma solidity >=0.8.0;

contract testRequire {
    function vote(uint age) public {
        require(age >= 18, "只有18岁以上才卡一投票");
                // ...
    }

    function transferOwnership(address newOwner) public {
        require(owner() == msg.sender, "调用者不是 Owner");
            // ...
    }
    
}

vote() 函数要求 age >= 18(表示在18岁以上才可以投票),否则撤销交易。

transferOwnership() 函数要求调用者是owner(), 否则撤销交易。

除了代码调用 require() 不满足表达式,会抛出异常外,下面这些情况也同样会触发 require 式异常(这类异常称为Error):

当 require 式异常发生时,EVM 使用 REVERT 操作码回滚交易,剩余未使用的 Gas 将返回给交易发起者。

  1. assert()

assert(bool condition)) 函数通常用来检查内部逻辑,assert 总是假定程序满足条件检查(假定condition为true),否则说明程序出现了一个未知的错误,如果正确使用assert()函数,Solidity 分析工具(如 STMChecker 工具)可以帮我们分析出智能合约中的错误。

以下是assert 使用例子:

pragma solidity >=0.8.0 ;

contract testAsset{
    bool public inited;

    function checkInitValue() internal  {
        // inited 应该永远为false
        assert(!inited);
        // 其他的逻辑...
    }
}

除了代码调用 assert() 不满足表达式,会抛出异常外,下面这些情况也同样会触发 assert 式异常(这类异常称为Panic):

require() 还是 assert()

这些情况优先使用require():

这些情况优先使用assert():

-(5)通常用于函数中间或结尾。

  1. revert() 也可以直接调用 revert() 来撤销交易,和require() 非常类似, revert 有两种形式:
pragma solidity ^0.8.4;

contract testRevert() {
  public owner;
    error NotOwner();
    
  function transferOwnership(address newOwner) public {
     if(owner != msg.sender)  revert NotOwner();
     owner = newOwner;
  }

}

require() 和 revert() 在功能上其实是等价的,例如,以下两个写法在功能上一样:

if(msg.sender != owner) { revert NotOwner(); }
require(msg.sender == owner, "调用者不是 Owner");

但使用自定义错误消耗的 Gas 更低。

捕获异常 try/catch

在合约代码里,和其他的合约进行交互(这个称之为外部调用)是很常见的操作,如果我们不在因外部调用失败而终止我们的交易,这个时候就可以使用 try……catch……来捕获外部调用的异常。

下面是一个try/catch使用示例:

contract CalledContract {    
    function getTwo() external returns (uint256) {
        // 一些其他逻辑,也许 revert 
        return 2;
    }
}

contract TryCatcher {
    CalledContract public externalContract;

    function executeEx() public returns (uint256, bool) {
        // 外部调用 getTwo()  
        try externalContract.getTwo() returns (uint256 v) {
            uint256 newValue = v + 2;
            return (newValue, true);
        } catch {
        }
    }
}

在进行try/catch时,允许获得外部调用的返回值。

注意,try/catch 仅适用于捕获外部调用的异常,内部代码异常是无法被 catch 的,例如:

function executeEx() public {
    try externalContract.getTwo() {
        // 尽管外部调用成功了,依旧会回退交易,无法被catch
        revert();
    } catch {
       // ...
    }
}

注意本地变量newValue和返回值只在try代码块内有效。类似地,也可以在catch块内声明变量。

catch 条件子句

在 catch 语句可以捕获异常的错误提示,错误提示转换为bytes(如果由于某种原因转码bytes失败,则try/catch会失败,会回退整个交易)。

catch 也提供了不同的子句来捕获不同类型的异常, 例如:

contract TryCatcher {
    
    event ReturnDataEvent(bytes someData);
    event CatchStringEvent(string someString);
    event SuccessEvent();
    
    function execute() public {
        try externalContract.someFunction() {
            emit SuccessEvent();
        } catch Error(string memory revertReason) {
            emit CatchStringEvent(revertReason);
        } catch (bytes memory returnData) {
            emit ReturnDataEvent(returnData);
        }
    }
}

在这段代码中,如果调用 externalContract.someFunction 发生 require 式异常(如require(condition,“reason string”)或revert(“reason string”)),则错误与catch Error(string memory revertReason)子句匹配。

在任何其他情况下, 例如发生 assert 式异常,则会执行更通用的catch(bytes memory returnData)子句。

小结

本节我们学习了 EVM 处理错误的方式,如果没有不做任何处理, 当 EVM 执行代码发生错误时, 就会回退整个交易。

为了让程序对外部调用者更友好,我们可以使用 require revert asset 来检查各种可能的错误,并给出相应的错误提示。

通过当我们的程序调用外部函数时,也可以用 try/catch 来捕获外部调用可能发生的错误。

继承

理解继承

继承是面向对象编程中的重要概念之一,它允许一个类(称为子类或派生类)从另一个类(称为父类或基类)继承属性和方法。

Solidity 也支持继承, 当然这里对应的是派生合约(或称子合约)及父合约。

派生合约通过继承获得了父合约的特性,合理的使用继承可以带来这些好处:

  1. 代码更好重用:派生合约可以直接获得父合约的属性和方法,不需要重复编写相同的代码。
  2. 方便扩展:在继承的基础上添加新的属性和方法,更方便扩展和定制父合约的功能。
  3. 提高维护性和可读性:继承可以使合约之间建立清晰的层次关系,使代码更加易于维护和理解。

实际上,正是因为 Solidity 有继承的特性,我们才可以使用大量的第三方合约库(如OpenZepplin)来简化我们的开发。

如下图是一个常见继承图:

在上面的图中,ERC20 是一个父合约,MyTokenA 和 MyTokenB 是继承自 ERC20 的派生合约,它们继承了 ERC20 的属性和方法,但可以拥有自己的值和方法实现。

使用继承 Solidity 使用关键字 is 来表示合约的继承关系:

pragma solidity ^0.8.0;

contract Base {
    uint public a;
}

// highlight-next-line
contract Sub is Base {
    uint public b ;
    constructor() {
        b = 2;
    }
}

把Sub合约部署上链, 可以看到Sub合约有两个属性,其中 a 继承自 Base 合约。

派生合约会继承父合约内的所有非私有(private)成员. 因此内部(internal)函数和公开的状态变量在派生合约里是可以直接使用,派生合约也会继承 external 方法,但不能在内部访问。

需要注意的是,在部署派生合约时,父合约不会连带被部署,可以理解为,在编译时,编译器会把父合约的代码拷贝进派生合约。因此,不能在派生合约再次声明父合约中已经存在的状态变量。

不过父合约函数可以重写,派生合约可以通过重写(overide)函数来更改父合约中函数的行为。

函数重写(overriding)

只有父合约中的虚函数(使用了virtual 关键字修饰的函数)可以在派生被重写,以更改它们在父合约中的行为,重写函数需要使用关键字override修饰表示正在重写父合约的某个函数。

以下一段代码,试一试调用重写的foo 之后,a 的结果是

pragma solidity >=0.8.0;

contract Base {
    uint public a;
    function foo() virtual public {
        a += 2;
    }
}

contract Sub is Base {
   // highlight-next-line
      function foo() public override {
          a += 1;
      }
}

a 的结果是1、2 还是 3 呢?

结果是 1 ,是因为当函数被重写后,父合约的函数就会被遮蔽。

重写的函数也可以再次重写,当需要被重写时,需要使用 virtual 来修饰, 例如:

contract Sub is Base {
      function foo() public virtual override {
          a += 1;
      }
}

contract Inherited is Sub {
      function foo() public virtual override {
          a += 3;
      }
}

使用 super 调用父合约函数

刚才我们说,当函数被重写后,父合约的函数就会被遮蔽。

有时候,我们会现在父合约函数的基础上,添加一些实现,要如何做呢?

我们可以在重写的函数中显式的用 super调用父合约的函数,例如:

pragma solidity >=0.8.0;

contract Base {
    uint public a;
    function foo() virtual public {
        a += 2;
    }
}

contract Sub is Base {

      function foo() public override {
        super.foo(); // 或者 Base.foo();
          a += 1;
      }
}

继承下构造函数

构造函数的处理与函数重写处理的方式不一样,当派生合约继承父合约时,如果父合约实现了构造函数,在部署派生合约时,父合约的构造函数也会执行。

这是一个例子:

contract Base {
    uint public a;
    constructor()  {
        a = 1;
    }
}


contract Sub is Base {
    uint public b ;
    constructor()  {
        b = 2;
    }
}

在部署 Sub 合约后,可以查看到 a 为1,b 为2。

说明父合约的构造函数被自动执行了,同时我们也可以做一些验证,会得到结论:父合约的构造函数代码会先调用而后调用派生合约的构造函数。

刚才父合约构造函数是没有参数的,可以自动执行,如果有参数呢?如何自动执行?如何给对父合约构造函数传参呢?

有两种方法:

  1. 在继承父合约的合约名中指定参数

示例代码如下:

contract Base {
    uint public a;
    constructor(uint _a) {
        a = _a;
    }
}

contract Sub is Base(1) {
    uint public b ;
   constructor() {
     b = 2;
   }
}

代码 contract Sub is Base(1) 对Base 构造函数传参进行初始化。

  1. 在派生构造函数中使用修饰符方式调用父合约

示例代码如下:

contract Sub is Base {
   uint public b ;

   constructor() Base(1)  {
        b = 2;
    }
}

或者是:

    constructor(uint _b) Base(_b / 2)  {
        b = _b;
    }

此时利用部署合约Sub的参数,传入到合约 Base 中。

多重继承

Solidity也支持多重继承,即可以从多个父合约继承,直接在is后面接多个父合约即可,例如:

contract Sub is Base1, Base2 {
    
}

如果多个父合约之间也有继承关系,那么 is 后面的合约的书写顺序就很重要,正确的顺序应该是:父合约在前,子合约在后,例如:下面的代码将无法编译:

contract X {}
contract A is X {}
// 编译出错
contract C is A, X {}

在多重继承下,如果有多个父合约有相同定义的函数,在函数重写时,override 关键字后必须指定所有的父合约名。

示例代码如下:

pragma solidity >=0.8.0;

contract Base1 {
    function foo() virtual public {}
}

contract Base2 {
    function foo() virtual public {}
}

contract Inherited is Base1, Base2 {
    // 继承自隔两个父合约定义的foo(), 必须显式的指定override
    function foo() public override(Base1, Base2) {}
}

抽象合约

有一些父合约,我们创建他们,只是为了在合约之间建立清晰的结构关系,而不是真实的部署这些父合约。

我们可以在这些不想被部署的合约前加上 abstract 关键字,表示这是一个抽象合约。

下面是抽象合约的示例代码:

abstract contract Base {
    uint public a;
}

抽象合约由于不需要部署,因此可以声明没有具体实现的纯虚函数,纯虚函数声明用”;“结尾,而不是”{ }“,例如:

pragma solidity >=0.8.0;

abstract contract Base {
    function get() virtual public;
}

这段代码声明了一个 get() 函数,没有函数体,这个函数需要有派生的合约来实现。

小结

本文,我们学习了继承的概念,继承的特性让我们更好重用代码,也使代码更加易于维护和理解。但也要尽量少使用复杂的多重继承。

我们只要掌握以下关键字,就知道在 Solidity 代码中如何使用继承了:

  1. is : 继承某合约
  2. abstract: 表示该合约不可被部署
  3. super:调用父合约函数
  4. virtual: 表示函数可以被重写
  5. override: 表示重写了父合约函数

接口

对于继承, 把各个合约都拥有的功能,作为统一接口在父合约里提供,让所有的子合约都可以继承。

接口(Interface)则更进一步,是一种定义了一组抽象方法的规范,接口描述了一组兑现应该具有哪些方法,但并不提供这些方法的具体实现。

接口只用来定义方法,而没有实现的方法体。抽象合约则可以有方法的实现,抽象合约可以实现一个或多个接口,以满足接口定义的方法要求。

接口的作用主要体现在以下几个方面:

  1. 规范行为:接口定义了一组方法,要求实现这个接口的合约必须提供这些方法的具体实现。通过实现接口,可以确保一组合约拥有相同的方法,并且这些方法的功能和行为是一致的,从而增强了代码的一致性和可预测性。

  2. 解耦合:接口可以将定义方法的部分与具体实现合约分离,从而实现了解耦合。因此我们可以基于接口来进行合约间的相互调用, 而不是基于实现。

接口也是合约设计中的方法规范,用于定义合约之间的协作方式,提高代码的可维护性和可读性。

使用接口

Solidity 用Interface 关键字定义接口,以下是一段示例代码定义了一个名为ICounter 的接口:

pragma solidity ^0.8.10;

interface ICounter {
    function increment() external;
}

由于接口是一组方法规范的,因此接口:

  1. 无任何实现的函数
  2. 不能继承自其他接口
  3. 没有构造方法
  4. 没有状态变量

合约可以实现一个接口:

contract Counter is ICounter {
    uint public count;

    function increment() external override {
        count += 1;
    }
}

接口中的所有方法都是隐含的 virtual 方法,因此即便没有 virtual,也可以被重写。

利用接口调用合约

软件设计中,有一个很重要的原则:依赖接口,而不是依赖实现。

假设我们链上已经部署了一个Counter合约, 合约地址为0xabcd…., 源代码文件:Counter.sol ,代码如下:

pragma solidity ^0.8.0;

contract Counter is ICounter {
    uint public count;

    function increment() external override {
        count += 1;
    }
}

如何在我们的合约里调用链上Counter合约的increment()方法呢?

import "./ICounter.sol";

contract MyContract {
    function incrementCounter(address _counter) external {
        ICounter(_counter).increment();
    }
}

ICounter(_counter).increment(); 的含义是:把合约地址 _counter 类型转化为接口ICounter类型(接口类型与合约类型一样,也是自定义类型),再调用接口内的increment() 方法。

还有一个方法是基于具体的实现合约,例如:

import "./Counter.sol";

contract MyContract {
    function incrementCounter(address _counter) external {
        Counter(_counter).increment();
    }
}

代码Counter(_counter).increment(); 的含义是:把合约地址 _counter 类型转化为合约Counter类似,再调用合约里的increment() 方法。

依赖接口与依赖实现两个方法在EVM层面没有区别,最终都是通过合约地址找到对应的函数来执行。

但是用接口来进行合约交互时,会更明确得传递一个含义:我在调用该接口,而不管他的实现,可以有任意的合约来进行实现。另外在接口文件里,由于没有实现细节,代码会更清晰,因此我会更推荐是用接口,

调用 ERC20 合约进行转账

合约间的交互,使用非常广泛,因此,这里再举一个示例:实现一个奖励合约,给用户发放 ERC20 代币奖励。

ERC20 代币如下:

pragma solidity ^0.8.9;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20 {
    constructor() ERC20("MyToken", "MTK") {}
}

先在脑海里想一想如何实现?

我们可以通过 IERC20 接口调用MyToken 的 transfer 方法,把奖励合约中的MyToken发送给用户。

代码可以这样写:

contract Award {
  IERC20 immutable token;
  // 部署时传入 MyToken 合约地址
  constrcutor(address t) {
     token = IERC20(t);
  }

  function sendAward(address user) public {
     token.transfer(user, 100);
  }
}

sendAward函数用来发送奖金,当然需要需要在 Award 合约创建好之后,向 Award 转入一些MyToken。

接口是一组抽象方法的规范,在合约间相互调用时,应该依赖接口,而不是依赖实现。

接口使用 interface 来定义,接口也是一个类型,在合约间相互调用时,我们把地址(合约实例)转化为接口,再调用相应的函数。

理解库

在软件开发中,有两个方式来实现代码的重用,一个是继承,一个是组合。库(Library)就是通过组合的方式来实现代码的复用。

下面的图示了继承和组合的区别:

继承表示“是” (is) , 如猫/狗(派生类/合约)是 动物(父类/合约)。

组合表示“有” (has), 如猫/狗有四条腿。

库(Library)是一组预先编写好功能模块的集合,使用库可提高开发效率,并且一些知名库经过多次审计及时间考验,使用他们他们也可以提高代码质量。

我们常说要避免重复造轮子,轮子很多时候指的就是各种库。

使用库

库使用关键字library来定义,例如,下面的代码定义了一个Math库。

pragma solidity ^0.8.19;

library Math {
    function max(uint256 a, uint256 b) internal pure returns (uint256) {
        return a > b ? a : b;
    }

    function min(uint256 a, uint256 b) internal pure returns (uint256) {
        return a < b ? a : b;
    }
}

Math 库封装了两个常用方法,max() 用来获取最大值,min() 用来获取最小值,这是库最典型的用法,将常用的功能封装起来,以便在多个不同的合约中复用。

这个是Math库,其实是 OpenZepplin Math 的简化版本。

在合约中引入库之后,可以直接调用库内的函数,参考下面的TestMax合约:

import "./Math.sol";

contract TestMax {
    function max(uint x, uint y) public pure returns (uint) {
        return Math.max(x, y);
    }
}

在使用库时,要牢记:库是函数的封装, 库是无状态的,库内不能声明变量,也不能给库发送Ether。

库有两种使用方式:一种是库代码嵌入引用的合约里部署(可以称为“内嵌库”),一种是作为库合约单独部署(可以称为“链接库”)。

内嵌库

如果合约引用的库函数都是内部函数,那么编译器在编译合约的时候,会把库函数的代码嵌入合约里,就像合约自己实现了这些函数,这时的库并不会单独部署,上面的Math库就属于这个情况, 它的代码会在 TestMax 合约编译时,加入到 TestMax 合约里。

绝大部分的库都是内嵌的方式在使用。

注意:内嵌库在合约的字节码层,是没有复用的,内嵌库的字节码会存在于每一个引入该库的合约字节码中。

链接库

如果库代码内有公共或外部函数,库就可以被单独部署,它在以太坊链上有自己的地址,引用合约在部署合约的时候,需要通过库地址把库“链接”进合约里,合约是通过委托调用的方式来调用库函数的。

下图是一个内嵌库和链接库在部署后的对比图:

在委托调用的方式下库合约函数是在发起的合约(下文称“主调合约”,即发起调用的合约)的上下文中执行的,因此库合约函数中使用的变量(如果有的话)都来自主调合约的变量(库代码不能声明自己的状态变量),库合约函数使用的this也是主调合约的地址。

把前面的Math库的add函数修改为外部函数,就可以通过链接库的方式来使用,示例代码如下:

pragma solidity ^0.8.19;

library Math {
    function max(uint256 a, uint256 b) external pure returns (uint256) {
        return a > b ? a : b;
    }

}

TestMax代码不用作任何的更改,不过因为Math库是独立部署的, TestMax合约要调用Math库就必须先知道后者的地址,这相当于TestMax合约会依赖于Math库,因此部署TestMax合约会有一点不同,需要让 TestMax合约与Math库建立链接, Solidity 开发框架会帮助我们进行链接,以Hardhat 为例,部署脚本这样写就好:

  const ExLib = await hre.ethers.getContractFactory("Math");
  const lib = await ExLib.deploy();
  await lib.deployed();

  await hre.ethers.getContractFactory("TestMax", {
    libraries: {
      Library: lib.address,
    },
  });

Using for

上面,我们通过Math.max(x, y)语法来调用库函数,还有一个语法糖是使用using LibA for B,它表示把所有LibA的库函数关联到类型B。这样就可以在B类型直接调用库的函数,代码示例如下:

contract testLib {
    using Math for uint;
    
    function callMax(uint x, uint y) public pure returns (uint) {
       return x.max(y);
    }

}

使用using…for…看上去就像扩展了类型的能力。比如,我们可以给数组添加一个indexOf函数,查看一个元素在数组中的位置,示例代码如下:

pragma solidity >=0.4.16;


library Search {
    function indexOf(uint[] storage self, uint value)
        public
        view
        returns (uint)
    {
        for (uint i = 0; i < self.length; i++)
            if (self[i] == value) return i;
        return uint(-1);
    }
}


contract C {
    using Search for uint[];
    uint[] data;


    function append(uint value) public {
        data.push(value);
    }


    function replace(uint _old, uint _new) public {
        // 执行库函数调用
        uint index = data.indexOf(_old);
        if (index == uint(-1))
            data.push(_new);
        else
            data[index] = _new;
    }
}

这段代码中indexOf的第一个参数存储变量self,实际上对应着合约 C 的data变量。

路上使用using LibA for B语法糖,大部分时候,可以让我们的代码更简洁。

例如:有一个库函数:isContract(address addr) , 可以使用 addr.isContract() 来调用库函数,代码就更简洁了。

若使用 using LibA for * 可以把 LibA 中的函数关联到任意的类型上。

solidity 进阶

ABI 应用二进制接口

大家应该很熟悉 API(Application Programming Interfaces),API 是一个接口,用它来访问某个服务,可以把API 理解两个软件彼此之间进行通信的桥梁。

ABI (Application Binary Interfaces),则是用来定义了智能合约中可以进行交互的方法、事件和错误,类似可以把 ABI 理解为与EVM 进行交互的桥梁。

EVM 是以太坊虚拟机,和其他的机器一样,他们无法执行人类可读代码的, 只能够识别和运行二进制数据,这是一串由 0 和 1 所组成的数据流。因此在调用函数时,需要借助 ABI ,把人类可读函数转化为EVM可读的字节码。

一句话总结:ABI 是 编码和解码规范,用来规范外部与 EVM 的交互,也可用于合约间的交互。

ABI 接口描述

在 Solidity 中,我们编译代码以后,会得到两个重要东西(称为artifact):bytecode(字节码) 和 ABI 接口描述。

ABI 接口描述是 JSON 格式的文件,定义了智能合约中外部可以进行交互的方法、事件和可解释的错误。

例如,下面的 Counter :

contract Counter {
  uint public counter;
  address private owner;

  error NotOwner();
  event Set(uint _value);  // 定义事件

  constructor() {
    owner = msg.sender;
  }

  function set(uint x) public {
      if(owner != msg.sender)  revert NotOwner();
      counter = x;
      emit Set(x);

  }
}

编译之后生成的 ABI 为:

[
    {
        "inputs": [],
        "name": "NotOwner",
        "type": "error"
    },
    {
        "anonymous": false,
        "inputs": [
            {
                "indexed": false,
                "internalType": "uint256",
                "name": "_value",
                "type": "uint256"
            }
        ],
        "name": "Set",
        "type": "event"
    },
    {
        "inputs": [],
        "name": "counter",
        "outputs": [
            {
                "internalType": "uint256",
                "name": "",
                "type": "uint256"
            }
        ],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [
            {
                "internalType": "uint256",
                "name": "x",
                "type": "uint256"
            }
        ],
        "name": "set",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    }
]

这是一个 JSON 格式的数组,每个对象定义了合约方法中可公开调用的方法(函数), 合约声明的事件及错误等。

以 set 函数为例:

    {
        "inputs": [
            {
                "internalType": "uint256",
                "name": "x",
                "type": "uint256"
            }
        ],
        "name": "set",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    }

可以里面定义了type : 定义是函数、事件或错误等,name :表示函数名称、事件名称、自定义错误名称, inputs: 函数输入参数,outputs : 函数输出参数,

当我们要调用一个函数时,使用 ABI JSON 的规范的要求,进行编码,传给 EVM, 同时在 EVM 层生成的字节数据(如时间日志等),ABI JSON 的规范进行解码。

ABI 编码

我们以调用 set() 函数为例,看看 ABI 是如何进行的,合约部署在 sepolia 网络,调用 set(10):

区块链浏览器交易记录如下:

调用 set()函数,经过 ABI 编码后,提交到链上的数据是:

0x60fe47b1000000000000000000000000000000000000000000000000000000000000000a

它包含两个部分:

  1. 函数选择器(前 4 个字节)
  2. 参数编码 0x60fe47b1 是函数选择器, 它是 ABI 描述中函数的签名:set(uint256) 进行 keccak256 哈希运算之后,取前4个字节:
  bytes4(keccak256("set(uint256)")) == 0x60fe47b1

参数部分 10 的十六进制是 a, 然后扩展到 32个字节, 参数编码细节可以参考文档 应用二进制接口说明 。

大部分时候,我们不需要了解详细的编码规则,Solidity / web3.js / ethers.js 库都提供了编码函数,例如在 Solidity 中,可通过以下代码获得完整的编码:

// 编码函数及参数 
abi.encodeWithSignature("set(uint256)", 10)

// 编码参数
uint a = 10;
abi.encode(a);   // 0x000000000000000000000000000000000000000000000000000000000000000a

Solidity 编码函数

Solidity 中有 5 个函数:abi.encode, abi.encodePacked, abi.encodeWithSignature, abi.encodeWithSelector 及abi.encodeCall 用于编码。

我们可以在 Chisel 里演练这几个编码函数,Chisel 是Foundry 提供的 Solidity 交互式命令工具

  1. abi.encode encode() 方法按EVM标准规则对参数编码,编码时每个参数按32个字节填充0 再拼在一起,当与合约交互时需要编码参数时,就使用该方法。
// 单个参数
uint a = 10;
abi.encode(a);   // 0x000000000000000000000000000000000000000000000000000000000000000a

uint8 s = 2;   // 占一个字节
abi.encode(s);  // 0x0000000000000000000000000000000000000000000000000000000000000002

address addr = 0xe74c813e3f545122e88A72FB1dF94052F93B808f;
abi.encode(addr); // 0x000000000000000000000000e74c813e3f545122e88a72fb1df94052f93b808f

// 多个参数
abi.encode(addr, a);  // 0x000000000000000000000000e74c813e3f545122e88a72fb1df94052f93b808f000000000000000000000000000000000000000000000000000000000000000a

bool b = true;
abi.encode(addr, a, b);  // 0x000000000000000000000000e74c813e3f545122e88a72fb1df94052f93b808f000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000001

若是动态类型的数据,编码会更加复杂:

// 动态类型的数据
uint[] memory arr = new uint[](2);
arr[0] =  1;
arr[1] = 2;

abi.encode(addr, a, b, array) // 0x000000000000000000000000e74c813e3f545122e88a72fb1df94052f93b808f000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002
  1. abi.encodePacked encodePacked 称为紧密编码,和 encode() 方法不同,参数在编码拼接时不会填充0, 而是使用实际占用的空间然后把各参数拼在一起,如果编码结果不是32字节整数倍数时,再末尾依旧会填充0)。例如在使用EIP712 时,需要对一些数据编码,就需要使用到 encodePacked 。
// 单个参数
uint a = 10;
abi.encodePacked(a);   // 0x000000000000000000000000000000000000000000000000000000000000000a

uint8 s = 2; // 占一个字节
abi.encodePacked(s);  // 0x0000000000000000000000000000000000000000000000000000000000000002

address addr = 0xe74c813e3f545122e88A72FB1dF94052F93B808f;
abi.encodePacked(addr);

bool b = true;
// 多个参数
abi.encodePacked(addr, s, b);  // 0xe74c813e3f545122e88a72fb1df94052f93b808f020100000000000000000000
  1. abi.encodeWithSignature 对函数签名及参数进行编码,第一个参数是函数签名,后面按EVM标准规则对参数进行编码,这样就可以直接获得 调用函数所需的 ABI 编码数据。
abi.encodeWithSignature("set(uint256)", 10) // 0x60fe47b1000000000000000000000000000000000000000000000000000000000000000a

// 参考上方 addr, s, b 的定义
abi.encodeWithSignature("addUser(address,uint8,bool)", addr, s, b) // 0x63f67eb5000000000000000000000000e74c813e3f545122e88a72fb1df94052f93b808f00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001
  1. abi.encodeWithSelector 它与abi.encodeWithSignature功能类似,只不过第一个参数为4个字节的函数选择器,例如:
abi.encodeWithSelector(0x60fe47b1, 10);
// 等价于
abi.encodeWithSelector(bytes4(keccak256("set(uint256)")), 10); // 0x60fe47b1000000000000000000000000000000000000000000000000000000000000000a


abi.encodeWithSelector(0x63f67eb5, addr, s, b);
// 等价于
abi.encodeWithSelector(bytes4(keccak256(""addUser(address,uint8,bool)")), addr, s, b) // 0x63f67eb5000000000000000000000000e74c813e3f545122e88a72fb1df94052f93b808f00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001
  1. abi.encodeCall encodeCall 可以通过函数指针,来对函数及参数编码,在执行编码时,执行完整的类型检查, 确保类型匹配函数签名。例如:
interface IERC20 {
    function transfer(address recipient, uint amount) external returns (bool);
}

contract EncodeCall {
    function encodeCallData(address _to, uint _value) public pure returns (bytes memory) {
        return abi.encodeCall(IERC20.transfer, (_to, _value));
    }
}

ABI 解码

解码是编码的”逆过程“, 区块链浏览器为何能把我们提交给链上的 0x60fe47b1000000…0a 显示为函数set(uint256 x), 就是对数据进行了解码。

准确来说,仅能对参数进行解码,函数选择器的计算过程中, 使用了 keccak256 哈希运算,哈希是不可逆的。

但当我们开源合约代码之后, 区块链浏览器可以计算出所有函数的函数选择器,从而可以通过函数选择器匹配对应的函数签名。

ABI 解码一个重要的使用场景是,解析交易中的事件日志。

在刚才的交易中,链上记录了如下日志:

日志的包含Topics 和 Data 两部分,我们该如何获知其表示的含义呢?

其实,Topics 的第一个主题是事件签名的 Keccak256 哈希,在上面 ABI JOSN 描述中,包含 Set 事件的描述,对应的事件签名是 Set(uint256), Keccak256 哈希结果是主题值。

通过匹配,我们就可以知道 EVM 产生的该条日志是由 Set(uint256) 事件生成, 从而根据事件的参数列表解析日志数据。Solidity / web3.js / ethers.js 库都提供了解码函数, 例如:

// solidity decode
(x) = abi.decode(data, (uint));

// ethers.js
const SetEvent = new ethers.utils.Interface(["event Set(uint256 value)"]);
let decodedData = SetEvent.parseLog(event);

ABI 编解码可视化工具

ChainToolDAO 开发了几个可视化工具,帮助我们来编解码。

  1. 函数选择器的查询及反查 :https://chaintool.tech/querySelector
  2. 事件签名的 Topic 查询:https://chaintool.tech/topicID
  3. Hash 工具提供Keccak-256 及 Base64:https://chaintool.tech/hashTool
  4. 交易数据(calldata)的编码与解码: https://chaintool.tech/calldata

ABI 是一个编解码的规范,是人类可读信息与以太坊虚拟机执行二进制数据的桥梁。

在理解 ABI 之上,分别介绍了 ABI 接口描述,ABI 编码与ABI 解码。

地址底层调用: call 与 delegatecall

理解底层调用

在我们知道一个合约的接口后, 就我们的合约中调用其函数, 例如下调用ERC20 的transfer 方法来发送奖励:

contract Award {
  function sendAward(address user) public {
    token.transfer(user, 100);
  }
}

然后这里也有一个前提:需要在编写我们的合约(这里为Award)前,先知道目标合约的接口(这里为 transfer )。

但有时我们在编写合约时,还不知道目标合约的接口,甚至是目标合约还没有创建。一个典型的例子是智能合约钱包,智能合约钱包会代表我们的身份调用任何可能的合约。显然我们无法在编写智能合约钱包时,预知未来要交互的合约接口。

这个问题该如何解决呢?

你也许知道很多编程语言(如Java)有反射的概念,反射允许在运行时动态地调用函数或方法。地址的底层调用和反射非常类似。

使用地址的底层调用功能,是在运行时动态地决定调用目标合约和函数, 因此在编译时,可以不知道具体要调用的函数或方法。

底层调用

地址类型还有3个底层的成员函数:

  1. targetAddr.call(bytes memory abiEncodeData) returns (bool, bytes memory)

  2. targetAddr.delegatecall(bytes memory abiEncodeData) returns (bool, bytes memory)

  3. targetAddr.staticcall(bytes memory abiEncodeData) returns (bool, bytes memory)

call 是常规调用,delegatecall 为委托调用,staticcall 是静态调用(不修改合约状态, 相当于调用 view 方法)。

这三个函数都可以用于与目标合约(targetAddr)交互,三个函数均接受 abi 编码数据作为参数(abiEncodeData)来调用对应的函数。

调用举例

在接口与函数调用 一节中,我们介绍过通过 ICounter(_counter).set(10); 调用以下set方法:

contract Counter {
  uint public counter;

  function set(uint x) public {
      counter = x;
  }
}

在 ABI 一节 我们知道调用 set()函数,实际上发送的是 ABI

编码数据0x60fe47b1000000000000000000000000000000000000000000000000000000000000000a

通过call 就可以直接使用编码数据发起调用:

bytes memory payload = abi.encodeWithSignature("set(uint256)", 10);
(bool success, bytes memory returnData) = address(_counter).call(payload);
require(success);

这段代码在功能上和 ICounter(_counter).set(10); 等价,但 call的方式可以动态构造 payload 编码数据对函数进行调用,从而实现对任意函数、任何类型及任意数量的参数的调用。

示例中的编码数据是通过 encodeWithSignature 构造,Solidity 提供了多个编码函数来构造编码数据,还可以通过工具和 Web3.js 等库在链下构造编码数据。

使用底层方法调用合约函数时, 当被调用的函数发生异常时(revert),异常不会冒泡到调用者(即不会回退), 而是返回错误 false。因此在使用所有这些低级函数时,一定要记得检查返回值。

call 与 delegatecall

call 是常规调用,delegatecall 为委托调用,staticcall 是静态调用,不修改合约状态, 相当于普通的 view 方法调用。

常规调用 call 与 委托调用 delegatecall 的区别是什么呢?

当我们在用钱包发起交易时,用合约接口调用函数,都是常规调用,每次常规调用都会切换上下文,切换上下文可以这样理解:每一个地址在 EVM 有一个独立的空间,空间有各自的摆设(变量布局),切换上下文就像从一个空间进入另一个空间(也可以携带一些东西进入另一个空间),每次进入一个空间后,只能使用当前空间内的东西。

委托调用不一样,没有上下文的切换,它像是给你一个主人身份(委托),你可以在当下空间做你想做的事。

我们用一个代码实例看看常规调用 call 与 委托调用 delegatecall 的不同的:

pragma solidity ^0.8.0;

contract Counter {
    uint public counter;
    address public sender;

    function count() public {
        counter += 1;
        sender = msg.sender;
    }
}

contract CallTest {
    uint public counter;
    address public sender;


    function lowCallCount(address addr) public {
    //  (Counter(c)).count();
        bytes memory methodData =abi.encodeWithSignature("count()");
        addr.call(methodData);
    }

    // 只是调用代码,合约环境还是当前合约。
    function lowDelegatecallCount(address addr) public {
        bytes memory methodData = abi.encodeWithSignature("count()");
        addr.delegatecall(methodData);
    }

}

在 Remix 中,分别部署 Counter 和 CallTest 合约,然后用 Counter 部署地址作为参数调用 lowCallCount ,想一下,是 Counter 还是 CallTest 合约的 counter 的值增加了? 再试试调用 lowDelegatecallCount 看看。

lowCallCount()  ->  Counter::counter + 1   

lowDelegatecallCount() -> CallTest::counter + 1   

lowCallCount 函数中使用call,上下文从 CallTest 地址空间跳到了 Counter地址空间, 因此是Counter内部的 counter 值 + 1 了。

lowDelegatecallCount 函数中使用delegatecall,上下文保证在 CallTest 地址空间,因此是CallTest的 counter 值 + 1 了。