文章详细介绍了 Solidity 事件的工作原理、最佳实践以及如何在以太坊中使用事件来快速检索交易信息。还提供了多个代码示例,解释了如何监听 ERC20 转账事件以及如何过滤特定地址的事件。文章还深入讨论了事件的存储机制、索引参数的选择以及事件的燃气成本。
Solidity 事件是 Ethereum 中最接近 print
或 console.log
语句的东西。我们将解释它们的工作原理,事件的最佳实践,以及许多在其他资源中常常被省略的技术细节。
这里是一个最小的示例,用于发出一个 Solidity 事件。
contract ExampleContract {
// 我们稍后会解释 indexed 参数的重要性。
event ExampleEvent(address indexed sender, uint256 someValue);
function exampleFunction(uint256 someValue) public {
emit ExampleEvent(sender, someValue);
}
}
也许最著名的事件是 ERC20 代币在转账时发出的事件。发送者、接收者和金额会在事件中记录。
这不是多余的事吗?我们可以通过查看过去的交易来看到转账,然后我们可以查看 calldata 以查看相同的信息。
这是正确的;可以删除事件并且对智能合约的业务逻辑没有影响。然而,这将不是一种有效的查看历史记录的方法。
以太坊客户端没有按“类型”列出交易的 API。如果你想查询历史交易,你的选择有:
getTransactionFromBlock
只能告诉你特定区块上发生了哪些交易,它不能在多个区块中定位智能合约。
getTransaction
只能检查你知道交易哈希的交易。另一方面,事件可以更容易地检索。以下是以太坊客户端的选项:
events
events.allEvents
getPastEvents
每一个都需要指定查询者希望检查的智能合约地址,并根据指定的查询参数返回智能合约发出的事件的子集(或全部)。
总结:以太坊没有提供一个机制来获取一个智能合约的所有交易,但它提供了一个机制来获取智能合约的所有事件。
这是为什么?快速检索事件需要额外的存储开销。如果以太坊针对每个交易都这样做,这会使链变得相当庞大。通过事件,Solidity 程序员可以选择性地决定哪类信息值得支付额外的存储开销,以实现快速的链外检索。
事件旨在被链外消费。
以下是使用上述 API 的示例。在此代码中,客户端订阅来自智能合约的事件。
此代码在每次 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(`检测到转账事件:from=${from}, to=${to}, value=${value}`);
});
如果我们想回顾过去的事件,可以使用以下代码。在此示例中,我们查看 ERC20 代币的授权交易。
const ethers = require('ethers');
const tokenAddress = '0x...';
const filterAddress = '0x...';
const tokenAbi = [
// ...
];
const tokenContract = new ethers.Contract(tokenAddress, tokenAbi, provider);
// 此行过滤特定地址的授权。
const filter = tokenContract.filters.Approval(filterAddress, null, null);
tokenContract.queryFilter(filter).then((events) => {
console.log(events);
});
如果你想查找两个特定地址之间的交易(如果存在这样的交易),ethers.js 的 JavaScript 代码如下:
tokenContract.filters.Transfer(address1, address2, null);
上述代码中的 null
表示“匹配该字段的任意值。”对于转账事件,我们匹配任意金额。
在 web3.js 中有类似的例子。注意添加了 fromBlock
和 toBlock
查询参数,我们将演示监听多个作为发送者的地址的能力。这些地址用“或”条件进行组合。
const Web3 = require('web3');
const web3 = new Web3('https://rpc-endpoint');
const contractAddress = '0x...'; // ERC20 合约的地址
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...']; // 要监视转账的地址
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);
}
});
范围查询是不可能的。你不能指定过滤器说“给我所有交易,其中金额在这个下限和上限之间。”你必须先获取所有事件,然后在客户端代码中进行过滤。
考虑一个带有捐赠功能的智能合约,你希望前端按给定金额进行排名。这是一个低效的解决方案:
contract Donations {
struct Donation {
address donator;
uint256 amount;
}
Donation[] public donations; // 前端查询此数据
fallback() external payable {
donations.push(Donation({
donator: msg.sender,
amount: msg.value
}));
}
// 更多函数供拥有者提取
}
如果捐赠不需要在链上读取,这就是简单的解决方案,因为这将显著增加向合约发送以太币的人的 Gas 成本。
这是一个更好的使用事件的解决方案:
contract Donations {
event Donation(address indexed donator; uint256 amount);
fallback() external payable {
emit Donation(msg.sender, msg.value);
}
// 更多函数供拥有者提取
}
前端可以简单地查询智能合约上的所有 Donation
事件,然后按金额字段排序。
事件存储在区块链的状态中,它们不是短暂的。因此,无需担心客户端“丢失”事件。它们只需重新查询合约的事件。
上述示例之所以有效,是因为 ERC20 中的 Approve
(和 Transfer
)事件设置了发送者为索引。以下是 Solidity 中的声明。
event Approval(address indexed owner, address indexed spender, uint256 value);
如果 owner
参数未被索引,早期的 JavaScript 代码将悄悄失败。这里的含义是,你无法过滤 ERC20 事件,获取特定的转账值,因为没有被索引。你必须拉取所有事件并在 JavaScript 端进行过滤;这无法在以太坊客户端中完成。
事件声明的索引参数被称为 topic。
通用的最佳实践是在发生重要状态变化时记录事件。以下是一些示例:
并非每次状态变化都需要事件。Solidity 开发人员应考虑的问题是:“是否有人会有兴趣快速检索或发现此交易?”
这将需要一些主观判断。请记住,未索引的参数无法直接搜索。一个好方法是观察一些成熟代码库如何设计它们的事件
作为一个一般规则,货币数量不应该被索引,而地址应该被索引,但此规则不应盲目应用。
一个例子是在铸造代币时增加一个事件,因为底层库已经发出了这个事件。
事件是状态变化的;它们通过存储日志来改变区块链的状态。因此,它们不能在视图(或纯)函数中使用。
事件在调试方面不像其他语言的 console.log 和 print 那样有用;因为事件本身就是状态变化的,当交易回滚时它们不会被发出。
对于未索引的参数,如果你使用的参数过多,你会很快达到堆栈限制。以下无意义的示例是有效的 Solidity:
contract ExampleContract {
event Numbers(uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256);
}
同样,没有对存储在日志中的字符串或数组的长度的固有限制。
但是,一个事件中不能有超过三个索引参数(主题)。匿名事件可以有四个索引参数(我们稍后将讨论这一区别)。
零参数的事件也是有效的。
以下事件的行为是相同的
event NewOwner(address newOwner);
event NewOwner(address);
一般来说,包含变量名称是理想的,因为以下示例的语义非常模糊
event Trade(address,address,address,uint256,uint256);
我们可以猜测地址对应于发送者和代币地址,而 uint256 代表金额,但这很难解读。
一般惯例是将事件的名称首字母大写,但编译器并不要求这样做。
当在父合约中声明事件时,子合约可以发出该事件。事件是内部的,无法修改为私有或公共。以下是一个例子
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);
}
}
类似地,事件可以在接口中声明,并在子合约中使用,如下例所示。
interface IExampleInterface {
event Deposit(address indexed sender, uint256 amount);
}
contract ExampleContract is IExampleInterface {
function deposit() external payable {
emit Deposit(msg.sender, msg.value);
}
}
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: 不会编译,匿名事件没有选择器
return SomeEvent.selector == keccak256("SomeEvent(uint256,uint256)");
}
}
由于事件签名作为其中之一的索引,匿名函数可以有四个索引主题,因为函数签名作为一个主题被“释放”。
contract ExampleContract {
// 有效
event SomeEvent(uint256 indexed, uint256 indexed, address indexed, address indexed) anonymous;
}
匿名事件在实际中很少使用。
本节描述了事件在 EVM 的汇编级别。这一部分可以跳过,对于初学 区块链开发 的程序员来说。
为了检索与智能合约发生的每一笔交易,以太坊客户端必须扫描每一个区块,这将是一个非常繁重的 I/O 操作;但以太坊使用了一种重要的优化。
事件存储在每个区块的 布隆过滤器 数据结构中。布隆过滤器是一种概率集合,可以高效地回答成员是否在集合中。客户端可以询问布隆过滤器某个事件是否在该区块中发出;查询布隆过滤器要比扫描整个区块快得多。
这使得客户端可以更快地搜索区块链以找到事件。
布隆过滤器是概率性的:它们有时错误地返回某个项为集合的成员,即使它实际上不是。存储在布隆过滤器中的成员越多,错误的几率就越高,并且布隆过滤器必须更大(在存储上)以补偿这一点。因此,以太坊不在布隆过滤器中存储交易,只存储事件。事件的数量远少于交易数量。这使得区块链上的存储大小保持可管理。
当客户端从布隆过滤器获得正会员响应时,它必须扫描区块以验证事件是否发生。然而,这仅在极少量的区块中发生,因此平均而言,以太坊客户端通过先检查布隆过滤器来确认事件的存在,节省了大量计算。
在 Yul 中间表示中,索引参数(主题)和未索引参数之间的区别变得清晰。
以下 yul 函数可用于发出事件(其 EVM 操作码同名)。该表复制自 yul 文档 并进行了简化。
op code | 用法 |
---|---|
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。非匿名事件将事件选择器视为索引参数,因此该成本通常包括在内。然后我们支付 8 倍于写入区块链的 32 字节字数量。因为这个区域存储在被发出之前的内存中,因此还需要考虑内存扩展成本。
事件 Gas 成本中最重要的因素是索引事件的数量,因此如果没有必要,就不要索引变量。
事件是为了让客户端快速检索可能感兴趣的交易。尽管它们并不改变智能合约的功能,但它们允许程序员指定哪些交易应快速可检索。这对于提高智能合约的透明度很重要。
相比其他操作,事件的 Gas 费用相对较低,但其成本的最重要因素是索引参数的数量,假设编码者不会消耗不合理的内存。
喜欢这里的内容吗?查看我们的 Solidity Bootcamp 以了解更多信息。
我们还有一个免费的 Solidity 教程 帮助你入门。
最初发布于 2023 年 4 月 1 日
- 原文链接: rareskills.io/post/ether...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!