Starknet 中的事件

本文深入探讨了Starknet中Cairo事件的工作原理和结构,首先介绍了Cairo中事件的基本结构,并通过示例展示了如何使用[event]属性定义事件枚举和结构体。

事件将合约执行中的数据发送到交易收据中。该收据保存了关于执行过程中发生的事情的元数据,可以被外部应用程序查询或索引。Cairo 的事件语法比 Solidity 的更冗长,但目的相同。

在本文中,你将了解事件在 Starknet 中是如何工作的。

Cairo 中的事件结构

Cairo 中的事件必须在用 #[event] 属性标记的 Event 枚举中列出。与 Solidity 的单个事件声明不同,Cairo 要求所有事件都组织在一个中心枚举结构中。

以下是一个列出两个事件的示例,一个用于用户注册,另一个用于用户登录:

// 新用户注册时发出的事件
##[derive(Drop, starknet::Event)]
pub struct UserRegistered {
    pub user_id: u32,
    pub username: ByteArray
}

// 用户登录时发出的事件
##[derive(Drop, starknet::Event)]
pub struct UserLoggedIn {
    pub user_id: u32,
    pub timestamp: u64
}

// 主要的事件枚举,包含此合约可以发出的所有可能的事件
##[event]
##[derive(Drop, starknet::Event)]
pub enum Event {
    NewUser: UserRegistered,   // 引用 UserRegistered 结构体
    UserLogin: UserLoggedIn    // 引用 UserLoggedIn 结构体
}

注意: Drop trait 允许 Cairo 在不再需要时自动从内存中清理结构体和枚举。你会在大多数 Cairo 结构体和枚举上看到 #[derive(Drop)]

这两个事件的 Solidity 表示形式将是:

event NewUser(uint32 userID, string username);
event UserLogin(uint32 userID, uint64 timestamp);

在上面的 Cairo 代码中,我们定义了两个事件结构体(UserRegisteredUserLoggedIn),它们指定了每种事件类型的数据结构。这两个结构体都通过 derive 属性实现了 starknet::Event trait。

然后,这些单独的结构体被统一(列出)在单个 Event 枚举下,其中每个变体都引用其对应的结构体。当事件被发出时,枚举变体名称(NewUserUserLogin)用作可搜索的事件标识符。

虽然你通常会看到 枚举变体(NewUser 关联的结构体(如 UserRegistered**\使用相同的名称,但它们不必匹配*。\*\ 此处使用不同的名称是为了高亮显示这种区别。

Starknet SDK(如 Starknet.js)可以使用这些标识符来过滤和查询事件。例如,要查找所有用户注册,你将查询名称为 "NewUser" 的事件。

在使用事件时,你通常希望按事件包含的特定数据进行过滤,例如查找特定用户 ID 或在特定值范围内的所有事事件。这就是索引参数的用武之地,就像在 Solidity 中一样。

索引事件(键字段)

可以使用 #[key] 属性标记事件字段以进行索引(类似于 Solidity 中的 indexed 关键字)。例如,如果我们想使 user_id 在用户注册中可搜索,timestamp 在用户登录中可搜索,我们可以这样做:

##[derive(Drop, starknet::Event)]
pub struct UserRegistered {
    #[key]
    pub user_id: u32,          // user_id 被标记为已索引(可搜索的键)
    pub username: ByteArray    // username 被存储为事件数据(未索引)
}

##[derive(Drop, starknet::Event)]
pub struct UserLoggedIn {
    pub user_id: u32,          // user_id 被存储为事件数据(未索引)
    #[key]
    pub timestamp: u64         // timestamp 被标记为已索引
}

// 主要的事件枚举,包含此合约可以发出的所有可能的事件
##[event]
##[derive(Drop, starknet::Event)]
pub enum Event {
    UserRegistered: UserRegistered,
    UserLoggedIn: UserLoggedIn
}

其 Solidity 等效项将是:

event UserRegistered(uint32 indexed userID, string username);
event UserLogin(uint32 userID, uint64 indexed timestamp);

#[key] 的位置取决于你想在事件日志中用于搜索的特定字段。字段是结构体中的数据元素,例如,user_idusernameUserRegistered 结构体中的字段。

UserRegistered 中,我们索引 user_id 以按用户进行过滤,而在 UserLoggedIn 中,我们索引 timestamp 以按时间进行过滤。

你只需要将 #[key] 添加到你实际查询或过滤的字段,每个字段必须根据你的特定过滤需求单独注释。

键字段与事务收据中的常规数据字段分开存储,以便 Starknet SDK 可以快速过滤事件,而无需处理所有事件数据。

事务收据中的事件数据结构

事务收据是一条记录,其中包含有关已完成交易的详细信息。它包括区块详细信息(block_hashblock_number)、交易哈希、执行状态(execution_statusfinality_status)、gas 消耗(execution_resources)、交易费用(actual_fee)等,以及执行期间发出的任何事件。

每个事务收据都包含一个 events 数组,其中包含所有发出的事件的键和数据,其中:

  • data – 表示一个包含序列化的非索引字段值的数组
  • from_address – 是发出事件的合约地址
  • keys – 是一个始终包含 keys[0] 处的事件选择器哈希以及 keys[1]keys[2] 等处的任何索引字段值的数组。

无论你是否在合约中使用 #[key] 注释,keys 数组都会出现在每个事件中。至少,它包含标识事件类型的事件选择器哈希(即 Transfer 等)。

以下显示了一个示例事务收据,其中 events 数组结构在粉红色框中高亮显示:

包含高亮显示的事件字段的 Cairo 事务

以下是基于使用 getTransactionReceipt 方法提供的交易哈希生成此交易收据的 Typescript 代码:

import { RpcProvider } from "starknet";
import * as dotenv from "dotenv";

dotenv.config();

async function getTxnReceipt() {
    // 使用 Sepolia 测试网端点初始化 RPC 提供程序
    const alchemyApiKey = process.env.ALCHEMY_API_KEY;

    // 使用 Alchemy 初始化 Sepolia 测试网的提供程序
    const provider = new RpcProvider({
      nodeUrl: `https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_8/${alchemyApiKey}`,
    });
    // 要查询的交易哈希(替换为实际哈希)
    const transactionHash =
      "0x5df0e42012440f59eb9cdd7994a3001b72cebc781bd8527fb3a5343cdb9d6f7";

    try {
        // 从网络获取交易收据
        const receipt: any = await provider.getTransactionReceipt(transactionHash);

        // 显示格式化的收据数据
        console.log(JSON.stringify(receipt, null, 2));
    } catch (error) {
        // 处理网络或交易错误
        console.error("Error getting transaction receipt:", error);
    }
}

// 执行该函数
getTxnReceipt();

此示例仅显示事件如何在交易收据中显示。使用 Starknet.js 的不同查询技术将在本文的后面部分介绍。

了解 Keys 数组

在上面的交易收据中,该事件只有一个键(keys[0]),其中包含事件选择器哈希 0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9

Screenshot 2025-09-02 at 11.16.37.png

此事件选择器哈希(keys[0])表示一个 没有索引 参数的 Transfer 事件 ****所有非索引字段都存储在 data 数组中:

Screenshot 2025-09-02 at 11.20.43.png

事件选择器哈希是使用以下方法计算的:

const nameHash = num.toHex(hash.starknetKeccak('EventName'));

Cairo 事件结构与 Solidity 的比较

在 Solidity 中,事件数据是使用主题和数据进行结构化的,如本示例中所见:

高亮显示主题和数据字段的 Solidity 事事件

  • topic0: 始终包含事件签名哈希。在上面的示例中,事件 NewUser(uint32,string) 的哈希值为 keccak256("NewUser(uint32,string)"),等于 0x37ecc4388271ab7af2220881c1f2f70fbea71e6b1635107f9daffa0fab84d5b3
  • topic1: 包含索引的 user_id 参数
  • 数据字段: 包含所有非索引参数(以十六进制表示)

Cairo 遵循与 keys 数组类似的模式。为了进行比较,请查看 Starknet Sepolia 上的此交易,其中显示了一个具有多个键的事件:

高亮显示索引参数和数据的 Starknet 事件

  • keys[0](由白色箭头高亮显示)保存事件选择器哈希(Event8Indexed)。
  • 数组中的后续元素(例如,keys[1]keys[2]、…keys[10]),用绿色箭头显示,表示事件的索引 (#[key]) 字段。
  • 非索引参数单独存储在 data 字段中,如紫色框所示。

根据上图,KEYS 部分包含总共十 (10) 个索引参数。这突出了 Cairo 优于 Solidity 的一个主要优势:虽然 Solidity 将索引参数限制为三个(topic1-topic3),topic0 始终保留给事件签名,但 Cairo 允许最多五十 (50) 个索引参数。这消除了对 Solidity 中匿名事件等解决方法的需求(匿名事件最多可以有 4 个索引参数,但会丢失事件签名)。

与 Solidity 中一样,Cairo 中的非索引字段需要手动搜索 data 数组,而索引字段(用 #[key] 标记)可以使用 Starknet SDK 有效地过滤。

事件的内部工作原理

Cairo 中 Starknet 的事件系统主要围绕两个 trait:

  • Event:处理事件序列化和反序列化。
  • EventEmitter:提供使用合约函数内部的 self.emit(...) 的发送功能。

Event Trait

Event trait 提供了序列化事件、反序列化事件以及生成用于事件过滤和索引的内部事件类型标识符的方法。任何用 #[derive(starknet::Event)] 标记的结构体或枚举都会自动获得这些关键方法的实现:

方法 目的
append_keys_and_data 通过将索引字段 (#[key]) 和非索引字段拆分为单独的 keysdata 数组来序列化事件
deserialize(ref keys, ref data) -> Option<T> 从发出的交易收据数据中重建原始事件;如果无效,则返回 None
event_type_name 用于计算事件选择器以进行过滤和索引的内部标识符

请注意,你无需手动实现这些方法。当你使用 #[derive(starknet::Event)] 时,它们会自动生成,这会在合约中设置事件,使其具备序列化和重建事件数据所需的一切。

了解事件序列化

Event trait 自动处理事件序列化,但当事件包含复杂字段类型(例如数组、嵌套结构体)时,它依赖于 Serde trait 来提供额外的序列化帮助。

Serde trait 将复杂的 Cairo 类型转换为与 Cairo VM 兼容的 felt252 值序列。在 Cairo 中,felt252 是 Cairo VM 唯一理解的原始类型,因此任何大于 252 位的数值都必须分解为 felt252 值的列表。

Event trait 自动处理的类型有:

  • 简单类型:u8u16u32u64u128boolfelt252ContractAddress
  • ByteArray:自动序列化,无需手动 Serde 派生

正如前面章节中所述,ByteArray 是一种 Cairo 类型,表示字符串。它是一个包含三个字段的结构体:

  • *data: Array<felt252>:包含字符串数据的 31 字节块*
  • *pending_word: felt252:用完整的 31 字节块填充 data 数组后剩余的字节(最多 30 个字节)
  • *pending_word_len: u32pending_word 中的字节数

Cairo 首先将完整的 31 字节块打包到 data 数组中,然后将所有剩余字节放入 pending_word 中。例如,“serah”序列化为:

  • *data[](空数组 – 5 字节字符串不需要 31 字节块)*
  • *pending_word0x7365726168(包含十六进制格式的实际字符串字节)*
  • *pending_word_len0x5(总共 5 个字节)*

由于 ByteArray 常用且是 Cairo 标准库的一部分,因此 Event trait 包括对其的自动序列化支持。

这种自动序列化使我们能够在事件中使用 ByteArray,而无需手动派生 Serde。我们将在本文后面检查实际交易数据时看到详细的 ByteArray 序列化分解。

需要手动 Serde 派生的复杂类型有:

  • 自定义结构体:用户定义的结构,例如嵌套数据
  • 数组:Array<u32>Array<ByteArray>
  • 带有数据的用户定义枚举

当事件包含这些复杂类型的字段时,这些字段类型必须派生 Serde,以便 Event trait 可以在发送期间对其进行序列化。否则,编译器无法正确处理事件。文章的 “处理复杂事件字段类型” 部分将展示一个实际示例。

EventEmitter Trait

EventEmitter trait 允许通过以下方式发送事件:

self.emit(EventStruct { ... });

在合约执行期间,它使用 Event trait 序列化事件并将结果存储在交易收据中。对于包含自定义结构体字段的事件,这些结构体必须单独派生 Serde,以便 Event trait 可以正确地对其进行序列化。

下图可视化了事件序列化工作流程,显示了哪些事件类型可以自动序列化,以及哪些事件类型在发送之前需要额外的 Serde 支持:

显示事件 trait 依赖关系的图

在介绍了基本知识之后,以下部分将探讨事件结构在嵌套或包含复杂类型时如何工作。

处理复杂事件字段类型

以下是更新后的 UserRegistered 事件,带有其他字段类型。UserMetadata 是一个自定义结构体,用于保存用户环境数据(设备类型和位置信息)。作为 UserRegistered 事件中的嵌套结构体,它需要正确的序列化:

##[derive(Drop, starknet::Event)]
pub struct UserRegistered {
    #[key]
    pub user_id: u32,
    pub username: ByteArray,
    pub metadata: UserMetadata,
    pub tag_count: u32,
    pub timestamp: u64,
}

##[derive(Drop, Serde)]
pub struct UserMetadata {
    pub device_type: ByteArray,
    pub ip_region: ByteArray,
}

由于 UserMetadata 是一种复杂类型,因此必须派生 Serde,以便可以将其正确地序列化为 felt252 值。

这又回到了我们之前关于 Cairo VM 约束的说明:复杂类型必须正确序列化才能成功发送事件。

如果在 UserMetadata 结构体中没有 Serde,代码将无法编译,如下所示:

Screenshot 2025-07-16 at 17.28.06.png

UserMetadata 需要 Serde,因为它是一个自定义结构体。UserRegistered 事件结构体只需要 starknet::Event – Event trait 可以自动处理基本类型,但会将复杂字段类型委托给 Serde。*

另请注意,复杂类型索引字段 (#[key]) 作为哈希值存储在事件的 keys 数组中,无法直接从事务日志中恢复。

当前 UserRegistered 事件(推荐方法):

此设计使用 user_idu32 原始类型)作为索引字段,该字段在事务日志中保持可读,以便进行高效的查询:

##[derive(Drop, starknet::Event)]
pub struct UserRegistered {
    #[key]
    pub user_id: u32,           // 原始类型 - 保持可读
    pub username: ByteArray,    // 非索引 - 在数据数组中
    pub metadata: UserMetadata, // 非索引 - 在数据数组中
    pub tag_count: u32,         // 非索引 - 在数据数组中
    pub timestamp: u64,         // 非索引 - 在数据数组中
}

事务收据:

{
  "keys": [\
    "0x...event_selector",    // *key[0] 始终是事件选择器.*\
    "0x7b"                    // user_id = 123(可读作十六进制)\
  ],
  "data": [\
    "username_serialized",\
    "metadata_serialized",\
    "tag_count_serialized",\
    "timestamp_serialized"\
  ]
}

使用此方法,你可以轻松地查询 user_id = 123 的事件,因为值 0x7b 在 keys 数组 (keys[1]) 中直接可读。

如果我们使用复杂的 UserMetadata 结构体作为索引字段,它会产生查询挑战:

##[derive(Drop, starknet::Event)]
pub struct UserRegistered {
    #[key]
    pub metadata: UserMetadata,  // 复杂结构体作为索引字段 - 错误!
    pub user_id: u32,
    pub username: ByteArray,
}

你在收据中看到的内容:

{
  "keys": [\
    "0x...event_selector",\
    "0xa1b2c3d4e5f67890..."    // 哈希 UserMetadata - 不可读!\
  ],
  "data": ["0x7b", "username_serialized"]
}

整个 UserMetadata 都会变成 keys 数组中不可读的哈希值。我们无法按 device_typeip_region 查询用户,因为这些值都隐藏在哈希值中。这就是为什么当我们需要有效地过滤事件时,u32 等原始类型对于索引字段效果更好的原因。因此,如果你计划按索引字段查询事件,最好使用 u32felt252ContractAddress 等原始类型。

使用 #[flat] 属性

#[flat] 属性会更改事件变体在事务日志中的命名和标识方式。它用于展平嵌套的事件枚举,以使特定事件的查询和过滤更容易。

此属性处理嵌套枚举结构,而不是复杂的字段类型。这是与我们刚刚讨论的复杂索引不同的概念。

#[flat] 属性展平事件命名层次结构,而不是数据结构本身。

当在 Event 枚举中的事件变体上使用时,它会将事件选择器哈希计算更改为使用内部变体名称而不是外部枚举名称。

外部枚举、内部枚举和内部变体

要了解 #[flat] 的工作原理,我们需要区分枚举结构的这三个级别:

*// 外部枚举 - 主要的 Event 枚举*
pub enum Event {
    UserRegistered: UserRegistered,
    #[flat]
    UserDataUpdated: UserDataUpdated,  *// <- 这引用内部枚举*
}

*// 内部枚举 - 嵌套在外部枚举结构中*
pub enum UserDataUpdated {
    DeviceType: UpdatedDeviceType,     *// <- 这些是内部变体*
    IpRegion: UpdatedIpRegion,         *// <- 这些是内部变体*
}
  • 外部枚举:主要的 Event 枚举,其中包含合约的所有可能事件
  • 内部枚举UserDataUpdated 枚举,其中包含特定变体(DeviceTypeIpRegion
  • 内部变体UserDataUpdated 枚举中的各个枚举变体(DeviceTypeIpRegion),每个变体都引用其自身的事件结构体

完整示例

以下示例显示了一个具有多种事件类型的合约:简单的结构体事件 (UserRegistered) 和嵌套的枚举事件 (UserDataUpdated),其中包含两个变体:

// 主要的事件枚举,其中包含此合约可以发出的所有可能事件
##[event]
##[derive(Drop, starknet::Event)]
pub enum Event {
    UserRegistered: UserRegistered,
    #[flat]                              // 新添加 - 展平嵌套事件枚举
    UserDataUpdated: UserDataUpdated,
}

// 用户注册事件
##[derive(Drop, starknet::Event)]
pub struct UserRegistered {
    #[key]
    pub user_id: u32,                      // 用于过滤的索引用户 ID
    pub username: ByteArray,               // 作为事件数据的用户名
    pub metadata: UserMetadata,            // 用户元数据结构体
}

// 嵌套事件枚举,其中包含不同类型的用户数据更新
##[derive(Drop, starknet::Event)]
pub enum UserDataUpdated {
    DeviceType: UpdatedDeviceType,           // 设备类型更改事件
    IpRegion: UpdatedIpRegion,               // IP 区域更改事件
}

// 设备类型更新的事件
##[derive(Drop, starknet::Event)]
pub struct UpdatedDeviceType {
    #[key]
    pub user_id: u32,                         // 索引用户 ID
    pub new_device_type: ByteArray,           // 新的设备类型值
}

// IP 区域更新的事件
##[derive(Drop, starknet::Event)]
pub struct UpdatedIpRegion {
    #[key]
    pub user_id: u32,                         // 索引用户 ID
    pub new_ip_region: ByteArray,             // 新的 IP 区域值
}

// 包含设备和位置信息的用户元数据结构
##[derive(Drop, Serde)]
pub struct UserMetadata {
    pub device_type: ByteArray,                // 用户的设备类型
    pub ip_region: ByteArray,                  //用户的 IP 区域
}

请注意 #[flat] 属性如何应用于主 Event 枚举中的 UserDataUpdated(嵌套枚举)变体,这会更改内部变体(DeviceTypeIpRegion)在事务日志中的显示方式。

回想一下,事件选择器哈希(存储在事务收据的 keys[0] 中)是使用 starknetKeccak("EventName") 计算的。

如果没有 #[flat] 属性,事件选择器哈希将从外部枚举名称派生:starknetKeccak("UserDataUpdated")。这意味着所有枚举变体(DeviceTypeIpRegion)共享相同的事件选择器,因此你无法查询特定变体,你只能查询一般的 "UserDataUpdated" 事件。

{
  "keys": ["0x...hash_of_UserDataUpdated"],  // 所有变体的选择器相同
  "data": [...],
  "from_address": "0x..."
}

但是,当我们使用 ****#[flat] 时,事件选择器哈希将从内部变体名称计算:starknetKeccak("DeviceType") / starknetKeccak("IpRegion"),因此 DeviceTypeIpRegion 各自获得自己的选择器哈希,以便进行精确的过滤和查询。

// DeviceType 事件
{
  "keys": ["0x...hash_of_DeviceType"],  // 唯一的选择器
  "data": [...],
  "from_address": "0x..."
}

// IpRegion 事件
{
  "keys": ["0x...hash_of_IpRegion"],   // 不同的唯一选择器
  "data": [...],
  "from_address": "0x..."
}

#[flat] 属性仅影响事件命名和选择器计算,实际的数据结构、字段和序列化保持不变。这使得在使用嵌套事件枚举时,事件过滤和日志检查更加容易。

*#[flat] 属性通常用于 OpenZeppelin 组件库中,以避免将多个组件集成到单个合约中时发生事件选择器冲突。*

例如,当同时使用 ERC20 和 Ownable 组件时,#[flat] 确保每个组件的事件都保持其不同的标识符。(组件将在第 – 章中详细介绍 – 现在,将其视为可重用的合约模块。)

请注意,用作事件枚举中枚举变体的结构体必须派生 starknet::Event,因为当它们在枚举结构中使用时,它们会成为事件类型本身。

测试事件日志

搭建一个新的 Scarb 项目 scarb new testinglog,然后选择“Starknet Foundry (default)”作为测试运行器:

初始化 Starknet Foundry 项目

要测试事件日志,请考虑下面的 UserManager 合约,该合约允许用户注册自己并跟踪已注册的用户数。

该合约使用计数器为用户分配唯一 ID,并将他们的信息存储在 Map 中。当用户注册时,该合约会发出一个 UserRegistered 事件,外部应用程序可以查询该事件。请注意 #[key] 属性以及 UserMetadata 结构体的存储方式。

将完整的代码复制并粘贴到你的 src/lib.cairo 文件中:

// 定义我们的 UserManager 合约将实现的函数的接口
##[starknet::interface]
pub trait IUserManager<TContractState> {
    fn register_user(ref self: TContractState, username: ByteArray);
    fn get_user_count(self: @TContractState) -> u32;
}

// 用于存储用户信息 - 派生 Store 以便能够在合约中进行存储
##[derive(Drop, Serde, starknet::Store)]
pub struct UserMetadata {
    pub user_id: u32,
    pub username: ByteArray
}

// 新用户注册时发出的事件 - user_id 被标记为键以便进行索引
##[derive(Drop, starknet::Event)]
pub struct UserRegistered {
    #[key]
    pub user_id: u32,
    pub username: ByteArray,
    pub timestamp: u64,
}

##[starknet::contract]
pub mod UserManager {
    use super::{UserRegistered, UserMetadata, IUserManager};
    use starknet::{
        get_block_timestamp, ContractAddress, get_caller_address,
        storage::{Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess}
    };

    #[storage]
    struct Storage {
        user_counter: u32,  // 跟踪已注册用户的总数
        users: Map<ContractAddress, UserMetadata> // 将用户地址映射到他们的元数据
    }

    // 主要的事件枚举,其中包含此合约可以发出的所有可能事件
    #[event]
    #[derive(Drop, starknet::Event)]
    pub enum Event {
        UserRegistered: UserRegistered,
    }

    #[abi(embed_v0)]
    impl UserManagerImpl of IUserManager<ContractState> {
        fn register_user(ref self: ContractState, username: ByteArray) {
            // 获取当前用户数并递增,以便生成新的用户 ID
            let current_counter = self.user_counter.read();
            let user_id = current_counter + 1;

            // 使用新的 ID 和提供的用户名创建用户元数据
            let metadata = UserMetadata {
                user_id,
                username: username.clone()
            };

            // 更新计数器并存储映射到调用者地址的用户数据
            self.user_counter.write(user_id);
            self.users.entry(get_caller_address()).write(metadata);

            // 发出包含用户详细信息和当前时间戳的事件
            self.emit(UserRegistered {
                user_id,
                username,
                timestamp: get_block_timestamp(),
            });
        }

        fn get_user_count(self: @ContractState) -> u32 {
            // 返回当前已注册用户的数量
            self.user_counter.read()
        }
    }
}

IUserManager trait 定义了两个函数;register_user 用于注册,get_user_count 用于检查已注册用户的总数。

  • UserMetadata 结构体存储用户信息(ID 和用户名),并且可以保存到合约存储中。它派生了 starknet::Store,因为它存储在 Map<ContractAddress, UserMetadata> 中的合约存储中。

任何需要从合约存储中读取或写入合约存储的自定义结构体都必须实现 Store trait,#[derive(starknet::Store)] 会自动生成该 trait。

  • UserRegistered 事件结构体记录了注册详细信息。user_id 字段用 #[key] 标记,使其被索引,以便在查询中进行高效过滤。

当调用 register_user 时,合约会:

  • 递增用户计数器以生成新的用户 ID
  • 创建和存储用户的元数据
  • 发出包含用户 ID、用户名和当前块时间戳的 UserRegistered 事件

导航到你的项目目录 cd testinglog 并运行 scarb build 以构建你的项目:

在终端中运行 scarb build

有多种方法可以使用 Starknet Foundry 测试事件。你可以测试以断言是否使用 assert_emitted 方法发出了事件,或者使用 assert_not_emitted 测试是否缺少事件发出。你还可以通过直接检查事件来手动测试。

对于手动事件测试,你可能希望从特定合约中过滤事件,而不是检查所有发出的事件。Events 结构上的 emitted_by 方法允许你将事件范围缩小到来自特定地址的事件。

将讨论 assert_emitted 方法和手动测试事件的方法。

测试 1:使用内置的 assert_emitted

下面的测试验证了注册用户是否发出具有预期数据的正确 UserRegistered 事件。导航到 tests/test_contract.cairo,将此测试复制并粘贴到其中:

use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, spy_events, EventSpyTrait, IsEmitted, Event, EventSpyAssertionsTrait};
use testinglog::{IUserManagerDispatcher, UserManager, UserRegistered, IUserManagerDispatcherTrait};
use starknet::{ContractAddress, get_block_timestamp};

fn deploy_contract(name: ByteArray) -> ContractAddress {
    let contract = declare(name).unwrap().contract_class();
    let (contract_address, _) = contract.deploy(@ArrayTrait::new()).unwrap();
    contract_address
}

##[test]
fn test_registration_event_emission() {
    // 声明和部署 UserManager 合约
    let contract = declare("UserManager").unwrap().contract_class();
    let (contract_address, _) = contract.deploy(@array![]).unwrap();

    // 创建一个调度程序以便与已部署的合约进行交互
    let dispatcher = IUserManagerDispatcher { contract_address };

// 在函数调用之前开始监听事件 let mut spy = spy_events();

// 注册一个用户 - 这应该会触发一个 UserRegistered 事件
dispatcher.register_user("serah");

// 验证是否触发了期望的事件,并且数据正确
spy.assert_emitted(
    @array![\
        (\
            contract_address,   // 事件应该来自我们的合约
            UserManager::Event::UserRegistered(\
                UserRegistered {\
                    user_id: 1,   // 第一个用户的 ID 为 1
                    username: "serah", // 用户名与我们传入的匹配
                    timestamp: get_block_timestamp() // 时间戳应该是当前区块时间
                }\
            )\
        )\
    ]
);

}


**Imports**(导入) 从 Starknet Foundry 和我们的合约定义中引入必需的测试工具。

从 `snforge_std` 中,我们导入 `declare` 来加载合约类,以及相关的 trait (特征),如 `ContractClassTrait` 和 `DeclareResultTrait` 用于合约部署。

```rust
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, spy_events, EventSpyTrait,IsEmitted, Event, EventSpyAssertionsTrait};

事件测试功能来自 spy_events,它创建了我们的事件监听器,EventSpyTrait 用于监听器交互,以及 EventSpyAssertionsTrait,它添加了断言方法,如 assert_emitted。 我们还导入了 IsEmittedEvent 类型用于事件处理操作。

从我们的 testinglog 模块中,我们导入自动生成的 IUserManagerDispatcher 及其 trait (特征) 用于调用合约函数,包含我们事件定义的 UserManager 合约模块,以及我们正在测试的特定 UserRegistered 事件结构体。

use testinglog::{IUserManagerDispatcher, UserManager, UserRegistered, IUserManagerDispatcherTrait};

starknet 核心库中,我们导入 ContractAddress 用于处理合约地址,以及 get_block_timestamp 用于检索当前区块时间戳。

use starknet::{ContractAddress, get_block_timestamp};

test_registration_event_emission() 使用了简化的方法 spy.assert_emitted()

  • declare() 加载 "UserManager" 合约类。
  • deploy() 使用 @array![] 创建合约的新实例,它表示空的构造函数参数,因为我们的合约不需要任何参数。
  • IUserManagerDispatcher { contract_address } 创建一个 dispatcher (调度器) 来与已部署的合约交互。
  • 在触发 action (动作) 之前,spy_events() 初始化事件监听。

通过 dispatcher (调度器) 调用 register_user("serah") 之后,spy.assert_emitted() 检查并验证是否触发了期望的 UserRegistered 事件,并且数据正确 ( user_id: 1, username: "serah", 以及当前时间戳)。 该断言检查了触发事件的合约地址和事件数据结构。

运行 scarb test 你应该看到测试通过,确认我们的事件测试工作正常。

测试 2:使用手动方法

test_event_structure() 测试以确保 register_user 函数工作正常,并触发期望的 UserRegistered 事件。

##[test]
fn test_event_structure() {
    // 声明和部署 UserManager 合约用于测试
    let contract = declare("UserManager").unwrap().contract_class();
    let (contract_address, _) = contract.deploy(@array![]).unwrap();

    // 创建 dispatcher (调度器) 以与合约交互
    let dispatcher = IUserManagerDispatcher { contract_address };

    // 启动事件监听器以捕获所有触发的事件
    let mut spy = spy_events();

    // 注册一个用户,这应该会触发一个 UserRegistered 事件
    dispatcher.register_user("serah");

    // 检索所有捕获的事件以进行分析
    let events = spy.get_events();
    assert(events.events.len() == 1, 'There should be one event');

    // 创建期望的事件结构体用于比较
    let expected_event = UserManager::Event::UserRegistered(
        UserRegistered {
            user_id: 1,
            username: "serah",
            timestamp: get_block_timestamp()
        }
    );

    // 检查是否实际触发了期望的事件
    assert!(events.is_emitted(contract_address, @expected_event));

    // 创建期望事件的数组用于精确比较
    let expected_events: Array<(ContractAddress, Event)> = array![\
        (contract_address, expected_event.into()),\
    ];
    assert!(events.events == expected_events);

    // 提取并检查原始事件数据
    let (from, event) = events.events.at(0);
    assert(from == @contract_address, 'Emitted from wrong address');

    // 验证事件 keys 结构 (事件选择器 + 索引字段)
    assert(event.keys.len() == 2, 'There should be two keys');
    assert(event.keys.at(0) == @selector!("UserRegistered"), 'Wrong event name');
}

当我们调用 register_user() 时,它使用 spy.get_events() 检索所有捕获的事件并执行检查:

  • 使用 events.is_emitted() 确认触发了期望的事件,并且
  • 检查原始事件结构,包括合约地址、key (键) 计数 (keys (键) 应该包含精确的 2 个元素:事件选择器 + 索引 user_id)、和事件选择器。

register_user() 触发一个包含用户数据的 UserRegistered 事件。

这种手动方法允许测试自动断言可能无法覆盖到的特定事件属性。

将第二个测试粘贴到同一文件 tests/test_contract.cairo 中,以便该文件包含第一个和第二个测试。 然后使用 scarb test 测试项目。

你的终端输出应显示测试通过。

Tests passing in Scarb

查看原始事件数据

要查看原始事件数据,你可以在 Voyager 上检查这个已部署的 UserManager 合约。 点击 "Events" 标签以查看调用 register_user 触发的事件日志,如下所示:

Viewing raw event data in the block explorer

如前所述,序列化的 ByteArray 是一个由 [data, pending_word, pending_word_len] 组成的结构体,每个都存储为 felt252。 这就是为什么 "serah" 占据了 data[0-2],如上图所示。

  • data (data[0]): 空数组 [0x0],因为 "serah" (5 字节) 不需要任何 31 字节的块
  • pending_word(data[1]): 0x7365726168 包含实际的字符串字节
  • pending_word_len(data[2]): 0x5 (总共 5 字节)
  • data[3]: 0x68c6c625 以十六进制表示时间戳。

这种原始视图准确地显示了 Cairo 如何序列化数据;一切都转换为 felt252 值的序列 (此处显示为十六进制),同时使其能够重构原始数据结构。

以下部分显示了在 Starknet 中事件数据检索和处理的三种基本方法。

查询和监控链上和链下事件

理解事件结构只是其中的一部分。 在实践中,你可能希望获得即时交易反馈、实时监控或历史分析。 每种情况都需要不同的方法。

解析事件日志

考虑一个最小的 TypeScript 示例,该示例说明了当需要从你自己的交易中获得即时反馈时,如何解析来自 Starknet 智能合约交易的事件。

  • 克隆这个 repository,然后 cd 进入 starknet-event-parsing 目录:
git clone https://github.com/Sayrarh/starknet-event-parsing.git
cd starknet-event-parsing
  • 如果你没有安装 yarn,请先使用 npm install -g yarn 安装。
  • 运行 yarn install 安装依赖项,然后安装 dotenv yarn add dotenv
  • 在根目录中创建一个 .env 文件:
ACCOUNT_ADDRESS=0x...
PK=0x...
ALCHEMY_API_KEY=your_alchemy_api_key_here
  • 将其替换为你从 Alchemy 获得的实际帐户地址、私钥和 API 密钥。
  • 编辑主脚本 (src/event.ts) 以指定你要解析 Transfer 事件的 ERC-20 合约的地址,或者任何其他合约地址 (此处使用 Sepolia 上的 STRK token (代币)),以及接收者地址:
const contractAddress = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d";
const recipientAddress = "0x0207d7324a20d6A080C7EF6237D289fD57F4fb11187A64f597d4099a720FE6C5";

确保你的帐户在链上处于活动状态,并且具有用于交易费用的 STRK token (代币)。

  • 使用 yarn dev 运行脚本。 该脚本将:

    • 连接到你在 Sepolia 上的 Starknet 帐户
    • 将 1 个 STRK token (代币) 转移到指定的接收者地址
    • 等待交易确认
    • 提取并显示在该交易期间触发的所有事件

A screen shot showing results after running yarn dev

这有助于解析来自 Starknet 上任何 ERC20 token (代币) 合约的 Transfer 事件。

你还可以为不同的合约和场景自定义脚本:

await eventLogic(
  "0x... 你的合约地址",
  "你的函数名",
  [arg1, arg2,...]
);

监听事件

当你需要实时监控合约 activity (活动) 时,这会派上用场。 将 src/event.ts 替换为以下代码示例,该示例在每次 ERC-20 token (代币) 触发 Transfer 事件时触发回调:

// 导入必要的 Starknet.js 组件以进行 RPC 交互
import { RpcProvider} from "starknet";
import * * as dotenv from "dotenv";

dotenv.config();

async function listenToTransfers() {
  const alchemyApiKey = process.env.ALCHEMY_API_KEY;

  // 使用 Alchemy 初始化 Sepolia 测试网的 provider (提供者)
  const provider = new RpcProvider({
    nodeUrl: `https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_8/${alchemyApiKey}`,
  });

  // 要监控事件的合约地址
  const contractAddress = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d";

  // 跟踪上次处理的区块以避免重新处理事件
  let lastBlock = 0;

  async function checkForEvents() {
    // 从网络获取当前区块号
    const currentBlock = await provider.getBlockNumber();

    // 仅在新区块时检查新事件
    if (currentBlock > lastBlock) {
      // 查询上次处理的区块和当前区块之间的 Transfer 事件
      const events = await provider.getEvents({
        address: contractAddress,     // 仅来自我们的目标合约的事件
        keys: [["0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9"]],  // Transfer 事件选择器 (keccak hash)
        from_block: { block_number: lastBlock + 1 },  // 从下一个未处理的区块开始
        to_block: { block_number: currentBlock },    // 查询至当前区块
        chunk_size: 100     // 分批处理 100 个事件
      });

      //  处理每个检测到的 Transfer 事件
      events.events.forEach(event => {
        console.log("Transfer event detected!", event);
      });

      // 将上次处理的区块更新为当前区块
      lastBlock = currentBlock;
    }
  }

  // 设置轮询:每 10 秒检查一次新事件
  setInterval(checkForEvents, 10000);

  // 立即运行初始检查
  checkForEvents();
}

// 启动事件监听器
listenToTransfers();

当你运行 yarn dev 时,你将在终端输出中以间隔看到新交易,直到你按下 Ctrl+C

Transfer events detected by the script

按范围筛选事件

当你需要历史数据分析和查询时,你可以使用 Starknet.js 中的 provider.getEvents() 查询特定区块范围内的历史事件。

再次将 src/event.ts 替换为以下代码示例,该示例在区块 8000 到 9000 (总共 1000 个区块) 中搜索来自指定合约的 Transfer 事件:

import { RpcProvider } from "starknet";
import * * as dotenv from "dotenv";

dotenv.config();

async function filterTransferEvents() {
    const alchemyApiKey = process.env.ALCHEMY_API_KEY;

    // 使用 Alchemy 初始化 Sepolia 测试网的 provider (提供者)
    const provider = new RpcProvider({
      nodeUrl: `https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_8/${alchemyApiKey}`,
    });

  // 要查询 Transfer 事件的目标合约地址
  const contractAddress = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d";
  const transferSelector = "0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9";

  // 查询特定区块范围内的Transfer 事件
  const events = await provider.getEvents({
    address: contractAddress,                    // 仅来自我们的目标合约的事件
    keys: [[transferSelector]],                  // 仅筛选 Transfer 事件
    from_block: { block_number: 8000 },         // 从区块 8000 开始搜索
    to_block: { block_number: 9000 },           // 搜索到区块 9000 (1000 个区块范围)
    chunk_size: 100                             // 分批处理 100 个事件
  });

  // 显示找到的 Transfer 事件总数
  console.log(`发现 ${events.events.length} 个 Transfer 事件`);

  // 处理并显示每个 Transfer 事件的详细信息
  events.events.forEach((event, index) => {
    console.log(`\n--- Transfer Event ${index + 1} ---`);
    console.log("From:", event.keys[1]);
    console.log("To:", event.keys[2]);
    console.log("Amount (hex):", event.data[0]);
    console.log("Amount (decimal):", parseInt(event.data[0], 16));
    console.log("Block:", event.block_number);
    console.log("Transaction:", event.transaction_hash);
  });
}

// 执行事件筛选函数
filterTransferEvents();

运行 yarn dev,你将获得指定区块范围内的筛选事件。 筛选使用以下参数:

  • contractAddress: 要查询事件的特定合约
  • transferSelector: 标识 Transfer 事件的事件签名哈希
  • keys: 按类型筛选事件;仅返回 Transfer 事件
  • from_block/to_block: 定义要在其中搜索的区块范围
  • chunk_size: 控制分页以避免过多的响应

然后解码事件数据以提取信息; 发送者地址 (keys[1])、接收者地址 (keys[2]) 和转移金额 (data[0])。

事件中的变量名是否像在 Solidity 中那样是可选的?

正如预期的那样,变量名在 Cairo 事件中不是可选的。 虽然 Solidity 允许匿名事件参数,但 Cairo 要求事件结构定义中的所有参数都具有显式字段名称。

事件可以通过父合约和接口继承吗?

Cairo 不支持事件继承。 相反,要跨合约重用事件,可以将 component (组件) 包含在合约中,并使用 #[flat] 属性在合约的 #[event] 枚举中显式列出它们的事件定义。 事件还必须公开导出,才能在其他合约中访问。

为了回顾 Solidity (固态) 和 Cairo 中事件之间的主要差异,下面是一个清晰比较的表格:

事件:Cairo 和 Solidity 之间的主要差异

方面 Cairo Solidity
变量名 所有参数都需要 可选 (允许匿名参数)
索引参数 #[key] 属性 (最多 50 个索引参数) indexed 关键字 (最多 3 个,或匿名事件 4 个)
参数总数 没有硬性限制 (实际限制) 17 个总参数 (数组计为 2 个)
继承 没有继承 - 使用 component (组件) 嵌入 完全支持继承
事件声明 #[derive(starknet::Event)] 结构体 event EventName(...)
事件触发 self.emit(Event::EventName { ... }) emit EventName(...)
嵌套事件 用于展平的 #[flat] 属性 不支持

结论

与 Solidity 相比,Cairo 事件需要更明确的结构,并且强制执行严格的类型定义和组合模式。 在 Cairo 中,事件依赖于三个协同工作的 trait (特征):

  • Serde 处理将复杂字段序列化为 felt252 值。
  • Event 准备用于接收的 keys (键) 和数据数组。
  • EventEmitter 触发结构化事件。

具有嵌套或非原始类型的 struct (结构体) 必须派生 Serde 才能编译。 用 #[key] 标记的索引字段单独存储以进行筛选,在 u32felt252ContractAddress 等原始类型上使用 #[key] 以进行有效查询,因为复杂类型会被哈希处理且变得不可读。 #[flat] 属性应用于嵌套事件枚举以展平命名层次结构,从而为更好的查询粒度启用不同的事件选择器。

本文是 Starknet 上的 Cairo 编程 教程系列的一部分

  • 原文链接: rareskills.io/post/cairo...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/