深入了解 Solidity 事件 - Event

定义事件以及如何使用事件主题哈希和签名来过滤日志,以及关于何时应该使用事件的一些建议。 你知道 检查-事件-交互 模式么? 看看本篇文章

在今天的文章中,我们将看一下 Solidity event,在更通用的以太坊和 EVM 中称为logs。我们将看到如何使用它们,它们的定义以及如何使用事件主题哈希和签名来过滤日志,以及关于何时应该使用这些的一些建议。

我们还将涵盖 检查-事件-交互 模式,这种著名的模式传统上应用于状态变量的重入,但我们将看到为什么这样的模式也应该应用于触发事件以及涉及的潜在风险和安全漏洞。

如何在 Solidity 中定义事件?

可以使用event关键字在 Solidity 中定义事件,如下所示。

event RegisteredSuccessfully(address user)

当使用动态类型和多值类型,如bytesstring或类型为T[]的数组时,在事件定义中不需要为参数指定数据位置。

Solidity 事件只能在合约、库或接口中定义。但是从 Solidity v0.8.22 以后,事件也可以在文件级别定义。

你还可以从另一个合约中访问在合约中定义的事件。从 Solidity v0.8.15 以后,可以从其他合约完全限定地访问事件。

你有又以下文件:

interface ILight {
    event SwitchedON();
    event SwitchedOFF();
    event BulbReplaced();
}

你可以通过完全限定的访问合约名称,后跟.和事件名称来从另一个合约中访问事件,如下所示:

import {ILight} from "ILight.sol";

contract LightHouse {

    function lightTowardsDirections(uint256 latitude, uint256 longitude) public {
      // code logic

      emit ILight.SwitchedON();
    }

}

在函数中触发事件

你可以在函数中使用emit关键字发出event

如果函数会触发事件,则不能将其定义为viewpure。这是因为触发事件会将数据写入区块链(到日志中)。

事件签名

在 Solidity 中,事件的签名形式与函数签名相同。

它简单地对应于事件名称+用逗号,分隔的参数类型,所有这些都用括号括起来。

使用我们之前的示例:

event RegisteredSuccessfully(address user)

事件签名将是:

RegisteredSuccessfully(address)

事件主题哈希

当监听合约的事件时,这些事件是使用事件主题哈希进行过滤的。

事件主题哈希对应于事件签名的keccak256哈希。

使用我们之前的示例:

event RegisteredSuccessfully(address user)

事件主题哈希将是:

keccak256("RegisteredSuccessfully(address)")

= 0x2a5fc519cb1ec56867d94a911a7ba739c06c6772c4841545feb12cea840ab90c

可以使用以下语法在 Solidity 中访问事件主题哈希。这将返回 32 字节的选择器主题。返回的类型确实是bytes32

bytes32 topicHash = RegisteredSuccessfully.selector;

请注意,只有 Solidity v0.8.15 以后,事件的 .selector 成员才能使用。

如果你查看发出的任何区块链日志,你会发现日志的主题的索引0(第一个)条目的对应于事件主题哈希。由于主题是能通过日志进行搜索的内容,因此我们可以用事件主题哈希能进行过滤:

  • 在特定地址的智能合约内搜索特定事件。
  • 在区块链上的所有合约中搜索特定事件。

我们将在下面进一步看到,anonymous 匿名事件是此规则的例外。anonymous关键字使它们不可搜索,因此使用术语“匿名”

基于这一事实,我们还可以推断,Solidity 中定义的最简单的事件,没有参数,比如上面定义的事件BulbReplacedSwitchedON,将在底层使用 LOG1 操作码来触发日志中的主题,因为事件本身是可搜索的。

可以添加更多的主题,其他主题将使用LOG2LOG3LOG4LOG5,只要这些参数被标记为indexed。让我们在下一节中看一下索引参数。

事件参数和索引参数

事件可以接受任何类型的参数,包括值类型(uintNbytesNbooladdress...),structenum和用户定义的值类型。

根据我在写本文的研究,唯一不允许的类型是内部函数类型。外部函数类型是允许的,但内部函数类型不允许。举例来说,下面的代码将无法编译。

// This is ok and valid
event SomeEvent(function () external callableFunction);

// This will not compile
event AnotherEvent(function () internal someInternalParameter);

Solidity 事件的参数可以指定为indexed。在这种情况下,它可以根据此事件中发出的特定值来缩小过滤事件。

标准的 Solidityevent可以包含最多 3 个indexed参数。

定义为anonymous的事件可以包含最多 4 个indexed参数。

额外说明,任何用作事件参数的复杂类型,如structenum或用户定义的值类型,将会将参数转换为 ABI 中的关联值类型。例如:

  • struct:作为在结构中指定类型的元组。
  • enum:作为uint8
  • 用户定义的值类型:其底层类型。

使用命名参数触发事件

与命名函数参数一样,可以使用对象{}语法使用命名参数触发事件。这可以帮助提高可读性。

LSP7DigitalAsset在 LUKSO LSP 智能合约上的实现为例。下面的屏幕截图显示了内部_mint函数中代码的一部分,强调了传递给Transfer事件的参数。

这提供了更好的可读性和清晰度。

img

来源:LSP7DigitalAssetCore.sol

匿名事件

在 Solidity 和 EVM 中,事件可以标记为anonymous。匿名事件在某种意义上是 Solidity 和 EVM 中的特殊事件,因为它们不能通过其名称进行过滤,因此不能直接监听。

Solidity 如下所述:

“…这意味着无法按名称过滤特定的匿名事件”

无法按名称过滤特定的匿名事件,你就只能按合约地址进行过滤。

例如,你可以监听智能合约发出的所有事件。这些匿名事件将出现在监听器中,但不能专门使用事件名称订阅它。这与其他事件不同。

你可以通过在事件定义(括号关闭后分号之前)之后和之前放置anonymous关键字来在 Solidity 中将事件定义为匿名。

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

contract AnonymousEvents {
    event SecretPasswordHashUpdated(bytes32 secretPasswordHash) anonymous;
}

如果事件声明为anonymous,在合约 ABI 中,事件的"anonymous"字段将标记为true

img

https://github.com/ethereum/solidity/issues/13086

匿名事件的一个优点是,它使你的合约更便宜部署,并且在触发时 Gas方面也更便宜。

匿名事件的一个很好的用例是对于只有一个事件的合约。监听合约中的所有事件是有意义的,因为只有这一个事件将出现在事件日志中。订阅其名称是无关紧要的,因为只定义了一个单一事件来由合约发出。因此,你可以将事件定义为匿名,并订阅来自合约的所有事件日志,并确认它们都是相同的事件。

查看匿名事件在流行代码库中的使用示例,如在 DappHub 的 DS-Note 合约 中。

img

来源代码

我们可以在上面的代码片段中看到,由于事件声明为匿名,这使得可以定义第四个“indexed”参数。

请注意,由于匿名事件没有 bytes32 主题哈希,因此匿名事件不支持 .selector 成员。

使用 LOG 操作码在汇编中触发事件

img

https://docs.soliditylang.org/en/v0.8.19/yul.html#evm-dialect

在汇编中触发事件是可能的,使用 logN 指令,该指令对应于 EVM 指令集中的操作码。

要在汇编中触发事件,你必须将要由事件发出的所有数据存储在 memory 中的特定位置。

一旦你将要由事件发出的数据存储在内存中,然后可以将以下参数指定给 logN 指令:

  • p = 从中开始获取数据的内存位置。基本上这是一个内存指针,或者是一个“偏移量”或“内存索引”,具体取决于你如何称呼它。
  • s = 你希望从 p 开始在事件中发出的字节数。
  • 所有其他参数 t1t2t3t4 都是你希望成为可索引的事件参数。请注意这里有两个重要的事情: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 成本

img

所有记录操作码(LOG0LOG1LOG2LOG3LOG4)都需要消耗 gas。它们具有的参数(主题)越多,它们消耗的 gas 就越多。

image-20240226195203141

此外,像索引或数据大小等其他因素也会导致事件发出消耗更多 gas。

检查 - 事件 - 交互模式

检查-生效-交互模式也适用于事件。

一种检测这些模式的方法是使用 Remix 静态分析工具。

这种模式也可以被 Slither 检测到。当对一个在外部调用后触发事件的合约运行 slither 时,你将得到一个发现,提示 “重入事件”。

因此,对于 dApp 来说,顺序很重要,这样你就可以正确地查看哪个事件首先、接下来和最后被发出。这在递归或重入调用的情况下尤其重要。如果在外部调用后触发事件,并且这个外部调用进行了一个重入调用,那么:

  1. 第一个发出的事件是第二次重入调用完成后的事件。
  2. 第二个发出的事件是初始交易后发出的事件。

理解这一点,也使得可以在链下提供清晰的审计跟踪,以监视合约调用。你可以看到哪些函数首先和最后被调用,以及在执行交易期间每个例程的运行顺序。

slither 检测器文档 - Solidity 和 Vyper 的静态分析器。

这种潜在的漏洞也在 Trail of Bits 对 Liquity 智能合约的审计中发现并报告。

img

img

何时应该触发事件?

在你的合约中可能有几种情况下触发事件可能很重要和有用。

  • 当受限制的用户和地址执行某些操作时(例如:所有者或合约管理员)。这包括例如受欢迎的 transfer ownership (address) 函数,该函数只能由所有者调用以更改合约的所有者。

img

  • 更改一些关键变量或算术参数,这些变量负责合约的核心逻辑。在 DeFi 协议的背景下尤其重要。

img

Slither 检测器文档中描述了更多关于这些情况的信息。

这也在 Trail 对 LooksRare 的审计报告中描述了。

img

  • 监视在生产中部署的合约以检测异常。

img

查看 0xprotocol 的详细信息,了解有关事件的安全相关问题。

参考

  1. 匿名事件使用目的的缺失文档(知其所以然)

  2. [匿名事件的优势]

--

本翻译由 DeCert.me 协助支持, 在 DeCert 构建可信履历,为自己码一个未来。

点赞 1
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
翻译小组
翻译小组
0x9e64...7c84
大家看到好的文章可以在 GitHub 提 Issue: https://github.com/lbc-team/Pioneer/issues 欢迎关注我的 Twitter: https://twitter.com/UpchainDAO