Move/Sui 事件 + 动态字段(Dynamic Fields) + Table + 增强版NFT
### 1. Sui 事件系统(Events)
日期: 2026-05-29 方向: Move / Sui 阶段: 第三阶段:安全审计 标签: #sui #events #dynamic-fields #table #nft-enhanced
今日目标
- 掌握 Sui 事件(Events)系统的使用和最佳实践
- 深入理解动态字段(Dynamic Fields)和动态对象字段(Dynamic Object Fields)
- 学习
sui::table::Table的使用场景 - 实现一个带动态属性的增强版 NFT 模块
核心概念
1. Sui 事件系统(Events)
事件是链上合约与链下应用沟通的桥梁。Sui 的事件系统类似 Solidity 的 event,但有一些关键差异。
事件定义和发送
module events_demo::events {
use sui::event;
// 事件 struct:必须有 copy + drop ability
struct ItemPurchased has copy, drop {
item_id: ID,
buyer: address,
price: u64,
timestamp: u64,
}
struct ItemListed has copy, drop {
item_id: ID,
seller: address,
price: u64,
}
public fun buy_item(/* params */) {
// ... 业务逻辑 ...
// 发送事件
event::emit(ItemPurchased {
item_id: object::id(item),
buyer: tx_context::sender(ctx),
price: 1000,
timestamp: tx_context::epoch(ctx),
});
}
}
Sui Events vs Solidity Events
| 特性 | Solidity Events | Sui Events |
|---|---|---|
| 定义方式 | event Transfer(...) | struct Transfer has copy, drop {} |
| 发送方式 | emit Transfer(...) | event::emit(Transfer { ... }) |
| 索引字段 | indexed 关键字(最多3个) | 按字段类型自动索引 |
| 存储位置 | 交易日志(logs) | 交易效果(effects) |
| 查询方式 | eth_getLogs + filter | Sui RPC suix_queryEvents |
| Gas 成本 | 按字段数量计费 | 包含在交易 Gas 中 |
事件查询(TypeScript SDK)
import { SuiClient } from '@mysten/sui/client';
const client = new SuiClient({ url: 'https://fullnode.mainnet.sui.io' });
// 按事件类型查询
const events = await client.queryEvents({
query: {
MoveEventType: 'package_id::module_name::ItemPurchased'
},
limit: 50,
order: 'descending',
});
// 按发送者查询
const senderEvents = await client.queryEvents({
query: {
Sender: '0x...',
},
});
// 按交易哈希查询
const txEvents = await client.queryEvents({
query: {
Transaction: 'digest_hash',
},
});
2. 动态字段(Dynamic Fields)
动态字段允许在运行时向对象添加/删除字段,而不需要在编译时定义。这是 Sui 最强大的特性之一。
为什么需要动态字段?
// 问题:如果 NFT 的属性数量不确定怎么办?
struct NFT has key, store {
id: UID,
name: String,
// strength: u64, ← 编译时不知道需要哪些属性
// speed: u64, ← 不同 NFT 可能有不同的属性集
// magic: u64, ← 不能无限添加字段
}
// 解决方案:使用动态字段
// 在运行时添加任意 key-value 对到对象
动态字段 vs 动态对象字段
| 类型 | 函数模块 | 值类型要求 | 值是否可被外部直接访问 |
|---|---|---|---|
| Dynamic Field | sui::dynamic_field | store | 否(嵌入在父对象中) |
| Dynamic Object Field | sui::dynamic_object_field | key + store | 是(保持独立对象身份) |
use sui::dynamic_field as df;
use sui::dynamic_object_field as dof;
// Dynamic Field: 值被"吸收"到父对象中
// 无法通过 Object ID 直接访问值
df::add(&mut parent.id, key, value);
// Dynamic Object Field: 值保持独立对象身份
// 仍然可以通过 Object ID 直接访问值
dof::add(&mut parent.id, key, child_object);
动态字段 API
use sui::dynamic_field as df;
// 添加字段
// Name: key 的类型(必须有 copy + drop + store)
// Value: value 的类型(必须有 store)
df::add<Name, Value>(&mut uid, name, value);
// 借用(只读)
let value_ref: &Value = df::borrow<Name, Value>(&uid, name);
// 借用(可变)
let value_mut: &mut Value = df::borrow_mut<Name, Value>(&mut uid, name);
// 删除并返回值
let value: Value = df::remove<Name, Value>(&mut uid, name);
// 检查是否存在
let exists: bool = df::exists_<Name>(&uid, name);
// 检查是否存在且类型匹配
let exists_typed: bool = df::exists_with_type<Name, Value>(&uid, name);
3. Table
sui::table::Table<K, V> 是基于动态字段实现的键值映射,类似 Solidity 中的 mapping:
use sui::table::{Self, Table};
// 创建
let mut my_table: Table<address, u64> = table::new(ctx);
// 添加
table::add(&mut my_table, @0x1, 100);
// 读取
let value: &u64 = table::borrow(&my_table, @0x1);
// 修改
let value_mut: &mut u64 = table::borrow_mut(&mut my_table, @0x1);
*value_mut = 200;
// 删除
let removed_value: u64 = table::remove(&mut my_table, @0x1);
// 检查
let has_key: bool = table::contains(&my_table, @0x1);
// 长度
let len: u64 = table::length(&my_table);
// 判空
let is_empty: bool = table::is_empty(&my_table);
// 销毁空 table
table::destroy_empty(my_table);
Table vs Dynamic Field vs Struct 字段
| 方式 | 适用场景 | 编译时确定? | 类型一致? |
|---|---|---|---|
| Struct 字段 | 固定已知的属性 | 是 | 每个字段可不同 |
| Dynamic Field | 运行时添加的属性 | 否 | 每对可不同 |
| Table<K,V> | 大量同类型的键值对 | 否 | 所有键同类,所有值同类 |
何时用什么?
固定属性(name, description)→ struct 字段
可变属性(游戏属性、自定义标签)→ dynamic field
大量同类数据(用户余额、价格列表)→ Table
需要保持子对象独立身份 → dynamic object field
4. Dynamic Field 的存储原理
动态字段在底层通过特殊的 Sui 对象存储。每个动态字段被存储为一个独立的存储条目,通过父对象的 UID 关联:
父对象 (UID: 0xABC)
├── 动态字段 1: key="strength", value=100
├── 动态字段 2: key="speed", value=75
└── 动态字段 3: key="weapon", value=Sword{...}
在链上存储中:
Object 0xABC: { id, name, ... }
DynField (parent=0xABC, key="strength"): 100
DynField (parent=0xABC, key="speed"): 75
DynField (parent=0xABC, key="weapon"): Sword{...}
代码实战
增强版 NFT 模块(带动态属性)
/// 增强版 NFT:使用动态字段支持可扩展属性
module enhanced_nft::nft {
use std::string::{Self, String};
use std::option::{Self, Option};
use sui::object::{Self, UID, ID};
use sui::transfer;
use sui::tx_context::{Self, TxContext};
use sui::event;
use sui::dynamic_field as df;
use sui::dynamic_object_field as dof;
use sui::table::{Self, Table};
use sui::url::{Self, Url};
// ===== 错误码 =====
const E_ATTRIBUTE_NOT_FOUND: u64 = 0;
const E_ATTRIBUTE_ALREADY_EXISTS: u64 = 1;
const E_NOT_CREATOR: u64 = 2;
const E_MAX_ATTRIBUTES_REACHED: u64 = 3;
// ===== 常量 =====
const MAX_ATTRIBUTES: u64 = 50;
// ===== 动态字段的 Key 类型 =====
/// 字符串属性的 key
struct StringAttrKey has copy, drop, store {
name: String,
}
/// 数值属性的 key
struct NumericAttrKey has copy, drop, store {
name: String,
}
/// 装备槽位的 key(用于 dynamic object field)
struct EquipmentSlotKey has copy, drop, store {
slot_name: String,
}
// ===== 事件 =====
struct NFTCreated has copy, drop {
id: ID,
name: String,
creator: address,
}
struct AttributeAdded has copy, drop {
nft_id: ID,
attr_name: String,
attr_type: String, // "string" or "numeric"
}
struct AttributeUpdated has copy, drop {
nft_id: ID,
attr_name: String,
}
struct EquipmentAttached has copy, drop {
nft_id: ID,
equipment_id: ID,
slot: String,
}
// ===== 对象定义 =====
/// 增强版 NFT
struct EnhancedNFT has key, store {
id: UID,
/// 基础属性(编译时确定)
name: String,
description: String,
image_url: Option<Url>,
creator: address,
/// 属性计数(动态字段数量追踪)
attribute_count: u64,
/// 属性名称索引(用于枚举所有属性)
attribute_names: vector<String>,
}
/// 装备对象(可以附加到 NFT 上)
struct Equipment has key, store {
id: UID,
name: String,
equipment_type: String, // "weapon", "armor", "accessory"
bonus_value: u64,
}
/// 集合注册表(跟踪所有 NFT)
struct Registry has key {
id: UID,
/// 使用 Table 记录 NFT 元数据
nft_metadata: Table<ID, NFTMetadataEntry>,
total_count: u64,
}
/// 注册表条目
struct NFTMetadataEntry has store {
name: String,
creator: address,
created_epoch: u64,
}
// ===== 初始化 =====
fun init(ctx: &mut TxContext) {
let registry = Registry {
id: object::new(ctx),
nft_metadata: table::new(ctx),
total_count: 0,
};
transfer::share_object(registry);
}
// ===== NFT 创建 =====
/// 创建增强版 NFT
public entry fun create_nft(
registry: &mut Registry,
name: vector<u8>,
description: vector<u8>,
image_url: vector<u8>,
ctx: &mut TxContext,
) {
let sender = tx_context::sender(ctx);
let nft_uid = object::new(ctx);
let nft_id = object::uid_to_inner(&nft_uid);
let url_opt = if (std::vector::length(&image_url) > 0) {
option::some(url::new_unsafe_from_bytes(image_url))
} else {
option::none()
};
let nft = EnhancedNFT {
id: nft_uid,
name: string::utf8(name),
description: string::utf8(description),
image_url: url_opt,
creator: sender,
attribute_count: 0,
attribute_names: vector::empty(),
};
// 在注册表中记录
table::add(&mut registry.nft_metadata, nft_id, NFTMetadataEntry {
name: nft.name,
creator: sender,
created_epoch: tx_context::epoch(ctx),
});
registry.total_count = registry.total_count + 1;
// 发送事件
event::emit(NFTCreated {
id: nft_id,
name: nft.name,
creator: sender,
});
transfer::transfer(nft, sender);
}
// ===== 动态属性管理 =====
/// 添加字符串属性
public entry fun add_string_attribute(
nft: &mut EnhancedNFT,
attr_name: vector<u8>,
attr_value: vector<u8>,
ctx: &TxContext,
) {
assert!(nft.creator == tx_context::sender(ctx), E_NOT_CREATOR);
assert!(nft.attribute_count < MAX_ATTRIBUTES, E_MAX_ATTRIBUTES_REACHED);
let name = string::utf8(attr_name);
let key = StringAttrKey { name };
assert!(!df::exists_(&nft.id, key), E_ATTRIBUTE_ALREADY_EXISTS);
// 添加动态字段
df::add(&mut nft.id, key, string::utf8(attr_value));
// 更新索引
vector::push_back(&mut nft.attribute_names, name);
nft.attribute_count = nft.attribute_count + 1;
event::emit(AttributeAdded {
nft_id: object::uid_to_inner(&nft.id),
attr_name: name,
attr_type: string::utf8(b"string"),
});
}
/// 添加数值属性
public entry fun add_numeric_attribute(
nft: &mut EnhancedNFT,
attr_name: vector<u8>,
attr_value: u64,
ctx: &TxContext,
) {
assert!(nft.creator == tx_context::sender(ctx), E_NOT_CREATOR);
assert!(nft.attribute_count < MAX_ATTRIBUTES, E_MAX_ATTRIBUTES_REACHED);
let name = string::utf8(attr_name);
let key = NumericAttrKey { name };
assert!(!df::exists_(&nft.id, key), E_ATTRIBUTE_ALREADY_EXISTS);
df::add(&mut nft.id, key, attr_value);
vector::push_back(&mut nft.attribute_names, name);
nft.attribute_count = nft.attribute_count + 1;
event::emit(AttributeAdded {
nft_id: object::uid_to_inner(&nft.id),
attr_name: name,
attr_type: string::utf8(b"numeric"),
});
}
/// 更新字符串属性
public entry fun update_string_attribute(
nft: &mut EnhancedNFT,
attr_name: vector<u8>,
new_value: vector<u8>,
ctx: &TxContext,
) {
assert!(nft.creator == tx_context::sender(ctx), E_NOT_CREATOR);
let name = string::utf8(attr_name);
let key = StringAttrKey { name };
assert!(df::exists_(&nft.id, key), E_ATTRIBUTE_NOT_FOUND);
let value_mut = df::borrow_mut<StringAttrKey, String>(&mut nft.id, key);
*value_mut = string::utf8(new_value);
event::emit(AttributeUpdated {
nft_id: object::uid_to_inner(&nft.id),
attr_name: name,
});
}
/// 更新数值属性
public entry fun update_numeric_attribute(
nft: &mut EnhancedNFT,
attr_name: vector<u8>,
new_value: u64,
ctx: &TxContext,
) {
assert!(nft.creator == tx_context::sender(ctx), E_NOT_CREATOR);
let name = string::utf8(attr_name);
let key = NumericAttrKey { name };
assert!(df::exists_(&nft.id, key), E_ATTRIBUTE_NOT_FOUND);
let value_mut = df::borrow_mut<NumericAttrKey, u64>(&mut nft.id, key);
*value_mut = new_value;
event::emit(AttributeUpdated {
nft_id: object::uid_to_inner(&nft.id),
attr_name: name,
});
}
/// 删除字符串属性
public entry fun remove_string_attribute(
nft: &mut EnhancedNFT,
attr_name: vector<u8>,
ctx: &TxContext,
) {
assert!(nft.creator == tx_context::sender(ctx), E_NOT_CREATOR);
let name = string::utf8(attr_name);
let key = StringAttrKey { name };
assert!(df::exists_(&nft.id, key), E_ATTRIBUTE_NOT_FOUND);
let _removed: String = df::remove(&mut nft.id, key);
nft.attribute_count = nft.attribute_count - 1;
// 从名称索引中移除(简化:在实际应用中可能用更高效的数据结构)
let (found, index) = vector::index_of(&nft.attribute_names, &name);
if (found) {
vector::remove(&mut nft.attribute_names, index);
};
}
// ===== 装备系统(Dynamic Object Field) =====
/// 创建装备
public entry fun create_equipment(
name: vector<u8>,
equipment_type: vector<u8>,
bonus_value: u64,
ctx: &mut TxContext,
) {
let equipment = Equipment {
id: object::new(ctx),
name: string::utf8(name),
equipment_type: string::utf8(equipment_type),
bonus_value,
};
transfer::transfer(equipment, tx_context::sender(ctx));
}
/// 将装备附加到 NFT(使用 dynamic object field)
/// 装备保持独立对象身份,但归属于 NFT
public entry fun attach_equipment(
nft: &mut EnhancedNFT,
equipment: Equipment,
slot_name: vector<u8>,
ctx: &TxContext,
) {
assert!(nft.creator == tx_context::sender(ctx), E_NOT_CREATOR);
let slot = string::utf8(slot_name);
let key = EquipmentSlotKey { slot_name: slot };
assert!(!dof::exists_(&nft.id, key), E_ATTRIBUTE_ALREADY_EXISTS);
let equipment_id = object::id(&equipment);
// 使用 dynamic object field:装备保持对象身份
dof::add(&mut nft.id, key, equipment);
event::emit(EquipmentAttached {
nft_id: object::uid_to_inner(&nft.id),
equipment_id,
slot: slot,
});
}
/// 从 NFT 上卸下装备
public entry fun detach_equipment(
nft: &mut EnhancedNFT,
slot_name: vector<u8>,
ctx: &mut TxContext,
) {
let slot = string::utf8(slot_name);
let key = EquipmentSlotKey { slot_name: slot };
assert!(dof::exists_(&nft.id, key), E_ATTRIBUTE_NOT_FOUND);
let equipment: Equipment = dof::remove(&mut nft.id, key);
transfer::transfer(equipment, tx_context::sender(ctx));
}
// ===== 只读访问器 =====
/// 读取字符串属性
public fun get_string_attribute(nft: &EnhancedNFT, attr_name: String): &String {
let key = StringAttrKey { name: attr_name };
df::borrow<StringAttrKey, String>(&nft.id, key)
}
/// 读取数值属性
public fun get_numeric_attribute(nft: &EnhancedNFT, attr_name: String): u64 {
let key = NumericAttrKey { name: attr_name };
*df::borrow<NumericAttrKey, u64>(&nft.id, key)
}
/// 读取装备信息
public fun get_equipment(nft: &EnhancedNFT, slot_name: String): &Equipment {
let key = EquipmentSlotKey { slot_name };
dof::borrow<EquipmentSlotKey, Equipment>(&nft.id, key)
}
/// 获取所有属性名称
public fun attribute_names(nft: &EnhancedNFT): &vector<String> {
&nft.attribute_names
}
/// 获取属性数量
public fun attribute_count(nft: &EnhancedNFT): u64 {
nft.attribute_count
}
/// 检查属性是否存在
public fun has_string_attribute(nft: &EnhancedNFT, attr_name: String): bool {
let key = StringAttrKey { name: attr_name };
df::exists_(&nft.id, key)
}
public fun has_numeric_attribute(nft: &EnhancedNFT, attr_name: String): bool {
let key = NumericAttrKey { name: attr_name };
df::exists_(&nft.id, key)
}
/// 注册表查询
public fun registry_count(registry: &Registry): u64 {
registry.total_count
}
// ===== 测试 =====
#[test_only]
use sui::test_scenario;
#[test]
fun test_enhanced_nft_full_lifecycle() {
let creator = @0xA;
let mut scenario = test_scenario::begin(creator);
// 初始化
{
init(test_scenario::ctx(&mut scenario));
};
// 创建 NFT
test_scenario::next_tx(&mut scenario, creator);
{
let mut registry = test_scenario::take_shared<Registry>(&scenario);
create_nft(
&mut registry,
b"Dragon Warrior",
b"A fierce dragon warrior NFT",
b"https://example.com/dragon.png",
test_scenario::ctx(&mut scenario),
);
assert!(registry_count(®istry) == 1, 0);
test_scenario::return_shared(registry);
};
// 添加属性
test_scenario::next_tx(&mut scenario, creator);
{
let mut nft = test_scenario::take_from_sender<EnhancedNFT>(&scenario);
// 添加字符串属性
add_string_attribute(
&mut nft,
b"element",
b"fire",
test_scenario::ctx(&scenario),
);
// 添加数值属性
add_numeric_attribute(
&mut nft,
b"strength",
100,
test_scenario::ctx(&scenario),
);
add_numeric_attribute(
&mut nft,
b"speed",
75,
test_scenario::ctx(&scenario),
);
assert!(attribute_count(&nft) == 3, 1);
// 读取属性
let element = get_string_attribute(&nft, string::utf8(b"element"));
assert!(*element == string::utf8(b"fire"), 2);
let strength = get_numeric_attribute(&nft, string::utf8(b"strength"));
assert!(strength == 100, 3);
// 更新属性
update_numeric_attribute(
&mut nft,
b"strength",
150,
test_scenario::ctx(&scenario),
);
let new_strength = get_numeric_attribute(&nft, string::utf8(b"strength"));
assert!(new_strength == 150, 4);
test_scenario::return_to_sender(&scenario, nft);
};
test_scenario::end(scenario);
}
}
关键要点总结
动态字段使用决策
需要给对象添加数据?
│
├── 数据结构编译时已知? → struct 字段
│
├── 数据结构运行时确定?
│ ├── 值是普通数据? → dynamic_field (df)
│ └── 值是 Sui 对象? → dynamic_object_field (dof)
│
└── 大量同类键值对? → Table<K, V>
事件设计最佳实践
| 实践 | 说明 |
|---|---|
| 包含 object ID | 方便关联事件和对象 |
| 包含操作者地址 | 方便追踪谁做了什么 |
| 包含关键数值 | 避免前端需要额外查询 |
| 事件类型有意义的命名 | ItemPurchased 而非 Event1 |
| 适度粒度 | 不要为每个小操作都发事件 |
Dynamic Field vs Dynamic Object Field
| 特性 | df (Dynamic Field) | dof (Dynamic Object Field) |
|---|---|---|
| 值类型要求 | store | key + store |
| 值是否保留对象 ID | 否(被吸收) | 是(保持独立) |
| 能否直接通过 ID 查询值 | 不能 | 能 |
| 适用场景 | 简单数据(数字、字符串) | 子对象(装备、附件) |
常见误区
误区 1:"动态字段像 Solidity 的 mapping"
部分正确。Table 类似 mapping,但动态字段更灵活——可以用不同类型的 key 添加不同类型的 value。它更像是一个"运行时可扩展的 struct"。
误区 2:"删除动态字段就回收了存储空间"
删除动态字段确实从存储中移除了数据。但如果值是一个没有 drop ability 的对象,你需要正确处理它(转移或解构)。不能简单地"丢掉"一个没有 drop 的值。
误区 3:"事件可以用来存储数据"
事件是"发射后忘记"(fire-and-forget)的。链上合约无法读取之前发射的事件。事件只能被链下索引器和前端读取。需要合约内访问的数据必须存在 struct 字段或动态字段中。
误区 4:"Table 和 vector 可以互换"
Table 适合大量数据的键值查找(O(1)),但不支持遍历。vector 支持遍历但大量数据时 Gas 昂贵。选择取决于使用模式。
面试关联
Q: 什么是 Sui 的动态字段?与 Solidity 的 mapping 有什么区别?
简短回答:动态字段允许在运行时向对象添加任意类型的键值对。与 mapping 不同,动态字段支持异构键值类型,且值可以是完整的 Sui 对象。
详细回答:
- Solidity mapping:编译时确定 K/V 类型,所有条目类型相同,存储在合约的存储 trie 中
- Sui Dynamic Field:运行时添加,可以给同一个对象添加不同类型的键值对,每个条目是独立的存储对象
- Dynamic Object Field 额外保持值的对象身份——可以通过对象 ID 直接查询
- Sui 的 Table 是动态字段的封装,更接近 mapping 的语义(同类型 K/V)
Q: 在设计链上游戏 NFT 时,如何使用 Sui 的动态字段?
回答:使用动态字段实现可扩展的 NFT 属性系统:
- 基础属性用 struct 字段(name, description, image)
- 游戏属性用 numeric dynamic field(strength, speed, luck)
- 装备系统用 dynamic object field(装备保持独立对象身份,可以拆卸和交易)
- 成就/标签用 string dynamic field(完成某任务后添加标记)
- 这种设计允许游戏在不升级合约的情况下添加新属性类型
Q: Sui 的事件系统与 Solidity 有什么不同?设计事件时有什么最佳实践?
回答:Sui 事件是 struct(需要 copy+drop),通过 event::emit() 发射,不支持 indexed 字段(Sui RPC 有自己的过滤机制)。最佳实践包括:在事件中包含对象 ID(关联性)、操作者地址(可追溯性)、关键数值(减少额外查询),以及合理的事件粒度(重要的状态变更才发事件)。
参考资源
- Sui Dynamic Fields — 官方动态字段文档
- Sui Events — 官方事件文档
- sui::dynamic_field Module — 源码
- sui::table Module — Table 源码
- Move Book: Collections — 集合类型
- Sui Move by Example: Dynamic Fields — 官方示例
- On-chain Game Design with Sui — 游戏设计案例