来源:登链社区
在今天的文章中,我们将看一下 Solidityevent,在更通用的以太坊和 EVM 中称为logs。我们将看到如何使用它们,它们的定义以及如何使用事件主题哈希和签名来过滤日志,以及关于何时应该使用这些的一些建议。
我们还将涵盖 检查-事件-交互 模式,这种著名的模式传统上应用于状态变量的重入,但我们将看到为什么这样的模式也应该应用于触发事件以及涉及的潜在风险和安全漏洞。
如何在 Solidity 中定义事件?
可以使用event
关键字在 Solidity 中定义事件,如下所示。
interface ILight {
event SwitchedON();
event SwitchedOFF();
event BulbReplaced();
}
你可以通过完全限定的访问合约名称,后跟.
和事件名称来从另一个合约中访问事件,如下所示:
event RegisteredSuccessfully(address user)
事件签名将是:
event RegisteredSuccessfully(address user)
事件主题哈希将是:
bytes32 topicHash = RegisteredSuccessfully.selector;
请注意,只有 Solidity v0.8.15 以后,事件的.selector
成员才能使用。
如果你查看发出的任何区块链日志,你会发现日志的主题的索引0
(第一个)条目的对应于事件主题哈希。由于主题是能通过日志进行搜索的内容,因此我们可以用事件主题哈希能进行过滤:
在特定地址的智能合约内搜索特定事件。
在区块链上的所有合约中搜索特定事件。
我们将在下面进一步看到,
anonymous
匿名事件是此规则的例外。anonymous
关键字使它们不可搜索,因此使用术语“匿名”。
基于这一事实,我们还可以推断,Solidity 中定义的最简单的事件,没有参数,比如上面定义的事件BulbReplaced
或SwitchedON
,将在底层使用LOG1
操作码来触发日志中的主题,因为事件本身是可搜索的。
可以添加更多的主题,其他主题将使用LOG2
,LOG3
,LOG4
和LOG5
,只要这些参数被标记为indexed
。让我们在下一节中看一下索引参数。
事件参数和索引参数
事件可以接受任何类型的参数,包括值类型(uintN
,bytesN
,bool
,address
...),struct
,enum
和用户定义的值类型。
根据我在写本文的研究,唯一不允许的类型是内部函数类型。外部函数类型是允许的,但内部函数类型不允许。举例来说,下面的代码将无法编译。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
contract AnonymousEvents {
event SecretPasswordHashUpdated(bytes32 secretPasswordHash) anonymous;
}
如果事件声明为anonymous
,在合约 ABI 中,事件的"anonymous"
字段将标记为true
。
匿名事件的一个优点是,它使你的合约更便宜部署,并且在触发时 Gas方面也更便宜。
匿名事件的一个很好的用例是对于只有一个事件的合约。监听合约中的所有事件是有意义的,因为只有这一个事件将出现在事件日志中。订阅其名称是无关紧要的,因为只定义了一个单一事件来由合约发出。因此,你可以将事件定义为匿名,并订阅来自合约的所有事件日志,并确认它们都是相同的事件。
查看匿名事件在流行代码库中的使用示例,如在 DappHub 的 DS-Note 合约[7] 中。
我们可以在上面的代码片段中看到,由于事件声明为匿名,这使得可以定义第四个“indexed”参数。
请注意,由于匿名事件没有 bytes32 主题哈希,因此匿名事件不支持.selector
成员。
使用 LOG 操作码在汇编中触发事件
在汇编中触发事件是可能的,使用logN
指令,该指令对应于 EVM 指令集中的操作码。
要在汇编中触发事件,你必须将要由事件发出的所有数据存储在memory
中的特定位置。
一旦你将要由事件发出的数据存储在内存中,然后可以将以下参数指定给 logN 指令:
p = 从中开始获取数据的内存位置。基本上这是一个内存指针,或者是一个“偏移量”或“内存索引”,具体取决于你如何称呼它。
s = 你希望从 p 开始在事件中发出的字节数。
所有其他参数
t1
、t2
、t3
和t4
都是你希望成为可索引的事件参数。请注意这里有两个重要的事情:1)这些参数应该与你事件定义中以相同顺序定义的参数相同,2)这些参数应该放在内存中以获取数据。
下面的代码片段显示了如何在汇编中执行此操作。
event ExampleEventAsm(bytes32 tokenId);
function _emitEventAssembly(bytes32 tokenId) internal{
bytes32 topicHash = ExampleEventAsm.selector;
assembly {
let freeMemoryPointer := mload(0x40)
mstore(freeMemoryPointer, topicHash)
mstore(add(freeMemoryPointer, 32), tokenId)
// emit the `ExampleEventAsm` event with 2 topics
log2(
freeMemoryPointer, // `p` = starting offset in memory
64, // `s` = number of bytes in memory from `p` to include in the event data
topicHash, // topic for filtering the event itself
tokenId // 1st indexed parameter
)
}
}
事件的 gas 成本
所有记录操作码(LOG0
、LOG1
、LOG2
、LOG3
、LOG4
)都需要消耗 gas。它们具有的参数(主题)越多,它们消耗的 gas 就越多。
此外,像索引或数据大小等其他因素也会导致事件发出消耗更多 gas。
检查 - 事件 - 交互模式
检查-生效-交互模式[9]也适用于事件。
一种检测这些模式的方法是使用 Remix 静态分析工具。
这种模式也可以被 Slither 检测到。当对一个在外部调用后触发事件的合约运行 slither 时,你将得到一个发现,提示 “重入事件”。
因此,对于 dApp 来说,顺序很重要,这样你就可以正确地查看哪个事件首先、接下来和最后被发出。这在递归或重入调用的情况下尤其重要。如果在外部调用后触发事件,并且这个外部调用进行了一个重入调用,那么:
第一个发出的事件是第二次重入调用完成后的事件。
第二个发出的事件是初始交易后发出的事件。
理解这一点,也使得可以在链下提供清晰的审计跟踪,以监视合约调用。你可以看到哪些函数首先和最后被调用,以及在执行交易期间每个例程的运行顺序。
slither 检测器文档[10] - Solidity 和 Vyper 的静态分析器。
这种潜在的漏洞也在 Trail of Bits 对 Liquity[11] 智能合约的审计中发现并报告。
何时应该触发事件?
在你的合约中可能有几种情况下触发事件可能很重要和有用。
当受限制的用户和地址执行某些操作时(例如:所有者或合约管理员)。这包括例如受欢迎的
transfer ownership (address)
函数,该函数只能由所有者调用以更改合约的所有者。
更改一些关键变量或算术参数,这些变量负责合约的核心逻辑。在 DeFi 协议的背景下尤其重要。
Slither 检测器文档[12]中描述了更多关于这些情况的信息。
这也在 Trail 对 LooksRare 的审计报告中描述了。
监视在生产中部署的合约以检测异常。
查看 0xprotocol[13] 的详细信息,了解有关事件的安全相关问题。
参考
匿名事件使用目的的缺失文档(知其所以然)[14]
[匿名事件的优势]