solidity 105
约 6348 字大约 21 分钟
2022-06-03
fallback & receive
receive
concept
来学习一个 Solidity 中的特殊函数 - receive
。
receive
函数只用于处理接收 ETH,一个合约最多只有一个,而且不能有任何的参数,不能返回任何值,必须包含 external
和 payable
。
比喻
简单来说,可以把 receive
理解成存钱窗口。你只能把钱放进去,也不能发送任何的消息,很多时候 receive
只是单纯的收钱窗口。
真实用例
在 Governor 合约中,使用 receive
函数来收取管理者可以开支的 Ether 。
/**
* @dev Function to receive ETH that will be handled by the governor (disabled if executor is a third party contract)
*/
receive() external payable virtual {
if (_executor() != address(this)) {
revert GovernorDisabledDeposit();
}
}
documentation
使用receive
关键字定义的一种特殊函数,必须包含external
和payable
。
//这里我们定义一个空的receive函数。
receive() external payable { ... }
FAQ
receive 函数是必须的吗
不是的,你可以选择定义 receive
也可以不定义,但是如果不定义,在合约收到转账时可能会报错。
receive 除了不能有参数和返回值之外和其他的函数还有什么区别?
receive 限制只能消耗2300 gas,这个数量的 gas 基本就只能收个 Ether
fallback
concept
来学习一个 solidity 当中的特殊函数 - fallback
。
fallback 函数充当了合约的默认处理函数,用于处理没有明确定义处理方式的消息。
fallback 函数会在三种情况下被调用
1.调用者尝试调用一个合约中不存在的函数时 2.用户给合约发 Ether 但是 receive
函数不存在 3.用户发 Ether,receive
存在,但是同时用户还发了别的数据( msg.data 不为空)
比喻
假设合约就像是一个快递员,当快递员收到一个包裹时,如果地址清晰,就按地址送达。如果地址不清晰,就执行默认操作,例如将包裹送回快递站。
在这个比喻中,fallback
函数就类似于快递员的默认操作。当你在合约中调用一个未定义的函数或者向合约发送以太币但合约中没有 receice
函数时,Solidity 会调用 fallback
函数作为默认处理方式。
documentation
fallback
是 solidity 中的特殊函数,定义方式为fallback()
关键字。需要注意的是 fallback
需要被定义为external
。
//这里我们定义了一个空的fallback函数。
fallback() external { }
fallback() external payable {...}
FAQ
每个合约都必须写 fallback 吗?
并不是每个合约都必须编写 fallback
函数。fallback
和 receive
一样都不是必须的。
fallback 和 receive 的区别?
二者都是处理 solidity 中默认逻辑的函数。fallback 可以不用被 payable
修饰,而 receive 必须被 payable 修饰。当 fallback 被定义为 payable 时,也可以充当 receive 的作用来接收 ETH 。
receive 像一个专门接收现金的收银员。当客户只是想付现金,而不需要任何其他服务时,他们就会去找这个收银员。
什么时候触发 fallback 函数 | receive 函数?
简单来说,合约接收 ETH 时,msg.data
为空且存在receive() 时,会触发 receive();msg.data 不为空或不存在 receive() 时,会触发 fallback() ,此时 fallback() 必须为 payable
。
需要注意的是,如果合约中既没有 receive
函数,也没有 payable
修饰的 fallback
函数,那么直接向合约发送以太币的操作将会失败,合约会拒绝接收以太币。但是,你仍然可以通过调用带有 payable
修饰的其他函数来向合约发送以太币。
selfdestruct & time
selfdestruct
concept
讲一个特殊情况:selfdestruct
函数。
顾名思义,selfdestruct
函数即自毁。这是 solidity 当中一个内置的特殊函数,调用该函数后,将触发合约的自毁,自毁将该合约从区块链中删除,在删除前,他还会将合约中存储的剩余 ETH 转移给指定的账户。
比喻
类似于现实中的销户并将所有的资金转移到另一个账户。例如,在下面的例子中,合约 A 调用了 selfdestruct
函数,并传入了合约 B 的地址。这将导致合约 A 的所有余额被转移到合约 B 的地址,并且合约 A 的代码将从区块链上被删除。
真实用例
selfdestruct
是一个极为特殊的函数,因为它可以将合约删除。我们查遍了 OpenZeppelin 的代码库,没有任何一个合约使用了这句语法。
selfdestruct 极易带来安全隐患,同样我们不建议任何合约为 selfdestruct 提供接口,如果一定要使用,请慎重考虑安全问题。
documentation
在函数中可以直接调用该内置函数。
contract DigitalWallet {
address payable public targetAddress;
//可以在部署时指定一个自毁时转移资产的地址
constructor(address payable _targetAddress) payable {
targetAddress = _targetAddress;
}
//自毁,会将合约剩余的所有ETH转给targetAddress地址。
function destroy() external {
selfdestruct(targetAddress);
}
}
FAQ
什么时候使用 selfdestruct?
这就相当于你有一个数字钱包,里面存有一定数量的加密货币(比如以太币)。你决定销毁这个数字钱包, 并且将其中的余额转移到你的另一个钱包地址上。你可以使用类似于 selfdestruct
函数的操作来实现这个过程。
time
concept
在 Solidity 中,时间戳是以秒为单位表示时间的,当我们想要表示一分钟时,可能还可以很快地意识到它是60秒,但是如果是一周、一个月或一年呢?
在编写代码时花费时间计算这些时间是不划算的。此外,直接使用秒数表示长时间间隔会降低代码的可读性。因此,Solidity 提供了一些全局变量,如 days,weeks,供开发者使用,以便更方便地表示一段时间。
比喻
假设你要设置一个7天后结束的活动,可以这样表示:
uint256 endTime = block.timestamp + 7 days;
真实用例
在 OpenZepplin的 AccessControlDefaultAdminRules 合约中,使用 5 days 作为 delay 的默认时间,既增加了可读性又避免了计算失误。
function defaultAdminDelayIncreaseWait() public view virtual returns (uint48) {
return 5 days;
}
documentation
使用 minutes, hours, days, weeks 这样的时间单位时,需要在前面指定单位的数量。
uint256 minute = 1 minutes;
uint256 minute = minutes; // 错误用法
uint256 hour= 1 hours;
uint256 day= 1 days;
uint256 week= 1 weeks;
FAQ
那一个月可能有30天或31天,一年可能存在闰年, solidity 是怎么处理 year 这种变量的呢?
正是因为出现这种分歧,所以这两种时间表示方式在 solidity 当中被禁用了。要表示 year,一般用 365 * day 来表示。
abi
abi.encode
concept
介绍 Solidity 当中特殊的全局变量 abi
。
首先,我们将学习全局函数 abi.encode
,它用于对给定的参数进行 ABI 编码,返回一个字节数组。
ABI (Application Binary Interface,应用二进制接口)是与以太坊智能合约交互的标准。在 EVM 处理数据时,所有的数据根据 ABI 标准进行编码。
比喻
就像人们在交流时需要共同的语言和规则一样,智能合约与外部世界进行交互时也需要一种共同的语言和规则。ABI 提供了这种共同的语言和规则,使得智能合约的函数调用和数据交换能够被正确编码和解码。
documentation
可以直接在函数中调用abi.encode()
函数对数据进行编码。
bytes memory encodedData = abi.encode(param1, param2);
●param1
和 param2
:这是要编码的参数。根据参数的类型,它们将被编码为字节数组。
●encodedData
:这是一个 bytes 类型的变量,用于存储通过 abi.encode(param1, param2)
对参数进行编码后的数据。编码后的数据将按照参数的类型和顺序进行紧凑的编码,形成一个动态字节数组。
FAQ
为什么要使用 abi.encode ?
abi.encode 是 Solidity 提供的一个非常有用的工具,用于将多个参数或变量编码为一个连续的字节数组,这在与智能合约交互时尤为重要。以下是使用abi.encode 的几个主要原因:
1.标准化编码:当与智能合约交互时,需要确保数据以特定的格式进行编码和解码。abi.encode
确保了数据按照 Ethereum 的 ABI 规范进行编码,从而确保数据的正确性和一致性。
2.提高代码可读性:直接使用 abi.encode
可以使代码更加简洁和可读,因为开发者不需要手动进行复杂的编码操作。
3.安全性:手动编码数据可能会导致错误,而 abi.encode
提供了一种安全、一致的方法来编码数据,从而减少了出错的可能性。
4.灵活性:abi.encode
可以用于编码各种不同的数据类型,包括结构体、数组和基本数据类型,这为开发者提供了很大的灵活性。
5.与其他函数和库的兼容性:许多 Ethereum 的函数和库都期望数据以特定的 ABI 格式进行编码。使用 abi.encode
可以确保与这些函数和库的兼容性。
abi.encode
是一个强大的工具,它简化了与智能合约交互的过程,确保了数据的正确性和一致性。
abi.decode
concept
如何使用 abi.decode
把 ABI 编码后的字节数组还原为其原始参数。
对于所有使用 abi.encode 编码的内容,我们都可以使用 abi.decode 解码。
documentation
使用 abi.decode()
函数可以对编码后的数据进行解码。第一个参数是编码数据的字节数组,第二个参数是解码后的数据类型。
address decodedAddress = abi.decode(encodedData, (address));
//多个参数
(uint256 decodedUint, address decodedAddress, string memory decodedString) = abi.decode(encodedData, (uint256, address, string));
FAQ
什么时候需要使用 abi.decode?
当我们与智能合约交互或在合约之间传递数据时,为了确保数据的完整性和一致性,我们经常使用 abi.encode 对数据进行编码。编码后的数据是一个字节数组,它代表了原始数据的 ABI 编码形式。
abi.decode 的使用场景主要包括:
1.数据验证:当我们从外部源(如其他合约或外部调用)接收到编码的数据并需要验证其内容时,我们会使用 abi.decode。
2.事件日志解析:当我们从智能合约的事件日志中获取编码的数据并希望解析它以获取具体的参数值时。
3.跨合约调用:当一个合约向另一个合约发送编码的数据,并且接收方合约需要解码这些数据以进行进一步的处理。
4.存储和恢复:当我们在合约的存储中保存编码的数据并在以后需要恢复原始数据时。
当我们面对已经被 abi.encode
编码的数据并需要访问其原始形式时,我们就会使用 abi.decode
。
abi.encodePacked
concept
在本节中,将学习 abi.encodePacked
,这是一个与 abi.encode
类似但有所不同的全局函数。它也用于将参数编码为符合 ABI 标准的字节数组,但不会为每个参数添加其类型的长度信息,也不会在参数之间添加分隔符,结果是一个紧密打包的字节数组。
比喻
将 abi.encodePacked
想象成一个紧凑的行李箱,其中所有物品都被紧密地放置,没有任何空隙。而 abi.encode
则是一个更大的行李箱,其中每个物品都有自己的专用空间。
documentation
可以直接在函数中调用abi.encodePacked()
函数对数据进行编码。
bytes memory encodedData = abi.encodePacked(param1, param2);
FAQ
和 abi.encode 有什么区别?
主要区别在于数据的压缩。
●abi.encodePacked
将参数紧密打包,就像将物品紧密地放在一起,没有任何额外的填充物或间隔。这种打包方式可以节省空间,但在解包时需要小心处理,因为物品之间没有明确的分隔符。
●相比之下,abi.encode
使用标准的分隔符和填充物进行组织。就像将物品放入不同的袋子,并每个袋子都有标签和规范,以确保物品的结构和类型完整性。尽管可能需要更多的空间,但在解包时更容易处理和识别每个物品。
由于紧密打包的特点,abi.encodePacked 不能编码结构体和嵌套数组。
使用场景?
abi.encodePacked
一般用在 hash
上。因为 abi.encodePacked
会比 abi.encode
编码出来的数据更短,所消耗的 gas 成本更低。
function selector
function signature
concept
接下来会学习 abi
编码在函数调用时的作用,在开始之前,你需要先了解什么是函数签名。
函数签名是一个函数的唯一标识符,它由函数名和参数类型组成。在 Solidity 中,所有函数调用都通过函数签名作为唯一标识。
documentation
函数签名是 函数名+参数字段类型的字符串。没有空格,不用缩写。
function hello(uint256 a, address b, bool c) {...}
signature = "hello(uint256,address,bool)";
function selector
concept
刚刚学习了函数签名,现在看看函数签名真正的用途:函数选择器。
函数选择器是函数签名的哈希前四个字节,用于在编码后的数据中唯一标识函数。
在 Solidity 中,所有函数调用其实是通过函数选择器作为唯一标识。
documentation
函数选择器是函数签名的哈希前4个字节。
在这里我们直接取函数签名的前4个字节即可,或者也可以直接使用functionName.selector
bytes4 selector = bytes4(keccak256(signature));
bytes4 selector = myFunction.selector;
FAQ
那么为什么要使用函数选择器而不直接使用函数签名呢
函数选择器只需要四个字节,大大节省了存储空间,这样会使链上的部署和调用都更加省 gas 。
选择器碰撞是什么?
选择器碰撞是指两个不同的函数他们的函数选择器是一样的。
例如transferFrom(address,address,uint256)
的函数签名哈希为:
0x23b872dd7302113369cda2901243429419bec145408fa8b352b3dd92b66c680b
而gasprice_bit_ether(int128)
的函数签名哈希为:
0x23b872ddd9b96c46b307d87a34b44cc03080be64e7bd1bf7c26e93b854ffbc75
他们的函数签名哈希其实是不同的,但是却有着相同的选择器:0x23b872dd
这样的两个函数在同一合约中是无法编译的,但是如果出现在代理合约和实现合约两个不同的合约中,就可能造成安全隐患。
abi.encodeWithSignature
concept
接下来我们将学习两种新的数据编码方式 abi.encodeWithSignature
和 abi.encodeWithSelector
。
首先我们会讲 abi.encodeWithSignature
。
有的时候,我们知道函数调用可能会失败,但是我们不希望调用失败后交易直接回滚。这时候,我们就需要 abi.encodeWithSignature
去和底层 EVM 交互。具体交互方式,会在后续的low level call
中讲解。但是 encodeWithSignature
提供了一种编码方式可以快捷的将调用函数需要的信息打包。
比喻
使用 abi.encodeWithSignature
类似于准备一块带有特定图案的独特拼图碎片,你可以将其发送给朋友,他们可以轻松将其与他们的拼图连接起来,确保拼图块完美匹配并传达出清晰的信息。
documentation
可以直接在函数中调用abi.encodeWithSignature
函数对数据进行编码。需要两种参数
1.函数签名
2.函数具体参数
abi.encodeWithSignature("myFunction(uint256,string)", 123, "Hello");
在上述代码中,我们使用了 abi.encodeWithSignature
函数来编码函数签名和参数。
FAQ
abi.encodeWithSignature 函数和 abi.encode 函数以及 abi.encodePacked 函数有什么区别?
●abi.encodeWithSignature 编码函数的签名和参数。类似于在菜谱上写下菜名(函数签名)和制作材料(参数),这样厨师就知道该做哪道菜以及需要哪些材料。
●abi.encode 和 abi.encodePacked 编码函数的参数,但不包括函数的签名。类似于菜谱上写下了制作材料(参数),没有写菜名(函数签名)。这种情况下,厨师知道需要使用哪些材料,但不知道应该制作哪道菜。
abi.encode
和 abi.encodePacked
更多的用途是在数据的存储和哈希上,而abi.encodeWithSignature
则是用于低级调用。
abi.encodeWithSelector
concept
在本节我们学习另一个数据编码函数 abi.encodeWithSelector
。
比喻
使用 abi.encodeWithSelector 就像制作一个签名印章,它不仅代表你的身份(函数),还包含了你书写的确切模式,这使得你可以在智能合约的上下文中精准而真实地授权和执行特定操作(函数调用)。
真实用例
在 OpenZeppelin 的 ShortStringsTest 合约中,有一个 testRevertLong
函数,该函数使用 abi.encodeWithSelector
来尝试调用 ShortStrings.StringTooLong
函数。
这里是这段代码的简化解释:
contract ShortStrings {
function StringTooLong(string memory input) external pure {
require(bytes(input).length >= 32, "String is not too long");
}
}
contract TestShortStrings {
ShortStrings public shortStrings;
constructor(address _shortStringsAddress) {
shortStrings = ShortStrings(_shortStringsAddress);
}
function testRevertLong(string memory input) external {
require(bytes(input).length >= 32, "Input should be a long string");
// 使用 abi.encodeWithSelector 来尝试调用 ShortStrings 的 StringTooLong 函数
bytes memory data = abi.encodeWithSelector(shortStrings.StringTooLong.selector, input);
(bool success,) = address(shortStrings).call(data);
require(!success, "Function did not revert as expected");
}
}
在这个简化的例子中,我们有一个 ShortStrings
合约,其中包含一个 StringTooLong
函数,该函数会在输入字符串长度小于32时失败。
TestShortStrings
合约有一个 testRevertLong
函数,该函数使用 abi.encodeWithSelector
来尝试调用 ShortStrings
中的 StringTooLong
函数,并检查其是否如预期那样失败。
documentation
可以直接在函数中调用 abi.encodeWithSelector
函数对数据进行编码。并通过bytes4(keccak256("函数名(参数列表)"))
的方式获取函数的选择器。
abi.encodeWithSelector(bytes4(keccak256("myFunction(uint256,string)")),123, "Hello");
//可以通过函数名.selector()的方式获取函数的选择器。
bytes4 selector = this.myFunction.selector;
abi.encodeWithSelector(selector, 123, "Hello");
FAQ
abi.encodeWithSelector 和 abi.encodeWithSignature 有什么区别?
●abi.encodeWithSelector
函数需要手动提供函数的选择器作为第一个参数。选择器是为函数签名哈希后的前4个字节,由函数名称和参数类型进行计算得到的。
●abi.encodeWithSignature
函数只需要提供函数签名的字符串形式作为第一个参数,不需要手动提供选择器。
使用 abi.encodeWithSelector
就像是你已经知道了菜名的简写(函数选择器),并把制作材料(参数)写在菜谱上。
low-level call
call
concept
从本节开始,我们将开始学习 Solidity 当中一种特殊的函数调用方式:低级调用。
低级调用其实是直接和 EVM(以太坊虚拟机)交互的一种调用方式,因此它具有更高的灵活性。
比喻
我们可以将合约之间的交互类比为人与人之间的交流方式。普通调用就像是使用电话这样的工具进行交流,非常方便和直接。
而低级调用则类似于书信的形式。在进行低级调用时,你需要提供更多关于接收方的详细信息,类似于收件人的地址和联系方式,才能与其进行交流。
真实用例
参考 OpenZeppelin 的ReentrancyMock 合约。这个合约是为了测试和演示重入攻击的防护机制而设计的。在这个合约中,有一个名为 countThisRecursive
的函数,它使用了低级的 address.call
函数来递归地调用自己:
function countThisRecursive(uint256 n) public nonReentrant {
if (n > 0) {
_count();
(bool success, ) = address(this).call(abi.encodeCall(this.countThisRecursive, (n - 1)));
require(success, "ReentrancyMock: failed call");
}
}
在这个函数中,我们首先检查 n 是否大于 0。如果是,我们增加计数器的值,并使用 address.call
方法调用 countThisRecursive
函数,其中 n 的值减少了1。这样,我们可以递归地调用这个函数,直到 n 变为0。
这个例子展示了如何在合约内部进行低级函数调用。这种方法提供了更多的灵活性,允许我们在运行时决定调用哪个函数,而不是在编写代码时硬编码函数名。
documentation
最基础的低级调用通常使用 address.call
函数来实现。
//abiEncodedData为我们上一章中提到的abi.encodeWithSignature
//和abi.encodeWithSelector的结果
(bool success, bytes memory data) = address(targetAddress).call{value: amount}(abiEncodedData);
在上述语法中:
●targetAddress
:是目标合约的地址。
●value
:是可选参数,用于向目标合约发送以太币。
●abiEncodedData
:是目标合约函数的ABI编码数据(通过 abi.encodeWithSignature
或者 abi.encodeWithSelector
编码)。
FAQ
为什么要使用低级调用?
低级调用更加灵活,它的优势主要体现在:
1.灵活的交互:低级调用允许你仅通过合约的链上地址与其进行交互,而无需提供接口或合约变量等详细信息。这使得与其他合约的交互更加简便,你只需关注目标合约的地址即可。
contract ContractA {
function aa() public {
//function body
}
}
contract B {
function bb(address contractAddress) public {
//正常调用
ContractA(contractAddress).aa();
//低级调用不需要知道合约
contractAddress.call(data);
}
}
2.非回滚性调用:使用低级调用进行函数调用时,如果调用失败并出现异常,不会导致整个交易的回滚。相反,它会返回一个布尔值来指示调用是否成功。这种设计使得开发者可以更加灵活地处理调用的结果,根据需要采取适当的措施。回滚性调用如下图:
delegatecall
concept
在上一节中,我们学习了低级调用中的 call。本节将为你介绍 call 的一个孪生兄弟 delegatecall 。
比喻
顾名思义,delegatecall 是委托调用。就像你委托了一个任务给另一个人来完成,而这个人将以你的名义执行任务。
在 Solidity 中体现为:它允许你将另外一个合约的代码拷贝到当前合约,并执行。
真实用例
参考 OpenZeppelin 的 Address library 合约。
function functionDelegateCall(address target, bytes memory data) internal returns (bytes memory) {
(bool success, bytes memory returndata) = target.delegatecall(data);
return verifyCallResultFromTarget(target, success, returndata);
}
这个 functionDelegateCall 函数使用 delegatecall
来调用目标地址 target 上的指定函数。由于使用了 delegatecall
,虽然函数逻辑来自目标合约,但所有的状态变化都会发生在当前合约中。
documentation
delegatecall
的语法与 call
语法一致,使用address.delegatecall()
来实现。
(bool success, bytes memory data) = address(targetAddress).delegatecall(abiEncodedData);
在上述语法中:
●targetAddress
:是目标合约的地址。
●abiEncodedData
:是目标合约函数的 ABI 编码数据。
FAQ
使用 delegatecall 需要注意!
当使用 delegatecall
时,它实际上是将要调用的函数的代码复制到当前合约中进行执行。这意味着被调用的函数将在当前合约的上下文环境下执行,允许外部合约来改变当前合约的存储布局。
这种调用模式非常危险!在以太坊的历史上,由于对delegatecall的错误使用而引发了许多安全漏洞和黑客攻击。
什么时候需要这么做?
由于部署的 Solidity 合约不可更改,那我们希望更新函数功能的话怎么办呢?我们先部署一个代理合约 A,在里面 delegatecall
合约 B 的功能。
更新时,只需要更改合约 B 的地址变成合约 C,这样合约 A 就可以使用新版合约 C 的功能。
msg.data
concept
在前面我们学习了 msg.value
这个全局变量,用于获取调用者附加的以太币价值。现在让我们继续学习 msg
中的全局变量 msg.data
。
msg.data 是一个 bytes 类型,它包含了函数调用的原始数据。通过使用 msg.data
,您可以访问传递给函数的原始字节数据,进而进行解析和处理。
真实用例
参考 OpenZeppelin 的 Context 合约。
abstract contract Context {
function _msgSender() internal view virtual returns (address) {
return msg.sender;
}
function _msgData() internal view virtual returns (bytes calldata) {
return msg.data;
}
}
在 Context 合约中,通过_msgData 函数提供了一个间接的方式来获取这些数据,这样可以为将来的扩展或修改提供更大的灵活性,例如处理元交易。
documentation
在 Solidity 中,我们可以通过 msg.data
全局变量获取函数调用的原始数据。
bytes memory data = msg.data;
FAQ
为什么需要 msg.data ?
当你调用合约的函数时,除了传递以太币外,还可以在函数调用中传递其他数据。这些数据可以是任何类型,包括字符串、字节数组等。通过使用 msg.data
,您可以访问这些传递的数据,并在合约中执行相应的逻辑。