1. 事件监听基础:Solidity事件定义与emit语法

事件(Event)在Solidity里是个很有意思的东西。我刚开始接触时,觉得它不就是个日志吗?后来踩过坑才明白——事件是智能合约与外部世界沟通的桥梁。说白了,你的合约跑在EVM里,外面的人想知道里面发生了什么,全靠事件。

1.1 Solidity事件定义

事件的定义语法其实很简洁。你只需要用 event 关键字,后面跟上事件名和参数列表。来看个例子:

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

contract TokenTransfer {
    // 定义一个转账事件
    event Transfer(
        address indexed from,
        address indexed to,
        uint256 value
    );
    
    // 定义一个授权事件
    event Approval(
        address indexed owner,
        address indexed spender,
        uint256 value
    );
}

这里有个关键点:indexed 关键字。加了 indexed 的参数会被存入 Topic,不加的存入 Data。我习惯把「谁干的」「对谁干的」这类需要检索的字段设为 indexed,把具体数值放 Data 里。你想想看,如果每个参数都 indexed,Gas 费用会高不少。

重要提示:一个事件最多可以有3个 indexed 参数。这是 EVM 的硬性限制,超过会报错。我见过有人试图塞4个 indexed 参数,结果编译都过不了。

1.2 emit 语法

定义好事件后,怎么触发它?用 emit 关键字。嗯,这里要注意:Solidity 0.4.21 之前不需要 emit,直接写事件名就行。但新版本强制要求加 emit,这样代码可读性更好。

contract TokenTransfer {
    event Transfer(address indexed from, address indexed to, uint256 value);
    
    function transfer(address to, uint256 amount) public {
        // 执行转账逻辑
        // ...
        
        // 触发事件
        emit Transfer(msg.sender, to, amount);
    }
}

我个人习惯把 emit 放在函数末尾,所有状态变更完成后才触发事件。为什么?因为如果中间 revert 了,emit 也不会执行,避免产生虚假事件。

小技巧:可以在事件参数中使用 struct 类型。Solidity 0.8.0 之后支持了。但要注意,struct 参数不能加 indexed,只能放 Data 里。

1.3 事件在EVM中的存储机制

事件到底存在哪?很多人以为存在合约存储里,其实不是。事件数据存在交易收据(Transaction Receipt)的日志(Log)中。每个区块的日志数据是独立的,不会影响合约状态。

一个事件日志包含两部分:

组成部分 说明 最大数量
Topic 事件签名 + indexed 参数 最多4个(1个签名 + 3个参数)
Data 非 indexed 参数,ABI编码后 无限制(受Gas限制)

Topic 和 Data 的区别在哪?Topic 可以被索引和检索,Data 只能被读取。我在项目中遇到过一个问题:有个同事把所有参数都加了 indexed,结果 Gas 费用暴涨。后来改成只索引关键字段,费用降了将近一半。

避坑指南:我曾经在事件里塞了一个很大的 bytes 数组,结果 Gas 费用高得离谱。事件不是用来存数据的,是用来通知的。大块数据请用事件+链下存储的方案。

1.4 事件签名哈希计算

每个事件都有一个唯一的签名哈希。这个哈希怎么算?很简单:对事件名和参数类型做 Keccak256 哈希。

比如 Transfer(address,address,uint256) 这个事件:

// 事件签名字符串
"Transfer(address,address,uint256)"

// 计算Keccak256哈希
keccak256("Transfer(address,address,uint256)") 
= 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

这个哈希值就是 Topic[0],也就是事件的第一个 Topic。你可以在区块链浏览器上验证——每个 Transfer 事件的第一个 Topic 都是这个值。

为什么需要这个签名哈希?说白了,就是让监听者能快速过滤出自己关心的事件类型。你监听 Transfer 事件时,客户端会先匹配 Topic[0] 是否等于 Transfer 的签名哈希,匹配上了才继续解析后面的参数。

实用技巧:在 JavaScript 中计算事件签名哈希:

const Web3 = require('web3');
const web3 = new Web3();
const signature = web3.utils.keccak256("Transfer(address,address,uint256)");
console.log(signature);
// 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

1.5 实战中的注意事项

最后分享几个我踩过的坑:

  • 事件参数顺序很重要:ABI 编码是按参数顺序来的,监听端解析时也必须按相同顺序。我见过有人改了合约参数顺序但忘了更新前端,结果解析出来的数据全乱了。
  • 匿名事件:Solidity 支持 event Transfer(...) anonymous; 这种写法。匿名事件不生成 Topic[0],Gas 更省,但无法通过事件签名过滤。除非你特别清楚自己在做什么,否则别用。
  • 事件重载:同一个合约里可以有同名但参数不同的事件。但我不建议这么做——监听端容易搞混。保持事件名唯一,这是好习惯。

嗯,事件监听的基础就这些。下一章我们会聊怎么在链下监听这些事件,以及如何解析日志数据。到时候我会分享一个我实际项目中用的事件监听框架,保证实用。