返回 SC 笔记
SC Day 57

Move/Sui Coin标准 + 泛型 + Witness模式 + 自定义Coin

### 1. 泛型编程(Generics)

2026-05-27
第三阶段:安全审计
suicoingenericwitnesstreasury-capotw

日期: 2026-05-27 方向: Move / Sui 阶段: 第三阶段:安全审计 标签: #sui #coin #generic #witness #treasury-cap #otw


今日目标

  1. 理解 Sui 的 Coin 标准框架和 Coin<T> 泛型设计
  2. 掌握 One-Time Witness (OTW) 模式的原理和安全性
  3. 实现一个完整的自定义 Coin 模块(创建/铸造/销毁/转移)
  4. 对比 Sui Coin 与 ERC20 的设计差异

核心概念

1. 泛型编程(Generics)

Move 支持泛型编程,允许编写类型参数化的函数和 struct:

// 泛型容器
struct Box<T: store> has key, store {
    id: UID,
    content: T, // T 可以是任何具有 store ability 的类型
}

// 泛型函数
public fun create_box<T: store>(content: T, ctx: &mut TxContext): Box<T> {
    Box {
        id: object::new(ctx),
        content,
    }
}

public fun unbox<T: store>(box: Box<T>): T {
    let Box { id, content } = box;
    object::delete(id);
    content
}

泛型约束(Constraints)

// T 必须有 copy + drop ability
fun clone_and_print<T: copy + drop>(x: T): T {
    let y = copy x; // 需要 copy
    y // 原始 x 被 drop(需要 drop)
}

// T 必须有 store ability(才能存入 struct)
struct Container<T: store> has key, store {
    id: UID,
    item: T,
}

// phantom 类型参数:T 不直接出现在字段中
// 但用于区分不同"品种"的结构体
struct Coin<phantom T> has key, store {
    id: UID,
    balance: Balance<T>,
}

phantom 关键字

当泛型参数只用于类型区分(不直接作为字段类型)时,使用 phantom

// Balance<T> 内部存储 u64,但 T 用于区分不同货币
struct Balance<phantom T> has store {
    value: u64,
}

// Coin<SUI> 和 Coin<USDC> 是不同类型
// 编译器保证你不能把 Coin<SUI> 当作 Coin<USDC> 使用

2. Sui Coin 框架

Sui 的 Coin 标准是一个基于泛型的框架,核心是 Coin<T> 类型:

// sui::coin 模块中的核心类型(简化版)
module sui::coin {
    struct Coin<phantom T> has key, store {
        id: UID,
        balance: Balance<T>,
    }

    struct TreasuryCap<phantom T> has key, store {
        id: UID,
        total_supply: Supply<T>,
    }

    struct CoinMetadata<phantom T> has key, store {
        id: UID,
        decimals: u8,
        name: string::String,
        symbol: ascii::String,
        description: string::String,
        icon_url: Option<Url>,
    }
}

核心类型解析

类型作用数量持有者
Coin<T>用户持有的代币对象无限多个各用户
TreasuryCap<T>铸造/销毁权限凭证全局唯一管理员
CoinMetadata<T>代币元数据(名称/符号等)全局唯一通常 freeze 为 immutable
Supply<T>总供应量追踪(在 TreasuryCap 内)全局唯一TreasuryCap 内部
Balance<T>内部余额表示(无 key,不是独立对象)嵌入在 Coin 中Coin 内部

3. One-Time Witness (OTW) 模式

OTW 是 Sui 中确保某操作只执行一次的安全模式。它是 coin::create_currency 的核心安全机制。

OTW 规则

一个类型要成为 OTW,必须满足:

  1. 结构体名称是模块名的大写
  2. 只有 drop ability(没有其他字段或 ability)
  3. 只在 init 函数的第一个参数中接收
  4. Sui runtime 保证该实例只创建一次(模块发布时)
module my_coin::my_coin {
    // OTW: 名称 = MY_COIN(模块名 my_coin 的大写)
    // 只有 drop ability
    // 没有任何字段
    struct MY_COIN has drop {}

    // init 函数的第一个参数接收 OTW
    // Sui runtime 在模块发布时自动创建并传入 MY_COIN {}
    fun init(witness: MY_COIN, ctx: &mut TxContext) {
        // 使用 witness 创建 currency
        // 这保证了每种货币只能创建一次
    }
}

为什么需要 OTW?

问题:如何保证每种 Coin 只有一个 TreasuryCap?
如果 create_currency 是普通函数,任何人都能反复调用创建多个 TreasuryCap。

解决:OTW 模式
1. MY_COIN 实例只在模块发布时由 runtime 创建一次
2. create_currency 消费(drop)这个实例
3. MY_COIN 没有 copy ability → 不能复制
4. 之后再也无法获得 MY_COIN 实例 → 无法再次调用 create_currency
5. 因此 TreasuryCap 全局唯一!

4. Coin vs ERC20 对比

特性ERC20 (Solidity)Coin<T> (Sui Move)
余额存储mapping(address => uint256)独立 Coin<T> 对象
转移方式修改映射中的数字移动整个对象
授权机制approve + transferFrom不需要(直接传对象)
类型安全地址参数可能传错编译期类型检查
并行性串行(共享 mapping)并行(独立对象)
铸造权限自定义(可能有bug)TreasuryCap 强制唯一
总供应追踪手动维护 totalSupplySupply<T> 自动追踪
可组合性需要 approve 先授权直接传入 Coin<T> 作为参数

ERC20 的 approve 问题

// ERC20 交互需要两步:
// 第一笔交易:approve
token.approve(dex, amount);
// 第二笔交易:DEX 调用 transferFrom
dex.swap(token, amount);

// 问题1: 两笔交易 = 两次 Gas
// 问题2: approve 无限授权是安全隐患
// 问题3: 前端抢跑攻击(front-running approve)
// Sui Move 交互只需一步:
// 直接传入 Coin 对象
public fun swap<X, Y>(coin_in: Coin<X>, pool: &mut Pool<X, Y>): Coin<Y> {
    // 不需要 approve!调用者直接把 Coin 传入
    // 函数获得 Coin 的所有权,可以消费它
    // ...
}

代码实战

完整自定义 Coin 模块

/// 自定义 Coin 模块:MOMO Token
/// 展示 Sui Coin 标准的完整用法
module momo_token::momo {
    use std::option;
    use std::string;
    use std::ascii;
    use sui::coin::{Self, Coin, TreasuryCap, CoinMetadata};
    use sui::transfer;
    use sui::tx_context::{Self, TxContext};
    use sui::object::{Self, UID};
    use sui::balance::{Self, Balance};
    use sui::event;
    use sui::url;

    // ===== 错误码 =====
    const E_EXCEEDS_MAX_SUPPLY: u64 = 0;
    const E_INSUFFICIENT_BALANCE: u64 = 1;
    const E_NOT_ADMIN: u64 = 2;

    // ===== 常量 =====
    const MAX_SUPPLY: u64 = 1_000_000_000_000_000_000; // 10亿 * 1e9 (9 decimals)
    const DECIMALS: u8 = 9;

    // ===== One-Time Witness =====
    /// OTW: 模块名 momo 的大写
    struct MOMO has drop {}

    // ===== 事件 =====
    struct MintEvent has copy, drop {
        amount: u64,
        recipient: address,
    }

    struct BurnEvent has copy, drop {
        amount: u64,
        burner: address,
    }

    // ===== 管理对象 =====
    /// 管理员凭证(用于额外的权限控制)
    struct AdminCap has key, store {
        id: UID,
    }

    /// 代币金库(持有部分代币用于特定用途)
    struct Treasury has key {
        id: UID,
        reserve: Balance<MOMO>, // 内部余额(非 Coin 对象)
    }

    // ===== 初始化 =====
    /// 模块发布时自动调用
    fun init(witness: MOMO, ctx: &mut TxContext) {
        let sender = tx_context::sender(ctx);

        // 使用 OTW 创建货币
        // 这个操作只能执行一次!
        let (treasury_cap, metadata) = coin::create_currency<MOMO>(
            witness,           // OTW,被消费后不可再用
            DECIMALS,          // 小数位数
            b"MOMO",           // 符号
            b"Momo Token",     // 名称
            b"A token for the Momo Web3 learning project", // 描述
            option::some(url::new_unsafe_from_bytes(
                b"https://example.com/momo-icon.png"
            )),                // 图标 URL
            ctx,
        );

        // 冻结元数据(变为 immutable object,永远不能修改)
        transfer::public_freeze_object(metadata);

        // 创建管理员凭证
        let admin_cap = AdminCap {
            id: object::new(ctx),
        };

        // 创建金库
        let treasury = Treasury {
            id: object::new(ctx),
            reserve: balance::zero<MOMO>(),
        };

        // 转移所有权
        transfer::public_transfer(treasury_cap, sender);
        transfer::transfer(admin_cap, sender);
        transfer::share_object(treasury); // 金库是共享对象
    }

    // ===== 铸造 =====

    /// 铸造新代币给指定接收者
    /// 需要 TreasuryCap(证明你有铸造权限)
    public entry fun mint(
        treasury_cap: &mut TreasuryCap<MOMO>,
        amount: u64,
        recipient: address,
        ctx: &mut TxContext,
    ) {
        // 检查是否超过最大供应量
        assert!(
            coin::total_supply(treasury_cap) + amount <= MAX_SUPPLY,
            E_EXCEEDS_MAX_SUPPLY
        );

        // 铸造并转移
        let coin = coin::mint(treasury_cap, amount, ctx);
        transfer::public_transfer(coin, recipient);

        event::emit(MintEvent { amount, recipient });
    }

    /// 铸造到金库
    public entry fun mint_to_treasury(
        treasury_cap: &mut TreasuryCap<MOMO>,
        treasury: &mut Treasury,
        amount: u64,
        ctx: &mut TxContext,
    ) {
        assert!(
            coin::total_supply(treasury_cap) + amount <= MAX_SUPPLY,
            E_EXCEEDS_MAX_SUPPLY
        );

        let minted_balance = coin::mint_balance(treasury_cap, amount);
        balance::join(&mut treasury.reserve, minted_balance);

        event::emit(MintEvent {
            amount,
            recipient: object::uid_to_address(&treasury.id),
        });
    }

    // ===== 销毁 =====

    /// 销毁代币
    public entry fun burn(
        treasury_cap: &mut TreasuryCap<MOMO>,
        coin: Coin<MOMO>,
        ctx: &TxContext,
    ) {
        let amount = coin::value(&coin);
        coin::burn(treasury_cap, coin);

        event::emit(BurnEvent {
            amount,
            burner: tx_context::sender(ctx),
        });
    }

    // ===== 金库操作 =====

    /// 从金库中提取代币
    public entry fun withdraw_from_treasury(
        _admin: &AdminCap, // 需要管理员权限
        treasury: &mut Treasury,
        amount: u64,
        recipient: address,
        ctx: &mut TxContext,
    ) {
        assert!(
            balance::value(&treasury.reserve) >= amount,
            E_INSUFFICIENT_BALANCE
        );

        let withdrawn = balance::split(&mut treasury.reserve, amount);
        let coin = coin::from_balance(withdrawn, ctx);
        transfer::public_transfer(coin, recipient);
    }

    /// 查看金库余额
    public fun treasury_balance(treasury: &Treasury): u64 {
        balance::value(&treasury.reserve)
    }

    // ===== 用户操作 =====

    /// 拆分 Coin(从一个 Coin 中分出指定数量)
    public entry fun split_coin(
        coin: &mut Coin<MOMO>,
        amount: u64,
        recipient: address,
        ctx: &mut TxContext,
    ) {
        let split = coin::split(coin, amount, ctx);
        transfer::public_transfer(split, recipient);
    }

    /// 合并 Coin(将多个 Coin 合为一个)
    public entry fun merge_coins(
        coin1: &mut Coin<MOMO>,
        coin2: Coin<MOMO>,
    ) {
        coin::join(coin1, coin2);
    }

    // ===== 只读函数 =====

    /// 获取 Coin 余额
    public fun balance(coin: &Coin<MOMO>): u64 {
        coin::value(coin)
    }

    // ===== 测试 =====
    #[test_only]
    use sui::test_scenario;

    #[test]
    fun test_mint_and_burn() {
        let admin = @0xA;
        let user = @0xB;

        let mut scenario = test_scenario::begin(admin);

        // 发布模块(触发 init)
        {
            init(MOMO {}, test_scenario::ctx(&mut scenario));
        };

        // 铸造代币给用户
        test_scenario::next_tx(&mut scenario, admin);
        {
            let mut treasury_cap = test_scenario::take_from_sender<TreasuryCap<MOMO>>(&scenario);

            mint(&mut treasury_cap, 1000000000, user, test_scenario::ctx(&mut scenario)); // 1 MOMO

            assert!(coin::total_supply(&treasury_cap) == 1000000000, 0);

            test_scenario::return_to_sender(&scenario, treasury_cap);
        };

        // 用户检查余额
        test_scenario::next_tx(&mut scenario, user);
        {
            let coin = test_scenario::take_from_sender<Coin<MOMO>>(&scenario);
            assert!(coin::value(&coin) == 1000000000, 1);
            test_scenario::return_to_sender(&scenario, coin);
        };

        // 用户销毁代币
        test_scenario::next_tx(&mut scenario, admin);
        {
            let mut treasury_cap = test_scenario::take_from_sender<TreasuryCap<MOMO>>(&scenario);

            // 从用户处获取 coin(在测试中模拟)
            test_scenario::next_tx(&mut scenario, user);
            let coin = test_scenario::take_from_sender<Coin<MOMO>>(&scenario);

            burn(&mut treasury_cap, coin, test_scenario::ctx(&scenario));
            assert!(coin::total_supply(&treasury_cap) == 0, 2);

            test_scenario::return_to_sender(&scenario, treasury_cap);
        };

        test_scenario::end(scenario);
    }

    #[test]
    #[expected_failure(abort_code = E_EXCEEDS_MAX_SUPPLY)]
    fun test_exceed_max_supply() {
        let admin = @0xA;
        let mut scenario = test_scenario::begin(admin);

        {
            init(MOMO {}, test_scenario::ctx(&mut scenario));
        };

        test_scenario::next_tx(&mut scenario, admin);
        {
            let mut treasury_cap = test_scenario::take_from_sender<TreasuryCap<MOMO>>(&scenario);

            // 尝试铸造超过最大供应量
            mint(
                &mut treasury_cap,
                MAX_SUPPLY + 1,
                admin,
                test_scenario::ctx(&mut scenario),
            );

            test_scenario::return_to_sender(&scenario, treasury_cap);
        };

        test_scenario::end(scenario);
    }
}

关键要点总结

Coin 创建流程

1. 定义 OTW struct(模块名大写 + drop ability)
2. 在 init() 中调用 coin::create_currency()
3. 获得 TreasuryCap + CoinMetadata
4. freeze CoinMetadata(不可变)
5. 保管好 TreasuryCap(铸造权限的唯一来源)

安全设计要点

要点说明
OTW 唯一性Runtime 保证 OTW 只创建一次 → TreasuryCap 唯一
总供应量追踪Supply<T> 在 mint/burn 时自动更新
类型安全Coin<SUI>Coin<MOMO> 编译期不同类型
权限分离TreasuryCap (铸造) vs AdminCap (管理)
对象所有权Coin 是 owned object,只有 owner 可操作

Balance vs Coin

类型是对象?key用途
Coin<T>key + store用户持有、交易传递
Balance<T>store合约内部存储(如 LP 池)
// Coin → Balance(拆包)
let balance = coin::into_balance(coin);

// Balance → Coin(打包)
let coin = coin::from_balance(balance, ctx);

// Balance 操作
balance::join(&mut b1, b2);        // 合并
let b2 = balance::split(&mut b1, amount); // 拆分
let value = balance::value(&b);    // 查询

常见误区

误区 1:"Sui Coin 和 ERC20 Token 本质相同"

错误!ERC20 中 token 余额只是映射中的数字,同一个合约处理所有转账。Sui Coin 中每个用户持有独立的 Coin<T> 对象,转账是移动整个对象。这意味着不同用户的操作可以完全并行。

误区 2:"TreasuryCap 丢了可以再创建"

不可以create_currency 消费 OTW,OTW 只在模块发布时创建一次。如果 TreasuryCap 丢失或被销毁,就再也无法铸造新币。这是设计上的安全保证。

误区 3:"phantom 类型参数没有实际作用"

phantom 类型参数虽然不直接出现在数据布局中,但它在编译期提供了类型区分。Coin<SUI>Coin<USDC> 是完全不同的类型,你不能把一个传入需要另一个的函数。

误区 4:"不需要 approve 就没有授权概念"

Sui 的授权是通过对象所有权实现的——你把 Coin 传入函数就是在"授权"那个函数使用它。这比 ERC20 的 approve 更直观和安全,不存在"无限授权"的风险。


面试关联

Q: 解释 Sui 的 Coin 标准与 ERC20 的核心区别?

简短回答:ERC20 是基于映射的余额追踪,Sui Coin 是基于对象的资产表示。Sui 不需要 approve 机制,类型安全性更强,且天然支持并行处理。

详细回答

  1. 存储模型:ERC20 用 mapping(address => uint256),所有转账共享状态树。Sui Coin 是独立对象,每个用户的 Coin 独立存储。
  2. 授权:ERC20 需要 approve + transferFrom 两步。Sui 直接传入 Coin 对象。
  3. 安全性:Sui 的 phantom T 泛型提供编译期类型检查,不可能把错误的 Coin 类型传入函数。
  4. 供应量:ERC20 需手动维护 totalSupply。Sui 的 Supply<T> 自动追踪。
  5. 铸造权限:ERC20 的铸造权限由开发者自定义(可能有bug)。Sui 的 TreasuryCap 通过 OTW 保证全局唯一。

Q: 什么是 One-Time Witness 模式?它解决什么问题?

回答:OTW 是 Sui 中确保某操作只执行一次的安全模式。具体到 Coin 标准,它确保每种代币只有一个 TreasuryCap(铸造权限)。OTW 的实例由 Sui runtime 在模块发布时创建并传入 init 函数,之后无法再获取。因为 OTW 类型没有 copy ability,所以不能复制;传入 create_currency 后被消费(drop),无法再次使用。

Q: Move 的泛型系统如何增强 DeFi 安全性?

回答:Move 的泛型系统提供编译期类型安全。例如 Coin<SUI>Coin<USDC> 是不同类型,DEX 池 Pool<SUI, USDC> 在编译期就确定了交易对。你不可能把 ETH 传入需要 USDC 的函数,这种错误在 Solidity 中只能在运行时通过地址检查发现。


参考资源