返回 SC 笔记
SC Day 59

Move/Sui 事件 + 动态字段(Dynamic Fields) + Table + 增强版NFT

### 1. Sui 事件系统(Events)

2026-05-29
第三阶段:安全审计
suieventsdynamic-fieldstablenft-enhanced

日期: 2026-05-29 方向: Move / Sui 阶段: 第三阶段:安全审计 标签: #sui #events #dynamic-fields #table #nft-enhanced


今日目标

  1. 掌握 Sui 事件(Events)系统的使用和最佳实践
  2. 深入理解动态字段(Dynamic Fields)和动态对象字段(Dynamic Object Fields)
  3. 学习 sui::table::Table 的使用场景
  4. 实现一个带动态属性的增强版 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 EventsSui Events
定义方式event Transfer(...)struct Transfer has copy, drop {}
发送方式emit Transfer(...)event::emit(Transfer { ... })
索引字段indexed 关键字(最多3个)按字段类型自动索引
存储位置交易日志(logs)交易效果(effects)
查询方式eth_getLogs + filterSui 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 Fieldsui::dynamic_fieldstore否(嵌入在父对象中)
Dynamic Object Fieldsui::dynamic_object_fieldkey + 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(&registry) == 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)
值类型要求storekey + 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 属性系统:

  1. 基础属性用 struct 字段(name, description, image)
  2. 游戏属性用 numeric dynamic field(strength, speed, luck)
  3. 装备系统用 dynamic object field(装备保持独立对象身份,可以拆卸和交易)
  4. 成就/标签用 string dynamic field(完成某任务后添加标记)
  5. 这种设计允许游戏在不升级合约的情况下添加新属性类型

Q: Sui 的事件系统与 Solidity 有什么不同?设计事件时有什么最佳实践?

回答:Sui 事件是 struct(需要 copy+drop),通过 event::emit() 发射,不支持 indexed 字段(Sui RPC 有自己的过滤机制)。最佳实践包括:在事件中包含对象 ID(关联性)、操作者地址(可追溯性)、关键数值(减少额外查询),以及合理的事件粒度(重要的状态变更才发事件)。


参考资源