Solidity 事件是 Ethereum 中最接近 print 或 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 代币在转移时触发的事件。发送者、接收者和金额会被记录在一个事件中。
这难道不冗余吗?我们已经可以查看过去的交易来了解转移情况,然后我们可以查看 calldata 来获取相同的信息。
没错;我们完全可以删除事件,而不会对智能合约的业务逻辑产生任何影响。然而,这并不是查看历史记录的有效方法。
更快地检索交易
Ethereum 客户端没有按“类型”列出交易的 API。如果你想查询历史交易,你有以下选项:
- getTransaction
- getTransactionFromBlock
getTransactionFromBlock 只能告诉你特定区块上发生了哪些交易,它无法跨越多个区块以智能合约为目标进行查询。
getTransaction 只能检查你已知交易哈希的交易。
相比之下,事件的检索要容易得多。以下是 Ethereum 客户端的选项:
eventsevents.allEventsgetPastEvents
每一个选项都需要指定查询者想要检查的智能合约地址,并根据指定的查询参数返回智能合约触发的事件的子集(或全部)。
总结一下:Ethereum 没有提供获取智能合约所有交易的机制,但它确实提供了获取智能合约所有事件的机制。
为什么会这样?要让事件能够被快速检索,需要额外的存储开销。如果 Ethereum 对每笔交易都这样做,那么链的体积将会变得非常大。有了事件,Solidity 程序员可以选择性地决定哪些信息值得支付额外的存储开销,以实现快速的链下检索。
监听事件
事件旨在在链下被消费。
以下是使用上述 API 的示例。在这段代码中,客户端订阅了智能合约的事件。
示例 1:监听 ERC20 Transfer 事件
每次 ERC20 代币触发 transfer 事件时,此代码都会触发回调。
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}`);
});
示例 2:过滤特定地址的 ERC20 approval 事件
如果我们想追溯查看事件,可以使用以下代码。在这个示例中,我们回顾过去在 ERC20 代币中发生的 Approval 交易。
const ethers = require('ethers');
const tokenAddress = '0x...';
const filterAddress = '0x...';
const tokenAbi = [
// ...
];
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 的 JavaScript 代码如下所示:
tokenContract.filters.Transfer(address1, address2, null);
上述代码中的 null 意味着“匹配此字段的任何值”。对于 transfer 事件,我们正在匹配任何金额。
这是在 web3.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);
}
});
范围查询是不可能的。你不能指定一个过滤器说“给我所有金额在这个下限和那个上限之间的交易”。你必须获取所有事件,然后在客户端代码中对它们进行过滤。
示例 3:在不存储条目的情况下创建排行榜
考虑一个带有捐款功能的智能合约,你希望前端按捐款金额对捐款进行排名。这是一个低效的解决方案:
contract Donations {
struct Donation {
address donator;
uint256 amount;
}
Donation[] public donations; // frontend queries this
fallback() external payable {
donations.push(Donation({
donator: msg.sender,
amount: msg.value
}));
}
// more functions for the owner to withdraw
}
如果不需要在链上读取捐款数据,这可以说是一种非常天真的解决方案,因为这会显著增加向合约发送 Ethereum 的人的 gas 成本。
这是使用事件的更好解决方案:
contract Donations {
event Donation(address indexed donator; uint256 amount);
fallback() external payable {
emit Donation(msg.sender, msg.value);
}
// more functions for the owner to withdraw
}
前端只需查询智能合约中所有的 Donation 事件,然后按金额字段对它们进行排序即可。
事件存储在区块链的状态中,它们不是短暂的。因此,不必担心客户端会“错过”事件。它们只需重新查询合约的事件即可。
Solidity indexed 事件与 non-indexed 事件
上面的示例之所以有效,是因为 ERC20 中的 Approve(以及 Transfer)事件将发送者设置为 indexed。这是它在 Solidity 中的声明。
event Approval(address indexed owner, address indexed spender, uint256 value);
如果 owner 参数不是 indexed 的,前面那段 javascript 代码将静默失败。这意味着你无法过滤带有特定转账金额的 ERC20 事件,因为该金额不是 indexed 的。你必须拉取所有事件并在 javascript 端对它们进行过滤;这无法在 Ethereum 客户端中完成。
在事件声明中被标记为 indexed 的参数被称为 topic。
Solidity 事件最佳实践
对于事件,普遍公认的最佳实践是在发生重大状态更改时记录它们。一些示例包括:
- 更改合约的所有者
- 转移 ether
- 执行交易
并非每个状态更改都需要一个事件。Solidity 开发人员应该问自己的问题是:“是否有人会有兴趣快速检索或发现这笔交易?”
为正确的事件参数添加 Index
这需要一些主观判断。请记住,非 indexed 的参数无法被直接搜索。培养直觉的一个好方法是看看成熟的代码库是如何设计其事件的:
作为一个普遍的经验法则,加密货币的金额不应该是 indexed 的,而地址应该是,但不应盲目应用此规则。
避免冗余事件
这种做法的一个例子是在代币被铸造时添加一个事件,因为底层的库已经触发了这个事件。
事件不能在 view 函数中使用
事件会改变状态;它们通过存储日志来改变区块链的状态。因此,它们不能在 view(或 pure)函数中使用。
事件在调试方面不如其他语言中的 console.log 和 print 有用;因为事件本身就是改变状态的,如果交易被回滚(reverts),它们就不会被触发。
一个事件可以接收多少个参数?
对于非 indexed 参数,如果你使用太多的参数,你会很快触及堆栈限制。以下毫无意义的示例在 Solidity 中是有效的:
contract ExampleContract {
event Numbers(uint256, uint256, uint256, uint256, uint256, uint256, uint256, uint256);
}
同样,存储在日志中的字符串或数组的长度也没有内在限制。
然而,一个事件中不能有超过三个 indexed 参数(topics)。anonymous 事件可以有 4 个 indexed 参数(我们稍后会介绍这种区别)。
没有参数的事件也是有效的。
事件中的变量名是可选的,但强烈建议提供
以下事件的行为完全相同
event NewOwner(address newOwner);
event NewOwner(address);
通常来说,包含变量名是最理想的,因为以下示例背后的语义非常模糊:
event Trade(address,address,address,uint256,uint256);
我们可以猜测这些地址分别对应发送者和代币地址,而 uint256 对应金额,但这很难解读。
按照惯例,事件的名称应该大写,但编译器并没有强制要求。
事件可以通过父合约和接口继承
当一个事件在父合约中声明时,它可以被子合约触发。事件是 internal 的,不能被修改为 private 或 public。这是一个示例:
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);
}
}
Event selector
EVM (Ethereum Virtual Machine) 使用事件签名的 keccak256 来识别事件。
对于 0.8.15 或更高版本的 Solidity,你还可以使用 .selector 成员来获取 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)");
}
}
Event selector 本身实际上就是一个 topic(我们将在后面的章节中进一步讨论)。
是否将变量标记为 indexed 不会改变 selector。
匿名事件
事件可以被标记为 anonymous,在这种情况下它们将没有 selector。这意味着客户端代码不能像我们之前的示例那样将它们专门作为子集分离出来。
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)");
}
}
因为事件签名被用作索引之一,所以一个被标记为 anonymous 的函数可以有四个 indexed topics,因为函数签名作为 topics 之一被“腾出”了。
contract ExampleContract {
// valid
event SomeEvent(uint256 indexed, uint256 indexed, address indexed, address indexed) anonymous;
}
匿名事件在实践中很少使用。
关于事件的高级主题
本节描述了 EVM 汇编级别的事件。对于刚接触 blockchain development 的程序员,可以跳过本节。
实现细节:Bloom filters
要检索发生在智能合约上的每一笔交易,Ethereum 客户端将不得不扫描每一个区块,这将是一个极其繁重的 I/O 操作;但 Ethereum 使用了一项重要的优化。
事件存储在每个区块的 Bloom Filter 数据结构中。Bloom Filter 是一种概率集合,可以有效地回答一个成员是否在集合中。客户端可以询问 Bloom filter 该区块中是否触发了某个事件,而不是扫描整个区块;查询 Bloom filter 比扫描整个区块要快得多。
这使得客户端可以在区块链中更快地搜索并找到事件。
Bloom Filters 具有概率性:它们有时会错误地返回某个项目是集合的成员,即使它并不是。在 Bloom Filter 中存储的成员越多,出错的几率就越高,因此 Bloom filter 就必须越大(在存储方面)以弥补这一点。因此,Ethereum 不会在 Bloom Filter 中存储交易,只存储事件。事件的数量远少于交易的数量。这使得区块链上的存储大小保持在可控范围内。
当客户端从 Bloom filter 得到积极的成员资格响应时,它必须扫描区块以验证事件确实发生过。然而,这只会在极小部分的区块中发生,因此平均而言,Ethereum 客户端通过首先检查 Bloom filter 中的事件存在与否,节省了大量的计算。
Yul Events (Solidity 汇编)
在 Yul 中间表示中,indexed 参数(topics)和非 indexed 参数之间的区别变得非常清晰。
以下 Yul 函数可用于触发事件(并且它们的 EVM opcode 具有相同的名称)。该表是从 yul documentation 中复制并做了一些简化的。
| op code | Usage |
|---|---|
| log0(p, s) | 记录没有 topics 的日志,数据范围 mem[p…(p+s)) |
| log1(p, s, t1) | 记录带有 topic t1 的日志,数据范围 mem[p…(p+s)) |
| log2(p, s, t1, t2) | 记录带有 topics t1, t2 的日志,数据范围 mem[p…(p+s)) |
| log3(p, s, t1, t2, t3) | 记录带有 topics t1, t2, t3 的日志,数据范围 mem[p…(p+s)) |
| log4(p, s, t1, t2, t3, t4) | 记录带有 topics t1, t2, t3, t4 的日志,数据范围 mem[p…(p+s)) |
一个日志最多可以有 4 个 topics,但一个非 anonymous 的 Solidity 事件最多只能有 3 个 indexed 参数。这是因为第一个 topic 被用来存储事件签名。没有能够触发超过四个 topics 的 opcode 或 Yul 函数。
未被 index 的参数只是在内存区域 [p…(p+s)) 中进行 ABI encoded,并作为一个长字节序列被发出。
回顾前面所说,原则上 Solidity 中的事件可以拥有多少个非 indexed 参数是没有限制的。其根本原因在于,对于 log opcode 的前两个参数所指向的内存区域的长度没有明确的限制。当然,合约大小和内存扩展所需的 gas 成本会带来一定的限制。
触发 Solidity 事件的 Gas 成本
与写入存储变量相比,事件的成本要低得多。事件并不打算被智能合约访问,因此相对较低的开销证明了较低的 gas cost 是合理的。
事件需要花费多少 gas 的公式如下(source):
375 + 375 * num_topics + 8 * data_size + mem_expansion_cost
每个事件至少花费 375 gas。每个 indexed 参数需要额外支付 375 gas。一个非 anonymous 的事件会将 event selector 作为一个 indexed 参数,所以大多数时候这个成本是包含在内的。然后我们支付 8 乘以写入链的 32 字节字(32 byte words)数量的 gas。因为这个区域在触发前会存储在内存中,所以内存扩展成本也必须被计算在内。
事件 gas 成本中最显著的因素是 indexed 参数的数量,所以如果没有必要,不要对变量使用 index。
结论
事件用于让客户端快速检索可能感兴趣的交易。尽管它们不会改变智能合约的功能,但它们允许程序员指定哪些交易应该是可被快速检索的。这对于提高智能合约的透明度非常重要。
与其他操作相比,事件在 gas 方面相对便宜,但其成本中最重要的因素是 indexed 参数的数量,前提是编码人员没有使用过多的内存。
了解更多
喜欢你在这里看到的内容吗?查看我们的 Solidity Bootcamp 了解更多信息。
我们还有一个免费的 solidity tutorial 可以帮助你入门。
最初发布于 2023 年 4 月 1 日