Solidity 事件是以太坊中最接近“打印”或“console.log”语句的东西。我们将解释它们如何工作以及何时使用它们。我们还将深入探讨其他资源中经常忽略的许多技术细节。
这是发出 solidity 事件的最小示例。
contract ExampleContract { // We will explain the significance of the indexed parameter later. event ExampleEvent(address indexed sender, uint256 someValue); function exampleFunction(uint256 someValue) public { emit ExampleEvent(sender, someValue); } }
也许最著名的事件是ERC20代币在转移时发出的事件。发件人、收件人和金额记录在一个事件中。
emit Transfer(from, to, amount);
这不是多余的吗?我们已经可以查看过去的交易以查看转账,然后我们可以查看交易调用数据以查看相同的信息。
这是正确的,可以删除事件并且不会影响智能合约的业务逻辑。但是,这不是查看历史的有效方式。
以太坊客户端没有用于按“类型”列出交易的 API,例如“ERC20 的所有转账交易”。如果您想查询交易,可以使用以下选项:
获取交易
getTransactionFromBlock
getTransactionFromBlock
API 只能告诉您在特定区块上发生了哪些交易,它不能针对多个区块的智能合约地址。
getTransaction
只能检查您知道交易哈希的交易。
另一方面,可以更容易地检索事件。
以下是以太坊客户端选项:
事件
事件.allEvents
获取过去事件
其中每一个都需要指定查询器希望检查的智能合约地址,并返回智能合约根据指定的查询参数发出的事件的子集(或全部)。
以下是为什么你会使用事件来跟踪交易而不是交易本身的关键见解:以太坊不提供一种机制来获取智能合约的所有交易,但它确实提供了一种机制来从智能合约中获取所有事件。
为什么是这样?使事件可快速检索需要额外的存储开销。如果以太坊对每笔交易都这样做,这将使链条变得更大。通过事件,solidity 程序员可以选择哪些信息值得为之支付额外的存储开销,以实现快速的链下检索。
下面是使用上述 API 的示例。在此代码中,客户端订阅来自智能合约的事件。这些例子都是用Javascript 编写的。
每次 ERC20 代币发出转账事件时,此代码都会触发回调。
const { ethers } = require("ethers"); // const provider = your provider const abi = [ "event Transfer(address indexed from, address indexed to, uint256 value)" ]; const tokenAddress = "0x..."; const contract = new ethers.Contract(tokenAddress, abi, provider); contract.on("Transfer", (from, to, value, event) => { console.log(`Transfer event detected: from=${from}, to=${to}, value=${value}`); });
如果我们想追溯查看事件,我们可以使用下面的代码。在此示例中,我们回顾过去的 ERC20 令牌中的批准交易。
const ethers = require('ethers'); const tokenAddress = '0x...'; const filterAddress = '0x...'; const tokenAbi = [ { "anonymous": false, "inputs": [ { "indexed": true, "name": "from", "type": "address" }, { "indexed": true, "name": "to", "type": "address" }, { "indexed": false, "name": "value", "type": "uint256" } ], "name": "Transfer", "type": "event" } ]; const tokenContract = new ethers.Contract(tokenAddress, tokenAbi, provider); // this line filters for Approvals for a particular address. const filter = tokenContract.filters.Approval(filterAddress, null, null); tokenContract.queryFilter(filter).then((events) => { console.log(events); });
如果你想寻找两个特定已知地址之间的交易(如果存在这样的交易),ethers.js。代码如下:
tokenContract.filters.Transfer(address1, address2, null);
这是web3.js而不是 ethers.js 中的类似示例。请注意,添加了fromBlock
和toBlock
查询参数(表示我们只关心这些块之间的事件),我们将演示侦听作为发件人的多个地址的能力。地址与“或”条件相结合。
const Web3 = require('web3'); const web3 = new Web3('https://rpc-endpoint'); const contractAddress = '0x...'; // The address of the ERC20 contract const contractAbi = [ { "anonymous": false, "inputs": [ { "indexed": true, "name": "from", "type": "address" }, { "indexed": true, "name": "to", "type": "address" }, { "indexed": false, "name": "value", "type": "uint256" } ], "name": "Transfer", "type": "event" } ]; const contract = new web3.eth.Contract(contractAbi, contractAddress); const senderAddressesToWatch = ['0x...', '0x...', '0x...']; // The addresses to watch for transfers from const filter = { fromBlock: 0, toBlock: 'latest', topics: [ web3.utils.sha3('Transfer(address,address,uint256)'), null, senderAddressesToWatch, ] }; contract.getPastEvents('Transfer', { filter: filter, fromBlock: 0, toBlock: 'latest', }, (error, events) => { if (!error) { console.log(events); } });
上面的示例之所以有效,是因为 ERC20 中的 Approve(和 Transfer)事件将发送者设置为索引。这是 Solidity 中的 ERC20 批准事件声明。
event Approval(address indexed owner, address indexed spender, uint256 value);
如果“owner”参数没有被索引,前面的 javascript 代码就会默默地失败。这里的含义是您不能过滤具有特定转账value
(代币数量)的 ERC20 事件,因为它没有被索引。您必须提取所有事件并在 javascript 端过滤它们;它不能在以太坊客户端中完成。
事件声明的索引参数称为主题。
普遍接受的事件最佳实践是在发生潜在的相应状态更改时记录它们。一些例子包括:
更改合约的所有者
移动以太
进行交易
并非每个状态更改都需要一个事件。 Solidity 开发人员应该问自己的问题是“有人有兴趣快速检索或发现此交易吗?”
这将需要一些主观判断。请记住,无法直接搜索未索引的参数,但在伴随索引参数时仍然可能是有用的数据。获得直觉的一个好方法是查看已建立的代码库如何设计它们的事件
作为一般经验法则,不应将加密货币数量编入索引,而应将地址编入索引,但不应盲目应用此规则。索引只允许您快速获取精确值,而不是值的范围。
这方面的一个例子是在铸造令牌时添加一个事件,因为底层库已经发出了这个事件。
事件是状态变化的;他们通过存储日志来改变区块链的状态。因此,它们不能用于视图(或纯)函数中。
事件不像其他语言中的 console.log 和 print 那样对调试有用;因为事件本身是状态变化的,所以如果交易恢复,它们就不会发出。
对于未索引的参数,参数数量没有内在限制,但当然有适用的合同大小和气体限制。以下无意义的示例是有效的可靠性:
contract ExampleContract { event Numbers(uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256); }
同样,日志中存储的字符串或数组的长度也没有内在限制。
但是,一个事件中不能有超过三个索引参数(主题)。一个匿名事件可以有 4 个索引参数(我们稍后会介绍这种区别)。
零事件的论证也是有效的。
以下事件的行为相同
event NewOwner(address newOwner); event NewOwner(address);
通常,包括变量名是理想的,因为以下示例背后的语义非常模糊(这不是您应该声明事件的方式!)
event Trade(address,address,address,uint256,uint256);
我们可以猜测地址对应于发送者和令牌地址,而 uint256es 对应于金额,但这很难破译。
将事件名称大写是惯例,但编译器并不要求这样做。这将是一个更好的声明:
event Trade(address trader, address token1, address token2, uint256 token1Amount, uint256 token2Amount);
当一个事件在父合约中声明时,它可以由子合约发出。事件是内部的,不能修改为私人或公共。
这是一个例子:
contract ParentContract { event NewNumber(uint256 number); function doSomething(uint256 number) public { emit NewNumber(number); } } contract ChildContract is ParentContract { function doSomethingElse(uint256 number) public { emit NewNumber(number); // valid } }
同样,事件可以在接口中声明并在子项中使用,如下例所示。
interface IExampleInterface { event Deposit(address indexed sender, uint256 amount); } contract ExampleContract is IExampleInterface { function deposit() external payable { emit Deposit(msg.sender, msg.value); // also valid } }
EVM(以太坊虚拟机)通过签名的 keccak256 识别事件。
对于 solidity 0.8.15 或更高版本,您还可以使用 .selector 成员检索选择器。
pragma solidity ^0.8.15; contract ExampleContract { event SomeEvent(uint256 blocknum, uint256 indexed timestamp); function selector() external pure returns (bool) { // true return SomeEvent.selector == keccak256("SomeEvent(uint256,uint256)"); } }
事件选择器本身实际上是一个主题(我们将在后面的部分进一步讨论)。
将变量标记为已索引或未索引不会更改选择器。
事件可以标记为匿名,在这种情况下它们将没有选择器。这意味着客户端代码无法像我们之前的示例那样专门将它们隔离为一个子集。客户端代码查看匿名事件的唯一方法是获取智能合约的所有事件。
pragma solidity ^0.8.15; contract ExampleContract { event SomeEvent(uint256 blocknum, uint256 timestamp) anonymous; function selector() public pure returns (bool) { // ERROR: does not compile, anonymous events don't have selectors return SomeEvent.selector == keccak256("SomeEvent(uint256,uint256)"); } }
因为事件签名用作索引之一,匿名函数可以有四个索引主题,因为函数签名作为主题之一被“释放”。
匿名事件最多可以有四个索引主题。一个非匿名事件最多可以有三个。
contract ExampleContract { // valid event SomeEvent(uint256 indexed, uint256 indexed, address indexed, address indexed) anonymous; }
匿名事件在实践中很少使用。
本节介绍 EVM 汇编级的事件。刚接触区块链开发的程序员可以跳过本节。
要检索智能合约发生的每笔交易,以太坊客户端必须扫描每个区块,这将是一项极其繁重的 I/O 操作;但是以太坊使用了一个重要的优化。
事件存储在每个块的布隆过滤器数据结构中。 Bloom Filter 是一个概率集合,可以有效地回答某个成员是否在集合中。客户端可以询问布隆过滤器是否在块中发出了事件,而不是扫描整个块。这允许客户端更快地扫描区块链以查找事件。
Bloom Filters 是概率性的:它们有时会错误地返回一个项目是集合的成员,即使它不是。存储在 Bloom Filter 中的成员越多,出错的可能性就越高,并且 Bloom Filter 必须(存储方面)越大才能对此进行补偿。因此,以太坊不会将所有交易存储在布隆过滤器中。事件远少于交易。这使区块链上的存储大小保持可管理。
当客户端从布隆过滤器获得肯定的成员资格响应时,它必须扫描块以验证事件是否发生。然而,这只会发生在一小部分块上,因此平均而言,以太坊客户端通过首先检查布隆过滤器的事件存在性来节省大量计算。
在 Yul 中间表示中,索引参数(主题)和非索引参数之间的区别变得清晰。
以下 yul 函数可用于发出事件(它们的 EVM 操作码具有相同的名称)。该表是从yul 文档中复制的,并进行了一些简化。
操作码 | 用法 |
---|---|
log0(p, s) | 没有主题和数据的日志 mem[p…(p+s)) |
log1(p, s, t1) | 记录主题 t1 和数据 mem[p…(p+s)) |
log2(p, s, t1, t2) | 记录主题 t1、t2 和数据 mem[p…(p+s)) |
log3(p, s, t1, t2, t3) | 记录主题 t1、t2、t3 和数据 mem[p…(p+s)) |
log4(p, s, t1, t2, t3, t4) | 记录主题 t1、t2、t3、t4 和数据 mem[p…(p+s)) |
一个日志最多可以有 4 个主题,但是一个非匿名的 solidity 事件最多可以有 3 个索引参数。这是因为第一个主题用于存储事件签名。没有用于发出超过四个主题的操作码或 Yul 函数。
未索引的参数在内存区域 [p…(p+s)) 中简单地进行 abi 编码,并作为一个长字节序列发出。
回想一下,原则上对于 Solidity 中一个事件可以有多少个未索引的参数没有限制。根本原因是日志操作代码的前两个参数指向的内存区域需要多长时间没有明确限制。当然,合约大小和内存扩展气体成本提供了限制。
事件比写入存储变量便宜得多。事件不打算通过智能合约访问,因此相对较少的开销证明较低的 gas 成本是合理的。
一个事件花费多少 gas 的公式如下(来源):
375 + 375 * num_topics + 8 * data_size + mem_expansion cost
每个事件至少花费 375 gas。为每个索引参数额外支付 375。非匿名事件将事件选择器作为索引参数,因此大部分时间都包含成本。然后我们支付写入链的 32 字节字数的 8 倍。由于此区域在发出前存储在内存中,因此还必须考虑内存扩展成本。
一般来说,你记录的越多,你支付的 gas 越多,这种直觉是准确的。
事件供客户快速检索可能感兴趣的交易。虽然它们不会改变智能合约的功能,但它们允许程序员指定哪些交易应该可以快速检索。这使得 dapps 更容易快速总结重要信息。
与存储相比,事件在 gas-wise 方面相对便宜,但它们成本中最重要的因素是索引参数的数量,假设编码器不使用过多的内存。
喜欢你在这里看到的吗?请参阅我们的高级Solidity 训练营以了解更多信息。
本文的主图是由 HackerNoon 的 AI Image Generator 通过提示“Events in Solidity”生成的