Move/Sui Coin标准 + 泛型 + Witness模式 + 自定义Coin
### 1. 泛型编程(Generics)
日期: 2026-05-27 方向: Move / Sui 阶段: 第三阶段:安全审计 标签: #sui #coin #generic #witness #treasury-cap #otw
今日目标
- 理解 Sui 的 Coin 标准框架和
Coin<T>泛型设计 - 掌握 One-Time Witness (OTW) 模式的原理和安全性
- 实现一个完整的自定义 Coin 模块(创建/铸造/销毁/转移)
- 对比 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,必须满足:
- 结构体名称是模块名的大写
- 只有
dropability(没有其他字段或 ability) - 只在
init函数的第一个参数中接收 - 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 强制唯一 |
| 总供应追踪 | 手动维护 totalSupply | Supply<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 机制,类型安全性更强,且天然支持并行处理。
详细回答:
- 存储模型:ERC20 用
mapping(address => uint256),所有转账共享状态树。Sui Coin 是独立对象,每个用户的 Coin 独立存储。 - 授权:ERC20 需要
approve+transferFrom两步。Sui 直接传入 Coin 对象。 - 安全性:Sui 的
phantom T泛型提供编译期类型检查,不可能把错误的 Coin 类型传入函数。 - 供应量:ERC20 需手动维护
totalSupply。Sui 的Supply<T>自动追踪。 - 铸造权限: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 中只能在运行时通过地址检查发现。
参考资源
- Sui Coin Standard — 官方 Coin 标准文档
- Sui Move by Example: Coin — 官方示例
- Move Book: Generics — 泛型编程
- Move Book: Witness Pattern — Witness 模式
- sui::coin Module — 源码
- ERC20 vs Sui Coin — 对比分析
- One-Time Witness Explained — OTW 详解