本章目录
- 1️⃣ 区块链基础
- 2️⃣ Hello World
- 3️⃣ 合约代码中的三种注释
- 4️⃣ 合约结构介绍
- 5️⃣ 全局的以太币单位
- 6️⃣ 接收 ETH
- 7️⃣ selfdestruct:合约自毁
- 🆗 实战 1: 同志们好
- 🆗 实战 2: 存钱罐合约
- 🆗 实战 3: WETH 合约
- #️⃣ 问答题
Solidity 是在兼容 EVM 的区块链上开发智能合约的语言,我们不需要关心所在区块链底层逻辑,只要是兼容 EVM 的公链,我们都可以使用 Solidity 进行智能合约的编码。简单了解以下的区块链概念:
- 事务
- 交易
- 地址
- 区块
- 存储/内存/栈
- BiliBili: 第一章第 1 节: 区块链基础
- Youtube: 第一章第 1 节: 区块链基础
具有原子性的操作,要么全部完成,要么一点都不执行。在合约中,如果出现异常或 gas 耗尽,事务中的修改将被回滚
比如从 A 地址向 B 地址转账 100 元,那么数据库里 A 减 100 元,B 加 100 元。如果因为某些原因导致 A 已经减了 100 元,但是 B 加 100 元中间出现了异常。因为事务的原子性,发生失败后 A/B 地址都不会发生任何修改。这种场景在合约中经常发生,会经常看到 out of gas 异常,这是因为 gas 被耗尽。此时合约中做的所有修改都会被回滚。
gas:合约的手续费;是作为用户为当前交易支付的手续费,每一笔交易都会收取 gas 费,目的是限制交易需要做的工作量,需要做的事情越多,所花费的 gas 也就越多;gas 会按照特定规则进行逐渐消耗,如果执行完成后还有剩余,gas 会在当前交易内原路返回到交易发起者的地址中。
是一个地址发送到另一个地址的消息,可能包含二进制数据和以太币。如果目标地址包含代码,代码将被执行并以 payload 作为输入。如果目标地址是零地址,交易将创建一个新的合约
- 如果目标地址含有代码,则此代码会被执行,并以 payload 作为入参。
- 如果目标地址是零地址,此交易将创建一个新合约。
- 这时候用来创建合约的 payload 会被转为 EVM 字节码并执行,执行的输出作为合约代码永久存在区块链上。
- 所以如果创建一个合约,并不需要向链上发送实际的合约代码,只需发送能够产生合约代码的代码就可以。
区块链中的交易遵守事务的特性。交易总是由发送人(创建交易的地址)进行签名。区块链底层会确保只有持有该地址密钥才能发起交易。正因为这个特性,所以才能为区块链上特定状态的修改增加保护机制。
比如在合约中指定某一个方法只有"管理员"账号可以用,我们只需要验证调用者是否为管理员地址就可以了,至于地址权限的保护事情并不需要关心,只要是该账号发起的交易,就认为是管理员在操作。安全方面我们需要考虑的是,如果某一个地址被盗了怎么样,通常这些是业务逻辑决定,比如多签钱包的业务。
地址很多时候也被称为账户,EVM 有外部地址和合约地址两类。
- 外部地址:由公钥-私钥对控制
- 常用的助记词,keystore 文件等只是方便用户储存,底层还是会转成私钥。
- 一般是钱包应用创建的地址。公钥就是
0xABC
的这种以太坊收款地址,私钥可能是助记词生成,可能是 keystore 文件生成,也可能是用户直接保存的。
- 合约地址:由地址一起存储的代码控制。 每个地址都有持久化存储和以太币的余额
无论外部地址,还是合约地址,对于 EVM 来说,都是一样的。每个地址都有一个键值对形式的持久化存储。其中 key 和 value 都是 256 位,我们称为存储。此外每个地址都会有一个以太币的余额,合约地址也是如此;余额会因为发送包含以太币的交易而改变。
我们的 Solidity 代码可以在 ETH、BSC、Matic、Eos EVM 等网络上运行。只要这些网络支持 EVM,我们的代码就能正常工作,无需考虑它们的底层逻辑。
然而,我们需要关心的是,区块可能被回滚,交易可能被作废,这意味着你发起的交易有可能被回滚甚至从区块链中删除。区块链不能保证当前的交易一定会包含在下一个区块中。这种特性需要我们在开发合约时予以注意。
如果你开发的合约中存在顺序关系,你需要特别注意这个特性。 合约内的逻辑,不能依赖于某个特定的区块。因为区块的状态可能会发生变化。
存储(Storage):存储提供了一个持久化的存储区域,用于在以太坊智能合约中保存数据。每一个以太坊地址都有一个持久化的存储区域,存储是将 256 位字的键值映射到 256 位字的键值存储区。合约只能读写自己存储区域的数据。数据类型的最大值是 uint256
/int256
/bytes32
。
存储的数据是永久存储的,即使合约执行结束或合约被销毁,存储的数据仍然会保留。
内存(Memory):合约在每一次消息调用时会获取一个被擦拭干净的内存实例。内存中存储的数据在函数执行完毕后会被销毁,内存是线性的,可按字节进行寻址,但读取的最大长度被限制为了256位。而写的长度可以是 8 位或 256 位。
栈(Stack):合约的所有计算都在一个被称为栈的区域执行,栈最多包含 1024 个元素,每一个元素长度为 256 位,因此,合约调用深度被限制为 1024 ,对复杂的操作,推荐使用循环而不是递归来避免栈溢出。而且栈上的数据存储和读取速度较快。
Solidity中,合约类似于面向对象语言中的类。合约中有用于数据持久化的状态变量,和可以修改状态变量的函数。 它通过状态变量和可修改状态的函数来实现数据持久化。状态变量存储合约的数据,并可以在合约的不同函数之间共享。当调用另一个合约实例中的函数时,合约执行的上下文切换,当前合约的状态变量无法直接访问。 后面会逐步展开介绍,国际惯例,使用当前语言的 Hello World 作为第一个例子。
- BiliBili: 第一章第 2 节: Hello World
- Youtube: 第一章第 2 节: Hello World
Remix IDE 是学习合约的好帮手,即开即用,无需各种依赖,让我们可以专注于合约的学习和开发。在运行实例程序前,我们需要了解 基本的 Remix 部署和代码测试流程。
1.编写代码:点击左侧 File explorer,然后点击 Create new file 图标,创建 helloworld.sol
文件,并在文件中编写 Hello World 例子的合约代码。。
2.编译代码:点击左侧 Solidity compile,然后点击 Compile helloworld.sol
,如果编译成功,将会显示一个绿色的对勾图标,表示编译成功。(小技巧:你可以选中页面上方的 "Auto compile" 选项,使 Remix 在保存代码后自动编译最新修改的代码,推荐使用此选项)
3.部署合约:点击左侧 Deploy & run transactions 页面,在页面上点击黄色按钮 「Depoly」,此时页面下方的 "Deployed Contracts" 区域会显示刚刚部署的合约的地址。
4.运行合约:展开Deployed Contracts区域中刚部署的Hello合约,在该合约下找到相应的函数 例如( message、fn1、fn2、fn3),点击相应的按钮即可读取到合约中存储的 "Hello World!" 的内容。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Hello {
// 24509 gas
string public message = "Hello World!"; // 状态变量
// 24473
function fn1() public view returns (string memory) {
return message;
}
// 21801. 内存中直接返回
function fn2() public pure returns(string memory){
return "Hello World!";
}
// 21880
function fn3() public pure returns(string memory){
return fn2(); // 使用方法;函数调用函数,没有this。直接调用
}
}
上面的代码定义了一个名为 Hello 的合约,该合约包含了三个函数和一个状态变量。
- 'message' 是一个公共的状态变量,存储着字符串 "Hello World!"。
- 'fn1'函数 是一个公共的视图函数(view),用于返回状态变量 'message' 的值。 因为它只读取数据而不修改状态,所以被声明为视图函数。
- 'fn2'函数 是一个公共的纯函数(pure),用于返回字符串 "Hello World!"。因为它不读取状态变量,所以被声明为纯函数。纯函数不访问合约的状态变量,只依赖于输入参数或其他纯函数的输出
- 'fn3'函数 是一个公共的纯函数(pure),它调用了函数 fn2 并返回其结果。
在 Remix 中调用合约的不同函数时,可以观察到它们消耗的 gas 不相同。通常直接访问状态变量message
的 gas 消耗更低,因为状态变量message
存储在合约的存储区域中,而函数调用涉及读取状态变量并返回结果。如函数helloWorld
是读取了状态变量然后再返回出去会消耗更多的gas。
需要之一的是在 Remix 中得到的 gas 消耗结果有时会有误差,所以不要过于依赖 Remix 中的 gas 消耗估算。
在编写 Solidity 代码时,除了保证安全性外,优化合约的 gas 消耗是一个重要的方向。后续的章节中,我们将探讨如何进行 gas 优化,在 Remix 中,代码顺序,变量名/函数名长短的修改都可以大大影响 gas 消耗。所以,不要过于依赖 Remix 中的 gas 消耗估算结果,实际 gas 消耗可能会有所不同。
我们看到第一行的代码是 // SPDX-License-Identifier: MIT
这里面的 //
符号,是注释符。注释使用 // 或 /* */ 符号进行表示,不会被程序执行,而是提供给开发者阅读和理解代码的辅助信息。
注释非常重要。代码注释可以帮助开发者更好地理解代码的意图和逻辑。尤其是在长时间不接触某段代码后,注释能够快速提醒开发者代码的功能和设计思路,节省理解代码所需的时间。此外,注释还可以作为文档的一部分,为其他开发者提供使用代码的指导和说明。
因此,请不要相信那种认为好的代码不需要注释的观点。在实际工作中,代码注释是一个好的开发习惯,对于自己和他人的代码理解和维护都有很大帮助。在编写注释时,尽量做到准确、简明扼要,注释内容要清晰表达代码的意图和关键信息。
- BiliBili: 第一章第 3 节: 合约代码中的三种注释
- Youtube: 第一章第 3 节: 合约代码中的三种注释
Solidity 支持 3 种注释方式;
- 单行注释:以
//
开头,用于在一行中注释单个语句或解释代码的特定部分。单行注释可以放置在代码行的末尾,或者在代码行之上单独一行注释。 - 块注释:以
/*
开头和*/
结尾,可以跨越多行,用于注释多个语句或一段代码的功能。块注释通常用于提供较长的注释内容,对于详细解释代码的逻辑或实现细节很有帮助。 - NatSpec 描述注释: NatSpec 是 Solidity 中一种特殊的注释格式,用于为合约和函数提供详细的描述文档。NatSpec 注释可以包含合约或函数的摘要、详细说明、参数说明、返回值说明等信息,有助于生成自动化文档或与其他工具进行集成。
使用不同的注释方式可以根据需要提供不同级别的注释和文档化。单行注释和块注释主要用于解释代码的逻辑、注明重要信息或给出简要的说明。而NatSpec 描述注释则提供了更结构化、详尽的文档描述,可以用于生成 API 文档或更复杂的代码文档工具。
在编写注释时,建议注释内容清晰、准确,用简洁的语言表达意图和关键信息。良好的注释能够提高代码的可读性和可维护性,有助于自己和他人更好地理解和使用代码。
单行注释以 //
开头,后面跟随注释内容
// SPDX-License-Identifier: MIT
string message = "Hello World!"; // 这是单行注释
如上,//
后面的注释内容将会被编译器忽略,为了提高可读性,通常会在//
后面加一个空格。
格式如下,在 /*
与 */
之间的内容,可以跨越多行
/*
这是块注释
*/
为了可读性,通常在块注释的每行开头加上 *
和一个空格
/**
* 这是块注释
* 这是块注释
*/
NatSpec 描述注释是一种更详细的注释形式,用于为函数、变量等提供丰富的文档。它以单行注释 ///
或多行注释 /** ... */
的形式使用。在编写合约时,强烈推荐为所有对外可见的接口(在 ABI 中呈现的内容)添加完整的 NatSpec 描述注释。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
/// @title 一个简单的数据存储演示
/// @author Anbang
/// @notice 您智能将此合约用于最基本的演示
/// @dev 提供了存储方法/获取方法
/// @custom:xx 自定义的描述/这个是实验的测试合约
contract TinyStorage {
// data
uint256 storedData;
/// @notice 储存 x
/// @param _x: storedData 将要修改的值
/// @dev 将数字存储在状态变量 storedData 中
function set(uint256 _x) public{
storedData = _x;
}
/// @notice 返回存储的值
/// @return 储存值
/// @dev 检索状态变量 storedData 的值
function get() public view returns(uint256){
return storedData;
}
/**
* @notice 第二种写法
* @param _x: XXXXX
* @dev XXXXX
* @return XXXXX
* @inheritdoc :
*/
}
上面所有标签都是可选的。下表解释了每个 NatSpec 标记的用途以及可以使用在哪些位置。我们可以选择合适的标记进行记录
标签 | 说明 | 语境 |
---|---|---|
@title |
描述 contract/interface 的标题 | contract, interface, library |
@author |
作者姓名 | contract, interface, library |
@notice |
向最终用户解释这是做什么的 | contract, interface, library, function, 公共状态变量 event |
@dev |
向开发人员解释任何额外的细节 | contract, interface, library, function, 状态变量, event |
@param |
记录参数(后面必须跟参数名称) | function, event, 自定义错误 |
@return |
函数的返回变量 | function, 公共状态变量 |
@inheritdoc |
从基本函数中复制所有缺失的标签(必须后跟合约名称) | function, 公共状态变量 |
@custom:... |
自定义标签,语义由应用程序定义 | 所有位置均可以 |
使用 NatSpec
描述注释的另一个好处是,当被编译器解析时,上述示例中的代码将生成两个不同的 JSON 文件。
- User Documentation:用于最终用户执行合约功能时作为通知使用的文档。
- Developer Documentation:用于开发人员参考和使用的文档。
如果将上述合约另存为,a.sol
您可以使用以下命令生成文档:
solc --userdoc a.sol
solc --devdoc a.sol
在后续涉及合约继承的部分将详细演示如何使用 NatSpec 描述注释。
当一个函数继承自另一个合约时,如果没有为继承函数添加NatSpec
描述注释,那么继承函数将自动继承其基本函数的文档。但是下面三种情况是例外的:
- 1.当参数名称不同时。
- 这种情况下,函数被视为重载,函数签名发生了改变,因此无法自动继承基础函数的文档。
- 2.当存在多个基础函数时。
- 如果有多个基础函数,编译器无法确定应该继承哪个函数的文档,就会发生冲突,因此无法自动继承文档。
- 3.当使用
@inheritdoc
标签明确指定了继承哪个合约时。- 当有一个明确的
@inheritdoc
标签指定应该使用哪个合约来继承时。那么该函数将继承指定的合约的文档,而不是自动继承基础函数的文档。
- 当有一个明确的
这些情况下,应该显式添加 NatSpec
描述注释,以提供准确的文档信息。
更多 NatSpec 请参考: https://github.com/aragon/radspec
在 Solidity中,合约的结构包括一下几个部分:
- SPDX 版权声明:用于声明合约采用的许可证类型,以确保代码的合法使用和分发。
- pragma solidity 版本限制: 指定Solidity 编译器的版本限制,以确保合约在编译时使用指定版本兼容的编译器。
- contract 关键字: 用于定义合约,标识一个只能合约的实现。
- import 导入声明: 用于引入其他合约文件,以便在当前合约中使用其定义的内容
- interface: 接口 : 用于定义合约的接口,规定了其他合约需要实现的函数和时间。
- library:库合约 : 用于定义可重用的库函数,以供其他合约使用。
- BiliBili: 第一章第 4 节: 合约结构介绍 1
- BiliBili: 第一章第 4 节: 合约结构介绍 2
- Youtube: 第一章第 4 节: 合约结构介绍 1
- Youtube: 第一章第 4 节: 合约结构介绍 2
合约结构的示例代码如下:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "./OtherContract.sol";
interface SomeInterface {
function someFunction(uint256 param) external returns (uint256);
event SomeEvent(address indexed sender, uint256 value);
}
library SomeLibrary {
function someFunction(uint256 param) external pure returns (uint256) {
// 实现库函数的逻辑
return param * 2;
}
}
contract MyContract is SomeInterface {
// 合约代码
}
在合约的开头,通常会包含一个SPXD
版权声明,它用于标识合约采用的许可证。SPXD
(Software Package Data Exchange)许可证标识符是一种用于标识软件许可证的标准化方式。通过在合约中包含SPXD
许可证标识符,可以让开发者更容易地识别出合约采用的许可证类型。
SPXD
许可证标识符通常使用 // SPDX-License-Identifier
注释行后根许可证标识符的形式
// SPDX-License-Identifier: MIT
上述代码中的 SPXD
许可证标识符指明了合约采用MIT
许可证。MIT
许可证是一种开源软件许可证,它允许用户自由地使用、修改和再分发软件。MIT
许可证是一种宽松的许可证,它允许用户在不受限制的情况下使用软件,甚至可以将其用于专有软件中。
如果一个项目开源了智能合约的源代码,可以更好地建立社区信任。但是由于提供源代码就不可避免的涉及到版权或者法律问题。所以 solidity 鼓励开源,但是开源并不等于放弃版权。如果你不想指定任何许可证,或者代码就是不想开源,Solidity 推荐使用 UNLICENSED
;UNLICENSED
不存在于 SPDX 许可证列表中,与 UNLICENSE (授予所有人所有权利)不同,它比 UNLICENSE
多一个 D
字母。
需要注意的是,SPDX 许可标识符的注释行可以放置在合约文件的任何位置,但通常建议将其放置在文件的顶部,作为文件的版权声明。
比如我可以写为 // SPDX-License-Identifier: ANBANG
,并不会影响代码的运行。但是这里的标示会被打包在 bytecode metadata
里。
当我们使用 remix
编译合约的时候,会在根目录创建 artifacts
文件夹,其中包含 build-info
记录构建信息的文件夹,以及每个合约名字作为文件名的文件夹,比如 contract Hello
将生成
Hello.json
文件Hello_metadata.json
文件
{
deploy: {},
data: {
bytecode: {},
deployedBytecode: {},
gasEstimates: {},
methodIdentifiers: {},
},
abi: [],
};
{
compiler: {
version: "0.8.17+commit.xxx",
},
language: "Solidity",
output: {
abi: [],
devdoc: {},
userdoc: {},
},
settings: {},
sources: {
"aaa.sol": {
keccak256:
"0x637c141739144cd991b9350336a1f8c3b948811d7ed743fefb4aad99d7bb362f",
license: "ANBANG",
urls: [
"bzz-raw://9eea517225b90242d6e3761046f5f5a8f0a2393747c89f3af01f34ad00764dc4",
"dweb:/ipfs/QmXp5wap9ZNC9fihdA7aLMe7bKWBjeAuv7khEuvKrgp9Bx",
],
},
},
version: 1,
};
// SPDX-License-Identifier: ANBANG
中的 ANBANG
就是在 sources -> filename.sol -> license
中
- 总结:
- 根据自己的合约情况,选择合适的版权声明可以避免很多不必要的版权麻烦。
- 扩展:
- 更多的 SPDX-License-Identifier 类型介绍,参照文章 SPDX License List 详细阅读。
- Solidity 遵循 npm 的 license 建议
- 更多的 bytecode metadata 参照文章 合约的 metadata
在合约代码的第二行使用 pragma solidity ^0.8.17;
指令,它告诉编译器在编译合约时应该使用的 Solidity 版本范围。
^0.8.17
表示选择的 Solidity 版本应该在 0.8.17 及以上,但不包括 0.9.0 及其以上的版本。
我当前的合约代码采用的是 Solidity 0.8.17 这个版本为基础编写的,解析部署时需要在匹配的版本下进行,在区块链浏览器上进行合约验证时,也需要选择匹配的版本。
而 ^0.8.17
中的 ^
表示小版本兼容,大版本不兼容,相当于 pragma solidity >= 0.8.17 < 0.9.0;
。他既不允许低于0.8.17
的编译器编译,也不允许大于等于 0.9.0
版本的编译器进行编译。使用^
符号的好处是可以允许小版本的兼容性更新,而不需要手动更改 pragma 指令来适应新的补丁版本
如果没有使用 ^ 符号,而是写死了具体的版本号(例如 pragma solidity 0.8.17;),则只能在指定的版本中进行编译,无法享受到未来补丁版本的改进。
如果你打算跨大版本的合约,可以使用>
/>=
/<
/<=
来操作,比如 pragma solidity >=0.7.0 <0.9.0;
。
注意:pragma
指令只对当前的源文件起作用,如果把文件 B
导入到文件 A
,文件 B 的 pragma 将不会自动应用于文件 A。
- 总结:
pragma solidity ^0.8.17;
是用来告诉编译器应该选择什么版本来解析编译当前代码。pragma
指令仅对当前的源文件起作用。- 使用
^
符号允许小版本的兼容性更新,不允许大版本的兼容性更新。
注:一份源文件可以包含多个版本声明、多个导入声明和多个合约声明。
第 3 行的 contract Hello {}
是合约的基本结构;其中 contract
声明了当前代码块内是一个完整的合约。而 Hello
是当前合约的名字,合约名称是必须的,首字母一般采用大写字母开头。
contract
代表特殊的意义,这种有特殊意义的词,在编程界里一般被称为 保留关键字
;保留关键字是现在或者将来被用到的特殊代指,都有固定意义,所以保留关键字不能作为变量名和函数名字。
- 总结:
- contract 基本结构是
contract ContractName {}
- Solidity 合约中,合约的名字是必须的。
- 合约的名称,一般约定为 大驼峰命名方式
- contract 是保留关键字
- 保留关键字不能作为变量名和函数名
- contract 基本结构是
- 扩展:
注:一份源文件可以包含多个版本声明、多个导入声明和多个合约声明。
合约内的 message
叫做状态变量,状态变量是永久地存储在合约存储中的值。关于变量的更多信息,会在后续 变量 那一章详细介绍
函数是代码的可执行单元,是一组逻辑的集合。关于变量的更多信息,会在后续 函数 那一章详细介绍
Solidity 中 this
代表合约对象本身;
- 可以通过
address(this)
获取合约地址。 - 可以通过
this.fnName
获取 external 函数
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Demo {
function contractAds() external view returns (address) {
return address(this);
}
function testExternal() external view returns (address) {
return this.contractAds();
}
}
这三个地址概念一定要完全理解。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
// 这三个地址的概念一定要理解清楚
contract Demo {
address public owner;
constructor() {
// 可以用在 constructor 内获取当前合约地址
owner = address(this);
// 不可以在构造函数内调用函数,因为此时合约还没有完成构建好。
// this.caller(); 相当于从外部调用 caller 方法
// owner = this.caller();
}
function caller() external view returns (address) {
return this.contractAds(); // 内部调用 external 可见性的函数
}
function contractAds() external view returns (address) {
return address(this);
}
}
type(C).name
:获得合约名type(C).creationCode
:获得包含创建合约字节码的内存字节数组。它可以在内联汇编中构建自定义创建例程,尤其是使用 create2 操作码。 不能在合约本身或派生的合约访问此属性。 因为会引起循环引用。type(C).runtimeCode
:获得合约的运行时字节码的内存字节数组。这是通常由 C 的构造函数部署的代码。 如果 C 有一个使用内联汇编的构造函数,那么可能与实际部署的字节码不同。 还要注意库在部署时修改其运行时字节码以防范定期调用(guard against regular calls)。 与 .creationCode 有相同的限制,不能在合约本身或派生的合约访问此属性。 因为会引起循环引用。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Hello {
string public message = "Hello World!";
}
contract Demo {
function name() external pure returns (string memory) {
return type(Hello).name;
}
function creationCode() external pure returns (bytes memory) {
return type(Hello).creationCode;
}
function runtimeCode() external pure returns (bytes memory) {
return type(Hello).runtimeCode;
}
}
除了上面介绍的版权声明,版本限制,contract 外,合约文件还包括 import
, interface
,library
,一起展开介绍下
功能:从其他文件内倒入需要的变量或者函数。
既可以导入本地文件,也可以导入 url(网络上的 ipfs,http 或者 git 文件)
- 导入所有的全局标志
import "filename";
到当前全局范围- 导入本地文件:
import "./ERC20.sol";
,其中./
表示当前目录,查找路径参考 - 导入网络文件:
import "https://github.com/aaa/.../tools.sol";
- 导入本地 NPM 库:
$ npm install @openzeppelin/contracts
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
- 导入本地文件:
- 导入所有的全局标志,并创建新的全局符号
- 方式一:
import * as symbolName from "filename";
- 方式二:
import "filename" as symbolName;
- 方式一:
- 按需导入,按需修改名称
import {symbol1 as aliasName, symbol2} from "filename";
不推荐导入变量标示名到当前全局范围的方式,因为不可控,容易污染当前的命名空间。如果全局导入,推荐使用 import "filename" as symbolName;
注:一份源文件可以包含多个版本声明、多个导入声明和多个合约声明。
上文中的 filename 总是会按路径来处理,以 /
作为目录分割符、以 .
标示当前目录、以 ..
表示父目录。 当 .
或 ..
后面跟随的字符是 /
时,它们才能被当做当前目录或父目录。 只有路径以当前目录 .
或父目录 ..
开头时,才能被视为相对路径。
用 import "./x.sol" as x;
语句导入当前源文件同目录下的文件 x.sol
。 如果用import "x.sol" as x;
代替,可能会引入不同的文件(在全局 include directory
中)。
最终导入哪个文件取决于编译器(见下文)到底是怎样解析路径的。 通常,目录层次不必严格映射到本地文件系统, 它也可以映射到能通过诸如 ipfs,http 或者 git 发现的资源。
在下面的例子中,定义了 cat 合约以及 dog 合约。他们都有 eat
方法.以此他们都可以被上面的 animalEat
接口所接收。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Cat {
uint256 public age;
function eat() public returns (string memory) {
age++;
return "cat eat fish";
}
function sleep1() public pure returns (string memory) {
return "sleep1";
}
}
contract Dog {
uint256 public age;
function eat() public returns (string memory) {
age += 2;
return "dog miss you";
}
function sleep2() public pure returns (string memory) {
return "sleep2";
}
}
interface AnimalEat {
function eat() external returns (string memory);
}
contract Animal {
function test(address _addr) external returns (string memory) {
AnimalEat general = AnimalEat(_addr);
return general.eat();
}
}
返回接口I
的 bytes4
类型的接口 ID,接口 ID 参考: EIP-165 定义的, 接口 ID 被定义为 XOR (异或) 接口内所有的函数的函数选择器(除继承的函数。
上面的代码种,可以增加如下的函数来查看 interfaceId
;
contract Animal {
// ...
function interfaceId() external pure returns (bytes4) {
return type(AnimalEat).interfaceId;
}
}
更多内容在 interface:接口 那一章详细介绍。
库与合约类似,但它的目的是在一个指定的地址,且仅部署一次,然后通过 EVM 的特性来复用代码。
library Set {
struct Data { mapping(uint => bool) flags; }
function test(){
}
}
其他合约调用库文件的内容直接通过库文件名.方法名例如:Set.test()
。
更多内容在 Library:库 那一章详细介绍。
现实生活中经常会听说,提现 1.5 个以太币,或者某笔交易手续费花了 0.02 个以太坊等。这些带有小数点的数字是日常交流使用的。但是在合约内,却没有这种小数概念的货币金额。比如 1 个 ETH 的金额是 10**18 wei
。
- BiliBili: 第一章第 5 节: 全局的以太币单位
- Youtube: 第一章第 5 节: 全局的以太币单位
为了方便合约开发者操作,也提供如下这种便捷的换算方式。
以太币单位之间的换算就是在数字后边加上 wei
、 gwei
、 ether
来实现的,如果后面没有单位,缺省为 wei
。例如 1 ether == 1e18
的逻辑判断值为 true。
1 ether = 1 * 10^18 wei
1 ether = 1 * 10^9 gwei
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Demo {
// 返回 true
function test() public pure returns (bool a,bool b,bool c) {
a = 1 wei == 1;
b = 1 gwei == 1e9;
c = 1 ether == 1e18;
}
}
注意: 这些后缀不能直接用在变量后边。如果想用以太币单位来计算输入参数,你可以使用乘法来转换: amountEth * 1 ether
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Demo {
uint256 public amount;
constructor() {
amount = 1;
}
function fnEth() public view returns (uint256) {
return amount + 1 ether; // 1000000000000000001
}
function fnGwei() public view returns (uint256) {
return amount + 1 gwei; // 1000000001
}
// 这些后缀不能直接用在变量后边。如果想用以太币单位来计算输入参数,你可以用如下方式来完成:
function testVar(uint256 amountEth) public view returns (uint256) {
return amount + amountEth * 1 ether;
}
}
三个关键字
- payable
- 使用 payable 标记的函数可以用于发送和接收 Eth。
- 使用 payable 标记的 地址变量,允许发送和接收 Eth。
- fallback
- 一个合约可以最多有一个回退函数。
- receive
- 一个合约最多有一个
receive
函数
- 一个合约最多有一个
fallback 和 receive 不是普通函数,而是新的函数类型,有特别的含义,它们前面不需要加 function
这个关键字。加上 function
之后,它们就变成了一般的函数,只能按一般函数来去调用。同时 receive
和 fallback
需要注意 gas 消耗。
本节介绍的是合约如何接收 ETH,至于合约如何发送 ETH,请阅读 两种形式的地址 这一节。
- BiliBili: 第一章第 6 节: 接收 ETH
- Youtube: 第一章第 6 节: 接收 ETH
- 使用 payable 标记的函数可以用于发送和接收 Eth。
- payable 意味着在调用这个函数的消息中可以附带 Eth。
- 使用 payable 标记的 地址变量,允许发送和接收 Eth。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Payable {
// payable 标记函数
function deposit1() external payable {}
function deposit2() external {}
// payable 标记地址
function withdraw() external {
payable(msg.sender).transfer(address(this).balance);
}
// 通过 balance 属性,来查看余额。
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
可以使用 deposit 存款,但是如果使用 calldata 转账,则会失败,报错 In order to receive Ether transfer the contract should have either 'receive' or payable 'fallback' function
fallback 函数是调用合约没有的方法时候执行,同时也可以设置为允许接收网络主币。
- 语法
- 不带参数:
fallback () external [payable]
- 带参数:
fallback (bytes calldata input) external [payable] returns (bytes memory output)
- 没有
function
关键字。必须是external
可见性,
// function fallback() external payable {} // 正确写法不带 function,直接写 fallback,fallback 如果使用 function 修饰,则有警告 // This function is named "fallback" but is not the fallback function of the contract. // If you intend this to be a fallback function, use "fallback(...) { ... }" without // the "function" keyword to define it.
- 不带参数:
- fallback 函数类型可以是
payable
,也可以不是payable
的;- 如果不是
payable
的,可以往合约发送非转账交易,如果交易里带有转账信息,交易会被 revert; - 如果是
payable
的,自然也就可以接受转账了。
- 如果不是
- 尽管
fallback
可以是 payable 的,但并不建议这么做,声明为payable
之后,其所消耗的 gas 最大量就会被限定在 2300。 - 它可以是
virtual
的,可以被重载也可以有修改器(modifier)。
回退函数在两种情况被调用:
- 向合约转账;
- 如果使用 call 转账,会执行 fallback。
- 如果使用合约内已有的
deposit
转账,不会执行 fallback
- 执行合约不存在的方法
- 就会执行 fallback 函数。(执行合约不存在的方法时执行)
fallback 函数始终会接收数据,但为了同时接收以太时,必须标记为 payable
。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Payable {
event Log(string funName, address from, uint256 value, bytes data);
function deposit() external payable {}
// 通过 balance 属性,来查看余额。
function getBalance() external view returns (uint256) {
return address(this).balance;
}
fallback() external payable {
emit Log("fallback", msg.sender, msg.value, msg.data);
}
}
如果在一个对合约调用中,没有其他函数与给定的函数标识符匹配 fallback 会被调用.或者在没有 receive 函数时,而没有提供附加数据对合约调用,那么 fallback 函数会被执行。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract StoneCat {
uint256 public age = 0;
event eventFallback(string);
// 发送到这个合约的所有消息都会调用此函数(因为该合约没有其它函数)。
// 向这个合约发送以太币会导致异常,因为 fallback 函数没有 `payable` 修饰符
fallback() external {
age++;
emit eventFallback("fallbak");
}
}
interface AnimalEat {
function eat() external returns (string memory);
}
contract Animal {
function test1(address _addr) external returns (string memory) {
AnimalEat general = AnimalEat(_addr);
return general.eat();
}
function test2(address _addr) external returns (bool success) {
AnimalEat general = AnimalEat(_addr);
(success,) = address(general).call(abi.encodeWithSignature("eat()"));
require(success);
}
}
上面例子种,执行 StoneCat 合约 calldata,参数 0x00
可以成功,但是如果发送了以太币,则会失败,因为没有 paybale。
直接使用方法是不行的,但是可以通过 call 调用,因为 call 不检查,这也官方是不推荐使用 call 的原因。
fallback 可以有输入值和输出值,都是 bytes
类型的数据。如果使用了带参数的版本,input
将包含发送到合约的完整数据,参数 input 等于msg.data
,可以省略,并且通过 output
返回数据。 返回数据不是 ABI 编码过的数据,相反,它返回不经过修改的数据。与任何其他函数一样,只要有足够的 gas 传递给它,回退函数就可以执行复杂的操作。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Demo {
bytes public inputData1;
bytes public inputData2;
fallback (bytes calldata input) external returns (bytes memory output){
inputData1 = input;
inputData2 = msg.data; // input 等于 msg.data
return input;
}
}
abi.decode
与数组切片语法一起使用来解码 ABI 编码的数据:
(c, d) = abi.decode(_input[4:], (uint256, uint256));
请注意,这仅应作为最后的手段,而应使用对应的函数。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract StoneCat {
uint256 public age = 0;
bytes public inputData1;
bytes public inputData2;
uint256 public c;
uint256 public d;
event eventFallback(string);
fallback (bytes calldata input) external returns (bytes memory output){
age++;
inputData1 = input;
inputData2 = msg.data;
(c, d) = abi.decode(msg.data[4:], (uint256, uint256));
emit eventFallback("fallbak");
return input;
}
}
interface AnimalEat {
function eat() external returns (string memory);
}
contract Animal {
function test2(address _addr) external returns (bool success) {
AnimalEat general = AnimalEat(_addr);
(success, ) = address(general).call(abi.encodeWithSignature("eat()",123,456));
require(success);
}
}
receive 只负责接收主币,一个合约最多有一个 receive
函数
- 语法
receive() external payable {}
- 没有
function
关键字
// function receive() external payable {} // receive 如果使用 function 修饰,则有如下警告 // This function is named "receive" but is not the receive function of // the contract. If you intend this to be a receive function, // use "receive(...) { ... }" without the "function" keyword to define it.
- 没有
- 没有参数、没有返回值。
external payable
是必须的- receive 函数类型必须是
payable
的,并且里面的语句只有在通过外部地址往合约里转账的时候执行。
- receive 函数类型必须是
- 它可以是
virtual
的,可以被重载也可以有 修改器(modifier) 。 - 如果没有定义
接收函数 receive
,就会执行fallback
函数。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Demo {
event Log(string funName, address from, uint256 value, bytes data);
receive() external payable {
// receive 被调用的时候不存在 msg.data,所以不使用这个,直接用空字符串
emit Log("receive", msg.sender, msg.value, "");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
上面例子通过 calldata 执行转账,无参数时候会触发 receive 函数。但是如果有参数,比如0x00
,则会报错 'Fallback' function is not defined
在对合约没有任何附加数据调用(通常是对合约转账)是会执行 receive
函数.例如 通过 .send()
or .transfer()
。
声明为 payable 之后,其所消耗的 gas 最大量就会被限定在 2300。除了基础的日志输出之外,进行其他操作的余地很小。下面的操作消耗会操作 2300 gas :
- 写入存储
- 创建合约
- 调用消耗大量 gas 的外部函数
- 发送以太币
扩展阅读 selfdestruct
的目标来接收以太币。一个合约不能对这种以太币转移做出反应,因此也不能拒绝它们。这是 EVM 在设计时就决定好的,而且 Solidity 无法绕过这个问题。这也意味着 address(this).balance
可以高于合约中实现的一些手工记帐的总和(例如在 receive 函数中更新的累加器记帐)。
注意:这里 fallback 需要是 payable
类型的。如下图:
/**
调用时发送了ETH
|
判断 msg.data 是否为空
/ \
是 否
是否存在 receive fallbak()
/ \
存在 不存在
/ \
receive() fallbak()
*/
总结: 只有 msg.data
为空,并且存在 receive
的时候,才会运行 receive
。
如果不存在 receive
以太函数,payable
的 fallback
函数也可以在纯以太转账的时候执行。但是推荐总是定义一个 receive 函数,而不是定义一个 payable 的 fallback 函数。否则会报警告
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
// 这个合约会保留所有发送给它的以太币,没有办法返还。
contract Demo {
uint256 public x;
uint256 public y;
event Log(string funName, address from, uint256 value, bytes data);
// 纯转账调用这个函数,例如对每个空empty calldata的调用
receive() external payable {
x = 1;
y = msg.value;
emit Log("receive", msg.sender, msg.value, "");
}
// 除了纯转账外,所有的调用都会调用这个函数.
// (因为除了 receive 函数外,没有其他的函数).
// 任何对合约非空calldata 调用会执行回退函数(即使是调用函数附加以太).
fallback() external payable {
x = 2;
y = msg.value;
emit Log("fallback", msg.sender, msg.value, msg.data);
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
- 输入以太币,然后无参数 calldata 调用
- 输入以太币,然后参数
0x00
进行 calldata 调用
如果 receive
函数不存在,但是有 payable
的 fallback 回退函数 那么在进行纯以太转账时,fallback 函数会调用.如果两个函数都没有,这个合约就没法通过常规的转账交易接收以太(会抛出异常)。
注意:receive
函数可能只有 2300 gas 可以使用(如,当使用 send
或transfer
时),
fallback
函数或 receive
函数的合约,直接接收以太币(没有函数调用,使用 send
或 transfer
)会抛出一个异常, 并返还以太币。所以如果你想让你的合约在任何情况下都可以接收以太币,必须实现 receive
函数(使用 payable fallback
函数不再推荐,因为它会让借口混淆)。
合约代码从区块链上移除的唯一方式是合约在合约地址上的执行自毁操作 selfdestruct
。selfdestruct
作用是 销毁合约,并把余额发送到指定地址类型 Address。
做了两件事:
- 销毁合约:它使合约变为无效,删除该地址地字节码。
- 它把合约的所有资金强制发送到目标地址。
- 如果接受的地址是合约,即使里面没有
fallback
和receive
也会发送过去
- 如果接受的地址是合约,即使里面没有
- 除非必要,不建议销毁合约。
- 如果有人发送以太币到移除的合约,这些以太币可能将永远丢失
- 如果要禁用合约,可以通过修改某个内部状态让所有函数无法执行,这样也可以达到目的。
- 即便一个合约的代码中没有显式地调用
selfdestruct
,它仍然有可能通过delegatecall
或callcode
执行自毁操作。
selfdestruct
删除,它仍然是区块链历史的一部分,区块链的链条中不可能无缘无故消失一个块,这样他们就没办法做校验了。 因此,使用 selfdestruct
与从硬盘上删除数据是不同的。
请注意 selfdestruct
具有从 EVM 继承的一些特性:
- 接收合约的
receive
函数 不会执行。 - 合约仅在交易结束时才真正被销毁,并且
revert
可能会“撤消”销毁。此外,当前合约内的所有函数都可以被直接调用,包括当前函数。
在 0.5.0 之前, 还有一个
suicide
,它和selfdestruct
语义是一样的。
- BiliBili: 第一章第 7 节: selfdestruct:合约自毁
- Youtube: 第一章第 7 节: selfdestruct:合约自毁
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Kill {
uint256 public aaa = 123;
constructor() payable {}
function kill() external{
selfdestruct(payable(msg.sender));
}
function bbb() external pure returns(uint256){
return 1;
}
fallback() external {}
receive() external payable {}
}
- 先调用
aaa
/bbb
,查看输出值 - calldata 形式进行转账
- kill 销毁合约
- 查看收到的金额
- 查看 aaa 的值
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Kill {
uint256 public aaa = 123;
constructor() payable {}
function kill() external {
selfdestruct(payable(msg.sender));
}
function bbb() external pure returns(uint256){
return 1;
}
fallback() external {}
receive() external payable {}
}
contract Helper {
// 没有 `fallback` 和 `receive`,正常没办法接受ETH主币
function getBalance() external view returns (uint256) {
return address(this).balance;
}
// kill 后,此时 Helper 余额就会强制收到ETH主币
function kill(Kill _kill) external {
_kill.kill();
}
}
- 部署 Kill
- 先调用
Kill.aaa
,查看输出值 - calldata 形式进行转账,查看余额
- 部署 Helper
- 查看
Helper.getBalance
返回值 - calldata 形式进行转账,此时会失败
- 调用
Helper.kill
- 查看
Helper.getBalance
返回值 - 查看
Kill.aaa
的值
为了让学习的内容,可以更好的使用,每一章后面都会最少有一个实战练习。当前是第一章,学习的内容比较简单和浅显,所以做一个简单的小联系就可以了。
为此我写了这个阅兵式里同志们好
的场景合约,用于能力自检,相当于加强版的 Hello World。再次提醒,本教程默认读者已经掌握了 Solidity 基本语言的使用方法。这并不是针对初次学习 Solidity 的教程。
同志们好的场景:
- 领导说“同志们好”,回复“领导好”
- 领导说“同志们辛苦了”,回复“为人民服务”
个人习惯是将代码按照功能进行区域划分,每一个区域使用如下注释标记。
/*
* ========================================
* State Variables
* ========================================
*/
废话不多说,直接上代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
/// @title 一个模拟同志们好的简单演示
/// @author Anbang
/// @notice 您只能将此合约用于最基本的模拟演示
/// @dev 本章主要内容的实战练习
/// @custom:experimental 这是实验的合约。
contract HelloComrades {
/*
* ========================================
* State Variables
* ========================================
*/
/// @notice 用于标记当前进度
/// @dev 0:等待领导说"同志们好",
/// @dev 1:等待同志们说"领导好",
/// @dev 2:等待领导说"同志们辛苦了"
/// @dev 3:等待同志们说"为人民服务"
/// @dev 4:等待销毁。
/// @return 当前进度
uint8 public step = 0;
/// @notice 用于标记领导地址
/// @dev 不可变量,需要在构造函数内指定,以后就不能修改了
/// @return 当前领导的地址
address public immutable leader;
/// @notice 用于遇到错误时的无脑复读机式回复
string internal constant UNKNOWN =
unicode"我不知道如何处理它,你找相关部门吧!";
/*
* ========================================
* Events
* ========================================
*/
/// @notice 用于对当前 step 被修改时的信息通知
/// @dev 只要发生 step 修改,都需要抛出此事件
/// @param 当前修改的最新 step
event Step(uint8);
/// @notice 用于对当前合约的金额变动通知
/// @dev 只要发生金额修改,都需要抛出此事件
/// @param tag: 标记内容
/// @param from: 当前地址
/// @param value: 当前发送金额
/// @param data: 当前调用的data内容
event Log(string tag, address from, uint256 value, bytes data);
/*
* ========================================
* Modifier
* ========================================
*/
/// @notice 检查只能领导调用
/// @dev 用于领导专用函数
modifier onlyLeader() {
require(msg.sender == leader, unicode"必须领导才能说");
_;
}
/// @notice 检查只能非领导调用
/// @dev 用于非领导地址检测
modifier notLeader() {
require(
msg.sender != leader,
unicode"不需要领导回答,需要同志们来回答"
);
_;
}
/*
* ========================================
* Errors
* ========================================
*/
/// @notice 自定义的错误,这种注释内容会在错误时显示出来
/// @dev 用于所有未知错误
/// This is a message des info.需要上方空一行,才可以显示出来
error MyError(string msg);
/*
* ========================================
* Constructor
* ========================================
*/
/// @dev 用于领导地址的指定,后续不可修改
constructor(address _leader) {
require(_leader != address(0), "invalid address");
leader = _leader;
}
/*
* ========================================
* Functions
* ========================================
*/
/// @notice 用于领导说"同志们好"
/// @dev 只能在 step 为 0 时调用,只能领导调用,并且只能说"同志们好"
/// @param content: 当前领导说的内容
/// @return 当前调用的状态,true 代表成功
function hello(string calldata content) external onlyLeader returns (bool) {
if (step != 0) {
revert(UNKNOWN);
}
if (!review(content, unicode"同志们好")) {
revert MyError(unicode"必须说:同志们好");
}
step = 1;
emit Step(step);
return true;
}
/// @notice 用于同志们说"领导好"
/// @dev 只能在 step 为 1 时调用,只能非领导调用,并且只能说"领导好"
/// @param content: 当前同志们说的内容
/// @return 当前调用的状态,true 代表成功
function helloRes(string calldata content)
external
notLeader
returns (bool)
{
if (step != 1) {
revert(UNKNOWN);
}
if (!review(content, unicode"领导好")) {
revert MyError(unicode"必须说:领导好");
}
step = 2;
emit Step(step);
return true;
}
/// @notice 用于领导说"同志们辛苦了"
/// @dev 只能在 step 为 2 时调用,只能领导调用,并且只能说"同志们辛苦了",还需给钱
/// @param content: 当前领导说的内容
/// @return 当前调用的状态,true 代表成功
function comfort(string calldata content)
external
payable
onlyLeader
returns (bool)
{
if (step != 2) {
revert(UNKNOWN);
}
if (!review(content, unicode"同志们辛苦了")) {
revert MyError(unicode"必须说:同志们辛苦了");
}
if (msg.value < 2 ether) {
revert MyError(unicode"给钱!!!最少2个以太币");
}
step = 3;
emit Step(step);
emit Log("comfort", msg.sender, msg.value, msg.data);
return true;
}
/// @notice 用于同志们说"为人民服务"
/// @dev 只能在 step 为 3 时调用,只能非领导调用,并且只能说"为人民服务"
/// @param content: 当前同志们说的内容
/// @return 当前调用的状态,true 代表成功
function comfortRes(string calldata content)
external
notLeader
returns (bool)
{
if (step != 3) {
revert(UNKNOWN);
}
if (!review(content, unicode"为人民服务")) {
revert MyError(unicode"必须说:为人民服务");
}
step = 4;
emit Step(step);
return true;
}
/// @notice 用于领导对
/// @dev 只能在 step 为 4 时调用,只能领导调用
/// @return 当前调用的状态,true 代表成功
function destruct() external onlyLeader returns (bool) {
if (step != 4) {
revert(UNKNOWN);
}
emit Log("selfdestruct", msg.sender, address(this).balance, "");
selfdestruct(payable(msg.sender));
return true;
}
/*
* ========================================
* Helper
* ========================================
*/
/// @notice 用于检查调用者说的话
/// @dev 重复检测内容的代码抽出
/// @param content: 当前内容
/// @param correctContent: 正确内容
/// @return 当前调用的状态,true 代表内容相同,通过检测
function review(string calldata content, string memory correctContent)
private
pure
returns(bool){
return keccak256(abi.encodePacked(content)) == keccak256(abi.encodePacked(correctContent));
}
receive() external payable {
emit Log("receive", msg.sender, msg.value, "");
}
fallback() external payable {
emit Log("fallback", msg.sender, msg.value, msg.data);
}
/// @notice 用于获取当前合约内的余额
/// @dev 一个获取当前合约金额的辅助函数
/// @return 当前合约的余额
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
- 部署合约
- 需要输入 Leader 地址作为参数
- 点击 【leader】 查看信息
- 点击 【destruct】 进行销毁
- 此时报错,因为步骤不对
- 【hello】
- 输入错的内容
- 输入同志们好
- 查看 step 值
- 【helloRes】
- 输入错的内容,此时提示,账号不对。
- 切换账号后输入错的内容,提示必须说:领导好
- 输入领导好
- 查看 step 值
- 【comfort】
- 输入错的内容,此时提示账号权限不对
- 切换账号后,输入错的内容,提示必须说:同志们辛苦了
- 点击【hello】,此时说提示我不知道如何处理它,你找相关部门吧!,因为 step 不对。
- 输入同志们辛苦了,此时提示必须给钱;(只有给了 2 个以上的以太币,才能说同志们辛苦了。)
- 我们给 2 个 wei,假装是 2 个 ETH,看能否通过。(结果还是不能通过)
- 给 2 个以太,并输入同志们辛苦了。此时可以通过了
- 点击【getBalance】查看合约的余额
- 查看 step 值
- 【comfortRes】
- 点击【helloRes】,此时说提示我不知道如何处理它,你找相关部门吧!,因为 step 不对。
- 切换账号后,输入错的内容,提示必须说:为人民服务
- 输入为人民服务
- 【calldata】调用
- 输入 1wei ,无参数直接调用;查看交易详情内的 logs,此时是 receive,余额变化多 1wei
- 输入 2wei,参数使用
0x00
调用,查看交易详情内的 logs,此时是 fallback,余额变化多 2wei
- 【destruct】调用,注意查看余额变化。
- 注意查看当前 leader 地址的余额
- 先使用非 leader 地址触发【destruct】,提示错误
- 然后是 leader 地址触发。查看交易详情种的 logs
- 查看 leader 地址/ balance/step,都已经是默认值
- 触发所有函数,此时函数都可以使用,但是都是默认值。
- 合约的基本用法
- 合约的构造函数使用
- 函数的基本用法
- 函数中条件判断和错误输出
- 事件和事件触发
- NatSpec 用法演示
- 自定错误使用和触发,以及结合
NatSpec
抛出错误
- 自定错误使用和触发,以及结合
fallback
和receive
的使用和不同之处immutable
不可变量的使用constant
常量的使用- unicode 字面常量
- modifier 使用
keccak256
结合abi.encodePacked
判断字符串是否相同
solc --userdoc --devdoc a.sol
这个例子中 step,因为只有几个选择,尝试将 step 改为 enum 类型。
- BiliBili: 第一章: 实战 1: 同志们好
- Youtube: 第一章: 实战 1: 同志们好
- 所有人都可以存钱
- ETH
- 只有合约 owner 才可以取钱
- 只要取钱,合约就销毁掉
selfdestruct
- 扩展:支持主币以外的资产
- ERC20
- ERC721
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Bank {
// 状态变量
address public immutable owner;
// 事件
event Deposit(address _ads, uint256 amount);
event Withdraw(uint256 amount);
// receive
receive() external payable {
emit Deposit(msg.sender, msg.value);
}
// 构造函数
constructor() payable {
owner = msg.sender;
}
// 方法
function withdraw() external {
require(msg.sender == owner, "Not Owner");
emit Withdraw(address(this).balance);
selfdestruct(payable(msg.sender));
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
- BiliBili: 第一章: 实战 2: 存钱罐合约
- Youtube: 第一章: 实战 2: 存钱罐合约
WETH 是包装 ETH 主币,作为 ERC20 的合约。
标准的 ERC20 合约包括如下几个
- 3 个查询
balanceOf
: 查询指定地址的 Token 数量allowance
: 查询指定地址对另外一个地址的剩余授权额度totalSupply
: 查询当前合约的 Token 总量
- 2 个交易
transfer
: 从当前调用者地址发送指定数量的 Token 到指定地址。- 这是一个写入方法,所以还会抛出一个
Transfer
事件。
- 这是一个写入方法,所以还会抛出一个
transferFrom
: 当向另外一个合约地址存款时,对方合约必须调用 transferFrom 才可以把 Token 拿到它自己的合约中。
- 2 个事件
Transfer
Approval
- 1 个授权
approve
: 授权指定地址可以操作调用者的最大 Token 数量。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract WETH {
string public name = "Wrapped Ether";
string public symbol = "WETH";
uint8 public decimals = 18;
event Approval(address indexed src, address indexed delegateAds, uint256 amount);
event Transfer(address indexed src, address indexed toAds, uint256 amount);
event Deposit(address indexed toAds, uint256 amount);
event Withdraw(address indexed src, uint256 amount);
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
function deposit() public payable {
balanceOf[msg.sender] += msg.value;
emit Deposit(msg.sender, msg.value);
}
function withdraw(uint256 amount) public {
require(balanceOf[msg.sender] >= amount);
balanceOf[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
emit Withdraw(msg.sender, amount);
}
function totalSupply() public view returns (uint256) {
return address(this).balance;
}
function approve(address delegateAds, uint256 amount) public returns (bool) {
allowance[msg.sender][delegateAds] = amount;
emit Approval(msg.sender, delegateAds, amount);
return true;
}
function transfer(address toAds, uint256 amount) public returns (bool) {
return transferFrom(msg.sender, toAds, amount);
}
function transferFrom(
address src,
address toAds,
uint256 amount
) public returns (bool) {
require(balanceOf[src] >= amount);
if (src != msg.sender) {
require(allowance[src][msg.sender] >= amount);
allowance[src][msg.sender] -= amount;
}
balanceOf[src] -= amount;
balanceOf[toAds] += amount;
emit Transfer(src, toAds, amount);
return true;
}
fallback() external payable {
deposit();
}
receive() external payable {
deposit();
}
}
ETH 上的 WETH 合约参考: https://cn.etherscan.com/address/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2#code
- BiliBili: 第一章: 实战 3: WETH 合约
- Youtube: 第一章: 实战 3: WETH 合约
-
因为区块可以被撤回,编码时候有些需要注意的?
- 会出现你发起的交易被回滚甚至从区块链中抹除掉的可能。区块链不能保证当前的交易一定包含在下一个区块中。如果你开发的合约有顺序关系,要注意这个特性。合约内的逻辑,不能将某一个块作为依赖。
-
标记版本号有哪些方法?
^0.8.17
0.8.17
pragma solidity >=0.7.0 <0.9.0;
-
常用的版权声明有哪些,分别什么区别?
MIT
/BUSL
-
聊一聊 NatSpec 注释
- 单行使用
///
开始,多行使用/**
开头以*/
结尾。NatSpec 描述注释的作用非常重要,它是为函数、返回变量等提供丰富的文档。在编写合约的时候,强烈推荐使用NatSpec
为所有的开放接口(只要是在ABI
里呈现的内容)进行完整的注释。 - 可以输出错误,而不消耗 gas
- 单行使用
-
聊一聊存储,内存,栈的内容
- 存储:每一个地址都有一个持久化的内存,存储是将 256 位字映射到 256 位字的键值存储区。所以数据类型的最大值是
uint256
/int256
/bytes32
,合约只能读写存储区内属于自己的部分。 - 内存:合约会试图为每一次消息调用获取一块被重新擦拭干净的内存实例。所以储存在内存中的数据,在函数执行完以后就会被销毁。内存是线性的,可按字节级寻址,但读的长度被限制为 256 位,而写的长度可以是 8 位或 256 位。
- 栈:合约的所有计算都在一个被称为栈(stack)的区域执行,栈最大有 1024 个元素,每一个元素长度是 256 bit;所以调用深度被限制为 1024 ,对复杂的操作,推荐使用循环而不是递归。
- 存储:每一个地址都有一个持久化的内存,存储是将 256 位字映射到 256 位字的键值存储区。所以数据类型的最大值是
-
interface 如何使用
-
定义一个拥有某个方法的接口,传入地址后,调用地址。
interface AnimalEat { function eat() external returns (string memory); } contract Animal { function test(address _addr) external returns (string memory) { AnimalEat general = AnimalEat(_addr); return general.eat(); } }
-
-
string message = "Hello World!";
这种没有明确标注可视范围的情况下,message
的可视范围是什么? 是internal
还是private
?- private
-
变量如何使用以太币单位?
- 如果想用以太币单位来计算输入参数,你可以使用乘法来转换:
amountEth * 1 ether
- 如果想用以太币单位来计算输入参数,你可以使用乘法来转换:
-
receive 和 fallback 共存的调用?
- 只有 msg.data 为空,并且存在 receive 的时候,才会运行 receive。
-
receive 和 fallback 区别?
- receive 只负责接收主币
- 调用没有的方法时候执行,因为可以设置 payable,可以接收网络主币。尽管 fallback 可以是 payable 的,但并不建议这么做,声明为 payable 之后,其所消耗的 gas 最大量就会被限定在 2300。
-
合约没有 receive 和 fallback 可以接受以太币么?
- 可以接受,可以方法标记 payable 进行转账
-
聊一聊合约自毁
selfdestruct
。- 合约代码从区块链上移除的唯一方式是合约在合约地址上的执行自毁操作 selfdestruct 。selfdestruct 作用是 销毁合约,并把余额发送到指定地址类型 Address。
- 销毁合约:它使合约变为无效,删除该地址地字节码。
- 它把合约的所有资金强制发送到目标地址。
- 如果接受的地址是合约,即使里面没有
fallback
和receive
也会发送过去
- 如果接受的地址是合约,即使里面没有
- 除非必要,不建议销毁合约。
- 如果有人发送以太币到移除的合约,这些以太币可能将永远丢失
- 如果要禁用合约,可以通过修改某个内部状态让所有函数无法执行,这样也可以达到目的。
- 即便一个合约的代码中没有显式地调用
selfdestruct
,它仍然有可能通过delegatecall
或callcode
执行自毁操作。 - 即使一个合约被
selfdestruct
删除,它仍然是区块链历史的一部分,区块链的链条中不可能无缘无故消失一个块,这样他们就没办法做校验了。 因此,使用selfdestruct
与从硬盘上删除数据是不同的。
-
合约进行
selfdestruct
后,还可以调用状态变量和函数么?- 可以调用,但是返回默认值。如果想调用,也可以在存档节点里指定未删除的高度进行调用。