事件将合约执行中的数据触发(emit)到交易回执(transaction receipt)中。回执中保存了执行期间所发生情况的元数据,外部应用程序可以对其进行查询或索引。Cairo 的事件语法比 Solidity 更繁琐,但用途相同。
在本文中,你将了解事件在 Starknet 中是如何工作的。
Cairo 中的事件结构
Cairo 中的事件必须列在一个标有 #[event] 属性的 Event 枚举(enum)中。与 Solidity 独立声明事件不同,Cairo 要求将所有事件组织在一个中心化的枚举结构中。
下面是一个列出两个事件的示例,一个用于用户注册,另一个用于用户登录:
// Event emitted when a new user registers
#[derive(Drop, starknet::Event)]
pub struct UserRegistered {
pub user_id: u32,
pub username: ByteArray
}
// Event emitted when a user logs in
#[derive(Drop, starknet::Event)]
pub struct UserLoggedIn {
pub user_id: u32,
pub timestamp: u64
}
// Main event enum that holds all possible events this contract can emit
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
NewUser: UserRegistered, // references UserRegistered struct
UserLogin: UserLoggedIn // references UserLoggedIn struct
}
注:Drop trait 允许 Cairo 在结构体和枚举不再需要时自动将它们从内存中清理。你会在大多数 Cairo 结构体和枚举上看到 #[derive(Drop)]。
这两个事件的 Solidity 表示形式将是:
event NewUser(uint32 userID, string username);
event UserLogin(uint32 userID, uint64 timestamp);
在上面的 Cairo 代码中,我们定义了两个事件结构体(UserRegistered 和 UserLoggedIn),它们指定了每种事件类型的数据结构。这两个结构体都通过 derive 属性实现了 starknet::Event trait。
然后,这些独立的结构体被统一(列出)在一个 Event 枚举下,每个变体引用其对应的结构体。当触发事件时,枚举变体名称(NewUser、UserLogin)将作为可搜索的事件标识符。
尽管你通常会看到 枚举变体(如 NewUser)与其关联的结构体(如 UserRegistered)使用相同的名称,但它们并不一定非要匹配。此处使用不同的名称是为了突出它们之间的区别。
Starknet SDK(例如 Starknet.js)可以使用这些标识符来过滤和查询事件。例如,要查找所有的用户注册记录,你可以查询名为 "NewUser" 的事件。
在处理事件时,你通常希望根据它们包含的特定数据来过滤事件,例如查找特定用户 ID 或特定数值范围内的所有事件。就像在 Solidity 中一样,这就是索引参数(indexed parameters)发挥作用的地方。
索引事件(键字段)
可以使用 #[key] 属性将事件字段标记为已索引(类似于 Solidity 中的 indexed 关键字)。例如,如果我们希望在用户注册中使 user_id 可搜索,而在用户登录中使 timestamp 可搜索,我们可以这样做:
#[derive(Drop, starknet::Event)]
pub struct UserRegistered {
#[key]
pub user_id: u32, // user_id IS MARKED is AS INDEXED (searchable key)
pub username: ByteArray // username IS STORED AS EVENT DATA (not indexed)
}
#[derive(Drop, starknet::Event)]
pub struct UserLoggedIn {
pub user_id: u32, // user_id IS STORED AS EVENT DATA (not indexed)
#[key]
pub timestamp: u64 // timestamp IS MARKED AS INDEXED
}
// Main event enum that holds all possible events this contract can emit
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
UserRegistered: UserRegistered,
UserLoggedIn: UserLoggedIn
}
其等效的 Solidity 代码为:
event UserRegistered(uint32 indexed userID, string username);
event UserLoggedIn(uint32 userID, uint64 indexed timestamp);
#[key] 的位置取决于你希望在事件日志中使哪个特定字段可搜索。字段(field) 是结构体中的数据元素,例如,user_id 和 username 是 UserRegistered 结构体中的字段。
在 UserRegistered 中,我们对 user_id 进行索引以便按用户过滤;而在 UserLoggedIn 中,我们对 timestamp 进行索引以便按时间过滤。
你应该只为你实际需要查询或过滤的字段添加 #[key],每个字段都 必须 根据你的具体过滤需求进行单独注解。
在交易回执中,键字段与常规数据字段分开存储,以使 Starknet SDK 能够快速过滤事件,而无需处理所有的事件数据。
交易回执中的事件数据结构
交易回执是一份包含成功交易详细信息的记录。它包含区块详情(block_hash、block_number)、交易哈希、执行状态(execution_status、finality_status)、Gas 消耗(execution_resources)、交易费用(actual_fee)等,以及在执行期间触发的任何事件。
每个交易回执都包含一个 events 数组,其中包含所有触发事件的键(keys)和数据(data),其中:
data:表示一个包含序列化后的非索引字段值的数组from_address:是触发该事件的合约地址keys:是一个数组,它 始终 在keys[0]处包含事件选择器哈希(event selector hash),并在keys[1]、keys[2]等位置包含任何索引字段值。
无论你是否在合约中使用 #[key] 注解,keys 数组都存在于每个事件中。它至少包含用于标识事件类型(如 Transfer 等)的事件选择器哈希。
下面显示了一个交易回执的示例,其中粉色框内高亮显示了 events 数组的结构:

下面是基于所提供的交易哈希,使用 getTransactionReceipt 方法生成此交易回执的 Typescript 代码:
import { RpcProvider } from "starknet";
import * as dotenv from "dotenv";
dotenv.config();
async function getTxnReceipt() {
// Initialize RPC provider with Sepolia testnet endpoint
const alchemyApiKey = process.env.ALCHEMY_API_KEY;
// initialize provider for Sepolia testnet with Alchemy
const provider = new RpcProvider({
nodeUrl: `https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_8/${alchemyApiKey}`,
});
// Transaction hash to query (replace with actual hash)
const transactionHash =
"0x5df0e42012440f59eb9cdd7994a3001b72cebc781bd8527fb3a5343cdb9d6f7";
try {
// Fetch transaction receipt from the network
const receipt: any = await provider.getTransactionReceipt(transactionHash);
// Display formatted receipt data
console.log(JSON.stringify(receipt, null, 2));
} catch (error) {
// Handle network or transaction errors
console.error("Error getting transaction receipt:", error);
}
}
// Execute the function
getTxnReceipt();
此示例仅展示了事件在交易回执中的显示方式。使用 Starknet.js 的各种查询技术将在本文后面的部分中介绍。
理解 Keys 数组
在上面的交易回执中,该事件只有一个键(keys[0]),包含事件选择器哈希 0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9。

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

事件选择器哈希的计算公式为:
const nameHash = num.toHex(hash.starknetKeccak('EventName'));
Cairo 事件结构与 Solidity 的比较
在 Solidity 中,事件数据使用 topics 和 data 进行结构化,如这个 例子 所示:

- topic0:始终 包含事件签名哈希。在上述示例中,事件
NewUser(uint32,string)的哈希值为keccak256("NewUser(uint32,string)"),等于0x37ecc4388271ab7af2220881c1f2f70fbea71e6b1635107f9daffa0fab84d5b3。 - topic1:包含被索引的
user_id参数。 - Data field(数据字段):包含所有非索引参数(十六进制形式)。
Cairo 在 keys 数组中遵循类似的模式。为了进行比较,请查看 Starknet Sepolia 上的这笔 交易,它展示了一个包含多个键的事件:

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 进行高效过滤。
事件在内部是如何工作的
Starknet 在 Cairo 中的事件系统主要围绕两个 trait 展开:
Event:处理事件的序列化和反序列化。EventEmitter:在合约函数内使用self.emit(...)提供触发(emission)能力。
Event Trait
Event trait 提供的方法用于序列化事件、反序列化事件,以及生成用于事件过滤和索引的内部事件类型标识符。任何标记有 #[derive(starknet::Event)] 的结构体或枚举都会自动获得这些关键方法的实现:
| 方法 | 目的 |
|---|---|
append_keys_and_data |
通过将索引字段(#[key])和非索引字段拆分为单独的 keys 和 data 数组来序列化事件 |
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 自动处理的类型有:
- 简单类型:
u8、u16、u32、u64、u128、bool、felt252、ContractAddress ByteArray:自动序列化,无需手动派生(derive)Serde
如前几章所述,ByteArray 是一个表示字符串的 Cairo 类型。它是一个包含三个字段的结构体:
data: Array<felt252>:包含字符串数据的 31 字节块*pending_word: felt252:在将完整的 31 字节块填充至data数组后剩余的字节(最多 30 个字节)pending_word_len: u32:pending_word中的字节数
Cairo 首先将完整的 31 字节块打包到 data 数组中,然后将任何剩余的字节放入 pending_word 中。例如,“serah” 被序列化为:
data:[](空数组 - 5 字节的字符串不需要 31 字节块)pending_word:0x7365726168(包含实际的字符串字节,十六进制格式)pending_word_len:0x5(总共 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 支持:

介绍完基础知识后,下一节将探讨当事件结构嵌套或包含复杂类型时是如何运作的。
处理复杂的事件字段类型
下面是包含附加字段类型的更新后 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,代码将无法编译,如下所示:

UserMetadata 需要 Serde,因为它是一个自定义结构体。UserRegistered 事件结构体只需要 starknet::Event(Event trait 自动处理基础类型,但对于复杂字段类型则委托给 Serde 处理。)
另外请注意,复杂类型的索引字段(#[key])会作为哈希值存储在事件的 keys 数组中,无法直接从交易日志中恢复。
当前的 UserRegistered 事件(推荐方法):
此设计使用 user_id(一种 u32 原语类型)作为索引字段,这在交易日志中保持可读,便于高效查询:
#[derive(Drop, starknet::Event)]
pub struct UserRegistered {
#[key]
pub user_id: u32, // Primitive type (stays readable)
pub username: ByteArray, // Non-indexed (in data array)
pub metadata: UserMetadata, // Non-indexed (in data array)
pub tag_count: u32, // Non-indexed (in data array)
pub timestamp: u64, // Non-indexed (in data array)
}
交易回执:
{
"keys": [
"0x...event_selector", // key[0] is always the event selector.
"0x7b" // user_id = 123 (readable as hex)
],
"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, // Complex struct as indexed field - BAD!
pub user_id: u32,
pub username: ByteArray,
}
你会在回执中看到:
{
"keys": [
"0x...event_selector",
"0xa1b2c3d4e5f67890..." // Hashed UserMetadata - unreadable!
],
"data": ["0x7b", "username_serialized"]
}
整个 UserMetadata 在 keys 数组中变成了一个不可读的哈希。我们无法根据 device_type 或 ip_region 查询用户,因为这些值隐藏在哈希中。这就是为什么当我们需要高效过滤事件时,像 u32 这样的基础类型作为索引字段效果更好。因此,如果你打算通过索引字段查询事件,最好使用像 u32、felt252 或 ContractAddress 这样的基础类型。
使用 #[flat] 属性
#[flat] 属性改变了事件变体在交易日志中的命名和标识方式。它用于展平(flatten)嵌套的事件枚举,使查询和过滤特定事件变得更加容易。
该属性解决的是嵌套枚举结构的问题,而不是复杂字段类型。这与我们刚刚讨论的复杂索引是一个完全独立的概念。
#[flat]属性展平的是事件的 命名层级(naming hierarchy),而不是数据结构本身。
当用于 Event 枚举中的事件变体时,它将改变事件选择器哈希的计算方式,从而使用内部变体名称而不是外部枚举名称。
外部枚举、内部枚举与内部变体
为了理解 #[flat] 的工作原理,我们需要区分这三个层级的枚举结构:
// OUTER enum (the main Event enum)
pub enum Event {
UserRegistered: UserRegistered,
#[flat]
UserDataUpdated: UserDataUpdated, // <- This references the INNER enum
}
// INNER enum (nested inside the outer enum structure)
pub enum UserDataUpdated {
DeviceType: UpdatedDeviceType, // <- These are the inner variants
IpRegion: UpdatedIpRegion, // <- These are the inner variants
}
- Outer enum(外部枚举):包含合约所有可能事件的主
Event枚举 - Inner enum(内部枚举):包含特定变体(
DeviceType和IpRegion)的UserDataUpdated枚举 - Inner variant(内部变体):
UserDataUpdated枚举中各自独立的枚举变体(DeviceType和IpRegion),每个都引用自己的事件结构体
完整示例
下面的示例展示了一个包含多种事件类型的合约:一个简单的结构体事件(UserRegistered)和一个包含两个变体的嵌套枚举事件(UserDataUpdated):
// Main event enum that holds all possible events this contract can emit
#[event]
#[derive(Drop, starknet::Event)]
pub enum Event {
UserRegistered: UserRegistered,
#[flat] // NEWLY ADDED (flattens nested event enum)
UserDataUpdated: UserDataUpdated,
}
// event for user registration
#[derive(Drop, starknet::Event)]
pub struct UserRegistered {
#[key]
pub user_id: u32, // Indexed user ID for filtering
pub username: ByteArray, // Username as event data
pub metadata: UserMetadata, // User metadata struct
}
// Nested event enum containing different types of user data updates
#[derive(Drop, starknet::Event)]
pub enum UserDataUpdated {
DeviceType: UpdatedDeviceType, // Device type change event
IpRegion: UpdatedIpRegion, // IP region change event
}
// Event for device type updates
#[derive(Drop, starknet::Event)]
pub struct UpdatedDeviceType {
#[key]
pub user_id: u32, // Indexed user ID
pub new_device_type: ByteArray, // New device type value
}
// Event for IP region updates
#[derive(Drop, starknet::Event)]
pub struct UpdatedIpRegion {
#[key]
pub user_id: u32, // Indexed user ID
pub new_ip_region: ByteArray, // New IP region value
}
// User metadata structure containing device and location info
#[derive(Drop, Serde)]
pub struct UserMetadata {
pub device_type: ByteArray, // User's device type
pub ip_region: ByteArray, // User's IP region
}
请注意 #[flat] 属性是如何应用于主 Event 枚举中的 UserDataUpdated(嵌套枚举)变体的,这正是改变内部变体(DeviceType 和 IpRegion)在交易日志中显示方式的原因。
回想一下,事件选择器哈希(存储在交易回执的 keys[0] 中)是使用 starknetKeccak("EventName") 计算的。
如果没有 #[flat] 属性,事件选择器哈希将从外部枚举名称派生:starknetKeccak("UserDataUpdated")。这意味着所有的枚举变体(DeviceType 和 IpRegion)共享相同的事件选择器,因此你无法查询特定的变体,你只能普遍地查询 "UserDataUpdated" 事件。
{
"keys": ["0x...hash_of_UserDataUpdated"], // Same selector for all variants*
"data": [...],
"from_address": "0x..."
}
但是当我们使用 #[flat] 时,事件选择器哈希是根据内部变体名称计算的:starknetKeccak("DeviceType") / starknetKeccak("IpRegion"),因此 DeviceType 和 IpRegion 各自拥有其独有的选择器哈希,以便进行精确过滤和查询。
// DeviceType event
{
"keys": ["0x...hash_of_DeviceType"], // Unique selector
"data": [...],
"from_address": "0x..."
}
// IpRegion event
{
"keys": ["0x...hash_of_IpRegion"], // Different unique selector
"data": [...],
"from_address": "0x..."
}
#[flat] 属性仅影响事件命名和选择器计算,实际的数据结构、字段和序列化保持不变。这使得在处理嵌套事件枚举时,事件过滤和日志检查变得更加容易。
#[flat] 属性通常用于 OpenZeppelin 组件库中,以确保组件事件符合标准的事件结构
例如,当使用 ERC20 和 Ownable 组件时,#[flat] 会从事件中删除组件 ID 前缀,这样一来 ERC20 的 Transfer 和 Approval 事件,以及 Ownable 的 OwnershipTransferred 事件,就会以它们自己的选择器哈希作为第一个键出现,就像在独立的合约中一样。(组件将在第 13 章详细解释——现在,你可以将它们视为可重用的合约模块。)
请注意,在事件枚举中用作枚举变体的结构体必须派生 starknet::Event,因为当它们用在枚举结构中时,它们本身就成为了事件类型。
测试事件日志
搭建一个新的 Scarb 项目 scarb new testinglog,并选择“Starknet Foundry (default)”作为你的测试运行器:

要测试事件日志,请考虑下面这个 UserManager 合约,它允许用户自行注册并追踪已注册用户的数量。
该合约使用计数器为用户分配唯一 ID,并将他们的信息存储在一个 Map 中。当用户注册时,合约会触发一个可供外部应用程序查询的 UserRegistered 事件。请注意 #[key] 属性以及 UserMetadata 结构体是如何存储的。
复制完整代码并将其粘贴到你的 src/lib.cairo 文件中:
// Interface defining the functions our UserManager contract will implement
#[starknet::interface]
pub trait IUserManager<TContractState> {
fn register_user(ref self: TContractState, username: ByteArray);
fn get_user_count(self: @TContractState) -> u32;
}
// Struct to store user information (derives Store to enable storage in contract)
#[derive(Drop, Serde, starknet::Store)]
pub struct UserMetadata {
pub user_id: u32,
pub username: ByteArray
}
// Event emitted when a new user registers (user_id is marked as key for indexing)
#[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, // Tracks total number of registered users
users: Map<ContractAddress, UserMetadata> // Maps user addresses to their metadata
}
// Main event enum that holds all possible events this contract can emit
#[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) {
// Get current user count and increment for new user ID
let current_counter = self.user_counter.read();
let user_id = current_counter + 1;
// Create user metadata with new ID and provided username
let metadata = UserMetadata {
user_id,
username: username.clone()
};
// Update counter and store user data mapped to caller's address
self.user_counter.write(user_id);
self.users.entry(get_caller_address()).write(metadata);
// Emit event with user details and current timestamp
self.emit(UserRegistered {
user_id,
username,
timestamp: get_block_timestamp(),
});
}
fn get_user_count(self: @ContractState) -> u32 {
// Return the current number of registered users
self.user_counter.read()
}
}
}
IUserManager trait 定义了两个函数;register_user 用于注册,get_user_count 用于检查已注册用户的总数。
-
UserMetadata结构体存储用户信息(ID 和用户名)并且可以被保存到合约存储中。它派生了starknet::Store,因为它被存储在合约存储的Map<ContractAddress, UserMetadata>中。任何需要读取或写入到合约存储中的自定义结构体都必须实现
Storetrait,而#[derive(starknet::Store)]会自动生成它。 -
UserRegistered事件结构体记录注册详情。user_id字段标有#[key],使其被索引以便在查询中进行高效过滤。
当调用 register_user 时,合约会:
- 递增用户计数器以生成新的用户 ID
- 创建并存储用户的元数据
- 触发带有用户 ID、用户名和当前区块时间戳的
UserRegistered事件
导航到你的项目目录 cd testinglog 并运行 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() {
// Deploy the UserManager contract
let contract_address = deploy_contract("UserManager");
// Create a dispatcher to interact with the deployed contract
let dispatcher = IUserManagerDispatcher { contract_address };
// Start spying on events before the function call
let mut spy = spy_events();
// Register a user - this should emit a UserRegistered event
dispatcher.register_user("serah");
// Verify that the expected event was emitted with correct data
spy.assert_emitted(
@array![
(
contract_address, // Event should come from our contract
UserManager::Event::UserRegistered(
UserRegistered {
user_id: 1, // First user gets ID 1
username: "serah", // Username matches what we passed
timestamp: get_block_timestamp() // Timestamp should be current block time
}
)
)
]
);
}
Imports 引入了 Starknet Foundry 所需的测试工具以及我们的合约定义。
从 snforge_std 中,我们导入了 declare 来加载合约类,以及相关用于合约部署的 trait,如 ContractClassTrait 和 DeclareResultTrait。
use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, spy_events, EventSpyTrait,IsEmitted, Event, EventSpyAssertionsTrait};
事件测试功能来自创建我们事件间谍(spy)的 spy_events、用于间谍交互的 EventSpyTrait,以及添加了像 assert_emitted 断言方法的 EventSpyAssertionsTrait。我们还导入了用于事件处理操作的 IsEmitted 和 Event 类型。
从我们的 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() 的简化方法
deploy_contract("UserManager")是一个声明并部署UserManager合约的辅助函数,并返回其地址IUserManagerDispatcher { contract_address }创建一个调度器(dispatcher)以与已部署的合约进行交互spy_events()在我们触发操作之前初始化事件间谍功能
在通过调度器调用 register_user("serah") 之后,spy.assert_emitted() 检查以验证是否触发了包含正确数据(user_id: 1,username: “serah”,以及当前时间戳)的预期 UserRegistered 事件。该断言同时检查了触发事件的合约地址和事件数据结构。
运行 scarb test 你应该会看到测试通过,这证实了我们的事件测试工作正常。
测试 2:使用手动方法
test_event_structure() 测试以确保 register_user 函数正确工作并触发了预期的 UserRegistered 事件。
#[test]
fn test_event_structure() {
// Deploy the UserManager contract
let contract_address = deploy_contract("UserManager");
// Create a dispatcher to interact with the deployed contract
let dispatcher = IUserManagerDispatcher { contract_address };
// Start event spy to capture all emitted events
let mut spy = spy_events();
// Register a user which should emit a UserRegistered event
dispatcher.register_user("serah");
// Retrieve all captured events for analysis
let events = spy.get_events();
assert(events.events.len() == 1, 'There should be one event');
// Create the expected event structure for comparison
let expected_event = UserManager::Event::UserRegistered(
UserRegistered {
user_id: 1,
username: "serah",
timestamp: get_block_timestamp()
}
);
// Check if the expected event was actually emitted
assert!(events.is_emitted(contract_address, @expected_event));
// Create array of expected events for exact comparison
let expected_events: Array<(ContractAddress, Event)> = array![
(contract_address, expected_event.into()),
];
assert!(events.events == expected_events);
// Extract and examine the raw event data
let (from, event) = events.events.at(0);
assert(from == @contract_address, 'Emitted from wrong address');
// Verify event keys structure (event selector + indexed fields)
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()确认已触发预期事件,并且 - 检查原始事件结构,包括合约地址、键数量(keys 应该正好包含 2 个元素:事件选择器 + 索引的
user_id)以及事件选择器。
register_user() 会触发一个包含用户数据的 UserRegistered 事件。
这种手动方法允许测试自动断言可能未涵盖的特定事件属性。
将这第二个测试粘贴到相同的文件 tests/test_contract.cairo 中,以便该文件同时包含第一个和第二个测试。然后继续使用 scarb test 测试项目。
你的终端输出应该显示测试通过

查看原始事件数据
要查看原始事件数据,你可以检查 Voyager 上 这个已部署的 UserManager 合约。点击“Events”选项卡以查看因调用 register_user 而触发的事件日志,如下所示:

如前所述,被序列化的 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 智能合约交易中解析事件。
- 克隆此 仓库,并 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来安装依赖项,然后使用yarn add dotenv安装 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 代币),并指定接收方地址:
const contractAddress = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d";
const recipientAddress = "0x0207d7324a20d6A080C7EF6237D289fD57F4fb11187A64f597d4099a720FE6C5";
确保你的账户在链上是活跃的,并且有 STRK 代币用于支付交易费用。
- 使用
yarn dev运行脚本。该脚本将:- 连接到你在 Sepolia 上的 Starknet 账户
- 向指定的接收方地址执行 1 枚 STRK 代币的转账
- 等待交易确认
- 提取并显示在该笔交易期间触发的所有事件

这有助于解析来自 Starknet 上任何 ERC20 代币合约的 Transfer 事件。
你也可以针对不同的合约和场景自定义脚本:
await eventLogic(
"0x... your contract address",
"your_function_name",
[arg1, arg2,...]
);
监听事件
当你需要对合约活动进行实时监控时,这就会派上用场。将 src/event.ts 替换为以下代码示例,该示例会在每次 ERC-20 代币触发 Transfer 事件时触发回调:
// Import necessary Starknet.js components for RPC interaction
import { RpcProvider} from "starknet";
import * as dotenv from "dotenv";
dotenv.config();
async function listenToTransfers() {
const alchemyApiKey = process.env.ALCHEMY_API_KEY;
// initialize provider for Sepolia testnet with Alchemy
const provider = new RpcProvider({
nodeUrl: `https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_8/${alchemyApiKey}`,
});
// Contract address to monitor for events
const contractAddress = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d";
// Track the last processed block to avoid re-processing events
let lastBlock = 0;
async function checkForEvents() {
// Get the current block number from the network
const currentBlock = await provider.getBlockNumber();
// Only check for new events if there are new blocks
if (currentBlock > lastBlock) {
// Query for Transfer events between the last processed block and current block
const events = await provider.getEvents({
address: contractAddress, // Only events from our target contract
keys: [["0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9"]], // Transfer event selector (keccak hash)
from_block: { block_number: lastBlock + 1 }, // Start from next unprocessed block
to_block: { block_number: currentBlock }, // Query up to current block
chunk_size: 100 // Process events in batches of 100
});
// Process each detected Transfer event
events.events.forEach(event => {
console.log("Transfer event detected!", event);
});
// Update last processed block to current block
lastBlock = currentBlock;
}
}
// Set up polling: check for new events every 10 seconds
setInterval(checkForEvents, 10000);
// Run initial check immediately
checkForEvents();
}
// Start the event listener
listenToTransfers();
当你运行 yarn dev 时,你将每隔一段时间在终端输出中看到新的交易,直到你按下 Ctrl+C。

按范围过滤事件
当你需要进行历史数据分析和查询时,你可以使用 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;
// initialize provider for Sepolia testnet with Alchemy
const provider = new RpcProvider({
nodeUrl: `https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_8/${alchemyApiKey}`,
});
// Target contract address to query for Transfer events
const contractAddress = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d";
const transferSelector = "0x99cd8bde557814842a3121e8ddfd433a539b8c9f14bf31ebf108d12e6196e9";
// Query for Transfer events within a specific block range
const events = await provider.getEvents({
address: contractAddress, // Only events from our target contract
keys: [[transferSelector]], // Filter for Transfer events only
from_block: { block_number: 8000 }, // Start searching from block 8000
to_block: { block_number: 9000 }, // Search up to block 9000 (1000 block range)
chunk_size: 100 // Process events in batches of 100
});
// Display total number of Transfer events found
console.log(`Found ${events.events.length} Transfer events`);
// Process and display details for each Transfer event
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);
});
}
// Execute the event filtering function
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 不支持事件继承。相反,如果要在跨合约重用事件,你需要使用组件(components)。组件定义它们自己的事件,当你在合约中包含某个组件时,你可以使用 #[flat] 属性在合约的 #[event] 枚举中引用该组件的事件类型。这允许不同的合约通过使用同一个组件触发相同的事件,而无需在每个合约中重新定义这些事件。
为了回顾 Solidity 和 Cairo 事件之间的关键区别,下面这张表展示了清晰的对比:
事件:Cairo 和 Solidity 之间的关键区别
| 方面 | Cairo | Solidity |
|---|---|---|
| 变量名(Variable Names) | 所有参数都必须要有 | 可选(允许匿名参数) |
| 索引参数(Indexed Parameters) | #[key] 属性(最多 50 个索引参数) |
indexed 关键字(最多 3 个,或对匿名事件最多 4 个) |
| 总参数量(Total Parameters) | 无硬性限制(仅受实际约束) | 总共 17 个参数(数组算作 2 个) |
| 继承(Inheritance) | 无继承 - 使用组件嵌入 | 支持完全继承 |
| 事件声明(Event Declaration) | #[derive(starknet::Event)] 结构体 |
event EventName(...) |
| 触发事件(Event Emission) | self.emit(Event::EventName { ... }) |
emit EventName(...) |
| 嵌套事件(Nested Events) | 用于展平的 #[flat] 属性 |
不支持 |
结论
与 Solidity 相比,Cairo 事件需要更显式的结构,并强制执行严格的类型定义和组合模式。在 Cairo 中,事件依赖于三个共同协作的 trait:
Serde处理将复杂字段序列化为felt252值的操作。Event为回执准备 keys(键)和 data(数据)数组。EventEmitter触发已被结构化的事件。
带有嵌套或非基础类型的结构体必须派生 Serde 才能编译。标有 #[key] 的索引字段会被单独存储以供过滤。请在像 u32、felt252 或 ContractAddress 这样的基础类型上使用 #[key] 以便进行有效的查询,因为复杂类型会被哈希化并变得不可读。#[flat] 属性应用于嵌套的事件枚举以展平命名层级,从而启用不同的事件选择器以获得更好的查询粒度。
本文是有关 Starknet 上的 Cairo 编程 教程系列的一部分