Move on Sui 介绍
-
本课程讲述了由 SuiFrenGroup 创造的 Sui Frens 的故事。随着时间的推移,通过各种游戏和活动积累经验,以进化成更好更强的类种。在本课程中,学员将了解对象(Objects),这是 Sui Move 的基本构建块,并学习以最佳的设计模式和实践方式来创建、管理和组合对象以构建更强大的应用。
-
这将为后续更高级的课程奠定基础,这些课程将深入研究一些利用 Sui 标准(如 Coin 和 NFT (Kiosk))以及特定应用设计模式(如 Defi、游戏等)等更复杂的应用。
-
在第一节课中,您将为一个SuiFrens聚集,玩不同的游戏,并共同进化的幻想世界打下基础。
-
我们将创建一个
fren_group
Move 模块,该具有以下功能: -
通过一个函数创建新的 SuiFrens。我们将从第一个 Fren 类型——Baby Shark 开始。记录所有已经诞生的 SuiFrens。每个 SuiFren 都将是独特的,具有自己的外观。您可以看到它们的外观可以变化并创造出独特的 SuiFrens——身体、耳朵、腹部、情感等。每个 SuiFren 都有一个表示其力量的等级字段。在后续的课程中,我们将通过装备升级、进化和繁殖新类型的Fren 使我们的 Sui Fren 世界变得更加精彩。为了让每个 SuiFren 能生成独特的外观,我们将随机生成属性。通常有两种方法可以做到这一点:
- 在链下生成每个 SuiFren,并仅随机化用户收到的 SuiFren 的 ID。每个 SuiFren 将在链下生成,并具有独特的方面组合并与特定 ID 关联。这让每用户都收到他们自己独特的 SuiFren。
- 在链上生成一个随机数,该随机数可以解析为不同的属性。在我们的 Move 探索旅程中,我们将专注于这一点。
Move的代码是以模块组成的。每个模块大致对应于其他区块链上的单个智能合约。然而,Move提供了更多功能,促使代码能更容易的组织成更小、更模块化的部分。每个模块通过其入口和公共函数提供一个API。用户可以通过交易或其他Move代码的方式调用这些模块的函数以便与模块交互。交易被发送到并由Sui区块链处理,一旦执行,结果将被保存。这种技术堆栈类似于Web2堆栈,其中Move模块充当具有不同路由/ API的服务器,Sui区块链充当运行服务器的框架并提供用于存储数据的数据库。开发者可以构建连接到此服务器和数据库的UI,为其用户提供丰富的功能类型。
module 0x996c4d9480708fb8b92aa7acf819fb0497b5ec8e65ba06601cae2fb6db3312c3::pool_script {
}
开发者通常将模块一起部署为单个包,该包将被分配给一个对象(object),并具有自己的地址,例如 0x996c4d9480708fb8b92aa7acf819fb0497b5ec8e65ba06601cae2fb6db3312c3
。
这造使该模块可以以其对象地址及名称来引用:例如 0x996c4d9480708fb8b92aa7acf819fb0497b5ec8e65ba06601cae2fb6db3312c3::pool_script
。
在这举例中,模块的名称是 pool_script
。Move还允许为地址定义别名,只需通过在Move.toml中定义(例如 cetus=0x996c4d9480708fb8b92aa7acf819fb0497b5ec8e65ba06601cae2fb6db3312c3
),然后将模块定义为cetus::pool_script
。当调用此模块上的函数时,用户可以发送一个交易内容来调用 0x996c4d9480708fb8b92aa7acf819fb0497b5ec8e65ba06601cae2fb6db3312c3::pool_script::open_position
,其中open_position
是函数的名称。
这种模块和函数标识方式的标准格式使得应用程序能够轻松部署、管理和与Move模块结合。模块化设计在Sui上被强烈鼓励 ,开发者应尽量保持每个模块尽可能小并放在单独的文件中。这样做可以保持数据结构和代码的整洁性, 同时使得应用程序更容易和这些模块结合,用户也能更容易理解他们发送的每笔交易。这与Web2开发中的单一责任原则(SRP)类似。
Sui区块链上的数据可以组织成结构体。结构体可以被理解为一组相关的字段,每个都有其自身的类型,如数字、布尔值和向量。结构体是Move中的一个基础概念。
module modules::my_module_03 {
use sui::object::UID;
// 所有作为对象核心的结构体都需要具有 `key` 属性,并且需要一个类型为 UID 的 id 字段。
public struct MyObject has key {
id: UID,
color: u64,
}
}
- 在以上的例子中,我们定义了一个简单的结构体
MyObject
,它有两个字段id
和color
。 - 每个结构体可以定义为具有“能力” (abilities) -
key
、store
、drop
、copy
。稍后我们会详细解释这些能力的含义。
Move类型
-
Move支持多种不同类型:
-
无符号整数:
u8
、u16
、u32
、u64
、u128
、u256
这些不同类型的整数可以存储不同的最大值。例如,u8
可以存储最大值为2^8
- 0-255, -
而
u256
可以存储最大值为2^256 - 1
。
布尔类型: bool
布尔类型就是真true
和假false
address
地址: address
。地址是区块链中的核心构造,用以代表用户身份。用户可以在链下使用仅他们拥有的密钥生成地址,并用它们来签署交易。这证明了交易确实来自用户,而不是伪造的。
string
字符串: String
向量
向量: Vector。例如,u64数组可以写作 vector
struct
自定义结构类型: 比如在前面的例子中通过 use sui::object::UID 导入的 UID。
在Sui中,我们首先需要探索的一个基本概念是对象(objects)。 Sui里的所有数据都表示为不同对象内部的字段。这模拟了现实生活中的一切事物都是对象 —— 椅子、桌子、灯等。人们在生活中通过与对象互动,观察它们以了解其特征,与它们互动并修改它们的状态。
在Sui区块链上,创建、读取、与对象交互并修改对象都是模块的操作范围。 当用户发送交易以调用区块链上的不同函数时,他们调用的函数可能需要从用户所拥有的多个对象中读取数据,并修改它们以反映用户交互的结果。 对象是Move中的核心基本构建块,也是任何应用程序的核心。在构建应用程序时, 开发者首先应该考虑的是应用程序数据的样子以及需要创建哪些对象来存储这些数据。
例如,作为票务应用程序的一部分,用户可能会调用一个的模块会首先给你一张票,并允许你检查它是否已过期:
module modules::ticket_module_04 {
use sui::clock::{Self, Clock};
use sui::object::{Self, UID};
use sui::transfer;
use sui::tx_context::TxContext;
public struct Ticket has key {
id: UID,
expiration_time: u64,
}
public fun create_ticket(ctx: &mut TxContext, clock: &Clock) {
let ticket = Ticket {
id: object::new(ctx),
expiration_time: clock::timestamp_ms(clock),
};
transfer::share_object(ticket);
}
}
步骤如下:
- 定义对象(我们使用结构体(struct)来表示对象)。在这情况下,我们定义Ticket对象。
该对象必须具有key能力,并且具有类型为
object::UID
的id字段(参见模块顶部的导入语句)。 - 调用
object::new
,并传入通过发起交易来调用所有函数时默认的&mut TxContext
参数。 创建新对象时需要&mut TxContext来调用object::new
,它会返回对象独有的id,可以分配给对象的id字段(在本例中为Ticket)。 - 调用
transfer::share_object
将对象设为共享对象。当对象中的数据被多个用户使用(全局数据)且不属于任何特定用户时, 这很有用。在本例中,从技术上讲,票不应共享,但我们将其设为共享以进行演示。 归属对象指的是是由特定用户所拥有的对象,仅可在该用户的许可下(通过签署交易)读取或修改。我们将在后续课程中详细介绍共享和归属对象。
之前,我们编写了一个简单的票务应用程序,该应用程序创建具有到期时间的票。现在让我们引入一个单独的函数来读取这个expiration_time字段并检查票是否已过期:
module modules::ticket_module_05 {
use sui::clock::{Self, Clock};
use sui::object::{Self, UID};
use sui::transfer;
use sui::tx_context::TxContext;
public struct Ticket has key {
id: UID,
expiration_time: u64,
}
public fun is_expired(ticket: &Ticket, clock: &Clock): bool {
ticket.expiration_time >= clock::timestamp_ms(clock)
}
}
要读取Ticket
对象(object)的数据,上述的is_expired函数接收一个对Ticket对象的不可变引用。
Move区分引用和对象值。当我们只需要在 is_expired
中读取现有票的状态时,
我们便使用引用,而不应该重新创建或复制整个票。这类似于电子票,
你可以在手机和笔记本电脑上有多个副本,但它们都指向同一张票。不可变引用只是意味着你不能更新相关结构体(struct)的字段,
其以为&StructName
类型来表示。请注意,is_expired还接收一个Clock对象引用(&Clock)。这是另一个自动参数,类似于系统传递的TxContext
。
我们稍后会详细介绍这一点。
在Move中进行数学运算非常简单,与其他编程语言非常相似:
加法:x + y 减法:x - y 乘法:x * y 除法:x / y 取模:x % y 指数运算:x ^ y
请注意,这是整数运算,意味着结果向下取整。例如,5 / 2 = 2。我们将在后续课程中学习如何进行分数运算。
在之前的课程中,我们看到了不同类型的整数:u8
、u32
、u64
、u128
、u256
。
虽然在相同类型的整数之间可以轻松进行数学运算,但直接在不同类型的整数之间进行数学运算是不可能的。
fun mixed_types_math(): u64 {
let x: u8 = 1;
let y: u64 = 2;
// 这里会报错
x + y
}
为纠正这个问题,我们需要将x强制转换为u64,写法是(x as u64)。请记住,在类型转换时需要使用括号()。
fun mixed_types_math(): u64 {
let x: u8 = 1;
let y: u64 = 2;
// 这里会吧 x 转换成 u64 类型
(x as u64) + y
}
当你需要一组数值时,可以使用向量(vectors)。在Move中,向量默认是动态的,没有固定的大小。
它可以根据需求增长和缩小。在Sui中,默认已经导入了向量,不需要额外添加。
你只需要在模块顶部使用 use std::vector
就可以访问它。我们将在后续课程中讨论其他数据结构和库。示例:
module 0x123::my_module {
use std::vector;
use sui::object::{Self, UID};
public struct MyObject has key {
id: UID,
values: vector<u64>,
bool_values: vector<bool>,
address_values: vector<address>,
}
}
你也可以通过引用结构体将对象存储在向量中。请注意,
为了将一个对象存储在另一个对象的字段中,它的结构体需要具有存储 store
能力。
module 0x123::my_module {
use std::vector;
use sui::object::{Self, UID};
public struct NestedObject has key, store {
id: UID,
owner: address,
balance: u64,
}
public struct GlobalData has key {
id: UID,
wrapped_objects: vector<NestedObject>,
}
}
创建一个空向量时,可以使用以下语法:
fun init() {
// 空向量还没有声明类型。第一个添加的值将决定它的类型。
let empty_vector = vector[];
let int_vector = vector[1, 2, 3];
let bool_vector = vector[true, true, false];
}
在之前的例子中,我们只使用了init函数来创建AdminCap对象。 这个init函数必须是私有的,并且在模块部署时由Sui虚拟机(VM)自动调用。
在本课程中,我们将创建一个公共函数,用户将调用该函数来创建一个新的Fren。在Move中,公共函数的形式如下:
public fun equals_1000(x: u64): bool {
x == 1000
}
请注意,这个函数带有关键字 public
,这意味着它可以从任何其他Move模块和交易中调用。
另一方面,私有函数只能在同一个模块中调用,而且不能从交易中调用。
当调用公共交易时,系统对象如TxContext
和Clock
可以选择性地传递。
取决于开发者的函数是否需要这些对象,则可以将它们作为参数添加进去。
良好的实践是将系统对象始终添加在参数列表的末尾。
在本课程中,我们将学习如何通过更新结构体的字段来修改现有对象。 首先我们需要讨论可变引用。在之前关于读取对象字段的课程中, 我们介绍了不可变引用以及在发送交易以读取对象状态时如何将其传递给公共函数。
为了修改对象,我们需要使用可变引用。语法上的区别非常简单 - 使用&mut StructName
代替&StructName
。
当与Sui区块链进行交互时,用户可以从函数的参数中清楚地看出,函数是只读还是读写对象,方法是检查它是否需要不可变对象(只读)或可变对象(读写)。
要编写一个更新对象的函数,我们首先需要通过可变引用指定要修改的对象,然后更新它的字段。所有修改后的对象在交易结束时会自动保存到区块链上。示例:
module modules::my_module_10 {
use std::vector;
use sui::object::{Self, UID};
use sui::transfer;
use sui::tx_context::TxContext;
public struct MyObject has key {
id: UID,
value: u64,
}
fun init(ctx: &mut TxContext) {
let my_object = MyObject {
id: object::new(ctx),
value: 10,
};
transfer::share_object(my_object);
}
public fun set_value(my_object: &mut MyObject, value: u64) {
my_object.value = value;
}
}
就是这样!非常简单,对吧?你只需传递一个可变引用给对象,而不是不可变引用。
在之前的课程中,我们学习了如何创建、读取和修改SuiFren对象,这些对象在所有用户之间是共享的。
有两种类型的对象:
-
共享对象(Shared Objects)可以被任何用户读取和修改。我们之前将AdminCap设置为共享对象,允许任何用户创建Sui Frens。 这可能不是预期的行为。
-
归属对象(Owned Objects)是私有对象,只有拥有它们的用户才能读取和修改。所有权在执行Sui上的交易时会自动验证。
-
请注意,只允许直接所有权,因此如果用户A拥有对象B,而对象B拥有对象C,则用户A无法发送包含对象C的交易。
-
可以使用Receiving
来绕过此限制,但我们稍后会讨论这一点。
让我们修改之前课程中的票务示例,创建真正的票据,这些票据只能分配给个别用户,而不是所有用户都可以访问:
module modules::ticket_module_11 {
use sui::clock::{Self, Clock};
use sui::object::{Self, UID};
use sui::transfer;
use sui::tx_context::{Self, TxContext};
public struct Ticket has key {
id: UID,
expiration_time: u64,
}
public fun create_ticket(ctx: &mut TxContext, clock: &Clock) {
let ticket = Ticket {
id: object::new(ctx),
expiration_time: clock::timestamp_ms(clock),
};
// tx_context::sender(ctx) returns the address of the user who sends this transaction.
transfer::transfer(ticket, tx_context::sender(ctx));
}
public fun is_expired(ticket: &Ticket, clock: &Clock): bool {
ticket.expiration_time >= clock::timestamp_ms(clock)
}
}
为了使Ticket对象成为归属对象,我们只需要明确地将对象转移到一个地址,而不是像之前那样调用 transfer::share
。
在这里,我们将新创建的票转移给发送调用create_ticket交易的用户。我们可以使用 tx_context::sender
(ctx)来获取用户的地址。
在之前的课程中,我们讨论了两种可以通过用户交易传递给函数的对象参数类型:不可变引用 &ObjectStruct
用于从对象读取数据,
以及可变引用 &mut ObjectStruct
用于修改对象。还有第三种对象参数类型可以传递给入口函数 —— 对象值,该对象值可用于从Sui存储中删除对象:
module modules::ticket_module_12 {
use sui::clock::{Self, Clock};
use sui::object::{Self, UID};
use sui::transfer;
use sui::tx_context::TxContext;
public struct Ticket has key {
id: UID,
expiration_time: u64,
}
public fun clip_ticket(ticket: Ticket) {
let Ticket {
id,
expiration_time: _,
} = ticket;
object::delete(id);
}
}
在上面的例子中,我们添加了一个新的函数clip_ticket,该函数将一个Ticket对象作为参数并删除它。 我们没有传递该对象的可变引用,因为我们并不打算修改它。而是传递整个Ticket结构体,这样我们可以删除它:
- 使用
let Ticket { id, expiration_time: _ } = ticket
解构Ticket结构体 - 使用
object::delete(id)
销毁Ticket对象
我们的模块快完成了!现在让我们添加事件。等等,什么是事件?事件是一种让模块向应用程序前端传达区块链上发生的事情的方式,前端可以“监听”某些事件并在事件发生时采取行动。如果没有事件,“链下”组件(智能合约被视为“链上”)很难监控票是否被创建、延期或兑换。它们需要查询每笔交易的结果,并手动检查结果以查看哪些对象发生了变化以及具体如何变化。这非常不容易,而事件可以帮助解决这个问题!示例:
module modules::ticket_module_13 {
use sui::clock::{Self, Clock};
use sui::event;
use sui::object::{Self, ID, UID};
use sui::transfer;
use sui::tx_context::{Self, TxContext};
public struct Ticket has key {
id: UID,
expiration_time: u64,
}
public struct CreateTicketEvent has copy, drop {
id: ID,
}
public struct ClipTicketEvent has copy, drop {
id: ID,
}
public fun create_ticket(ctx: &mut TxContext, clock: &Clock) {
let uid = object::new(ctx);
let id = object::uid_to_inner(&uid);
let ticket = Ticket {
id: uid,
expiration_time: clock::timestamp_ms(clock),
};
transfer::transfer(ticket, tx_context::sender(ctx));
event::emit(CreateTicketEvent {
id,
});
}
public fun clip_ticket(ticket: Ticket) {
let Ticket { id, expiration_time: _ } = ticket;
event::emit(ClipTicketEvent {
id: object::uid_to_inner(&id),
});
object::delete(id);
}
}
为了在Sui上发出事件,你只需做两件事:
- 定义事件结构体,例如ClipTicketEvent。
- 调用event::emit来发出在步骤(1)中定义的事件。
注意,如果我们想在事件中包含对象ID(本质上是一个地址),我们需要使用 object::uid_to_inner
将原始的UID类型转换为ID类型。UID不能被复制或存储。
在之前的课程中,我们熟悉了共享对象和归属对象:
public struct SharedObject has key {
id: UID,
}
public struct OwnedObject has key {
id: UID,
}
public fun create_shared_object(ctx: &mut TxContext) {
let shared_object = SharedObject {
id: object::new(ctx),
};
transfer::share_object(shared_object);
}
public fun create_owned_object(ctx: &mut TxContext) {
let owned_object = OwnedObject {
id: object::new(ctx),
};
transfer::transfer(owned_object, tx_context::sender(ctx));
}
使用归属对象的一个关键好处是,它们可以并行处理,因为涉及到它们的交易不会重叠(不读取或修改相同的数据)。 然而,如果共享对象被修改,则无法并行处理,并且需要经过更严格的执行过程,这会更慢且不可扩展。 另一个需要注意的重要事项是,共享对象只能在创建它们的同一交易中被设为共享。以下情况是不可行的:
public struct SharedObject has key {
id: UID,
}
public fun create_object(tx_context: &mut TxContext) {
let object = SharedObject {
id: object::new(ctx),
};
transfer::transfer(object, tx_context::sender(ctx));
}
public fun share_object(object: SharedObject) {
transfer::share_object(object);
}
如果我们在第一笔交易中调用create_object创建了一个最初是归属对象的对象,然后在第二笔交易中尝试用 share_object
来共享它,这将失败!
模块
- 模块组织:Move代码被组织成模块,每个模块类似于其他区块链上的单个智能合约。
- API和交互:模块通过入口函数和公共函数提供API。用户通过调用函数来与这些模块交互,可以是通过交易或其他Move代码。这种交互由Sui区块链处理,并保存任何由此产生的更改。
- 在Sui中强调模块化设计。开发者被鼓励保持模块的小型化,并放置在单独的文件中,遵循清晰的数据结构和代码风格。这有助于应用程序更容易地集成,也使用户更清晰地理解。
结构体
- 结构体是一组相关字段,每个字段都有其自己的类型,如数字、布尔值和向量。
- 每个结构体可以定义具有“能力” - key、store、drop、copy。
- MoveSui支持以下数据类型:无符号整数、布尔值、地址、字符串、向量和自定义结构体类型。
对象
完成本模块后,您应该能够理解:
- 对象的生命周期
- 如何读取对象
- 如何更新对象
- 如何删除对象
- 共享对象与归属对象。
向量
向量可以理解为动态数组,在智能合约中管理项目列表至关重要,反映了区块链应用程序对灵活数据结构的需求。
事件
事件是模块向应用程序前端传达在区块链上发生的事件的一种方式。应用程序可以监听特定事件,并在事件发生时采取相应的操作。
函数
- 公共函数(使用public关键字):可以从任何其他Move模块和交易中调用。
- 私有函数(默认不使用任何关键字):只能在同一模块中调用,不能从交易中调用。
包保护函数 - public(package)
在之前的课程中,我们已经介绍了Move的基本概念:模块、函数、对象和事件。在这门课程中, 我们将深入探讨Move和对象中更多有用的概念,以便构建一个更加有趣和复杂的Sui Fren世界。
首先让我们讨论函数。在之前的课程中,我们看到了公共函数和私有函数:
- 公共函数可以被交易调用(通过我们稍后会介绍的可编程交易块),也可以被其他Move代码(同一模块或不同模块)调用。
- 私有函数只能在同一模块内部调用。
module 0x123::my_module {
public fun public_equal(x: u64): bool {
x == 1000
}
fun private_equal(x: u64): bool {
x == 1000
}
}
如果你还记得,模块在Sui上部署时被分组为包。这导致了第三种函数可见性 - public(package)
。
public(package)
函数类似于其他语言中的包可见函数,只能被同一包中的模块调用。
这使开发人员能够限制危险函数仅被自己的模块调用,而不被其他模块调用。
module 0x123::my_other_module {
use 0x123::my_module;
public fun do_it(x: u64): bool {
my_module::friend_only_equal(x)
}
}
module 0x123::my_module {
friend 0x123::my_other_module;
public(package) fun friend_only_equal(x: u64): bool {
x == 1000
}
}
要创建一个public(package)
函数,我们只需要使用相应的可见性修饰符 - public(package)
。
在上面的例子中,调用public(package)
函数的任何模块,例如 0x123::my_other_module
。
使public(package)
函数成为包可见函数。
可编程交易块和入口函数
另一种重要的函数类型是入口函数。在旧的 Move 语言中,有两种入口函数—— public entry
和 entry
。
在 Sui 网络上,可编程交易块(PTB)允许用户指定一系列动作(交易), 将其作为单个交易发送到网络。动作将按顺序执行,并且是原子的, 即:如果其中任何一个动作失败,整个 PTB 将失败,所有更改会自动回滚。
PTB 是一个强大的概念,会在后续课程中详细介绍。目前,我们将 PTB 视为用户发送到 Sui 区块链的交易。 PTB 可以调用任何用 Move 模块编写的公共函数、公共入口函数和私有入口函数。 因此,在 Sui 中,公共函数和公共入口函数实际上没有区别,尽管这些概念仍然继承自经典 Move。
我们需要学习唯一的新函数类型是私有入口函数(简称入口函数),它只能直接从交易中调用,不能从其他 Move 代码中调用。
私有入口函数对于开发者希望直接向用户提供的功能非常有用,这些功能只能作为交易的一部分调用, 而不能在其他模块中调用。一个例子是剪票——我们希望用户必须显式地将其作为交易的一部分来调用, 不希望其他模块代表用户剪票。后者对于用户来说更难检测,他们可能不会期望在发送交易时会发生这种情况。
entry fun clip_ticket(ticket: Ticket) {
let Ticket {
id,
expiration_time: _,
} = ticket;
object::delete(id);
}
结构体能力 - key
, copy
, drop
, store
在之前的课程中,我们学习了结构体,并了解到一个结构体需要具备 key 能力才能成为对象:
public struct AdminCap has key {
id: UID,
num_frens: u64,
}
除了 key
能力,结构体还可以具有其他 3 种能力:store
、copy
和 drop
。
结构体可以具有多种能力。然而,若要使一个结构体具有特定能力,它的所有字段必须具有相同的能力。
store
能力允许一个结构体成为其他结构体的一部分。请注意,
如果下面的 NestedStruct
有另一个 DoubleNestedStruct
字段,该结构体也需要具有 store
能力。
public struct NestedStruct has store {
value: u64,
}
public struct Container has key {
id: UID,
nested: NestedStruct,
}
copy
能力允许结构体被“复制”,这会创建一个具有相同字段值的结构体实例。
请注意,对象结构体(具有键能力和 ID 字段的那些)不能具有复制能力,因为 UID 结构体没有复制能力。
public struct CopyableStruct has copy {
value: u64,
}
fun copy(original: CopyableStruct) {
let copy = original;
original.value = 1;
copy.value = 2;
// 现在有两个拥有不同值的 CopyableStructs
}
drop
能力允许结构体在函数结束时被隐式销毁(析构),而不需要显式“销毁”:
public struct DroppableStruct has drop {
value: u64,
}
fun copy() {
let droppable = DroppableStruct { value: 1 };
// 在此函数结束时,droppable 将被销毁。
// 我们不需要显式地销毁它:
// let DroppableStruct { value: _ } = droppable;
}
务必要记住,只有在所有字段都具有相同能力的情况下,结构体才能拥有某种能力。 如果不记住这一点,开发人员在尝试创建一个可销毁的结构体时,但却有一个不可销毁的字段,会感到非常困惑。
对象包装 - 将 SuiFren 包装在GiftBox中
我们创建了一个包含 SuiFren 的新对象类型 GiftBox。但是如何将 SuiFren 放入其中呢?这里有两种选择:
- 在 sui_fren 模块中创建一个新的函数
create
,用于创建一个 SuiFren 对象并返回它,而不是像 mint 函数那样立即将其转移给发送者。 - 先铸造 SuiFren。一旦在
mint
中完成转移,我们无法在同一交易中检索 SuiFren,需要在后续交易中显式地将该 SuiFren 作为参数传递。 我们可以在fren_summer
模块中添加一个 包装函数,允许发送者将现有的 SuiFren 包装成一个礼品盒,然后可以将其发送给朋友。
在第二种情况下,将 SuiFren 放入 GiftBox 称为对象包装。这不仅仅是你想的那样——它会将被包装的对象从对象存储中取出。 这意味着,如果你有一个可以显示用户所拥有的所有 SuiFren 的界面,那么被包装的 SuiFren 将从列表中消失。
public struct Box has key {
id: UID,
thing: Thing,
}
public struct Thing has key, store {
id: UID,
}
public fun wrap(thing: Thing, ctx: &mut TxContext) {
let box = Box { id: object::new(ctx), thing };
transfer::transfer(box, tx_context::sender(ctx));
}
请注意,wrap
函数接受的是值,而不是引用!我们在前面的课程中已经介绍了按值传递对象的方式以及如何将对象从存储中移除。
不可变对象
到目前为止,我们已经创建了两个对象:SuiFren 和 GiftBox。这两个都是归属对象。 它们由用户拥有,只有所有者才能将其包含在交易中。我们还简要讨论了共享对象——那些可以包含在任何交易中的对象。 在本课中,我们将讨论第三种对象类型——不可变对象。
不可变对象与共享对象几乎相同。任何用户都可以将它们作为其交易的一部分。 然而,共享对象可以作为可变引用包含,因此可以被任何人修改。而不可变对象在“冻结”后永远不能改变。
public struct ColorObject has key {
id: UID,
red: u8,
green: u8,
blue: u8,
}
public entry fun freeze_owned_object(object: ColorObject) {
transfer::freeze_object(object)
}
public entry fun create_immutable(red: u8, green: u8, blue: u8, ctx: &mut TxContext) {
let color_object = ColorObject {
id: object::new(ctx),
red,
green,
blue,
};
transfer::freeze_object(color_object);
}
在上面的示例中,create_immutable
创建一个对象并立即将其冻结,使其成为不可变对象。而 freeze_owned_object
则是接收一个现有的归属对象并将其变为不可变对象。请注意,如果在共享对象上调用 transfer::freeze_object
会导致错误。freeze_owned_object
展示了不可变对象与共享对象之间的另一个关键区别——归属对象不能变为共享对象(需要在创建对象后的同一交易中立即调用 transfer::share_object
),但归属对象可以在任何时候根据所有者的意愿变为不可变对象。
不可变对象可以通过不可变引用(&)在任何时候包含:
public fun read_immutable(color: &ColorObject): (u8, u8, u8) {
(color.red, color.green, color.blue)
}
read_immutable
可以在任何不可变(冻结的)颜色对象上调用,并且不需要所有权。
转移政策 - 对象的公共和私有转移
我们已经看到归属对象的两种操作方式:
public struct OwnedObject has key {
id: UID,
}
public fun create_owned_object(tx_context: &mut TxContext) {
let owned_object = OwnedObject {
id: object::new(ctx),
};
transfer::transfer(owned_object, tx_context::sender(ctx));
}
我们还了解了结构体的能力 —— key
, store
, copy
, drop
。
通过结合 store
能力和 transfer 模块提供的功能,你可以获得一个特殊的隐藏功能——转移政策。
通过转移政策,开发人员可以决定他们定义的对象是否可以在其模块代码之外进行转移:
- 具有
store
能力的对象可以通过transfer::public_transfer
在其定义的同一模块之外进行转移。
module 0x123::my_module {
public struct OwnedObject has key, store {
id: UID,
}
}
module 0x123::your_module {
use 0x123::my_module::OwnedObject;
public fun transfer(object: OwnedObject, receiver: address) {
transfer::public_transfer(object, receiver);
}
}
- 没有
store
能力的对象只能在其定义的同一模块内通过transfer::transfer
转移。 在上面的示例中,如果我们从 OwnedObject 中移除store
能力,your_module 中的 transfer 函数将停止工作。 OwnedObject 现在只能在 my_module 内直接转移:
module 0x123::my_module {
public struct OwnedObject has key {
id: UID,
}
public fun transfer(object: OwnedObject, receiver: address) {
transfer::transfer(object, receiver);
}
}
转移政策 - share
vs public_share
, freeze
vs public_freeze
类似于 transfer::transfer
和 transfer::public_transfer
函数,transfer 模块还提供以下功能:
share
和public_share
freeze
和public_freeze
这些函数的工作方式与 transfer
和 public_transfer
类似——只有当传递给它们的对象具有 store
能力时,才能使用 public
版本。
这为开发人员提供了更多的灵活性来控制他们的对象如何被使用。
使用 share
和 public_share
,开发人员可以允许他们的对象仅仅被拥有或者也可以被共享。例如:
- 一个开发人员创建了一个 PunchCard 对象,每次用户购买珍珠奶茶时可以打一个孔。开发人员希望 PunchCard 可以在用户之间共享,
这样不同用户可以使用同一个 PunchCard。他们只需要为 PunchCard 添加
store
能力。 - 另一个开发人员为他们的健身房创建了一个 GymMembership 对象,但不希望会员资格被多个用户共享。
他们只需要不给 GymMembership 添加
store
能力。
使用 freeze
和 public_freeze
,开发人员同样可以决定是否希望所有者能够使他们的对象变为不可变:
- 之前提到的 PunchCard 对象如果用户想放弃,可以将其变为不可变。
- GymMembership 对象不能变为不可变,因为会员资格到期后应该被删除。
对象包装 vs 非对象结构体
在之前的课程中,我们学习了对象包装,以及如何将一个 SuiFren 对象包装成 GiftBox:
public struct GiftBox has key {
id: UID,
inner: SuiFren,
}
entry fun wrap_fren(fren: SuiFren, ctx: &mut TxContext) {
let gift_box = GiftBox {
id: object::new(ctx),
inner: fren,
};
transfer::transfer(gift_box, tx_context::sender(ctx));
}
还有一种具有类似的语法能替代对象包装的方法 —— 使用仅具有 store 能力的非对象结构体:
public struct SuiFren has store {
generation: u64,
birthdate: u64,
attributes: vector<String>,
}
这种方法通常只有在开发人员不打算将嵌套结构体类型转变为对象时才有用。这可以帮助将一个长的对象结构体分解成较小的相关组件,例如:
public struct LongObject has key {
id: UID,
field_1: u64,
field_2: u64,
field_3: u64,
field_4: u64,
field_5: u64,
field_6: u64,
field_7: u64,
field_8: u64,
}
对比
public struct BigObject has key {
id: UID,
field_group_1: FieldGroup1,
field_group_2: FieldGroup2,
field_group_3: FieldGroup3,
}
public struct FieldGroup1 has store {
field_1: u64,
field_2: u64,
field_3: u64,
}
public struct FieldGroup2 has store {
field_4: u64,
field_5: u64,
field_6: u64,
}
public struct FieldGroup3 has store {
field_7: u64,
field_8: u64,
}
共享对象 - 并行执行和共识
作为 Sui 开发人员,了解以下隐藏知识是很有用的:一般来说,最好尽可能多地在智能合约中使用归属对象。 共享对象通常只用于跨多个用户共享状态的情况,如果该状态不需要改变,建议优先使用不可变对象而非共享对象。
共享对象在 Sui 网络上通过一个单独的执行路径进行处理,因为它需要完全共识。 如果任何用户都可以更改共享对象,可能会因为顺序不同而导致不同的最终结果。 因此,Sui 网络需要使用完整的共识过程,并确保所有 Sui 验证器在将结果提交到存储之前同意顺序和最终结果。 由于这一原因,共享对象的执行时间会更长,并且会给 Sui 网络带来比归属对象或不可变对象更高的成本。 可以这样理解:如果尽可能多的对象是归属对象或不可变对象,Sui 网络的工作效率将会是最好的。
一般的经验法则是:
- 如果数据从未改变,则使用不可变对象作为合约的所有共享状态。
- 对于可以更新的共享状态,使用共享对象。
- 对于其他所有情况,使用归属对象。
系统对象:一次性见证对象和发布者对象
我们已经见过 TxContext
对象,它可以作为可变或不可变引用传递。现在让我们讨论其他特殊对象。
在部署模块时,任何 init
函数都会自动被调用。init
函数还可以接收一个见证对象 —— 一种特殊的系统对象,只在模块第一次部署时创建一次:
module 0x123::my_module {
public struct MY_MODULE has drop {}
fun init(witness: MY_MODULE) {
// Do something with the witness object.
}
}
为了在 init 函数中接收见证对象,您需要声明一个与模块同名但全大写的结构体(任何 _ 都保留)。
该结构体必须具有 drop
能力。现在,当您定义 init
函数时,可以将该类型的见证对象添加为第一个参数。
见证对象目前只有两种主要情况,但 Sui 团队在不久的将来可能会添加更多:
- 声明发布者对象。发布者对象是证明持有人已部署对象的证据。
fun init(witness: MY_MODULE, ctx: &mut TxContext) {
assert!(types::is_one_time_witness(&witness), ENotOneTimeWitness);
let publisher_object = package::claim(witness, ctx);
// Use or store the publisher object...
}
- 当调用其他模块的函数时,证明这是在初始化流程的中间。这通常在需要与多个不同模块一起完成一系列初始化项目的操作时很有用。
module 0x123::module_b {
fun init(module_a_witness: MODULE_A, ctx: &mut TxContext) {
assert!(types::is_one_time_witness(&module_a_witness), ENotOneTimeWitness);
// We know that this is being called from module A's init function.
}
}
发布者对象目前也只有两个使用案例,但很快会添加更多:
- 创建显示对象。更多内容将在下一课中介绍。
- 在 Sui 的 Kiosk(NFT 标准)中设置转移政策。这将在 NFT 课程中讲解。
系统对象:显示对象
显示对象(Display
public struct Display<phantom T: key> has key, store {
id: UID,
/// 包含展示的字段
/// 现支持的字段: name, link, image 和 description.
fields: VecMap<String, String>,
/// 版本号只能有出版人手动更新
version: u16
}
如果一个账户同时持有 MyObject 和 Display
module 0x123::my_module {
public struct MyObject has key {
id: UID,
num_value: u64,
string_value: String,
}
public fun create_display_object(publisher: &Publisher, ctx: &mut TxContext) {
let display_object = display::new<MyObject>(&publisher, ctx);
display::add_multiple(
&mut display,
vector[
utf8(b"num_value"),
utf8(b"string_value"),
],
vector[
utf8(b"Value: {num_value}"),
utf8(b"Description: {string_value}"),
],
);
display::update_version(&mut display);
}
}
为了创建显示对象,我们需要一个指向发布者对象的引用,该对象位于部署 MyObject 的模块中。
假设我们已经创建了这个对象(参见上一课)并将其存储在我们控制的账户中,我们可以使用这个账户来调用 create_display_object
。
创建显示对象后,我们可以通过调用 display::add_multiple
并传递两个向量来添加格式化规则,
一个用于 MyObject 中要显示的字段列表,另一个用于格式化规则。
设置格式化规则后,我们可以调用 display::update_version
来最终确定对显示对象的更新。
一旦创建了显示对象,将会发出一个事件,允许 Sui 网络节点检测显示对象。
随后,每当通过节点 API 获取对象时,其显示字段也会按照指定的格式计算,并与对象的其他字段一起返回。
系统对象 - 时钟
我们将要研究的最后一个系统对象是时钟对象。它允许用户获取 Sui 区块链上记录的当前时间:
use sui::clock;
public entry fun get_time(clock: &Clock) {
let timestamp_ms = clock::timestamp_ms(clock);
}
请注意,返回的时间戳是以毫秒为单位的(1 秒 = 1000 毫秒)。 时间戳有两种常见的使用方式:
- 获取时间戳以进行记录或触发事件。
public struct TimeEvent has copy, drop {
timestamp_ms: u64,
}
public entry fun get_time(clock: &Clock) {
event::emit(TimeEvent { timestamp_ms: clock::timestamp_ms(clock) });
}
- 生成一个伪随机数。这在技术上容易受到验证者操纵,因为验证者可以在非常小的误差范围内设置时间戳。
entry fun flip_coin(clock: &Clock): u64 {
let timestamp_ms = clock::timestamp_ms(clock);
// 0 is heads, 1 is tails
timestamp % 2
}
系统对象:TxContext 和避免安全漏洞
另一个非常常用的系统对象是 TxContext。我们已经看到它在两个主要用例中的应用:
- 使用
object::new
创建一个新对象的 id - 使用
tx_context::sender
获取发送者
public struct MyObject has key {
id: UID,
value_1: u64,
value_2: u64,
}
public fun create_object(value_1: u64, value_2: u64, ctx: &mut TxContext) {
let object = MyObject {
id: object::new(ctx),
value_1,
value_2,
};
transfer::transfer(object, tx_context::sender(ctx));
}
开发人员应该密切关注如何使用从 tx_context::sender
返回的发送者地址。
将新创建的对象发送到该地址是可以的,但将其用作认证或用户直接调用此函数的意图证明可能会有问题。例如:
module 0x123::safe_module {
public fun claim_rewards(amount: u64, receiver: address, ctx: &mut TxContext) {
let sender = tx_context::sender(ctx);
assert!(sender == @0x12345, ENOT_AUTHORIZED);
// 发送定额奖励到接收者地址
}
}
在上面的示例中,我们希望编写一个特殊函数 claim_rewards
,允许特定地址调用并提取一定数量的奖励金。
表面上看起来是安全的,但可能会被利用!恶意开发者可以编写一个模块,向用户承诺空投,并在代码中执行以下操作:
module 0x123::malicious_module {
const MALICIOUS_DEVELOPER: address = @0x98765;
public fun airdrop(ctx: &mut TxContext) {
safe_module::claim_rewards(1000, MALICIOUS_DEVELOPER, ctx);
}
}
这样会立即耗尽用户的奖励!safe_module
无法轻易区分 ctx 对象是由 VM(首次函数调用)传递的,还是由其他对象的另一个函数传递的。
一个解决方案是将 claim_rewards
设置为入口函数,这样它必须由用户直接调用。
然而,在某些情况下,如果我们希望自己的代码(来自同一包中的不同模块)调用此函数,这可能并不理想。
总的来说,使用 tx_context::sender
作为认证机制是有风险的,如果可能存在任何利用的可能性,应进行非常彻底的评估。
tx_context
模块提供的其他功能包括:
digest
:返回交易哈希epoch
和epoch_timestamp_ms
:返回当前的 epoch 编号及相应的时间戳fresh_object_address
:使用与 object::new 相同的底层函数生成新对象的地址
结构体数据访问
我们已经看到了各种不同类型的结构体,包括对象结构体(具有 key 能力的)和非对象结构体(不具有 key 能力的)。
为了访问结构体实例的字段,无论是对象还是非对象,只需要使用点符号并调用 struct_instance.field
。
struct_instance
可以是一个可变或不可变的引用,或是一个值。然而,这在结构体定义模块之外是无效的:
module 0x123::a {
public struct MyData has key {
id: UID,
value: u64,
}
// 这可行
public fun get_value_from_reference(object: &MyData): u64 {
object.value
}
// 这可行
public fun get_value_from_mut_reference(object: &mut MyData): u64 {
object.value
}
}
module 0x123::b {
use 0x123::a::MyData;
// 这不可行
public fun get_value(object: &MyData): u64 {
object.value
}
}
在上面的示例中,b::get_value
将无法编译,因为它试图访问 MyData 的 value 字段,
但 MyData 是在模块 a 中定义的,而不是在模块 b 中。在 Move 中,只有定义结构体的模块才能自由访问其字段。
如果其他模块需要访问某个字段,则需要通过原始模块的 getter 函数来访问:
module 0x123::b {
use 0x123::a::MyData;
// This now works
public fun get_value(object: &MyData): u64 {
a::get_value_from_reference(object)
}
}
一般来说,这称为结构体数据访问的模块范围规则。这允许开发人员为其数据(结构体)的读取或写入定义规则。
他们可以将数据保密,不允许任何外部模块访问内部字段,或者他们可以实现公共 getter
(返回内部字段的值)和 setter
(设置内部字段的值),
这些函数可以被外部模块调用。他们还可以实现自定义认证模块,要求特定的权限对象才能更新结构体的值。
用户所有权规则
在第一门课程中,我们已经介绍了归属对象及其只能通过所有者发送的交易进行访问和修改。当交易执行时,这一点会被 Sui 区块链自动验证,并保证在未经许可的情况下,账户的归属对象不能被读取或修改。
通过执行这种所有权,用户可以放心,没有模块可以在未经他们明确签署授权交易的情况下读取或修改他们的归属对象。
总结
这个模块进一步加深了您对基本 Sui Move 的理解。
函数
我们学习了另外两种类型的函数:
- public(package)函数:类似于其他语言中的包可见函数,只能被同一包中的模块调用。
- 这允许开发人员限制危险函数只能被自己的模块调用,而不能被其他模块调用。
- entry:只能直接从交易中调用,而不能从其他 Move 代码中调用。
可编程交易块(PTB) 允许用户指定一系列操作(交易)作为单个交易发送到网络。 这些操作按顺序执行,并且是原子的——如果其中任何一个操作失败,整个 PTB 将失败,所有更改将自动还原。
结构体
除了 key 能力,结构体还提供另外三种能力:
- 存储能力(store ability)允许结构体成为其他结构体的一部分。
- 复制能力(copy ability)允许结构体被“复制”,即创建一个具有相同字段值的结构体实例。
- 销毁能力(drop ability)允许结构体在函数结束时被隐式销毁,而不需要显式“销毁”。
结构体数据访问:这允许开发人员定义其数据(结构体)的读取或写入规则。他们可以将数据保密,不允许任何外部模块访问内部字段, 或者实现公共 getter(返回内部字段的值)和 setter(设置内部字段的值),这些函数可以被外部模块调用。 他们还可以实现自定义认证模块,要求特定的权限对象才能更新结构体的值。
对象
- 对象包装(Object Wrapping):一种将对象封装在其他对象中的方法,是创建分层数据结构并更细粒度地管理所有权和访问的关键技术。
- 不可变对象(Immutable Object):除了共享对象和归属对象之外的第三种对象类型。不可变对象可以作为用户交易的一部分, 但在“冻结”后永远不能被更改。
- 系统对象:见证对象、发布者对象、显示对象、时钟。
可转移性
- 具有存储能力的对象可以在其定义的同一模块之外转移。没有存储能力的对象只能在其定义的同一模块内通过 transfer::transfer 转移。
- 共享和公共共享对象:开发人员可以允许他们的对象仅仅被拥有或者也可以被共享。
- 冻结和公共冻结对象:开发人员同样可以决定是否希望所有者能够使他们的对象变为不可变。
选择对象类型使用的黄金法则:
- 对于所有共享状态的合约,如果数据从未改变,则使用不可变对象。
- 对于可以更新的共享状态,使用共享对象。
- 对于其他所有情况,使用归属对象。
动态字段
在之前的课程中,我们深入探讨了对象,并创建了具有自身属性的 SuiFrens 对象以及告诉 Web 界面如何显示它们的 display
对象。
我们还讨论了以下主题:
- 嵌套结构体:具有存储能力的结构体可以嵌入到另一个结构体中。这通常用于非对象结构体,以将长列表的字段划分为逻辑组件。
- 对象包装:将一个 SuiFren 放入另一个对象中。这也使用了存储能力,但实际上从存储中移除了被包装的对象。 被包装到 GiftBoxes 中的 SuiFren 在按对象搜索时将不再被查找。
在本课中,我们将讨论另一种将结构体和对象组合在一起的方法——动态字段。您可以将动态字段视为未在对象结构体上显式定义的“隐形”字段。 假设我们有一个具有以下初始属性的 Laptop 对象:
public struct Laptop has key {
id: UID,
screen_size: u64,
model: u64,
}
假设我们想在将来为这台笔记本电脑动态添加更多的属性,但还不知道这些属性到底是什么。 我们不能立即在结构体中定义它们。这时动态字段就派上用场了——你不需要知道所有要添加到对象的字段,因为这些属性可以作为动态字段添加!
use sui::dynamic_field;
public fun add_attribute(laptop: &mut Laptop, name: String, value: u64) {
dynamic_field::add(&mut laptop.id, name, value);
}
为了给对象添加动态字段,您需要该对象的可变引用。请记住,对于归属对象,只有所有者可以发送使用其对象的可变/不可变引用或值的交易。
共享对象可以被任何人修改。添加动态字段可以简单地通过 dynamic_field::add
,使用可变对象的 id、键和值来完成。
键和值可以是基本类型(数字、字符串等)或结构体。注意,作为键使用的结构体必须具有 copy
、drop
和 store
能力,
而作为值使用的结构体必须具有 store
能力。
public struct StickerName has copy, drop, store {
name: String,
}
public struct Sticker has store {
image_url: String,
}
public fun add_sticker(laptop: &mut Laptop, name: String, image_url: String) {
let sticker_name = StickerName { name };
let sticker = Sticker { image_url };
dynamic_field::add(&mut laptop.id, sticker_name, sticker);
}
动态字段的名称是独特的,所以你不能多次添加相同名称的字段。
要读取或修改动态字段,分别需要对象的不可变引用和可变引用以及字段名称(键)。
如果字段名称是一个结构体,你需要结构体的值本身,这就是为什么作为键使用的结构体必须具有 copy
和 drop
能力的原因。
public fun read_image_url(laptop: &Laptop, name: String): String {
let sticker_name = StickerName { name };
let sticker_reference: &Sticker = dynamic_field::borrow(&laptop.id, sticker_name);
sticker_reference.image_url
}
public fun set_image_url(laptop: &mut Laptop, name: String, new_url: String) {
let sticker_name = StickerName { name };
let sticker_mut_reference: &mut Sticker = dynamic_field::borrow_mut(&mut laptop.id, sticker_name);
sticker_mut_reference.image_url = new_url;
}
请注意,通常你需要指定借用字段的类型(在上面的示例中为 &Sticker 和 &mut Sticker)。 你也可以使用可变对象引用和键来移除现有的动态字段:
public fun remove_sticker(laptop: &mut Laptop, name: String) {
let sticker_name = StickerName { name };
dynamic_field::remove(&mut laptop.id, sticker_name);
}
你可以使用 dynamic_field::exists_(&laptop.id, sticker_name)
来检查动态字段是否存在。
动态对象字段 - 将对象作为动态字段存储
你可能会对动态字段有一个疑问 - 如果我们将一个对象存储在动态字段中会怎样? 虽然这是可能的,但将 Sticker 对象作为动态字段存储在 Laptop 上的一个副作用是, Sticker 对象将从存储中移除,无法通过其 id 在链外查找(例如在 Web 界面中)。这与对象包装具有相同的副作用。
public struct Sticker has key, store {
id: UID,
image_url: String,
}
public fun add_sticker(laptop: &mut Laptop, name: String, sticker: Sticker) {
dynamic_field::add(&mut laptop.id, name, sticker);
}
请注意,Sticker 对象也必须具有存储能力才能存储在动态字段中。 因为 Sticker 现在是一个对象,我们还需要通过交易显式地传递它。如果它是一个归属对象,只有所有者才能这样做(所有权规则)。
如果你不希望 Sticker 对象从全局存储中移除,以便你的 Web 界面仍然可以查找它,你可以使用 dynamic_object_field
模块,
而不是 dynamic_field
模块。这两个模块及其功能在 Move 内部看起来几乎相同——关于全局存储的区别仅对链外组件(例如 Web 界面)有影响。
use sui::dynamic_object_field;
public struct Sticker has key, store {
id: UID,
image_url: String,
}
public fun add_sticker(laptop: &mut Laptop, name: String, sticker: Sticker) {
dynamic_object_field::add(&mut laptop.id, name, sticker);
}
一般来说,动态对象字段比动态字段更适合用于动态存储对象。只有在你有意将对象从全局存储中移除时才应使用动态字段。 例如,当你给你的 SuiFren 戴上帽子时,你希望帽子从全局存储中消失,以表示它已被领取。
额外信息:你可能会好奇,当一个对象被添加为另一个对象的动态字段后,这个对象的所有权会发生什么变化。 答案是它将由动态字段本身拥有!在幕后,当动态字段被添加时,每个字段会秘密地创建为全局存储中与定义该字段的父对象绑定的一个单独对象。
这个动态字段对象将拥有添加到它的对象。在上面的例子中,Laptop 拥有 sticker 动态字段,而 sticker 动态字段拥有 Sticker 对象。 是不是很困惑?大多数开发人员其实不需要知道这些细节
动态字段 vs 动态对象字段 vs 包装
让我们总结一下到目前为止我们学习的不同对象组合方法:
-
对象包装:将对象存储在另一个对象中(例如,将 SuiFren 存储在 GiftBox 中)。 这会将被包装的对象(SuiFren)从全局存储中移除。链外的 Web 界面在包装后无法查找它们。 该对象不再有任何所有者。你可以将其视为将对象转换为普通的非对象结构体实例。
-
动态字段:也可以用于存储对象。这也会将对象从全局存储中移除。所有权也会被移除。 这与对象包装非常相似,只不过字段是动态添加的,而不是在结构体中显式定义的。
-
动态对象字段:不会将对象从全局存储中移除。所有权会转移到一个特殊的“动态字段对象”, 对于 Web 界面来说通常不容易查找。在大多数情况下,这几乎与放弃对象的所有权具有相同的效果。
entry fun wrap_fren(fren: SuiFren, ctx: &mut TxContext) {
let gift_box = GiftBox {
id: object::new(ctx),
inner: fren,
};
transfer::transfer(gift_box, tx_context::sender(ctx));
}
// Dynamic fields
public fun color_hat(sui_fren: &mut SuiFren, color: String) {
if (dynamic_field::exists_(&sui_fren.id, string::utf8(HAT_KEY))) {
let hat = dynamic_field::borrow_mut(&mut sui_fren.id, string::utf8(HAT_KEY));
hat.color = color;
} else {
dynamic_field::add(&mut sui_fren.id, string::utf8(HAT_KEY), Hat { color });
}
}
// Dynamic object fields
public fun color_hat(sui_fren: &mut SuiFren, color: String, ctx: &mut TxContext) {
if (dynamic_object_field::exists_(&sui_fren.id, string::utf8(HAT_KEY))) {
let hat = dynamic_object_field::borrow_mut(&mut sui_fren.id, string::utf8(HAT_KEY));
hat.color = color;
} else {
let hat = Hat {
id: object::new(ctx),
color,
};
dynamic_object_field::add(&mut sui_fren.id, string::utf8(HAT_KEY), hat);
}
}
要选择如何组合对象,开发人员应注意以下几点:
- 该字段是否应在结构体中显式定义。这是使用动态字段的一个缺点,因为从对象结构体定义中不容易看到这些字段。 这意味着其他开发人员需要通读整个模块的代码,才能找到所有可能添加的动态字段。一般不建议添加超过 10 个单独的动态字段。
- 该对象是否应从全局存储中移除,从而在 Web 界面中不可见。
对象拥有对象
另一种组合对象的方法是让对象拥有其他对象。对于其他三种方法——对象包装、动态字段、动态对象字段,所有权被放弃或设置为隐藏对象(动态字段对象)。在某些情况下,希望父对象拥有子对象(例如,SuiFren 拥有它所戴的帽子)。这可以被视为在复杂应用设计中明确表示对象层次结构的一种方法,其中包含多种类型的对象。
对象拥有对象的另一个有趣用例是可组合的NFT——就像 SuiFren 拥有它们的帽子一样。这允许将 NFT 强有力地组合在一起,使其对用户而言是自然的,这在使用 NFT 的 Web 3 游戏中经常见到。 与其他三种方法相比,对象拥有对象的一个缺点是以后很难移除被拥有的对象。因此,如果关系很少或从未改变,对象拥有对象通常更有用。
public struct Laptop has key {
id: UID,
}
public struct Sticker has key, store {
id: UID,
image_url: String,
}
public fun add_sticker(laptop: &Laptop, sticker: Sticker) {
transfer::public_transfer(sticker, object::uid_to_address(&laptop.id));
}
在上面的例子中,我们显式地将 Sticker 对象转移到 Laptop。它现在由 Laptop 拥有。为了以后移除它,我们需要使用 Receiving
public fun remove_sticker(laptop: &mut Laptop, sticker: Receiving<Sticker>) {
let sticker = transfer::public_receive(&mut laptop.id, sticker);
// 用Sticker做点什么
}
为了提取先前转移到 Laptop 对象中的 Sticker 对象,我们需要 Laptop 的所有者调用 remove_sticker
函数,传入 Laptop 的可变引用和一个类型为 Receivingtransfer::public_transfer
来获取底层的 Sticker。如你所见,这比对象包装或动态字段要复杂一些。
请注意,从另一个对象中提取对象也受转移政策的约束(参见前一课程的相应章节):
- 如果内部对象具有存储能力,可以调用
transfer::public_receive
将其从定义其结构体的模块之外提取。 - 如果内部对象没有存储能力,不能调用
transfer::public_receive
。只能在定义其结构体的模块内部调用transfer::receive
。
使用动态字段管理合约状态
动态字段的另一个有用应用是管理合约状态。你可能会倾向于为每个状态结构体使用多个共享对象。 例如,我们可以为笔记本电脑商店的配置设置如下:
public struct PriceConfigs has key {
id: UID,
price_range: vector<u64>,
}
public struct StoreHours has key {
id: UID,
open_hours: vector<vector<u8>>,
}
public struct SpecConfigs has key {
id: UID,
specs_range: vector<u64>,
}
fun init(ctx: &mut TxContext) {
let price_configs = PriceConfigs {
id: object::new(ctx),
price_range: vector[1000, 5000],
};
let store_hours = StoreHours {
id: object::new(ctx),
open_hours: vector[vector[9, 12], vector[1, 5]],
};
let spec_configs = SpecConfigs {
id: object::new(ctx),
specs_range: vector[1000, 10000],
};
transfer::share_object(price_configs);
transfer::share_object(store_hours);
transfer::share_object(spec_configs);
}
创建共享对象并不是一项繁重的工作。每个对象都是共享的,因此用户在购买笔记本电脑时可以访问和/或修改这些对象。例如:
public fun purchase_laptop(price_configs: &PriceConfigs, store_hours: &SotreHours,
spec_configs: &SpecConfigs, laptop: String, price: u64, ctx: &mut TxContext) {
}
如你所见,这个函数的签名太长且难以阅读。此外,代表用户提交交易的 Web 界面需要跟踪所有这些共享对象的地址。想象一下, 如果我们有 5 个甚至 10 个共享对象会发生什么!这也显得过于复杂,因为这些不同的结构体从未分离并独立存在,因此它们实际上并不需要成为对象。
更简洁的方法是创建一个单一的状态配置对象,并在那里添加动态字段:
public struct StateConfigs has key {
id: UID,
}
const PRICE_CONFIGS: vector<u8> = b"PRICE_CONFIGS";
public struct PriceConfigs has store {
price_range: vector<u64>,
}
const STORE_HOURS: vector<u8> = b"STORE_HOURS";
public struct StoreHours has store {
open_hours: vector<vector<u8>>,
}
const SPEC_CONFIGS: vector<u8> = b"SPEC_CONFIGS";
public struct SpecConfigs has store {
specs_range: vector<u64>,
}
fun init(ctx: &mut TxContext) {
let state_configs = StateConfigs {
id: object::new(ctx),
};
dynamic_fields::add(&mut state_configs.id, PRICE_CONFIGS, PriceConfigs {
id: object::new(ctx),
price_range: vector[1000, 5000],
});
dynamic_fields::add(&mut state_configs.id, STORE_HOURS, StoreHours {
id: object::new(ctx),
open_hours: vector[vector[9, 12], vector[1, 5]],
});
dynamic_fields::add(&mut state_configs.id, SPEC_CONFIGS, SpecConfigs {
id: object::new(ctx),
specs_range: vector[1000, 10000],
});
transfer::share(state_configs);
}
用户交易只需要跟踪并传递一个对象的地址:
public fun purchase_laptop(state_configs: &StateConfigs, laptop: String, price: u64, ctx: &mut TxContext) {
}
正如你所见,这样简洁得多!
动态字段用于扩展性和可升级性
在之前关于动态字段和动态对象字段的课程中,我们讨论了如何动态地向现有对象添加新属性。 这是一种强大的功能,特别是在升级现有的 Move 模块时,可以用来扩展现有对象。
什么是升级?
当 Move 模块部署到 Sui 区块链时,其包会被分配一个地址,如第一课中讨论的那样。 如果我们想添加更多代码并覆盖我们部署到的地址的代码,我们需要升级代码。然而,升级需要遵守特定的规则,这取决于你第一次部署代码时指定的兼容性策略:
Sui 官方文档中的兼容性策略和规则
- 不可变:没有人可以升级该包。
- 仅依赖性:你只能修改包的依赖项。
- 附加性:你可以向包中添加新功能(例如,新公共函数或结构体),但不能更改任何现有功能(例如,不能更改现有公共函数中的代码)。
- 兼容性:最宽松的策略。除了更严格的策略允许的操作外,在包的升级版本中:
- 你可以更改所有函数的实现。
- 你可以删除函数签名中泛型类型参数的能力约束。
- 你可以更改、删除或公开任何私有、包级别访问函数和入口函数的签名。
- 你不能更改公共函数的签名(除了前面提到的能力约束的情况)。
- 你不能更改现有类型(结构体)。
如你所见,在升级现有代码时,你不能更改任何现有的结构体!这意味着你不能添加新属性或更改现有属性的名称。 动态字段是唯一可以动态扩展现有对象/结构体的方法,因此你只需添加新函数或更新现有函数即可实现这一点。
正如之前课程中讨论的那样,一般不建议添加超过 10 个动态字段,因为它们可能会散布在代码中,难以找到。 有一种很好的方法可以解决这个问题,同时仍然可以轻松扩展现有对象——将新添加的属性分组到一个单独的结构体中:
use sui::dynamic_field;
public struct Laptop has key {
id: Id,
}
const EXTENSION_1: u64 = 1;
public struct PurchaseDetails has store {
customer_name: String,
street_address: String,
price: u64,
}
public fun add_purchase_details(laptop: &mut Laptop, customer_name: String, street_address: String, price: u64) {
dynamic_field::add(&mut laptop.id, EXTENSION_1, PurchaseDetails {
customer_name,
street_address,
price,
});
}
我们将现有的 Laptop 结构体扩展为包含 PurchaseDetails,并添加 3 个新属性。这意味着我们只需要添加一个新的动态字段。请注意:
PurchaseDetails 不是一个对象。我们不打算让它独立存在于 Laptop 之外,因此将其设为对象没有意义。 我们使用扩展号作为键。如果我们需要多次扩展 Laptop 对象,这个扩展号会递增。如果需要,也可以使用字符串或其他类型。 我们还可以使用相同的模式来扩展已作为动态对象字段添加到另一个对象上的对象。
use sui::dynamic_object_field;
use sui::dynamic_field;
public struct Laptop has key {
id: Id,
}
public struct Sticker has key, store {
id: Id,
}
const EXTENSION_1: u64 = 1;
public struct StickerPurchaseDetails has store {
customer_name: String,
street_address: String,
price: u64,
}
public fun add_sticker_purchase_details(laptop: &mut Laptop, sticker_name: String, customer_name: String, street_address: String, price: u64) {
let sticker: &mut Sticker = dynamic_object_field::borrow_mut(laptop, sticker_name);
dynamic_field::add(&mut sticker.id, EXTENSION_1, StickerPurchaseDetails {
customer_name,
street_address,
price,
});
}
对象数据结构 - 一袋混合对象
在上一节课中,我们介绍了如何将 SuiFren 对象包装到 GiftBox 对象中。
public struct GiftBox has key {
id: UID,
inner: SuiFren,
}
entry fun wrap_fren(fren: SuiFren, ctx: &mut TxContext) {
let gift_box = GiftBox {
id: object::new(ctx),
inner: fren,
};
transfer::transfer(gift_box, tx_context::sender(ctx));
}
假设我们希望让 GiftBox 变得更加令人惊喜,允许打开它的用户可能会收到 1 到 5 个 Frens。你可能会倾向于创建 5 个 SuiFren 字段:
public struct GiftBox has key {
id: UID,
inner_1: SuiFren,
inner_2: SuiFren,
inner_3: SuiFren,
inner_4: SuiFren,
inner_5: SuiFren,
}
这看起来很乱,而且实际上并不有效!这将迫使 GiftBox 总是包含恰好 5 个 SuiFren。 如果我们还允许 GiftBox 为空,可能不包含任何 SuiFren,该怎么办?更好的方法是使用向量:
public struct GiftBox has key {
id: UID,
frens: vector<SuiFren>,
}
这简洁多了!现在每个 GiftBox 可以包含任意数量的 SuiFren,包括零(空向量)。 但如果我们想让它更有趣一些——每个 GiftBox 也可以包含一些帽子呢?我们可以创建第二个帽子向量 。 但你看到了问题所在。如果我们希望 GiftBox 包含不同类型对象的混合集合, 向量在这里不起作用,因为它只能包含单一类型的对象。更好的方法是使用对象袋(Object Bag)!
use sui::object_bag::{Self, ObjectBag};
public struct MyBag has key {
id: UID,
object_bag: ObjectBag,
}
public fun create_bag(ctx: &mut TxContext) {
transfer::transfer(MyBag {
id: object::new(ctx),
object_bag: object_bag::new(ctx),
}, tx_context::sender(ctx));
}
public fun add_to_bag<SomeObject>(my_bag: &mut MyBag, key: String, object: SomeObject) {
object_bag::add(&mut my_bag.object_bag, key, object);
}
在底层,对象袋(ObjectBag)使用动态对象字段来存储不同类型的对象。
语法与动态字段非常相似——要向袋子中添加对象,你需要一个原始类型的键(例如 u64 类型的索引)或具有 copy
/drop
/store
能力的结构体,
以及一个具有 key
能力(对象结构体)的结构体类型值。
object_bag 模块还提供其他函数,例如 borrow
、borrow_mut
、contains
(类似于 dynamic_field::exists_)、
length
(返回袋子中的对象数量)和 is_empty
。
对象数据结构 - ObjectTable
另一种可以用来存储对象的很酷的数据结构是 ObjectTable。它与 ObjectBag 不同,只允许存储单一类型的对象。 尽管 ObjectTable 更有限,但当用户希望为表中同一类型的不同对象指定特定的键名时,它仍然非常有用。
use sui::object_table::{Self, ObjectTable};
public struct MyObject has key, store {
id: UID,
}
public struct MyTable has key {
id: UID,
table: ObjectTable<String, MyObject>,
}
public fun create_table(ctx: &mut TxContext) {
transfer::transfer(MyTable {
id: object::new(ctx),
table: object_table::new(ctx),
}, tx_context::sender(ctx));
}
public fun add_to_table(my_bag: &mut MyBag, key: String, object: MyObject) {
object_table::add(&mut my_bag.object_bag, key, object);
}
在底层,ObjectTable 也使用动态对象字段,类似于 ObjectBag。在定义对象表字段时,我们还需要指定键和值的确切类型。
ObjectTable 还提供与 ObjectHat 相同的函数:add
、contains
、remove
、borrow
、borrow_mut
、length
和 is_empty
。
对象ID和地址
我们已经学到了很多关于对象的酷技巧。我们想要深入探讨的对象的一个基本方面是对象ID。
你是否想过对象的ID和地址是如何创建的?从技术上讲,对象的ID和地址是相同的——它们是唯一的标识符,可以让开发人员识别和获取对象中的数据。 这在链上(在Move中)和链下(通过Web UI查询对象的所有数据和字段)都是如此:
public fun get_object_id_from_address(object_addr: address): ID {
object::id_from_address(address)
}
public fun get_object_address(object: &MyObject): address {
object::id_to_address(&object.id)
}
请注意,对象ID的返回类型是ID,这与每个对象结构体都有的UID字段不同。
不过,你可以使用object::uid_to_inner
将UID引用转换为ID。这允许你比较对象是否与存储的地址相同。
当对象被创建时,其ID字段通过object::new(ctx)
生成,其中ctx是Sui虚拟机(VM)传递给交易的TxContext类型的可变引用。以下是代码示例:
public fun new(ctx: &mut TxContext): UID {
UID {
id: ID { bytes: tx_context::fresh_object_address(ctx) },
}
}
如你所见,在幕后,首先通过 tx_context::fresh_object_address(ctx)
生成一个唯一的地址,
然后转换为字节以创建对象的ID。fresh_object_address
是 Move 中的一种特殊类型的函数——原生函数。
原生函数非常特殊,因为它们的实现是用 Rust 编写的,作为 Move VM 的一部分。
这使得函数运行得更快,并且可以访问 VM 的内部结构。在这种情况下,fresh_object_address
可以看到用户的交易负载并使用它来生成对象的特殊地址。
它还使用一个计数器来跟踪在同一交易中创建的对象数量。然后,fresh_object_address
对交易负载和计数器进行哈希处理,并确保结果在同一交易中创建的多个对象中是唯一的。
地址生成过程的一个有趣用例是潜在地将其用作随机性来源——从新创建的对象的ID生成的字节可以用作随机值。这通常是因为交易负载和计数器的哈希值看起来往往是随机的(不遵循任何模式):
use sui::bcs;
public fun get_random_value(ctx: &mut TxContext): u64 {
let object_id = object::new(ctx);
let bytes = object::uid_to_bytes(&object_id);
let random_number = bcs::peel_u64(&mut bcs::new(bytes));
object::delete(object_id);
random_number
}
get_random_value
可以多次调用以创建伪随机数(看似随机)。这取决于使用场景,可以通常用作随机值的来源,但它可能不安全,因为用户可能会操纵它。他们可以更改交易中的到期时间戳和其他字段,以生成所需的对象ID和结果随机数(例如,为了赢得链上彩票)。包括时间戳(来自系统对象Clock)可能更安全,但仍然存在风险:
- 用户仍然可以在一定程度上操纵随机值(不像以前那么多,因为他们无法控制Clock对象)。
- 验证者也可以部分操纵随机值,因为他们可以将Clock对象的时间戳设置为特定值,在一个小范围的误差内。
我们将在后续课程中介绍更安全的随机值生成方法。
对象设计陷阱:太多对象
类似于之前讨论的关于太多合约状态对象的问题,一些开发人员常见的陷阱是创建过多的对象。 例如,对于笔记本电脑的情况,从技术上讲,你可以为每个笔记本电脑组件(如屏幕、键盘、硬盘等)创建一个单独的对象:
public struct Laptop has key {
id: UID,
screen: Screen,
keyboard: Keyboard,
hard_drive: HardDrive,
}
public struct Screen has key, store {
id: UID,
}
public struct Keyboard has key, store {
id: UID,
}
public struct HardDrive has key, store {
id: UID,
}
正如你所见,每个组件都是一个独立的对象。这是不必要的,因为屏幕、键盘和硬盘对象总是被包装在笔记本电脑对象中。 它们从未作为独立对象分离出来。只有当我们有意将键盘作为一个独立组件销售并可以集成到另一台笔记本电脑中时,这种设计才有意义。 如果我们经营的是一家硬件商店,这可能是需要的,但我们这里只是一个笔记本电脑商店。
Move 模块创建过多对象的其他问题:
- 如果一个交易创建和更改了太多对象,就很难理解。对于在探索器界面上查看这些交易的用户来说,他们看到的只是一个非常长的更新或创建的所有对象的列表, 以及它们所有关联的字段/数据。如果列表中有 10 多个对象,这可能会让人不知所措。
- 如果用户需要与这些对象中的多个对象交互,构建交易会变得复杂,因为界面需要找到并传递所有相关对象的地址。
- 除了使用类似于我们在管理状态对象的课程中介绍的动态字段外,如果开发人员确实想创建许多不同类型的对象,他们还可以使用之前介绍的数据结构, 如 ObjectBag 和 ObjectTable。这些数据结构可以将许多对象收集到一个易于理解和传递的容器对象中。
总结
不同的对象组合方法
-
对象包装
- 将对象存储在另一个对象中(例如,将 SuiFren 存储在 GiftBox 中)。这会将被包装的对象(SuiFren)从全局存储中移除。链外的 Web 界面在包装后无法查找它们。对象不再有任何所有者。你可以将其视为将对象转换为普通的非对象结构体实例。
-
动态字段
- 可以理解为未在对象结构体中显式定义的隐藏字段。
- 也可以用于存储对象。这也会将对象从全局存储中移除,所有权也会被移除。这与对象包装非常相似,只不过字段是动态添加的,而不是在结构体中显式定义的。
- 要向对象添加动态字段,你需要该对象的可变引用。可以通过 dynamic_field::add 方法,使用可变对象的 id、键和值来添加动态字段。
- 要读取或修改动态字段,你需要对象的不可变引用和可变引用以及字段名(键)。如果字段名是一个结构体,你需要该结构体的值,这就是为什么用作键的结构体必须具有 copy 和 drop 能力的原因。
- 动态对象字段比动态字段更适合用于动态存储对象。只有在有意想要将对象从全局存储中移除时,才应使用动态字段。
-
动态对象字段
- 不会将对象从全局存储中移除。所有权会转移到一个特殊的“动态字段对象”,对于 Web 界面来说通常不容易查找。在大多数情况下,这几乎与放弃对象的所有权有相同的效果。
-
对象拥有其他对象
- 完美的用例是可组合的NFT。这允许将多个NFT强有力地组合在一起,使其对用户而言自然合理,这在使用NFT的 Web 3 游戏中经常见到。
- 适用于复杂应用设计中有明确对象层次结构的情况,其中包含多种类型的对象。
- 其缺点是以后很难移除被拥有的对象。因此,如果关系很少或从未改变,对象拥有对象通常更有用。
有趣的对象数据结构:
- 对象袋(Object Bag):一种多功能的数据结构,用于存储不同类型对象的混合集合——适用于需要灵活和多样化对象组合的应用,如随机或组合NFT。
- 对象表(Object Table):一种用于存储单一对象类型集合的数据结构,并具有特定的键名——对需要结构化和命名相似对象集合的应用有益。
SUI 标准
本课程深入探讨了 Sui 上的标准,如 NFTs 和 Coins,Sui 的 ERC-20 和 ERC-1155 等效标准,并向学习者展示如何将它们结合到应用程序中。课程讨论了管理 NFTs 的不同设计模式,从手动(基于创作者)到自动化(基于共享对象),并通过实践练习来加深理解和直觉。
本课程将帮助新开发者和那些已经熟悉其他网络上类似概念的开发者快速掌握 Sui 上的独特概念,以及如何构建丰富且灵活的应用程序。这也为学习者奠定了基础,使他们能够进一步深入学习涵盖完整 e2e 开发的课程,并了解如何将其他链(Solidity/EVM,Sui/Move)上的完整应用程序迁移到 Sui 上,并构建类似但更简单的应用程序,这要归功于对象模型的可表达性和简单性。
介绍代币、Coin协议 和货币
在之前的课程中,我们已经涵盖了开发人员在 Move 中入门所需的大部分基本概念。在本课中,我们将深入探讨一个更实际的话题,即区块链中两种最常见的核心构建块之一——代币。
代币在加密货币世界中起着关键作用,作为价值或资产的数字表示。它们类似于数字证书,授予对各种资产的所有权或访问权,无论是有形资产还是虚拟资产, 所有这些都安全地记录在区块链上。以太坊区块链上的一个著名标准是 ERC-20。
代币
这些数字资产仅以电子形式存在于区块链上,即去中心化的数字账本。 它们代表有价值的东西,无论是加密货币、公司股份、去中心化组织的投票权,甚至是游戏中的虚拟收藏品。 代币可以在区块链上转移或交易,其所有权和交易透明地记录在案。由于智能合约的存在,一些代币可以自动化诸如红利分配或特定生态系统内的服务访问等流程 。首次代币发行(ICOs)通常使用代币作为筹资手段,投资者购买代币期望其未来价值的升值。
Coin协议
ERC-20 标准是关于如何实现代币的第一个规范之一,定义了一组代币在以太坊区块链上必须遵守的接口函数。 这些函数提供了与代币交互的通用框架,并确保不同应用程序和钱包之间的互操作性。以下是最重要的 ERC-20 接口函数的摘要:
totalSupply()
: 该函数返回流通中的代币总供应量。balanceOf(address _owner)
: 它允许你检查特定以太坊地址拥有的代币余额。transfer(address _to, uint256 _value)
: 该函数使得从发送者地址向另一个地址转移指定数量的代币成为可能。
尽管 ERC-20 标准易于实现,但所有开发人员都需要提供自己的实现方式,并且最终会一次又一次地编写几乎相同的代码。 这是由于以太坊网络(以及许多其他 EVM 网络)的限制,那里没有核心智能合约可以作为开发人员使用的库,类似于许多语言(如 Java)中的核心库。
Move 通过在 0x2
直接定义 Coin
标准解决了这个问题,使开发人员可以直接定义和管理代币,而不必每次都重新编写实现。Sui 网络上的代币称为 Coin
。
另一个 Coin 设计中的关键设计原则是反映现实世界中货币的自然设计。 例如,当某人收到 $1 时,他们可以将这 $1 放进口袋并稍后取出。这与 EVM 链中的代币余额情况不同,在 EVM 链中,所有余额都记录在定义 USDC 代币(与美元挂钩的代币)的智能合约中。 这种集中式余额设计更类似于银行存款系统,所有余额仅在银行系统中定义。这给新接触加密货币的用户带来了很多困惑, 因为他们认为自己的钱包(例如硬件钱包)实际上持有各种代币。
在 Move 中,Coin 更自然且易于理解——当用户收到 Coins 时,这些 Coins 实际上存储在属于该用户的对象中(可以被认为是钱包)。 用户可以稍后轻松地从该对象中取出 Coins 并随意使用它们。
SuiFren Candy - 创建一种新的 Coin 类型
在 Move 中,开发人员只需调用一个模块(智能合约)来创建和管理他们的代币。为了区分不同开发人员创建的不同类型的代币,Coin
使用了泛型(类型参数):
/// 获取代币余额的不可变引用。
public fun balance<T>(coin: &Coin<T>): &Balance<T> {
&coin.balance
}
上述函数用于检查用户所拥有的代币钱包对象的余额。注意,函数名称末尾有一个
module my_coin::my_coin {
public struct MYCOIN has drop {}
}
这类似于 SUI 代币的定义方式。
然后,开发人员可以通过调用 coin::create_currency
创建新的代币,通常作为 init 函数的一部分,
因为你需要一个 Coin 类型的一次性见证(otw)对象(在这种情况下为 MYCOIN):
use std::string;
use sui::url;
fun init(otw: MYCOIN, ctx: &mut TxContext) {
let (treasury_cap, metadata) = coin::create_currency(
otw,
9,
b"MYC",
b"MyCoin",
b"My Coin description",
option::some(url::new_unsafe(string::utf8(b"https://mycoin.com/logo.png"))),
ctx,
);
transfer::public_freeze_object(metadata);
transfer::public_transfer(treasury_cap, tx_context::sender(ctx));
}
coin::create_currency
返回一个元数据对象,用于存储关于代币的信息:符号(Coin 将显示的缩写)、
名称、描述和 logo URL。这允许链外组件(如 Web UI)查找并显示这些信息。开发人员可以选择冻结元数据对象,
这样名称/符号等就不能再更改,或者保留它的所有权并转移到一个账户以供以后管理(更多内容将在后续课程中介绍)。
coin::create_currency
还返回一个 TreasuryCap 对象,可用于管理代币。我们将在后续课程中详细讨论这个内容。
现在代币已经创建,开发人员可以在调用代币函数时使用 NYCOIN 作为 Coin 类型参数,例如:
public fun my_coin_balance(coin: &Coin<MYCOIN>): &Balance<MYCOIN> {
// <MYCOIN> is technically not required here as the type can be inferred.
// It's just included explicitly for demonstration purposes.
coin::balance<MYCOIN>(coin)
}
使用 TreasuryCap 对象铸造 SuiFren Candy
在上一课中,我们创建了我们的第一个代币,并将 TreasuryCap 对象暂时转移给发送者(模块的部署者)。通过这个 TreasuryCap,该账户现在可以铸造 MYCOIN 代币:
use std::string;
use sui::url;
fun init(otw: MYCOIN, ctx: &mut TxContext) {
let (treasury_cap, metadata) = coin::create_currency(
otw,
9,
b"MYC",
b"MyCoin",
b"My Coin description",
option::some(url::new_unsafe(string::utf8(b"https://mycoin.com/logo.png"))),
ctx,
);
transfer::public_freeze_object(metadata);
transfer::public_transfer(treasury_cap, tx_context::sender(ctx));
}
entry fun mint(treasury_cap: &mut TreasuryCap<MYCOIN>, ctx: &mut TxContext) {
let coins = coin::mint(treasury_cap, 1000, ctx);
// Do something with the coins
}
有四个重要的点需要指出:
coin::mint
创建一个新的 Coin(钱包)对象。这意味着用户其他钱包中的现有余额不会改变。- 如果你记得,归属对象在作为参数传递给交易时会进行验证,并且只有它们的所有者才能这样做。在这种情况下,只有拥有 TreasuryCap
的账户可以调用 mint。 - TreasuryCap 也有一个类型参数(MYCOIN)。这指定了国库上限管理的代币类型。
coin::mint
不需要指定 MyCoin 作为类型参数,因为编译器可以从 treasury_cap(类型为 TreasuryCap)中推断出来。
还需要注意的是,TreasuryCap 的类型是一个完全限定的类型名——例如,如果我们的模块地址是 0x123
,那么它的类型是 0x123::my_coin::MYCOIN
。这意味着如果其他人在他们的模块中创建了一个名为 MYCOIN 的结构体,即使结构体名称相同,也会被视为完全不同的代币。除了 coin::mint
,开发人员还可以使用 coin::mint_and_transfer
直接铸造并转移到指定账户。
另一种常见的模式是在 init 函数中铸造初始分配的代币:
use std::string;
use sui::url;
fun init(otw: MYCOIN, ctx: &mut TxContext) {
let (treasury_cap, metadata) = coin::create_currency(
otw,
9,
b"MYC",
b"MyCoin",
b"My Coin description",
option::some(url::new_unsafe(string::utf8(b"https://mycoin.com/logo.png"))),
ctx,
);
coin::mint_and_transfer(treasury_cap, 1000000, tx_context::sender(ctx), ctx);
transfer::public_freeze_object(metadata);
transfer::public_transfer(treasury_cap, tx_context::sender(ctx));
}
这允许开发人员创建初始数量的代币以供流通。他们可以选择实现一个铸币函数,以便以后创建更多的代币。
编程化代币铸造
我们已经学习了如何使用生成的 TreasuryCap
如果你查看 TreasuryCap 对象结构体的定义,它具有 store
能力:
/// 允许持有者铸造和销毁类型为 `T` 的代币的能力。可转让。
public struct TreasuryCap<phantom T> has key, store {
id: UID,
total_supply: Supply<T>
}
这意味着,它可以存储在其他结构体和对象中!因此,解决方案是将其包装在一个任何人都可以访问并提供给铸币函数作为参数的共享对象中:
use std::string;
use sui::url;
public struct MYCOIN has drop {}
public struct TreasuryCapHolder has key {
id: UID,
treasury_cap: TreasuryCap<MYCOIN>,
}
fun init(otw: MYCOIN, ctx: &mut TxContext) {
let (treasury_cap, metadata) = coin::create_currency(
otw,
9,
b"MYC",
b"MyCoin",
b"My Coin description",
option::some(url::new_unsafe(string::utf8(b"https://mycoin.com/logo.png"))),
ctx,
);
transfer::public_freeze_object(metadata);
let treasury_cap_holder = TreasuryCapHolder {
id: object::new(ctx),
treasury_cap,
};
transfer::share_object(treasury_cap_holder);
}
entry fun mint(treasury_cap_holder: &mut TreasuryCapHolder, ctx: &mut TxContext) {
let treasury_cap = &mut treasury_cap_holder.treasury_cap;
let coins = coin::mint(treasury_cap, 1000, ctx);
// 用coin做点什么
}
在上述示例中,我们将 TreasuryCap&mut TreasuryCapHolder
传递给铸币函数,自由地铸造 MYCOIN 代币。实际上,开发人员应该添加一些限制,例如总共可以铸造多少代币、每个用户可以铸造多少代币、基于白名单的控制等。
如在对象课程中讨论的那样,除了将 TreasuryCap 对象包装到另一个持有对象中之外,我们还可以利用动态字段、动态对象字段甚至对象所有权来保留 TreasuryCap。然而,包装通常是首选,因为在访问时它更简单。
代币与余额 - take、put、transfer、zero、destroy_zero
新开发人员在 Sui 中对 Coin 常见的困惑之一是,有一个看起来相似的 Balance 对象:
public struct Coin<phantom T> has key, store {
id: UID,
balance: Balance<T>
}
/// 可存储的余额 - Coin 类型的内部结构体。
/// 可用于存储不需要 key 能力的代币。
public struct Balance<phantom T> has store {
value: u64
}
Coin 和 Balance 的区别究竟是什么,什么时候应该使用其中一个?最佳对比如下:
- Coin 对象更像一个钱包。Coin 钱包有一个内部余额,现金(Balance)可以从中取出并存入另一个 Coin 钱包对象中。
- Balance 就像现金。它不能单独存储,需要放入钱包或口袋。开发人员可以选择创建自己的“口袋”对象来存储 Balance。
从技术上讲,由于 Coin 对象也具有 store 能力,开发人员可以将整个 Coin 钱包放入他们自定义的对象中。 然而,这样做相当奇怪,在这种情况下应该使用 Balance,因为 Coin 已经像一个容器。 此外,将 Coin 钱包对象包装到另一个对象中实际上会将其从全局对象存储中移除,正如我们在对象课程中讨论的那样。这通常是不希望的,因为这会使钱包“消失”。
entry fun transfer_coins(from_wallet: &mut Coin<MYCOIN>, amount: u64, to_wallet: &mut Coin<MYCOIN>, ctx: &mut TxContext) {
let cash = coin::take(coin::balance_mut(from_wallet), amount, ctx);
coin::put(coin::balance_mut(to_wallet), cash);
}
在上述示例中,我们从 from_wallet Coin
对象中取出一些代币并存入 to_wallet Coin
对象中。注意,只有 from_wallet
和 to_wallet
的所有者可以调用 transfer_coins
。我们需要使用 coin::balance_mut
来获取 Coin 对象的内部余额,因为结构体字段在定义模块(在这种情况下为 coin)之外是不可见的。
Sui 中的 Coin 模块不提供直接从一个 Coin 对象转移到另一个 Coin 对象的功能。开发人员需要手动使用 take
和 put
来完成转移。
Coin 模块中的一些其他函数是:
balance(&Coin)
: 返回 Coin 对象的余额。zero()
: 创建一个余额为零的 Coin 对象(空钱包)。destroy_zero()
: 销毁一个空钱包。一个有非零余额的钱包不能被销毁,必须先将其中的代币转移到其他地方。
更多 Balance 函数
定义 Balance 对象的 sui::balance 模块还提供了一些值得注意的函数:
value
返回 Balance 对象中的代币数量:
/// 获取储存在`Balance`里的值.
public fun value<T>(self: &Balance<T>): u64 {
self.value
}
join
接受一个可变的 Balance 和另一个 Balance,并将第二个 Balance 添加到第一个 Balance 中:
/// 结合两个Balance.
public fun join<T>(self: &mut Balance<T>, balance: Balance<T>): u64 {
let Balance { value } = balance;
self.value = self.value + value;
self.value
}
split
从一个可变的 Balance 中提取一定数量的代币,并创建第二个 Balance:
/// 拆分一个 `Balance` 并从中获取一个子余额。
public fun split<T>(self: &mut Balance<T>, value: u64): Balance<T> {
assert!(self.value >= value, ENotEnough);
self.value = self.value - value;
Balance { value }
}
withdraw_all
类似于split
,但会提取所有可变余额中的代币,将其清空:
/// 提取所有余额. 之后余额将为0.
public fun withdraw_all<T>(self: &mut Balance<T>): Balance<T> {
let value = self.value;
split(self, value)
}
通过这些不同的函数,开发人员可以在从用户拥有的 Coin 对象中取出余额后,进行更复杂的操作。 余额可以拆分和合并,将一定数量的代币分成更小的部分并发送到不同的钱包中。这在处理费用时非常常见,例如:
/// 提取所有余额. 之后余额将为0.
public fun trade(wallet: &mut Coin<Sui>, amount: u64) {
let coins_to_trade = balance::split(coin::balance_mut(wallet), amount);
// 1% 手续费.
let fees = balance::split(&mut coins_to_trade, amount / 100);
// 将手续费存到某个地方然后继续交易
}
销毁代币
在前面的课程中,我们讨论了如何使用 TreasuryCap 铸造代币。
use std::string;
use sui::url;
public struct MYCOIN has drop {}
public struct TreasuryCapHolder has key {
id: UID,
treasury_cap: TreasuryCap<MYCOIN>,
}
entry fun mint(treasury_cap_holder: &mut TreasuryCapHolder, ctx: &mut TxContext) {
let treasury_cap = &mut TreasuryCapHolder.treasury_cap;
let coins = coin::mint(treasury_cap, 1000, ctx);
// 用coin做点什么
}
我们也可以使用 TreasuryCap 来销毁代币:
entry fun burn(treasury_cap_holder: &mut TreasuryCapHolder, coins: Coin<MYCOIN>) {
let treasury_cap = &mut TreasuryCapHolder.treasury_cap;
coin::burn(treasury_cap, coins);
}
coin::burn
将销毁给定 Coin 对象中的所有代币,并且还会销毁 Coin 对象本身。这相当于销毁整个钱包。
如果开发人员只想从 Coin 对象中销毁特定数量的代币,他们需要先提取出该数量的代币,然后只销毁这些特定的代币:
entry fun burn(treasury_cap_holder: &mut TreasuryCapHolder, coins: &mut Coin<MYCOIN>, amount: u64, ctx: &mut TxContext) {
let coins_to_burn = coin::take(coin::balance_mut(coins), amount);
let treasury_cap = &mut TreasuryCapHolder.treasury_cap;
coin::burn(treasury_cap, coins_to_burn);
}
更新 Coin 元数据
当我们之前创建 MyCoin 时,我们冻结了返回的元数据对象。这将不允许将来对元数据(小数位数/符号/名称/描述/logo URL)进行任何更改。
说到小数位数,我们从未真正解释过它的作用。小数位数通常用于代币/代币,以减少舍入误差。 大多数智能合约语言,包括 Move,都没有分数,所有数学运算都是基于整数的。这意味着在 Move 中,5 / 2 = 2,导致舍入误差为1。 如果没有小数位数,人们将会损失大量资金。加密货币中通常使用至少6位小数,有时可以高达18位(1个代币 = 10^18单位)。 在大多数情况下,9位小数足以使舍入误差小到用户可以忽略不计。
回到元数据对象,如果你认为将来可能会想要更改代币的元数据,那么你不应该冻结它,而应该将其转移到一个管理员账户进行保管。 将来,你可以利用 coin 模块中的不同函数来更新你想要的元数据:
/// 更新`CoinMetadata`里coin的名字
public entry fun update_name<T>(
_treasury: &TreasuryCap<T>, metadata: &mut CoinMetadata<T>, name: string::String
) {
metadata.name = name;
}
/// 更新`CoinMetadata`里coin的符号
public entry fun update_symbol<T>(
_treasury: &TreasuryCap<T>, metadata: &mut CoinMetadata<T>, symbol: ascii::String
) {
metadata.symbol = symbol;
}
/// 更新`CoinMetadata`里coin的描述
public entry fun update_description<T>(
_treasury: &TreasuryCap<T>, metadata: &mut CoinMetadata<T>, description: string::String
) {
metadata.description = description;
}
/// 更新`CoinMetadata`里coin的链接
public entry fun update_icon_url<T>(
_treasury: &TreasuryCap<T>, metadata: &mut CoinMetadata<T>, url: ascii::String
) {
metadata.icon_url = option::some(url::new_unsafe(url));
}
请注意,小数位数(decimals)是一个特殊情况,没有相应的更新函数。这是因为小数位数是代币的一个基本属性,如果被更新,会改变每个人的余额。 因此,为了安全性和简便性,Sui 的 Coin 标准不允许修改小数位数。
要调用更新函数,例如 coin::update_symbol
,调用者需要访问 TreasuryCap 和 metadata 对象。
请注意,TreasuryCap 和 Metadata 结构体都有 store 能力,因此我们可以将它们存储在某个地方,以便以后可以进行编程访问和修改:
use std::string;
use sui::url;
public struct MYCOIN has drop {}
public struct CoinDataHolder has key {
id: UID,
treasury_cap: TreasuryCap<MYCOIN>,
metadata: CoinMetadata<MYCOIN>,
}
fun init(otw: MYCOIN, ctx: &mut TxContext) {
let (treasury_cap, metadata) = coin::create_currency(
otw,
9,
b"MYC",
b"MyCoin",
b"My Coin description",
option::some(url::new_unsafe(string::utf8(b"https://mycoin.com/logo.png"))),
ctx,
);
let treasury_cap_holder = TreasuryCapHolder {
id: object::new(ctx),
treasury_cap,
metadata,
};
transfer::share_object(treasury_cap_holder);
}
entry fun update_symbol(holder: &mut CoinDataHolder, new_symbol: String) {
let metadata = &mut holder.metadata;
let treasury_cap = &holder.treasury_cap;
coin::update_symbol(treasury_cap, metadata, new_symbol);
}
在上面的例子中,我们将 TreasuryCap 和 Metadata 对象都包装在一个共享对象中,以便以后任何人都可以访问该对象来更新代币的元数据。 在实际应用中,开发人员可以添加逻辑,要求发送者是特定的管理员地址,以确保用户不会随意更新代币元数据。
管理多个 Coin 对象 - join和split
想象一下,每次你与应用程序互动时,它都会创建一个新的 Coin 对象来存储退款或付款到你的账户。 在一天的互动之后,你最终会有100个不同的 Coin 对象。现在你的代币被分成100个不同的部分,你不再有足够的代币进行更多的互动了。你现在该怎么办?
有两个解决方案:
- 应用程序和开发人员尽可能避免创建新的代币,除非必须这样做。他们应该尝试从用户那里取出一个现有的 Coin,将退款/付款合并到其中。
- 用户可以将代币合并在一起,作为他们 PTB(可编程交易块)的最后一步。
在 Move 中,代币也可以很容易地使用 coin::split
和 coin::join
进行拆分和合并。
public fun trade(input_coins: &mut Coin<SUI>) {
let refund_coins - ...;
coin::join(input_coins, refund_coins);
}
代币也可以被拆分。然而,请记住,创建的代币要么必须合并到其他代币中,要么需要发送到一个账户。这是因为 Coin 没有 drop 能力,不能在函数结束时被丢弃。
public fun split_and_send(input_coins: &mut Coin<SUI>, ctx: &TxContext) {
let refund_coins = coin::split(input_coins, 1000);
transfer::public_transfer(refund_coins, tx_context::sender(ctx));
}
你可能还注意到,这与我们在前面课程中学习到的 balance::split
和 balance::join
很相似。
除了这些函数处理的类型不同——Coin 和 Balance 之外,它们在 Move 中的行为几乎是相同的。
然而,coin 的函数更多地用于 PTB(可编程交易块),因为应用程序通常返回 Coin 而不是 Balance,而 balance::split
和 balance::join
更多地用作 Move 流程的一部分。
糖果空投 - 一个简单的代币空投模块
我们在前面的课程中已经讨论了许多关于代币的有用概念:
- 使用
coin::create_currency
创建新代币会返回用于铸造、销毁和更新元数据的 TreasuryCap 和元数据对象。 - 代币像钱包一样,可以拆分和合并。余额像现金,可以从一个代币转移到另一个代币。
- TreasuryCap 和元数据对象都可以存储起来进行程序化访问,而不需要单个拥有账户反复签署交易。
在自定义对象中存储 Balance 和 Coin
如前几节课讨论的那样,Coin 和 Store 对象都具有 store
能力,可以嵌入到其他结构体中(在 Coin 的情况下,因为它是一个对象结构体,所以是“包装”)。
public struct MyObjectWithBalance has key {
id: UID,
balance: Balance<MYCOIN>,
}
public struct MyObjectWithCoin has key {
id: UID,
coins: Coin<MYCOIN>,
}
正如前一课所讨论的那样,更常见的是存储 Balance 而不是 Coin。但为什么有人会在自定义结构体中存储 Balance 呢? 这种结构最常见的原因是代币由智能合约或模块程序化地拥有。 例如,用户可以建立一个市场,用户可以列出他们自己的代币以与其他代币进行交易。在这种情况下,当买家出现时,我们不希望卖家也必须签署购买交易。 如果买家只需签署并交易自动完成——他们收到他们购买的代币,而支付的代币从他们的钱包(Coin 对象)中取出,这会更顺畅。
public struct Listing<phantom CoinType> has key {
id: UID,
seller: address,
listed_coins: Balance<CoinType>,
amount_asked: u64,
}
public fun buy_coins<CoinType>(listing: Listing<CoinType>, payment: Coin<SUI>): Balance<CoinType> {
let Listing<CoinType> { id, seller, listed_coins, amount_asked } = listing;
object::delete(id);
assert!(coin::value(&payment) == amount_asked, EINSUFFICIENT_PAYMENT);
transfer::public_transfer(payment, seller);
listed_coins
}
在上述示例中,卖家可以创建一个列表,将他们想要出售的代币直接作为共享对象包含在内。列出的代币包含在列表对象中。
一旦买家带着付款出现,列表可以被销毁以返回内部列出的代币作为 Balance<CoinType>
。
在 PTB(可编程交易块)中,卖家可以选择将 Balance<CoinType>
合并到他们拥有的任何同类型的 Coin 对象中。
请注意,CoinType
之前有一个 phantom
关键字。这是必需的,因为 Listing
的字段中没有一个直接是 CoinType
类型。
我们看到的是 Balance<CoinType>
,但 CoinType
在这里用作泛型而不是直接类型。简而言之,如果类型仅在一个或多个字段中用作泛型,则结构体需要使用 phantom
关键字。
还需注意的是,我们在这里使用泛型 <CoinType>
,因此该系统可以适用于任何代币类型!
SUI 代币与支付 gas 费用及赞助交易
我们在前面的课程中只简要介绍了 Coin<SUI>
,但没有深入探讨。在本课中,我们将探索 SUI 代币的作用。
简而言之,SUI 在 Sui 网络上的主要用途是支付交易的“gas”费用和作为货币。
SUI 代币的 TreasuryCap
保存在 Sui 框架内,用于作为其代币经济的一部分向网络发放奖励。没有任何用户或账户可以访问 TreasuryCap
,
而铸造/销毁 SUI 代币由 Sui 网络通过部署在 0x3 的“系统”智能合约管理。
Gas
在区块链领域,gas 是指在网络上执行操作(如交易或智能合约)所需的费用。 这是一个至关重要的概念,因为它确保了资源的有效分配,并通过附加成本来帮助优先处理活动。 用户支付 gas 费用以补偿执行操作所需的计算资源,矿工优先处理 gas 费用较高的交易。这一机制维护了区块链的可靠性和安全性。
区块链中交易的 gas 费用与 Web 2.0(集中式)世界中云基础设施成本的处理方式有显著不同。以下是主要区别:
- 去中心化 vs. 中心化:区块链以去中心化方式运行,没有单一实体或公司控制网络。 相反,Web 2.0 服务通常依赖于由 Amazon Web Services (AWS)、Google Cloud 或 Microsoft Azure 等公司提供的集中式云基础设施。
- 用户支付 vs. 公司支付:在区块链中,用户在执行操作(如进行交易或与智能合约交互)时直接支付 gas 费用。 这些费用确保用户承担其活动的成本。在 Web 2.0 中,公司通常承担其云基础设施的费用,用户在大多数操作中无需直接支付费用。
- 激励:区块链中的 gas 费用作为激励,促使矿工(或验证者)处理交易并维护网络的安全性和完整性。 在 Web 2.0 中,云基础设施成本通常由公司作为运营费用的一部分承担,用户或第三方没有直接的财务激励参与基础设施维护。
- 透明度和控制:区块链的 gas 费用是透明的,用户可以根据交易的紧急程度调整 gas 价格,以优先实现更快的确认时间或降低成本。 在 Web 2.0 中,云基础设施成本通常对终端用户隐藏,用户对基础设施的底层控制有限。
- 资源分配:区块链中的 gas 费用通过确保消耗更多资源的用户支付更高费用来公平和有效地分配计算资源。 在 Web 2.0 中,资源分配通常由集中式云提供商管理,用户无法直接看到或控制资源分配决策。
总之,区块链中的 gas 代表了一种以用户为中心的、承担成本的机制,保持了区块链网络的去中心化和透明性。 相比之下,Web 2.0 依赖于集中式云基础设施,由公司承担成本,用户对底层基础设施的控制和透明度较低。
SUI 代币的其他用途
除了 gas 费用,网络代币(如 Sui 区块链上的 SUI)还可以作为一种货币,因为它可能是流动性(资金量)最高的货币。这意味着它可以是:
- 用于在其他应用程序特定的代币或代币之间进行交换的中间价值。
- 用户持有其资金的价值。
- 通过质押(锁定)Sui 并运行验证节点来参与保护 Sui 网络。
SUI 的总供应量限制在 10,000,000,000(百亿)代币。在主网上线时,部分 SUI 总供应量成为流动性,剩余代币将在未来几年内解锁,或作为未来质押奖励的补贴分配。
支付 gas 费用
在 Sui 网络上发送交易时,用户必须指定支付 gas 费用的 Coin 对象。我们讨论了用户如何拥有多个 Coin 对象,
并可以结合使用 coin::split
和 coin::join
以及 transfer::public_transfer
来管理它们。在 gas 的上下文中,
用户还可以使用 pay::split
,它结合了 coin::split
和 transfer::public_transfer
,以方便用户使用:
/// 将代币 `self` 拆分成两个代币,其中一个的余额为 `split_amount`,剩余的余额保留在 `self` 中。
public entry fun split<T>(
self: &mut Coin<T>, split_amount: u64, ctx: &mut TxContext
) {
keep(coin::split(self, split_amount, ctx), ctx)
}
当 UI 在用户的操作下构建交易时,可以结合使用以下方法来有效管理他们的 SUI 余额以支付 gas 费用:
- 如果用户的 SUI 资金分散在多个 Coin 对象中,并且没有单个对象有足够的余额支付 gas 费用,他们可以将这些对象合并起来。
- 如果用户想要明确指定一个 Coin 对象作为他们的 gas 资金,他们可以从大部分资金所在的地方拆分出一个 Coin 对象。
赞助交易
Sui 赞助交易是指由一个 Sui 地址(赞助者的地址)支付另一个地址(用户的地址)初始化的交易的 gas 费用。 你可以使用赞助交易来为你的网站或应用程序上的用户支付费用,这样他们就不会被收取这些费用。 这消除了 Web 2.0 用户进入 Web3 时遇到的一个重大障碍,因为他们通常需要购买代币才能在链上进行交易。例如,你可以赞助玩家的早期交易以提高转换率。
更多详情可以在这里找到:https://docs.sui.io/concepts/transactions/sponsored-transactions。
高级:处理多种代币类型
在前一课中,我们实现了一个简单的代币市场,卖家列出他们想要出售的代币,买家可以使用 SUI 自动从列表中购买,而无需卖家参与或签署交易。
public struct Listing<phantom CoinType> has key {
id: UID,
seller: address,
listed_coins: Balance<CoinType>,
amount_asked: u64,
}
public fun buy_coins<CoinType>(listing: Listing<CoinType>, payment: Coin<SUI>): Balance<CoinType> {
let Listing<CoinType> { id, seller, listed_coins, amount_asked } = listing;
object::delete(id);
assert!(coin::value(&payment) == amount_asked, EINSUFFICIENT_PAYMENT);
transfer::public_transfer(payment, seller);
listed_coins
}
如果我们希望卖家指定他们接受哪种代币作为支付,而不是总是要求使用 Sui,该怎么办? 这意味着我们必须将两种不同类型的代币作为类型参数用于 Listing 对象和 buy_coins 函数:
public struct Listing<phantom ListedCoin, phantom PaymentCoin> has key {
id: UID,
seller: address,
listed_coins: Balance<ListedCoin>,
amount_asked: u64,
}
public fun buy_coins<ListedCoin, PaymentCoin>(listing: Listing<ListedCoin, PaymentCoin>, payment: Coin<PaymentCoin>): Balance<CoinType> {
let Listing<ListedCoin, PaymentCoin> { id, seller, listed_coins, amount_asked } = listing;
object::delete(id);
assert!(coin::value(&payment) == amount_asked, EINSUFFICIENT_PAYMENT);
transfer::public_transfer(payment, seller);
listed_coins
}
我们做了以下更改:
- Listing 现在有两种单独的(虚拟)类型。我们可以为类型命名,这里我们使名称非常明确,以免混淆类型——ListedCoin 和 PaymentCoin。
- buy_coins 现在也接受两种代币类型。支付现在必须是
Coin<PaymentCoin>
类型,而不是Coin<SUI>
。 - buy_coins 内部的逻辑变化不大。我们不需要明确验证支付是否为正确的类型,因为支付代币的类型已经在
Listing<ListedCoin, PaymentCoin>
中直接指定。买家必须提供正确的支付代币,否则他们根本无法调用此函数! - 类型正确性由 Move 编译器和 Sui VM 在执行交易时保证。
在非常复杂的情况下,例如允许在多种不同代币类型之间进行 3 方或 4 方交易的交易所,您可能会看到非常长的函数,例如:
public fun buy_coins<Coin1, Coin2, Coin3, Coin4>(...) {
}
CoinTypes 必须始终显式声明为类型参数。如果我们希望使用上面的函数来支持 1 种、2 种、3 种或 4 种类型的交易,这可能会导致一些复杂性,因为用户在调用此函数时不能将缺失的类型留空。为了解决这个问题,可以定义一个“Null”代币类型,用户在不需要时可以传递这个类型:
use std::type_name;
public struct Null has drop {}
public fun buy_coins<Coin1, Coin2, Coin3, Coin4>(...) {
if (type_name::into_string<Coin3>() == type_name::into_string<Null>()) {
// Coin3 is not specified and should be ignored
}
}
如上所示,开发人员可以使用 type_name::into_string
来比较类型,以确定哪些类型未被指定。
结构体类型参数中的 Coin 类型顺序
在之前的市场示例中,我们创建了类型为 Listing<ListedCoin, PaymentCoin>
的市场列表。
然后 buy_coins
函数需要 Listing<ListedCoin, PaymentCoin>
作为参数,以知道买家想要从哪个列表中购买。
public struct Listing<phantom ListedCoin, phantom PaymentCoin> has key {
id: UID,
seller: address,
listed_coins: Balance<ListedCoin>,
amount_asked: u64,
}
public fun buy_coins<ListedCoin, PaymentCoin>(listing: Listing<ListedCoin, PaymentCoin>, payment: Coin<PaymentCoin>): Balance<CoinType> {
let Listing<ListedCoin, PaymentCoin> { id, seller, listed_coins, amount_asked } = listing;
object::delete(id);
assert!(coin::value(&payment) == amount_asked, EINSUFFICIENT_PAYMENT);
transfer::public_transfer(payment, seller);
listed_coins
}
假设有人为 Listing<MyCoin, SUI>
创建了一个列表,但买家弄错了顺序,将列表指定为 Listing<Sui, MyCoin>
。那么会发生什么呢?
当这个交易发送到 Sui 网络时,验证层会报错,因为在提供的相同地址上,Listing<SUI, MyCoin>
是无效的(SUI 是列出的代币,而 MyCoin 是支付代币)。
不必担心这会意外指向另一个 SUI 的列表,因为每个对象的地址是唯一的,并且只能有一种类型。如果另一个卖家确实创建了 Listing<SUI, MyCoin>
,这个列表对象的地址会有所不同。
这是用户尤其是在复杂市场/交易所中常遇到的一个常见问题,因为这些市场/交易所具有许多不同类型的列表,涉及不同的代币对。
与我们上面举的 Listing
示例不同,在 Sui 的大多数交易所中,代币的顺序实际上对核心对象本身并不重要。
public struct Market<phantom Coin1, phantom Coin2> has key {
id: UID,
reserves_1: Balance<Coin1>,
reserves_2: Balance<Coin2>,
}
在这个市场中,Coin1 和 Coin2 具有相似的角色——买家可以用 Coin2 购买 Coin1,或者用 Coin1 购买 Coin2。 在这里,顺序技术上并不重要,但不幸的是,当 Market 对象被创建时,我们仍然需要选择哪个 Coin 是第一个类型(Coin1)。 在后台,对象仍然按照 Market<Coin1, Coin2> 的顺序创建。因此,当用户与这个市场互动时,他们仍然需要按照创建时的正确顺序指定代币。
为了让用户更容易使用,一个解决方案是确保在创建 Market 对象时,Coin1 和 Coin2 也按字母顺序排序。 然而,由于缺乏字符串比较功能,这在 Sui Move 中目前还不容易实现。
总结
Sui 代币标准是 Sui 网络上与以太坊网络上的 ERC-20 等效的概念,但更容易使用。
coin::create_currency
创建一个新代币,返回一个存储有关该代币(符号、名称、描述和 logo URL)信息的元数据对象,并返回用于管理代币的 Treasury Cap 对象。
coin::mint
创建新代币,无需指定 MyCoin
作为类型参数,因为编译器可以推断出来。
我们可以使用 TreasuryCap<CoinType>
对象来铸造代币。然而,只有 TreasuryCap 的所有者才能调用它。如果我们想允许用户自由铸造代币,
可以将 TreasuryCap 对象包装在一个共享对象中。
我们还可以通过调用 coin::burn
函数使用 TreasuryCap 来销毁代币。这也会销毁 Coin 对象本身。
Coin 与 Balance
- Coin 对象更像一个钱包。Coin 钱包有一个内部余额,可以从中取出现金(Balance)并存入另一个 Coin 钱包对象。
代币也可以很容易地使用
coin::split
和coin::join
拆分和合并。 - Balance 更像是现金。它不能单独存储,需要放入钱包或口袋中。开发人员可以选择创建自己的“口袋”对象来存储 Balance。
几个重要的 Balance 函数是
sui::balance
、split
和withdraw_all
。
小数 通常用于代币/代币以减少舍入误差。小数是一个特殊情况,没有更新函数,因为小数是代币的基本属性, 如果更新会改变每个人的余额。因此,为了安全和简化,Sui 的代币标准不允许修改小数。
Gas 是在网络上执行操作(如交易或智能合约)所需的费用。它代表了一种以用户为中心的、承担成本的机制,
维护了区块链网络的去中心化和透明性。应用程序可以接受 SUI 代币作为其应用程序的 gas 费用,或者它们可以接受其他类型的代币作为付款。
在后一种情况下,可以使用 Listing
对象和 buy_coins
函数。
介绍NFT
近年来,一项突破性技术席卷了艺术、娱乐和数字收藏品世界——非同质化代币(NFT)。 这些数字资产彻底改变了我们在数字时代对所有权和真实性的看法。
NFT 是基于区块链技术的独特数字代币,这与比特币和以太坊等加密货币背后的技术相同。 NFT 的独特之处在于它们的个体性;每个代币代表一个独一无二的项目,无论是数字艺术作品、音乐、视频片段、虚拟房地产、游戏内物品,甚至是推文和表情包。 通过加密签名来验证代币的真实性和所有权,实现了这一独特性,使其防篡改并且可以在公共账本上验证。
NFT 为艺术家、创作者和收藏家开辟了激动人心的机会,使他们能够以前所未有的方式货币化和交易数字创作。 它们还引发了关于技术、所有权和数字文化未来的讨论。随着 NFT 不断发展并扩展到各个行业,它们正在重塑我们对数字资产的认知和互动方式,展示了区块链技术在加密货币之外的潜力。
NFT(非同质化代币)通过智能合约和唯一标识符在区块链上表示。其工作原理如下:
-
智能合约:NFT 是通过智能合约创建和管理的,这些合约是具有预定义规则和条件的自执行协议。 Sui等区块链平台上的智能合约负责 NFT 的创建、转移和所有权。这些合约包含了管理 NFT 行为的逻辑,例如如何创建、购买、出售和转移。
-
唯一标识符:每个 NFT 都有一个唯一标识符,将其与区块链上的其他所有代币区分开来。这个标识符通常是一长串字符,关联到特定的 NFT。 它通常被称为“代币 ID”或“代币索引”。这个标识符确保了 NFT 的唯一性,并允许它在区块链上轻松跟踪。
-
元数据:NFT 通常包括元数据,即与代币关联的数字资产的附加信息。 元数据可以包括创作者的详细信息、资产的描述、其属性以及实际数字文件的链接(例如图像、视频或音频文件)。 由于区块链的大小限制,元数据通常存储在链外(区块链外),并在 NFT 的智能合约中包含对这些元数据的引用。 这允许用户在不将全部内容存储在区块链上的情况下访问和显示关于 NFT 的信息。
-
所有权和转移:NFT 的所有权在区块链上跟踪。当创建 NFT 时,智能合约会记录初始所有者的钱包地址。 当 NFT 转移给另一方时,智能合约中的所有权信息会更新,以反映新所有者的钱包地址。这个转移过程是安全和透明的,因为它记录在区块链的公共账本上。
-
互操作性:NFT 通常在各种区块链平台上创建和交易,每个平台都有自己的一套标准。 为了确保不同平台之间的互操作性,出现了一些标准和协议,允许 NFT 在多个生态系统中被识别和使用。例如,以太坊上的 NFT 可以在遵循相同标准的各种 NFT 市场和平台上支持。
在 Sui 区块链上,使用对象创建和管理 NFT 非常容易。每个 NFT 都可以是一个对象,它本身就是唯一的,并且可以包含由创建者/开发者定义的任何元数据。 在接下来的课程中,我们将详细讨论如何在 Sui 上实现 NFT。
NFT 标准 - 集合和代币对象
为了理解如何实现 NFT,让我们定义 NFT 的核心组件——创作者、收藏品、代币(NFT):
创作者
创作者是负责生产和铸造 NFT 的个人或实体。他们可以是艺术家、音乐家、游戏开发者、内容创作者或任何生成独特数字内容的人,这些内容可以被代币化为 NFT。
创作者使用区块链平台和智能合约来铸造 NFT,定义每个代币的属性、所有权和相关规则。他们还可以为 NFT 指定属性和元数据,以向买家和收藏家提供附加信息和背景。
NFT 收藏品
NFT 收藏品是具有共同主题、风格或创作者的非同质化代币(NFT)组或集合。它们是独特数字资产的策划集合,通常围绕特定类型、艺术家、项目或类别组织。
例如,一个收藏品可能以数字艺术为中心,展示由各种艺术家创作的 NFT。另一个收藏品可能围绕虚拟房地产展开,提供虚拟世界中的独特虚拟土地。 每个收藏品中的 NFT 都是独特的,但它们根据共同的主题或关联被分组在一起。
NFT 代币
NFT 代币被分组到收藏品中。一旦创作者创建了一个收藏品,他们可以允许用户在同一收藏品中铸造 NFT 代币,每个代币都有唯一的标识符和属性。 是否限制一个收藏品可以铸造的 NFT 代币数量由创作者通过智能合约的实现方式来定义。
实现
在 Move 中,我们可以将 NFT 收藏品和代币表示为对象。一个收藏品对象可以有以下字段:
creator
:收藏品创作者的地址name
:收藏品的名称,如“SuiFrens”description
:收藏品的描述limit
:可以在收藏品中铸造的代币数量url
:收藏品的 URL
一个 NFT 代币通常有以下字段:
collection
:收藏品对象的地址name
:代币的名称,例如“SuiFren #1234”url
:代币图像的 URLattributes
:代币的属性列表,如出生日期、世代等,就像我们在之前的课程中看到的那样。
NFT 代币还可以有其他属性,如显示信息、版税等,我们将在以后的课程中讨论更多。
社区 NFT 标准
尽管我们之前的简单标准应该可以用来创建我们自己的 SuiFren 集合和 NFT,但它有两个问题:
- 它不是其他开发者/创作者使用的标准。如果没有关于如何定义 NFT 属性的统一标准,钱包(用户用于签署和发送交易到 Sui 网络)和 UI 将难以正确显示数据。
- 开发者无法每次创建自己的 NFT 集合时重新实现所有功能。
由于上述原因,社区拥有一个通用标准和功能集对于 NFT 来说是合理的,这样可以让创作者更容易创建集合, 并让 UI 和钱包在不需要大量手动工作的情况下良好地显示它们。
目前,Sui 网络上的大多数 NFT 集合使用两个主要标准:
- OriginByte: https://github.com/Origin-Byte/nft-protocol。这是 Sui 上市场份额最大的 NFT 标准。
- Suiet: https://std.suiet.app/
在本课程的剩余部分,我们将深入探讨 OriginByte 标准,因为它们拥有最多的功能,同时仍然允许游戏开发者和艺术家进行非常灵活的 NFT 设计。用他们自己的话来说:
Origin-Byte 是一个由工具、标准和智能合约组成的生态系统,旨在使 Web3 游戏开发者和 NFT 创作者的生活更轻松。从简单的艺术作品到复杂的游戏资产,我们希望帮助您接触公众,并提供链上市场基础设施。
该生态系统分为三个关键部分:
1. **NFT 标准**:包括核心的 Nft、Collection 和 Safe 类型,控制每个 NFT 的生命周期和属性。
2. **一级市场**:包括 Marketplace、Listing 和众多市场,控制 NFT 的初始铸造和销售。
3. **二级市场**:主要包括 Orderbook,允许您交易现有的 NFT。
这些库合约已经部署在所有 Sui 网络(测试网和主网)上。地址可以在他们的 Move.toml 文件中查看。为了使用这些标准进行开发,开发者只需将 nft_protocol
和 permissions
目录复制到他们的源代码中,并直接使用其中提供的模块/函数/结构类型。在接下来的几节课中,我们将详细介绍它们提供的所有功能。
我们鼓励社区建设者改进和建议在现有标准(如 OriginByte)上添加功能。通过探索一个特定标准, 我们希望向您展示构建丰富而强大的 NFT 在艺术收藏、游戏和许多其他类型的应用程序中常用的“基本”功能集。即使在未来社区采用了新标准, 大多数概念和功能仍然适用,开发者应该能够更轻松地切换到那些新标准。
创建Collection和Display属性
使用 OriginByte 标准,开发者可以轻松创建一个集合。第一步是包含上节课提到的 OriginByte 的 nft_protocol
和 permissions
包。
之后,可以使用 collection
模块创建一个集合:
module my_nft::kite {
use nft_protocol::attributes::Attributes;
use nft_protocol::collection;
use std::string::String;
use sui::url::Url;
/// 一次性见证对象只能在 init 方法中实例化。
public struct KITE has drop {}
public struct KiteNFT has key, store {
id: UID,
name: String,
description: String,
url: Url,
attributes: Attributes,
}
fun init(otw: KITE, ctx: &mut TxContext) {
let (collection, mint_cap) =
collection::create_with_mint_cap<KITE, KiteNFT>(&otw, option::none(), ctx);
transfer::public_share_object(collection);
transfer::public_share_object(mint_cap);
}
}
一个 Collection 只能在 init 函数中创建,因为它需要一个见证对象(在第一课中讨论过)。collection::create_with_mint_cap
需要两个类型参数:
- 见证对象类型。在上述例子中,这是 KITE。
- Collection 中的 NFT 类型。我们为此创建了 KiteNFT 类型。将会在接下来的课程中详细讨论这些字段。
collection::create_with_mint_cap
返回两个对象——类型为 Collection<Kite>
的集合对象和 mint_cap
,
后者可以用于程序化地铸造代币(稍后会讨论)。这两个对象都成为共享对象,因为它们无论如何都不能被修改,只能读取。稍后我们会讨论 MintCap
是否可以是一个拥有的对象。
集合创建后,可以添加如名称/描述等属性:
use nft_protocol::display_info;
use ob_permissions::witness;
use std::string::{Self, String};
/// 可用于创建后授权其他操作。至关重要的是,这个结构体不能随意提供给任何合约,因为它充当了授权令牌。
public struct Witness has drop {}
fun init(otw: KITE, ctx: &mut TxContext) {
let (collection, mint_cap) =
collection::create_with_mint_cap<KITE, KiteNFT>(&otw, option::none(), ctx);
let delegated_witness = witness::from_witness(Witness {});
collection::add_domain(
delegated_witness,
&mut collection,
display_info::new(
string::utf8(b"Kites"),
string::utf8(b"A NFT collection of Kites on Sui"),
),
);
}
为了设置集合的名称/描述,我们需要以下几个步骤:
- 从 OriginByte 提供的 permissions 包中导入
ob_permissions::witness
。 - 在同一个包中声明一个新的结构体
Witness {}
。 - 使用
witness::from_witness
创建一个“委托见证”对象。 - 使用包含名称和描述的正确
DisplayInfo
对象调用collection::add_domain
。可以作为单独的域添加更多属性。
铸造Token
现在我们已经创建了一个集合。让我们来讨论铸造 NFT。OriginByte 标准只规定了集合的结构:
- 它们的集合对象非常简洁,所有属性都需要作为显示属性添加(通过
collection::add_domain
):
public struct Collection<phantom T> has key, store {
id: UID,
version: u64,
}
- OriginByte 还提供了集合的常见功能,如创作者、版税等。
- 开发者需要定义自己的 NFT 结构体,就像我们在前一课中看到的那样。OriginByte 只提供管理该代币的常见功能:权限(MintCap)、属性标准、显示标准等。
- 铸造和销毁事件将 NFT 绑定回集合。
开发者/创作者首先需要定义 NFT 对象结构体,这是创建集合所需的,正如在前一课中讨论的那样:
module my_nft::kite {
use nft_protocol::attributes::Attributes;
use nft_protocol::collection;
use std::string::String;
use sui::url::Url;
/// One time witness is only instantiated in the init method
public struct KITE has drop {}
public struct KiteNFT has key, store {
id: UID,
name: String,
description: String,
url: Url,
attributes: Attributes,
}
fun init(otw: KITE, ctx: &mut TxContext) {
let (collection, mint_cap) =
collection::create_with_mint_cap<KITE, KiteNFT>(&otw, option::none(), ctx);
}
}
一旦他们有了一个集合,他们可以像下面这样铸造 NFT:
use nft_protocol::attributes::{Self, Attributes};
use nft_protocol::mint_cap;
use nft_protocol::mint_event;
use sui::url;
public fun mint_nft(
mint_cap: &MintCap<Kite>,
name: String,
description: String,
url: String,
ctx: &mut TxContext,
) {
let nft = KiteNFT {
id: object::new(ctx),
name,
description,
url: url::new_unsafe(url),
attributes: attributes::from_vec(vector[], vector[])
};
transfer::public_transfer(nft, tx_context::sender(ctx));
mint_event::emit_mint(
witness::from_witness(Witness {}),
mint_cap::collection_id(mint_cap),
&nft,
);
}
您可能会意识到这与创建一个对象看起来没有什么不同!这是正确的,只有一个小区别——属性是 OriginByte 提供的标准,用于存储所有代币属性。我们将在后面的课程中对此进行更多讨论。开发者还可以在这里添加更多逻辑,例如限制每个用户只能铸造一个代币,以及他们喜欢的其他规则。
铸造函数还接收一个 MintCap 对象以发出铸造事件。这是必需的,以便链外组件能够知道 KiteNFT 属于 Kite 集合。OriginByte 提供的 mint_event::emit_mint
还需要在 init 函数中添加显示属性时传递的委托见证对象。在大多数情况下,MintCap 可以是一个共享对象,以允许任何用户调用 mint_nft
函数。
fun init(otw: KITE, ctx: &mut TxContext) {
let (collection, mint_cap) =
collection::create_with_mint_cap<KITE, KiteNFT>(&otw, option::none(), ctx);
transfer::public_share_object(mint_cap);
}
如果不是这种情况,并且开发者希望限制谁可以铸造,他们可以将 MintCap 设为一个拥有的对象,并在 init 函数中将其转移到可以铸造的账户。这使得 mint_nft
成为一个仅由特定账户调用的权限函数。
另一个链外组件可以识别集合中 NFT 的方法是查看它们的类型。集合对象有一个类型参数,所以在上述示例中,其类型将是 Collection<Kite>
。
NFT属性
如上一课所示,向代币添加属性的最佳方式是在它刚被铸造时。mint_nft
函数可以从用户那里获取属性列表,或者根据需要添加自己的属性:
use nft_protocol::attributes::{Self, Attributes};
use nft_protocol::mint_cap;
use nft_protocol::mint_event;
use sui::url;
public struct KiteNFT has key {
id: UID,
url: Url,
attributes: Attributes,
}
public fun mint_nft(
mint_cap: &MintCap<Kite>,
name: String,
description: String,
url: String,
ctx: &mut TxContext,
) {
let attributes = attributes::from_vec(
vector[string::utf8(b"name"), string::utf8(b"description")],
vector[name, description],
);
let nft = KiteNFT {
id: object::new(ctx),
url: url::new_unsafe(url),
attributes,
};
transfer::public_transfer(nft, tx_context::sender(ctx));
mint_event::emit_mint(
witness::from_witness(Witness {}),
mint_cap::collection_id(mint_cap),
&nft,
);
}
开发者在决定如何向 NFT 添加属性时,有一个有趣的权衡:
- 直接作为 NFT 结构体中的字段(例如名称、描述)
- 通过 attributes 添加
使用 attributes 的主要好处是,因为可以在以后(铸造后)添加新属性,而不必向 NFT 结构体添加新字段。模块部署后,现有的结构体不能被修改或添加新字段。
use sui::vec_map;
public fun add_new_attributes(kite_nft: &mut KiteNFT, new_attribute_name: String, new_attribute_value: String) {
let new_attributes = vec_map::empty<String, String>();
vec_map::insert(&mut new_attributes, new_attribute_name, new_attribute_value);
attributes::add_new(&mut kite_nft.id, new_attributes);
}
attributes::add_new
需要一个 VecMap(键到值的映射),所以在调用该函数之前我们需要创建一个。键和值的类型可以是任何类型(原语、结构体等),只要键的类型是可复制的。
版税
NFT 版税是一种机制,允许创作者和艺术家在其数字资产在二级市场出售时,获得该资产转售价格的一定百分比。 即使在初次销售之后,这种机制也为创作者提供了从其作品增值中继续受益的方式。
以下是 NFT 版税的工作原理:
初次销售
当 NFT 首次由创作者铸造并出售时(通常称为“初次销售”),创作者可以指定他们希望收到的销售价格的百分比作为版税。 这一百分比通常在铸造过程中在 NFT 的智能合约中设置。
二级销售
在初次销售之后,NFT 可以由其所有者在二级市场(如 NFT 市场)转售或交易。当发生二级销售时,创作者自动有权获得转售价的指定百分比(即版税)。 促成销售的平台或市场会自动扣除这部分版税,并将其转移到创作者的钱包中。
持续收益
只要 NFT 被转售,版税就为创作者提供了一条持续的收入来源。这激励艺术家和创作者生产高质量和受欢迎的数字内容,因为他们可以随着时间的推移参与其作品的增值。
透明性
NFT 中的版税是透明和可验证的。与 NFT 相关的智能合约指定了版税的百分比,并确保其在二级销售中自动执行。 这种透明性建立了创作者和买家之间的信任,因为双方都可以看到版税是如何分配的。
收藏家的利益
版税对收藏家和投资者也有利,因为他们可以确信自己对 NFT 的投资可能会随着时间的推移而增值。 知道未来转售收益的一部分将返回给原始创作者,可以增强 NFT 的感知价值。
需要注意的一点是,版税通常由诸如 OriginByte 这样的标准保证执行。它们通常由支持上市销售 NFT 的市场和交易所强制执行。 这是因为支付方式可以有所不同(不同类型的代币、其他 NFT、债务票据等),并且金额由市场自我报告。
然而,像 OriginByte 这样的标准确实为创作者提供了定义政策(OriginByte 称之为“策略”)的机制,关于如何计算和执行版税。 这为版税提供了标准化格式,并使交易所更容易收取版税。
use nft_protocol::royalty;
use nft_protocol::royalty_strategy_bps;
use ob_permissions::witness;
use std::string::{Self, String};
use sui::package;
/// 可用于创建后授权其他操作。至关重要的是,这个结构体不能随意提供给任何合约,因为它充当授权令牌。
public struct Witness has drop {}
fun init(otw: KITE, ctx: &mut TxContext) {
let (collection, mint_cap) =
collection::create_with_mint_cap<KITE, KiteNFT>(&otw, option::none(), ctx);
let delegated_witness = witness::from_witness(Witness {});
collection::add_domain(
delegated_witness,
&mut collection,
display_info::new(
string::utf8(b"Kites"),
string::utf8(b"A NFT collection of Kites on Sui"),
),
);
// 定义版税
let shares = vector[100];
let creator = tx_context::sender(ctx);
let shares = utils::from_vec_to_map(creator, shares);
// 100 BPS (Basis points) == 1%
royalty_strategy_bps::create_domain_and_add_strategy(
delegated_witness, &mut collection, royalty::from_shares(shares, ctx), 100, ctx,
);
// 确保版税能轻易被执行
let publisher = package::claim(otw, ctx);
let (transfer_policy, transfer_policy_cap) =
transfer_request::init_policy<KiteNFT>(&publisher, ctx);
royalty_strategy_bps::enforce(&mut transfer_policy, &transfer_policy_cap);
}
在上面的例子中,我们添加了 1% 的版税,该版税的 100% 支付给单个创作者(模块的部署者)。注意,在幕后,
royalty_strategy_bps::create_domain_and_add_strategy
实际上是通过 collection::add_domain
以添加其他属性的方式,将版税作为属性(“域”)添加。
它还创建并通过 royalty_strategy_bps::enforce
强制执行一个特殊的转移策略。我们将在后面的 Kiosk 课程中详细介绍 TransferPolicy,因为 TransferPolicy
是 Kiosk 标准引入的一个概念。
市场如何通过 royalty_strategy_bps
、TransferPolicy 和 TransferRequest 的组合来强制执行版税的具体细节超出了本课程的范围。
好奇的学习者被鼓励查看源代码(https://github.com/Origin-Byte/nft-protocol/blob/main/contracts/nft_protocol/sources/rules/royalty_strategy.move#L65),以了解这在幕后是如何真正工作的。
添加、更新和删除 Collection 的属性
在前一课中,我们了解了如何使用预定义属性列表创建 NFT 集合:
use nft_protocol::display_info;
use ob_permissions::witness;
use std::string::{Self, String};
/// 可用于创建后授权其他操作。至关重要的是,这个结构体不能随意提供给任何合约,因为它充当授权令牌。
public struct Witness has drop {}
fun init(otw: KITE, ctx: &mut TxContext) {
let (collection, mint_cap) =
collection::create_with_mint_cap<KITE, KiteNFT>(&otw, option::none(), ctx);
let delegated_witness = witness::from_witness(Witness {});
collection::add_domain(
delegated_witness,
&mut collection,
display_info::new(
string::utf8(b"Kites"),
string::utf8(b"A NFT collection of Kites on Sui"),
),
);
}
这些属性在集合创建之后也可以进行修改。在权限方面,开发者有两个选择:
- 允许任何人修改集合的属性,但须遵守特定规则(例如由协议定义的特定账户)。在这种情况下,Collection 对象需要是共享对象。
- 将 Collection 对象保持为拥有对象,并将其转移到有权限修改集合属性的账户。
无论哪种方式,这都可以在调用添加/更新/删除集合属性的函数时,指定集合对象的可变引用。
要在以后添加一组属性(域),我们只需要委托见证对象并再次调用 collection::add_domain
。
public fun add_collection_attributes_group(collection: &mut Collection<KiteNFT>, attributes: vector<String>) {
collection::add_domain(
delegated_witness,
&mut collection,
attributes,
);
}
由于域可以是任何类型,它可以是单个属性或一组属性——您可以添加向量、VectorMap 或任何其他包含多个属性的容器。 请注意,每种类型的域只能添加一次,因为它们在幕后是通过动态字段添加的。 要修改现有的域(可以是单个属性或包含属性的容器):
public fun update_collection_string_attribute(collection: &mut Collection<KiteNFT>, new_value: String) {
let string_attribute = collection::borrow_domain_mut<KiteNFT, String>(
delegated_witness,
&mut collection,
);
*string_attribute = new_value;
}
要修改使用 DisplayInfo 为集合设置的名称或描述:
public fun update_collection_name_and_desc(collection: &mut Collection<KiteNFT>, new_name: String, new_desc: String) {
display_info::change_name(collection::borrow_uid_mut(Witness {}, collection), new_name);
display_info::change_description(collection::borrow_uid_mut(Witness {}, collection), new_desc);
}
要删除一个域,包括可删除的 DisplayInfo,请执行以下操作:
public fun delete_collection_display_info(collection: &mut Collection<KiteNFT>) {
collection::remove_domain<KiteNFT, DisplayInfo>(Witness {}, collection);
}
NFT 的显示属性
在之前的课程中,我们已经了解了如何在铸造 NFT 时添加属性:
use nft_protocol::attributes::{Self, Attributes};
use nft_protocol::mint_cap;
use nft_protocol::mint_event;
use sui::url;
public struct KiteNFT has key {
id: UID,
url: Url,
attributes: Attributes,
}
public fun mint_nft(
mint_cap: &MintCap<Kite>,
name: String,
description: String,
url: String,
ctx: &mut TxContext,
) {
let attributes = attributes::from_vec(
vector[string::utf8(b"name"), string::utf8(b"description")],
vector[name, description],
);
let nft = KiteNFT {
id: object::new(ctx),
url: url::new_unsafe(url),
attributes,
};
transfer::public_transfer(nft, tx_context::sender(ctx));
mint_event::emit_mint(
witness::from_witness(Witness {}),
mint_cap::collection_id(mint_cap),
&nft,
);
}
在中级对象课程中,我们讨论了 Display 对象及其如何用来指导 Web UI 和钱包向用户显示对象。 它还可以允许添加仅用于显示目的的属性,而不会增加实际对象的负担,并且可以用于应用于所有相同类型对象的跨领域更改。 我们还可以使用 Display 对象来决定 NFT 对象的显示方式,通过在集合铸造时创建一个 Display 对象来实现:
use nft_protocol::display_info;
use ob_permissions::witness;
use std::string::{Self, String};
/// 可用于创建后授权其他操作。至关重要的是,这个结构体不能随意提供给任何合约,因为它充当授权令牌。
public struct Witness has drop {}
fun init(otw: KITE, ctx: &mut TxContext) {
let (collection, mint_cap) =
collection::create_with_mint_cap<KITE, KiteNFT>(&otw, option::none(), ctx);
let delegated_witness = witness::from_witness(Witness {});
collection::add_domain(
delegated_witness,
&mut collection,
display_info::new(
string::utf8(b"Kites"),
string::utf8(b"A NFT collection of Kites on Sui"),
),
);
let publisher = package::claim(otw, ctx);
let display_object = display::new<KiteNFT>(&publisher, ctx);
display::add_multiple(
&mut display,
vector[
utf8(b"All attributes"),
utf8(b"url"),
],
vector[
utf8(b"All attributes: {attributes}"),
utf8(b"Image url: {url}"),
],
);
display::update_version(&mut display);
transfer::public_transfer(display, tx_context::sender(ctx));
}
一旦 Display 对象被创建,就会触发一个事件,使 Sui 网络节点能够检测到 Display 对象。 随后,每当通过节点 API 获取对象时,其显示属性也会按照指定的格式进行计算,并与对象的其他字段一起返回。
在上面的例子中,我们还将 Display 对象发送给模块部署者,以便在未来需要更新显示属性和格式时进行修改。 如果开发者确定显示属性不会改变,他们也可以冻结该对象。如果他们希望自定义逻辑来决定何时可以添加/修改/删除显示属性,他们也可以共享该对象。
销毁NFTs
类似于普通对象,NFT 也可以被销毁。这是游戏中的常见功能,可用于:
- 实现物品制作。用户可以烧毁一些材料物品来制作武器或调制药水。
- 表示物品损失。当护甲破损时,它会被销毁并从用户的库存中消失。
应用程序还可以销毁消耗性NFT,例如音乐会门票或抽奖券。
use nft_protocol::mint_event;
public struct Witness has drop {}
public struct Ticket has key {
id: UID,
expiration: u64,
}
public fun clip_ticket(
collection: &mut Collection<Ticket>,
ticket: Ticket,
) {
let Ticket {id, expiration: _ } = ticket;
object::delete(id);
// 更新集合的供应量
}
这将销毁 NFT——它将在交易执行后从对象存储中移除。请注意,如果集合跟踪门票的供应量(当前有多少门票可用),则需要更新供应属性。有关更多详细信息,请参阅前面的课程“更新集合的属性”。
多个创建者
我们从前面的课程中看到,创建者(通常是模块部署者)可以拥有多种权限:
- 创建者可以拥有集合对象,用于添加、更新或删除属性。
- 创建者可以拥有集合中NFT的显示对象,以控制它们在用户界面中的显示方式。
- 创建者可以在NFT在市场上交易时接收版税付款。
use nft_protocol::royalty;
use nft_protocol::royalty_strategy_bps;
use ob_permissions::witness;
use std::string::{Self, String};
use sui::package;
/// 可用于创建后授权其他操作。至关重要的是,这个结构体不能随意提供给任何合约,因为它充当授权令牌。
public struct Witness has drop {}
fun init(otw: KITE, ctx: &mut TxContext) {
let (collection, mint_cap) =
collection::create_with_mint_cap<KITE, KiteNFT>(&otw, option::none(), ctx);
let delegated_witness = witness::from_witness(Witness {});
collection::add_domain(
delegated_witness,
&mut collection,
display_info::new(
string::utf8(b"Kites"),
string::utf8(b"A NFT collection of Kites on Sui"),
),
);
// 单个创建者获取所有版税
let shares = vector[100];
let creator = tx_context::sender(ctx);
let shares = utils::from_vec_to_map(creator, shares);
// 100 BPS (Basis points) == 1%
royalty_strategy_bps::create_domain_and_add_strategy(
delegated_witness, &mut collection, royalty::from_shares(shares, ctx), 100, ctx,
);
// 确保版税能轻易被执行
let publisher = package::claim(otw, ctx);
let (transfer_policy, transfer_policy_cap) =
transfer_request::init_policy<KiteNFT>(&publisher, ctx);
royalty_strategy_bps::enforce(&mut transfer_policy, &transfer_policy_cap);
}
对于某些集合,特别是在游戏中,可能会有多个创建者,这可能是因为有多个不同的人参与,或者只是为了将版税支付拆分到多个账户中以提高安全性。 OriginByte 提供了一个简单的创建者模块接口,允许添加多个创建者。这可以用于:
- 向集合添加一个创建者属性,以跟踪所有创建者的地址。
- 按指定百分比将版税支付拆分给这些不同的创建者账户。
use nft_protocol::creators;
use ob_utils::utils;
fun init(otw: KITE, ctx: &mut TxContext) {
let (collection, mint_cap) =
collection::create_with_mint_cap<KITE, KiteNFT>(&otw, option::none(), ctx);
let delegated_witness = witness::from_witness(Witness {});
collection::add_domain(
delegated_witness,
&mut collection,
display_info::new(
string::utf8(b"Kites"),
string::utf8(b"A NFT collection of Kites on Sui"),
),
);
// 列出所有创建者
let creators = vector[
@0xA01, @0xA05, @0xA06, @0xA07, @0x08
];
collection::add_domain(
delegated_witness,
&mut collection,
creators::new(utils::vec_set_from_vec(&creators)),
);
// 版税支付在5个账户之间按每个20%进行拆分。
let shares = vector[2_000, 2_000, 2_000, 2_000, 2_000];
let shares = utils::from_vec_to_map(creator, shares);
// 100 BPS (Basis points) == 1%
royalty_strategy_bps::create_domain_and_add_strategy(
delegated_witness, &mut collection, royalty::from_shares(shares, ctx), 100, ctx,
);
// 确保版税能轻易被执行
let publisher = package::claim(otw, ctx);
let (transfer_policy, transfer_policy_cap) =
transfer_request::init_policy<KiteNFT>(&publisher, ctx);
royalty_strategy_bps::enforce(&mut transfer_policy, &transfer_policy_cap);
}
创建者也可以通过控制集合对象(无论是作为拥有对象还是共享对象)进行添加或删除。
Kiosk 标准 - 交易NFTs
NFT市场简介
NFT(非同质化代币)市场是NFT生态系统中的重要枢纽,能够买卖和交易独特的数字资产。它们的重要性体现在以下几个关键方面:
- 可访问性:NFT市场使人们能够轻松进入数字收藏品和独特内容的世界。
- 可发现性:这些平台为用户提供了工具,使他们能够找到符合自己兴趣的NFT,促进探索和连接。
- 信任和安全:区块链技术确保了透明性、真实性和安全的交易。
- 二级销售:NFT市场促进转售,使创作者能够赚取版税,投资者也有可能获利。
- 社区和互动:它们围绕NFT建立社区,促进互动和协作。
- 创作者的货币化:艺术家和创作者可以直接在这些平台上将作品变现。
Kiosk - Sui的市场标准
Sui Kiosk是一个对象市场的标准,支持上市和销售。它作为Sui框架的一部分,部署在0x2
,拥有许多强大的功能,可以很好地支持NFT交易。
Kiosk模块可以在这里找到,提供以下功能(也适用于非NFT对象):
- 创建和管理新的NFT市场(一个kiosk)
- 转移NFT市场的所有权
- 上市和下架NFT
- 购买上市的NFT
- TransferPolicy允许NFT类型所有者(创作者)为其NFT的每次交易定义自定义规则,包括版税执行(我们在之前的课程中看到的OriginByte)和白名单
Kiosk目前仅支持使用SUI代币作为支付方式,但开发者可以部署自己的Kiosk版本,以支持其他代币。
在Kiosk上交易
首先需要创建一个kiosk:
public struct KioskManagement has key {
id: UID,
owner_cap: KioskOwnerCap,
}
public fun create_kiosk(ctx: &mut TxContext) {
let (kiosk, owner_cap) = kiosk::new(ctx);
transfer::public_share_object(kiosk);
let kiosk_management = KioskManagement {
id: object::new(ctx),
owner_cap,
};
transfer::public_share_object(kiosk_management);
}
在上述示例中,我们调用了 kiosk::new
来创建 kiosk。由于它返回了两个都不可删除的对象,
我们需要将它们共享或转移。如果开发者选择转移并保留这些对象,那么这些 kiosks 可以被视为“个人 kiosks”,
因为所有列表和购买都需要对 kiosk 和/或所有者权限的可变访问,而这些只有所有者拥有。
在上述示例中,我们将 kiosk 对象设为共享,并将所有者权限添加到共享的 KioskManagement
对象中。
这将使 kiosk 无需权限——任何人都可以在其上进行列表,前提是遵守我们在列表函数中设定的任何规则:
public fun list_on_kiosk<T: key + store>(
kiosk: &mut Kiosk,
kiosk_management: &KioskManagement,
nft: T,
) {
// 想对列出的NFT进行的任何验证
kiosk::place(kiosk, &kiosk_management.owner_cap, nft);
// 跟踪列出者
}
kiosk::place
需要一个对 KioskOwnerCap
对象的引用,可以从共享的 kiosk_management
对象中获取。
用户必须调用我们的 list_on_kiosk
函数,而不能直接调用 kiosk::place
,因为他们无法直接访问 KioskManagement
中的所有者权限对象。
这里还有一个注意事项是,NFT要被列出售,其类型必须具有 store
能力。
卖家也可以下架NFT。需要确保调用 delist
的人是最初创建该列表的人。这可以通过具有动态字段的共享对象来跟踪。
public fun delist<T: key + store>(
kiosk: &mut Kiosk,
kiosk_management: &KioskManagement,
nft_id: ID,
) {
let sender = tx_context::sender(ctx);
// 验证发送者是否与创建列表的人相同。
let nft = kiosk::take<T>(kiosk, &kiosk_management.owner_cap, nft_id);
transfer::public_transfer(nft, sender);
}
由于 kiosk 是一个共享对象,任何人都可以直接调用 sui::kiosk::purchase
从列表中购买一个 NFT:
/// 进行交易:支付物品的所有者并请求转移到 `target` kiosk(以防止物品被批准方拿走)。
/// 收到的 `TransferRequest` 需要由 T 的发布者处理,如果他们有允许交易的方法实现,可以请求他们的批准(通过调用某个函数),以便最终完成交易。
public fun purchase<T: key + store>(
self: &mut Kiosk, id: ID, payment: Coin<SUI>
): (T, TransferRequest<T>) {
let price = df::remove<Listing, u64>(&mut self.id, Listing { id, is_exclusive: false });
let inner = dof::remove<Item, T>(&mut self.id, Item { id });
self.item_count = self.item_count - 1;
assert!(price == coin::value(&payment), EIncorrectAmount);
df::remove_if_exists<Lock, bool>(&mut self.id, Lock { id });
coin::put(&mut self.profits, payment);
event::emit(ItemPurchased<T> { kiosk: object::id(self), id, price });
(inner, transfer_policy::new_request(id, price, object::id(self)))
}
请注意,尽管这会返回购买的 NFT,但它也会返回一个不能丢弃的 TransferRequest,该请求需要在交易结束前“验证”。 我们将在下一课中讨论有关 TransferPolicy 的更多内容。
Kiosk 标准 - 转移策略
Kiosk 有一个非常强大的功能,允许 NFT 创建者制定其 NFT 交易的规则,例如我们在之前课程中看到的版税:转移策略 (TransferPolicy)。 在前面的课程中,我们看到购买 NFT 首先会返回一个不能丢弃的 TransferRequest:
public fun purchase<T: key + store>(
self: &mut Kiosk, id: ID, payment: Coin<SUI>
): (T, TransferRequest<T>) {...}
为了处理这个 TransferRequest,用户需要调用 sui::transfer_policy::confirm_request
:
/// 允许类型 `T` 的 `TransferRequest`。该调用受类型约束保护,因为只有 `T` 的发布者才能获得 `TransferPolicy<T>`。
///注意:除非 `T` 有允许转移的策略,否则 Kiosk 交易将无法进行。
public fun confirm_request<T>(
self: &TransferPolicy<T>, request: TransferRequest<T>
): (ID, u64, ID) {
let TransferRequest { item, paid, from, receipts } = request;
let completed = vec_set::into_keys(receipts);
let total = vector::length(&completed);
assert!(total == vec_set::size(&self.rules), EPolicyNotSatisfied);
while (total > 0) {
let rule_type = vector::pop_back(&mut completed);
assert!(vec_set::contains(&self.rules, &rule_type), EIllegalRule);
total = total - 1;
};
(item, paid, from)
}
如你所见,transfer_policy::confirm_request
会检查请求中是否满足所有“规则”。
请注意,confirm_request
需要一个 TransferPolicy,而这个 TransferPolicy 只能通过调用 transfer_policy::new
来获得,正如我们在之前的版税课程中看到的那样:
use nft_protocol::royalty;
use nft_protocol::royalty_strategy_bps;
use ob_permissions::witness;
use std::string::{Self, String};
use sui::package;
/// 可用于创建后授权其他操作。至关重要的是,这个结构体不能随意提供给任何合约,因为它充当授权令牌。
public struct Witness has drop {}
fun init(otw: KITE, ctx: &mut TxContext) {
let (collection, mint_cap) =
collection::create_with_mint_cap<KITE, KiteNFT>(&otw, option::none(), ctx);
let delegated_witness = witness::from_witness(Witness {});
collection::add_domain(
delegated_witness,
&mut collection,
display_info::new(
string::utf8(b"Kites"),
string::utf8(b"A NFT collection of Kites on Sui"),
),
);
// 定义版税
let shares = vector[100];
let creator = tx_context::sender(ctx);
let shares = utils::from_vec_to_map(creator, shares);
// 100 BPS (Basis points) == 1%
royalty_strategy_bps::create_domain_and_add_strategy(
delegated_witness, &mut collection, royalty::from_shares(shares, ctx), 100, ctx,
);
// 确保版税能轻易被执行
let publisher = package::claim(otw, ctx);
let (transfer_policy, transfer_policy_cap) =
transfer_request::init_policy<KiteNFT>(&publisher, ctx);
royalty_strategy_bps::enforce(&mut transfer_policy, &transfer_policy_cap);
}
这意味着 TransferPolicy 只能由创建者创建,因此创建者可以将其存储在某个地方,并添加一个在检查版税已支付后确认/解决 TransferRequest 的函数。
他们还可以通过调用 transfer_policy::add_rule
添加更多规则。
public struct TransferPolicyHolder<phantom T> has key {
id: UID,
transfer_policy: TransferPolicy<T>,
}
public fun confirm_request(holder: &TransferPolicyHolder, request: TransferRequest<T>) {
// Verify our rules and add receipts for each confirmed rule to the request.
transfer_policy::confirm_request(&holder.transfer_policy, request);
}
可组合NFTs
类似于对象,NFTs可以使用我们在前几节课程中学习的不同对象技术组合在一起(“组合”):
- 包装(Wrapping) - 将具有
store
能力的NFT作为另一个NFT的字段添加。这将从存储中移除子NFT。 - 动态对象字段(Dynamic object fields) - 将子NFT添加为动态对象字段。不将其从存储中移除。
- 动态字段(Dynamic fields) - 类似于动态对象字段,但子NFT将从存储中移除。
- 对象拥有对象(Objects owning objects) - 类似于动态对象字段,但有不同的访问子NFT(
Receiving<T>
)的语法,并保持严格的所有权链。
在这些不同的方法中,使用对象包装是最简单明了的。一个强大的好处是,对于用户界面和游戏来说, 哪些NFT可以添加到另一个NFT中是非常清楚的,例如向英雄NFT添加武器、盔甲等。子NFT还可以用来表示NFT的“特征”,例如背景。
public struct Background has key, store {
id: UID,
type: String,
}
public struct Eyewear has key, store {
id: UID,
type: String,
}
public struct Armor has key, store {
id: UID,
defense: u64,
durability: u64,
}
public struct Weapon has key, store {
id: UID,
num_uses: u64,
power: u64,
}
public struct Hero has key {
id: UID,
background: Background,
eyewear: Eyewear,
armor: Armor,
weapon: Weapon,
}
在上面的示例中,Hero 是一个单一的 NFT,它将其他 NFT 作为特定字段包装起来。按照目前的写法, Hero 必须始终拥有所有的子 NFT —— 背景、眼镜、盔甲、武器。如果我们想让盔甲变得可选(例如,一个英雄可以穿或不穿盔甲),我们可以使用 Option 类型:
use std::option::Option;
public struct Hero has key {
id: UID,
background: Background,
eyewear: Eyewear,
armor: Option<Armor>,
weapon: Weapon,
}
public fun wear_armor(hero: &mut Hero, armor: Armor) {
option::fill(&mut hero.armor, armor);
}
public fun take_off_armor(hero: &mut Hero) {
let armor = option::extract(&mut hero.armor);
transfer::public_transfer(armor, tx_context::sender(ctx));
}
然后我们可以使用 option::fill
和 option::extract
来添加或移除盔甲。
使用这种可组合NFT的好处是,如果我们在市场上出售Hero NFT,所有被包装的物品也会随之一起出售。我们还可以自由移除这些可选的物品, 并在我们拥有的不同Hero NFT之间转移它们。可组合NFT可以成为构建丰富游戏和应用程序的非常强大的原语。
NFT铸造的效率及其对Sui网络的影响
使用对象模型,特别是拥有对象,Sui网络被构建为能够处理每秒数十万笔交易(TPS),因为仅修改特定拥有对象的交易可以并行执行而不会相互冲突:
- Alice 给她的英雄NFT添加盔甲。她拥有盔甲和英雄NFT。
- Bob 给Alice发送一些SUI作为礼物,这会修改他们各自的主要Coin对象。
以上两笔交易不涉及相同的对象,可以并行运行,从而显著减少执行时间。此外,涉及拥有对象的交易不需要通过完整的共识过程,这通常对大多数网络来说既繁重又缓慢。
构建能够在Sui网络上表现良好的可扩展应用程序的一般经验法则是尽量减少争用——如果太多应用程序构建不当,它们可能会占用更多的执行时间和Sui网络的资源, 从而可能导致Sui网络未能达到设计的规模。
开发人员应尽量避免以下一些错误:
- 在没有理由的情况下使用可修改的共享对象。例如,在NFT铸造期间,Collection对象有一个供应字段,每次铸造新NFT时都会增加。 这种供应字段也可以用来生成每个NFT的递增ID(例如SuiFren #124)。如果可能,应避免这种情况。
- 使用向量跟踪哪些地址已经铸造了NFT。这在允许列表功能中很常见,只有被列入允许名单的特定账户才能铸造NFT。 铸造模块会跟踪谁已经铸造过,因此一个账户最多只能铸造一个NFT。如果开发人员使用向量或vec_set/vec_map(底层都是使用向量), 允许列表会被不正确地实现。如果允许列表足够大(例如超过10,000项),检查某个地址是否已经铸造过会对网络非常昂贵,因为这需要很长的执行时间。在这种情况下,使用表格会更好。
- 尽可能冻结对象而不是共享它们,如果它们从未改变。
Sui 框架深入探讨
本课程深入探讨了开发者在 Sui 上可访问的各种库和数据结构。完成本课程后,学习者将能够为其应用程序选择合适的数据结构,并在复杂性、便利性和设计简洁性之间找到适当的平衡。此外,本课程还将讨论误用 Sui 框架的反模式和陷阱。
最后,本课程涵盖了单元测试,并介绍了如何在 Sui Move 中正确设置测试,以实现简便性和最大测试覆盖率。
向量 - Vector
Move中的向量是一个动态数组,可以增大或缩小。它是一种通用类型,这意味着它可以容纳任何类型的数据,从原始类型到复杂的结构。 它类似于Rust中的Vec类型或Java中的ArrayList。Move中的vector模块具有多个原生函数,允许你创建、访问和操作向量。 这些原生函数直接用Rust编写,并通过Sui Move虚拟机调用,因此比用Move编写相同的函数更快、更高效。创建向量有两种方法:
- 使用向量字面量:
let v = vector[1, 2, 3, 4];
- 使用
empty
函数:
let v = vector::empty<u64>();
请注意,如果在同一函数中向此向量添加元素,则可能不需要类型提示
let v = vector::empty();
vector::push_back(&mut v, 1);
要从向量中读取值,请使用 borrow
函数:
let v = vector[1, 2, 3, 4];
let x = vector::borrow(&v, 2);
请注意,x 是一个引用类型,这意味着它不能被修改。如果向量中的值是可复制的(具有 copy 能力),你可以通过解引用来制作副本:
let v = vector[1, 2, 3, 4];
let x = *vector::borrow(&v, 2);
要原地修改向量的值,请使用 borrow_mut
函数:
let v = vector[1, 2, 3, 4];
let x = vector::borrow_mut(&mut v, 2);
*x = *x + 1;
要移除向量中的最后一个元素,请使用 pop_back
函数:
let v = vector[1, 2, 3, 4];
let x = vector::pop_back(&mut v);
你还可以通过使用 index_of
和 swap_remove
函数从向量中移除特定的元素:
let v = vector[1, 2, 3, 4];
let (found, i) = vector::index_of(&v, 3);
if (found) {
vector::swap_remove(&mut v, i);
}
数组可以合并和反转:
let v1 = vector[1, 2, 3];
let v2 = vector[4, 5, 6];
vector::append(&mut v1, v2);
vector::reverse(&mut v1);
表格 - Table
表格是一种类似映射的集合。它们在底层使用动态字段,并将键和值存储在对象系统中。这使得表格成为一种高效且灵活的数据结构, 可以存储多达一千条记录,这也是动态字段的限制。表格并不直接存储在对象系统中,而只是对象系统中的句柄。 当开发人员需要存储大量数据时,例如用于白名单,他们应该使用表格。需要注意的是,表格中的键和值的类型有一些限制。
键的类型必须具有 copy
、drop
和 store
能力,而值的类型必须具有 store
能力。这是因为表格需要能够复制键,并且需要在对象系统中存储值。
一般来说,所有原始类型(数字、布尔值等)都可以用作键和值。自定义结构需要遵循上述规则,才能在表格中用作键或值。请注意,表格也是对象,
因为 Table 结构具有 UID 字段和 key 能力。这意味着它们可以独立于其他对象存在或被包含在其他对象中。
创建表格的方法如下:
let table = table::new<u64, bool>();
如果在创建后立即向表格添加条目,则不需要类型说明 <u64, bool>
。键和值的类型将从添加到表格的第一个条目中推断出来。
let table = table::new();
// 创建一个类型为 <string, u64> 的表格:
table::add(&mut table, string::utf8(b"My key"), 1);
从表格中读取值的方法
let table = table::new<u64, bool>();
table::add(&mut table, 0, false);
let value = table::borrow(&table, 0);
如果值具有 copy 能力,可以通过解引用来复制它:
let value = *table::borrow(&table, 0);
直接修改表格中的值的方法:
let value = table::borrow_mut(&table, 0);
*value = true;
要从表格中移除一个值,我们需要键:
let table = table::new<u64, bool>();
table::add(&mut table, 0, false);
let value = table::remove(&mut table, 0);
请注意,remove 会返回该值,以便在移除后使用,或者在值不具有 drop 能力(因此不能自动删除)时使用。
表格不具有 drop 能力,当所有项目被移除时需要显式销毁:
let table = table::new<u64, bool>();
table::add(&mut table, 0, false);
table::remove(&mut table, 0);
table::destroy_empty(table);
如果值具有 drop 能力,表格在未清空时也可以销毁。然而,在这里需要注意的是,丢弃包含大量值的大表可能会消耗大量的 gas:
let table = table::new<u64, MyStruct>();
table::add(&mut table, 0, MyStruct());
table::drop(table);
链表 - Linked Table
链表(Linked List)是一种数据结构,由一系列元素组成,每个元素指向下一个元素。链表是实现更复杂协议(如订单簿)的有用数据结构,具有以下优点:
-
动态大小:链表是动态数据结构,可以根据需要增长和缩小。
-
插入和删除效率高:插入和删除操作各为一步,而在向量中删除一个元素则需要移动其后的所有元素。
-
插入顺序:元素按插入顺序存储,这对于特定程序非常有用。 在 Sui Move 中,可以使用 Linked Table 创建链表,并具有以下额外优点:
-
扩展性:Linked Table 使用动态字段存储元素,可以用于存储大量元素。
-
独立性:Linked Table 可以是独立对象,也可以是另一个对象的字段。
-
迭代支持:Linked Table 可以使用 prev 和 next 函数遍历链表,无需循环。
创建 Linked Table:
let mut table = linked_table::new::<u64, u64>(ctx);
类型说明 <u64, u64> 在插入操作立即进行后实际上并不是必须的:
let mut table = linked_table::new(ctx);
linked_table::push_back(&mut table, 1, 10);
在表的前面插入键值对:
linked_table::push_front(&mut table, 2, 20);
在表的后面插入键值对:
linked_table::push_back(&mut table, 3, 30);
请注意,添加到链接表中的键必须具有 copy、drop 和 store 能力。值必须具有 store 能力。
从表中移除键值对并返回值:
let value = linked_table::remove(&mut table, 2);
链接表可以使用 prev
和 next
函数遍历:
let current = linked_table::front(&table);
while option::is_some(current) {
let key = option::unwrap(current);
let value = linked_table::borrow(&table, key);
current = linked_table::next(&table, key);
}
当前键可以传递给其他函数以不使用循环进行迭代(例如,使用递归)。
类似于 Table,Linked Table 不能自动删除,因为它没有 drop
能力。它只能通过以下两种方式显式销毁:
linked_table::destroy_empty(table)
可用于销毁空表。linked_table::drop(table)
可用于删除可能不为空的表。注意,如果表很大,这可能会非常昂贵(gas消耗)。
大向量 - Table Vec
标准向量在添加大量项目时效率不高(超过几十个项目)。这是因为向量越大,更新它所需的 gas 越多, 例如添加一个新元素会导致更多的 I/O 成本来执行这样的交易。一般来说,开发者应该使用基于表的向量(TableVec)来存储大量项目。 TableVec 是一种使用 Table 模块实现的可扩展向量。它在添加、删除和访问元素时非常高效。
创建 TableVec:
let t = table_vec::empty::<u64>(ctx);
请注意,TableVec 只能用于存储具有 store 能力的值。这包括所有基本类型和具有 store 能力的结构体。
向 TableVec 的末尾添加元素:
table_vec::push_back(&mut t, 10);
获取 TableVec 的长度:
let len = table_vec::length(&t);
获取特定索引处的元素:
let element = table_vec::borrow(&t, 0);
从 TableVec 中移除最后一个元素:
let last_element = table_vec::pop_back(&mut t);
移除特定索引处的元素:
let element = table_vec::swap_remove(&mut t, 0);
类似于 Table 和 LinkedTable,TableVec 不能自动丢弃,必须显式销毁:
- 如果 TableVec 为空,可以使用 table_vec::destroy_empty(t) 销毁。
- 如果 TableVec 非空,可以使用 table_vec::drop(t) 丢弃。使用此函数时请谨慎,因为如果 TableVec 很大,执行可能需要消耗大量的 gas。
修改特定索引处的元素:
let mut element = table_vec::borrow_mut(&mut t, 0);
*element = 20;
Bag - 异构数据袋
与只能存储相同类型元素的向量和基于表的数据结构不同,Bag 可以存储不同类型的元素。 这是因为 Bag 是一组键值对的集合,其中键是唯一标识符,值是与键关联的数据。 键可以是任何类型,值也可以是任何类型。这使得 Bag 成为一种非常灵活的数据结构,但也比向量或表更复杂一些。表类数据结构和 Bag 在底层都使用了动态字段。 Bag 特别适用于开发人员希望接受不同类型的支付代币或杂项对象的情况。请注意,由于 Bag 也使用动态字段,因此它最多只能存储 1000 个项目(动态字段的限制)。
创建 Bag:
let bag = bag::new();
将键值对添加到 Bag 中:
bag::add(&mut bag, b"name", b"John Doe");
bag::add(&mut bag, b"age", 25);
具有相同键的键值对不能存在于同一个 Bag 中。只有具有复制(copy)、丢弃(drop)和存储(store)能力的键才能添加到 Bag 中。只有具有存储(store)能力的值才能添加到 Bag 中。
从 Bag 中检索值:
let name = bag::borrow(&bag, b"name");
let age = bag::borrow(&bag, b"age");
修改 Bag 中的值:
let age = bag::borrow_mut(&mut bag, b"age");
*age = 26;
有两种方法可以检查 Bag 中是否存在某个元素:
- 检查特定键是否存在于 Bag 中:
if (bag::contains(&bag, b"name")) {
// 做点什么
}
检查键是否存在以及该值是否为特定类型:
if (bag::contains_with_type::<vector<u8>, u64>(&bag, b"age")) {
// 做点什么
}
从 Bag 中移除值
let age = bag::remove(&mut bag, b"age");
Bag 不能自动销毁。它必须在为空的情况下才能被销毁:
bag::destroy_empty(bag);
优先队列 - Priority Queue
优先队列是一种数据结构,允许你插入带有优先级的元素,然后弹出具有最高优先级的元素。 它是一种非常有用的数据结构,广泛应用于许多算法和应用程序中。优先队列非常适合保持项目的排序并快速找到具有最高优先级的项目。 在 Move 中,可以为每个条目创建具有自定义优先级的优先队列。比较函数可能会在 Move 的后续版本中添加。
创建一个新的优先队列:
let h = priority_queue::new(create_entries(vector[3,1,4,2,5,2], vector[10, 20, 30, 40, 50, 60]));
元素的类型可以从创建的条目中推断出来。
插入一个新的条目:
priority_queue::insert(&mut h, 7, 70);
弹出具有最高优先级的条目:
let (priority, value) = priority_queue::pop_max(&mut h);
目前,优先级队列不允许更改现有条目的优先级或值,也不允许删除任意条目。这些功能可能会在Sui Move的后续版本中添加。
向量映射 - VecMap
VecMap 是一种由向量支持的简单映射数据结构。该映射保证不包含重复的键,但条目并不是按键排序的——条目是按插入顺序包含的。 与向量类似,当元素数量较多时,不应使用 VecMap,因为这会导致访问和更新的 gas 成本较高。当元素数量较多时,应使用表。
VecMap 允许通过键或索引(基于插入顺序)访问。
创建一个新的 VecMap:
let map = vec_map::empty<u64, u64>();
插入一个新的键值对:
vec_map::insert(&mut map, 1, 2);
删除一个键值对:
vec_map::remove(&mut map, &1);
通过键访问元素:
let value = vec_map::get(&map, &1);
如果不确定键是否存在并且不想因为错误而中止整个交易,可以使用 try_get
:
let value_opt = vec_map::try_get(&map, &1);
if (option::is_some(&value_opt)) {
let value = option::unwrap(value_opt);
}
通过索引访问元素:
let (key, value) = vec_map::get_entry_by_idx(&map, 0);
通过键修改元素:
*vec_map::get_mut(&mut map, &1) = 3;
通过索引修改元素:
*vec_map::get_entry_by_idx_mut(&mut map, 0).1 = 3;
将映射解包成键和值的向量:
let (keys, values) = vec_map::into_keys_values(map);
VecMap 不能自动丢弃。必须先明确删除所有元素,然后才能销毁 VecMap:
vec_map::destroy_empty(map);
其他有用的函数包括:
size
: 获取映射中元素数量的is_empty
: 检查映射是否为空keys
: 获取映射中键列表
向量集 - VecSet
类似于 VecMap,VecSet 是一种基于向量的数据结构。集合在以下用例中非常有用:
- 对输入的元素向量进行去重。
- 在插入元素到集合中时避免重复。
需要注意的是,VecSet 的插入和删除操作是 O(n) 的,而不是像 HashSet 的 O(1) 或基于树的集合的 O(log n)。这是因为 VecSet 是由向量支持的,它需要扫描整个向量以检查重复项。另一个限制是,VecSet 不应用于存储大量元素,因为所有操作的 gas 成本将会很高。
创建 VecSet 的方法如下:
let mut set = vec_set::empty<u64>();
将元素插入集合中:
vec_set::insert(&mut set, 1);
插入到 VecSet 的元素必须具有 copy 和 drop 属性。
从集合中删除元素:
vec_set::remove(&mut set, &1);
检查集合中是否存在某个元素:
vec_set::contains(&set, &1);
获取集合中的元素数量:
vec_set::size(&set);
检查集合是否为空:
vec_set::is_empty(&set);
对象数据结构:对象袋、对象表
ObjectBag 和 ObjectTable 是与 Bag 和 Table 类似的数据结构,分别用于存储不同类型的元素。不同之处在于 ObjectBag 和 ObjectTable 旨在存储对象,并且对象存储在存储系统中。这样,外部工具可以访问这些对象。这一差异在 Move 中是不可见的。
创建 ObjectBag 或 ObjectTable:
let bag = object_bag::new::<T>(ctx);
let table = object_table::new::<K, V>(ctx);
向袋子或表中添加对象:
object_bag::add(&mut bag, obj);
object_table::add(&mut table, key, obj);
从袋子或表中借用对象:
let obj = object_bag::borrow(&bag, id);
let obj = object_table::borrow(&table, key);
从袋子或表中修改对象:
let obj = object_bag::borrow_mut(&mut bag, id);
obj.property = value;
let obj = object_table::borrow_mut(&mut table, key);
obj.property = value;
从袋子或表中移除对象:
let obj = object_bag::remove(&mut bag, id);
let obj = object_table::remove(&mut table, key);
检查袋子或表中是否存在对象:
let exists = object_bag::contains(&bag, id);
let exists = object_table::contains(&table, key);
获取袋子或表中的对象数量:
let len = object_bag::length(&bag);
let len = object_table::length(&table);
总结
总结起来,当选择使用哪种数据结构时,请考虑以下几点:
-
gas燃气成本:基于表的数据结构和袋子在存储大量对象时具有较低的燃气成本。这是因为在表或袋中访问对象的成本与存储的对象数量无关。相比之下,在向量中访问对象的成本与存储的对象数量成正比。
-
协议需求:使用提供协议所需操作和功能的正确数据结构,比使用原始数据结构并从头实现功能更高效。例如,优先队列和链表比使用向量并实现优先队列或链表的功能更高效。
字符串 - String
字符串在 Move 中并不严格是原始类型,而是作为标准库中的一个模块实现的。字符串不同于其他结构,它们得到了虚拟机 (VM) 和 API 的大量支持。 字符串可以直接作为交易参数传递,通过 SDK 传递,并且在后台会被正确地序列化/反序列化为 String 结构。
Move 中的函数可以直接接受 String 参数:
public fun do_something(s: String) {
// ...
}
在 Move 中,由于字符串不是原始类型,它没有直接的字面量语法。相反,你可以使用 string 模块中的 utf8 函数来创建字符串:
let s = std::string::utf8(b"Hello, World!");
b"Hello, World!" 是一个字节数组字面量 (vector
字符串不能直接作为常量设置,因为常量表达式不支持函数调用。你需要先将字节值赋给常量,然后再将其转换为字符串:
const HELLO_WORLD: vector<u8> = b"Hello, World!";
public fun hello_world(): String {
std::string::utf8(HELLO_WORLD)
}
这将创建一个 UTF-8 字符串。
Move 还支持 ASCII 字符串,这是一个不同的模块:
let s = std::ascii::from_asciistring(b"Hello, World!");
然而,UTF-8 字符串更常用,ASCII 字符串则较为罕见。
字符串是可变的。你还可以将字符串连接在一起:
let s1 = std::string::utf8(b"Hello, ");
let s2 = std::string::utf8(b"World!");
std::string::append(&mut s1, s2);
或取子字符串:
let s = std::string::utf8(b"Hello, World!");
let sub = std::string::sub_string(&s, 0, 5);
你也可以在特定索引处插入字符串:
let s = std::string::utf8(b"Hello, World!");
let s2 = std::string::utf8(b"Brave ");
std::string::insert(&mut s, 7, s2);
你还可以找到子字符串的索引:
let s = std::string::utf8(b"Hello, World!");
let s2 = std::string::utf8(b"World");
let index = std::string::index_of(&s, &s2);
Option
Option 类型是一种常见模式,在 Rust 和其他语言中使用。它用于表示一个可能存在或不存在的值。在 Move 中,Option 类型使用大小为零或一个元素的向量实现。
Options 有以下用途:
- 传递给函数的可选参数
- 结构体中的可选字段
- 函数的可选返回值
Option 类型定义在 std::option 模块中。Option 类型是一个泛型类型,这意味着它可以包含任何类型的值。Option 类型有两个变体:Some 和 None。Some 变体包含一个值,而 None 变体则不包含任何值。
可以使用以下方式创建一个可选值:
let absent = option::none();
let some_value = option::some(42);
Options 可以用于基本类型和结构体:
public struct MyStruct {
value: u64
}
let some_struct = option::some(MyStruct{value: 42});
let value = option::borrow(&some_struct).value;
Option 类型有几种方法可以处理可选值:
is_none
:如果 Option 是 None,则返回 true
let absent = option::none();
assert!(option::is_none(&absent), 0);
is_some
:如果 Option 是 Some,则返回 true
let some_value = option::some(42);
assert!(option::is_some(&some_value), 0);
contains
:如果 Option 包含特定值,则返回 true
let some_value = option::some(42);
assert!(option::contains(&some_value, &42), 0);
可以从 Option 中读取一个值。如果 Option 是 None,这将中止:
let some_value = option::some(42);
let value = option::borrow(&some_value);
如果 Option 是 None,可以从 Option 中读取一个带有默认值的值:
let absent = option::none();
let value = option::borrow_with_default(&absent, &42);
可以从 Option 中获取一个可变引用,并用它来修改值:
let some_value = option::some(42);
let value = option::borrow_mut(&some_value);
*value = 43;
Option 中的值可以被新的值替换:
let some_value = option::some(42);
let old_value = option::swap(&some_value, 43);
值也可以从 Option 中填充或提取:
let absent = option::none();
option::fill(&absent, 42);
let value = option::extract(&absent);
Option 类型可以被销毁并提取其值:
let some_value = option::some(42);
let value = option::destroy_some(some_value);
事务上下文 - Transaction Context
正如我们在之前关于对象和更高级Move的课程中所看到的,事务上下文(tx_context)在许多方面都非常有用:
- 创建新对象。这需要一个可变的tx_context。
- 访问事务的发送者。
tx_context被认为是一种特殊的系统对象,会自动传递给函数:
public fun my_function(ctx: &mut TxContext) {
let sender = ctx.sender();
let new_object = object::new(ctx);
}
在调用 my_function 时,发送者不需要显式传递 tx_context。它会在交易执行时由虚拟机自动附加。这也是为什么交易上下文应该始终是函数签名中的最后一个参数的原因。
public fun my_function(arg1: u64, arg2: u64, ctx: &mut TxContext) {
// ...
}
你可能会看到一些库函数或依赖的其他合约函数需要交易上下文。在大多数情况下,这是为了在这些函数内部创建对象。还有一些有用的交易上下文函数你可以使用:
ctx.epoch()
返回当前的 epoch 编号
let epoch = tx_context::epoch(ctx);
Sui 区块链被组织成不重叠的 epoch,每个 epoch 长度为 24 小时。
ctx.epoch_timestamp_ms()
返回当前 epoch 的开始时间(自 Unix epoch 以来的毫秒数)
let epoch_start = tx_context::epoch_timestamp_ms(ctx);
ctx.fresh_object_address()
返回一个新的、唯一的对象地址。这对于创建新对象非常有用。
let new_object = object::new(ctx);
请注意,这个唯一的对象地址甚至可以用作不是对象的项目的唯一 ID。这些 ID 保证在所有交易和所有对象中都是唯一的。
ctx.digest()
返回当前交易的哈希值。这对于创建确定性的随机数非常有用。
let random_number = std::hash::digest(ctx.digest());
数学和fixed_point32
数学 - Math
数学模块为在 Move 中处理无符号整数提供了有用的数学函数。这与 EVM 世界不同,在 EVM 中用户需要部署自己的数学库。
Math模块提供了以下函数:
max
: 返回 x 和 y 中较大的值
let max = math::max(5, 10);
assert!(max == 10, 0);
min
: 返回 x 和 y 中较小的值
let min = math::min(5, 10);
assert!(min == 5, 0);
diff
: 返回 x - y 的绝对值
let diff = math::diff(5, 10);
assert!(diff == 5, 0);
pow
: 返回基数的幂值
let pow = math::pow(2, 3);
assert!(pow == 8, 0);
sqrt
: 获取 x 的最接近的整数平方根
let sqrt = math::sqrt(9);
assert!(sqrt == 3, 0);
sqrt_u128
: 类似于math::sqrt
,但用于 u128 数字
let sqrt = math::sqrt_u128(9);
assert!(sqrt == 3, 0);
divide_and_round_up
: 计算 x / y,但结果向上舍入
let divide_and_round_up = math::divide_and_round_up(5, 2);
assert!(divide_and_round_up == 3, 0);
fixed_point32
Move 没有原生的浮点数。所有数字都是无符号整数。这使得实现如 Defi 协议中的复杂数学计算变得困难。然而,Sui Move 的标准库提供了一个基于 Move 的定点数实现,具有 32 位小数位。这是一种二进制表示形式,因此十进制值可能无法精确表示,但它提供了小数点前后超过 9 位的精度(总共 18 位)。相比之下,双精度浮点数的十进制精度不到 16 位,因此在使用浮点数将这些值转换为十进制时需要小心。然而,由于 Move 表达式相对于 Move 语言中的 Rust 原生实现增加了额外的 gas 成本。目前仅支持 32 位小数和整数部分的定点数。
要创建一个定点数:
let one_half = fixed_point32::create_from_rational(1, 2);
let two = fixed_point32::create_from_raw_value(2);
要对定点数进行算术运算:
let sum = fixed_point32::add(one_half, two);
let product = fixed_point32::multiply(one_half, two);
let quotient = fixed_point32::divide(one_half, two);
要将定点数转换为 u64:
let value = fixed_point32::get_raw_value(one_half);
要检查定点数是否为零:
let is_zero = fixed_point32::is_zero(one_half);
BCS
不要与 std::bcs 中的 bcs 模块混淆,本课介绍 sui::bcs 模块,它具有更多功能。BCS 是 Sui 和 Move 的默认序列化格式。这是一种用于序列化数据以进行存储和传输的二进制格式。BCS 格式用于序列化数据以进行存储和传输。当值作为参数在交易中传递时,它们会被序列化为 BCS 格式。当交易执行时,这些值会从 BCS 格式反序列化为 Move 中的实际值。这种序列化和反序列化给 Sui 区块链带来了成本,开发者应该注意这一点。默认情况下,交易不能将非对象结构作为参数,字符串除外。
BCS 模块可以提供一种解决方法,允许开发者将类结构的值作为一系列 BCS 编码字节传递,Move 代码可以逐个提取这些值,只要它们都是原始类型、向量或选项。这允许前端序列化一个结构(这只是生成一个接一个的值序列),并且可以一次从字节中提取一个值的序列:
此函数从输入中读取 u8 和 u64 值。
use sui::bcs;
public fun deserialize(bytes: vector<u8>): (u8, u64) {
let prepared = bcs::new(bytes);
let (u8_value, u64_value) = (
bcs::peel_u8(&mut prepared),
bcs::peel_u64(&mut prepared)
);
(u8_value, u64_value)
}
向量可以类似地提取(或“剥离”):
public fun deserialize(bytes: vector<u8>): vector<u8> {
let prepared = bcs::new(bytes);
let u8_vector = bcs::peel_vec_u8(&mut prepared);
let u64_vector = bcs::peel_vec_u64(&mut prepared);
u8_vector
}
Option:
public fun deserialize(bytes: vector<u8>): Option<u8> {
let prepared = bcs::new(bytes);
let u8_option = bcs::peel_option_u8(&mut prepared);
let u64_option = bcs::peel_option_u64(&mut prepared);
u8_option
}
加密库
Sui Move 提供了许多加密函数,允许各种不同的使用场景:
- bls12381:BLS12-381 签名验证。可用于验证 bls12381 签名。BLS 签名的验证通常比 ECDSA 签名更高效。
- ed25519:Ed25519 签名验证。这是 Sui 中的默认签名/密钥对方案。
- ecdsa_k1 或 sep256k1:secp256k1 签名验证。这是比特币使用的签名方案。
- ecdsa_r1 或 secp256r1:secp256r1 签名验证。这是以太坊和其他 EVM 链使用的签名方案。
- blake2b (sui::hash 模块):Blake2b 哈希。这是一种快速且安全的哈希算法。
- keccak256 (sui::hash 模块):Keccak256 哈希。这是以太坊使用的哈希算法。
- groth16:Groth16 证明验证。用于零知识证明。
使用这些函数可以使开发者在 Sui 上构建安全且高效的应用程序,并能够与其他区块链(如以太坊)生成的签名或证明进行互操作。
public fun verify_ethereum_signature(
signature: vector<u8>,
public_key: vector<u8>,
msg: vector<u8>,
hash: u8,
): bool {
sui::ecdsa_r1::secp256r1_verify(&signature, &public_key, &msg, hash)
}
在大多数涉及加密函数的流程中,开发者可以编写函数,接受一个签名消息或证明以及每种加密机制所需的其他参数。然后,Move 代码可以在链上进行验证,保证正确性和去中心化。 请注意,加密函数会消耗大量的 gas,因为它们在后台需要大量计算,这些计算在 Rust 中实现为本地函数。开发者应避免编写进行大量验证的函数。
类型名称 - Type Name
Move 中的类型定义为结构体,包含三个组件:
- 模块地址:定义该类型的模块的地址
- 模块名称:定义该类型的模块的名称
- 类型名称:结构体的名称
Move 中的类型也作为类型参数在函数之间传递。在某些情况下,开发者可能希望检查类型以应用不同的逻辑。这时,type_name 模块就派上用场了。 它提供了一种将 Move 类型转换为值的方法。这对于调试、日志记录和其他需要在运行时检查值的类型的使用场景非常有用。
// 例子用途:
public fun process<T>(val: T) {
let type_name = type_name::get<T>();
let type_name_str = type_name::into_string(type_name);
if (type_name_str == utf8(b"0xdeadbeef::my_module::MyStruct")) {
// 操作
} else if (type_name_str == "0xdeadbeef::my_module::MyOtherStruct") {
// 操作
} else {
abort 0;
}
}
不同的组件也可以从一个类型中提取出来:
let type_name = type_name::get<T>();
let module_name = type_name::get_module(&type_name);
let address = type_name::get_address(&type_name);
类型名称应谨慎使用,因为比较类型名称不是确定值类型的可靠方法。建议尽可能使用 Move 的类型系统来确定值的类型。
链接 - Url
Url 是 Sui Move 库中提供的一种方便的类型,用于明确标记结构体中的字段或函数的参数为 URL。这对于 Move 语言来说很有用,因为它可以理解字段的类型,并在字段使用不当时提供更好的错误消息。开发者应尽可能将存储值用作 Url 或接受 Url 参数。请注意,交易不能将 Url 作为参数传递,因此 Url 应仅用作非用户直接调用的函数的参数。
public struct MyStruct {
url: Url,
}
public fun take_url(url: Url) {
// ...
}
Url 可以使用 new_unsafe
函数创建,该函数接受一个字符串作为参数。此函数不会验证字符串,因此开发者有责任确保字符串是一个有效的 URL。new_unsafe_from_bytes
函数也可以用来从字节向量创建一个 Url。如果字节不是有效的 ASCII,这个函数将中止。
use 0x1::sui::url;
public fun main() {
let url = url::new_unsafe(b"https://example.com");
let url_bytes = vector::from_hex("68747470733a2f2f6578616d706c652e636f6d");
let url_from_bytes = url::new_unsafe_from_bytes(url_bytes);
}
inner_url
函数可以用于从 Url 获取内部 URL 作为字符串。update
函数可以用于更新 Url 的内部 URL。
use 0x1::sui::url;
let url = url::new_unsafe(b"https://example.com");
let inner_url = url::inner_url(&url);
一个 Url 可以使用 update 函数进行更新。此函数接受一个对 Url 的可变引用和一个字符串作为参数。该字符串将替换 Url 的内部 URL。
use 0x1::sui::url;
let url = url::new_unsafe(b"https://example.com");
url::update(&mut url, b"https://example2.org");
l使用 versioned 管理版本
类似于 Url,versioned 是一个方便的库,用于提供显式的版本管理。开发者可以使用原始版本号(例如 u64),但 versioned 更加明确且具有自我文档功能。versioned 可用于管理对象的版本、应用程序的数据或基本上任何其他内容。Versioned 是一个实际对象,可以转移到账户中或保存在另一个对象内部(通过包装或动态字段)。Versioned 还可以在其内部存储实际值,而不是将值作为一个单独的对象存储,这是一个更常见的用例。
要创建一个新的 Versioned 对象,可以使用 create 函数。初始版本和值是必需的。该值必须是一个 store 类型。
let v = versioned::create(1, b"hello", &mut ctx);
要获取内部类型的当前版本,请使用 version 函数。
let ver = versioned::version(&v);
要基于当前版本加载内部值,请使用 load_value 函数。调用者需要指定一个预期的类型 T。如果类型不匹配,加载将失败。
let val = versioned::load_value(&v);
要取出内部对象进行升级,请使用 remove_value_for_upgrade 函数。为了确保我们总是正确升级,会返回一个能力对象,必须在升级时使用。
let (val, cap) = versioned::remove_value_for_upgrade(&mut v);
要使用新版本和新值升级内部对象,请使用 upgrade 函数。必须使用调用 remove_value_for_upgrade 时返回的能力对象。
versioned::upgrade(&mut v, 2, b"world", cap);
要销毁这个 Versioned 容器并返回内部对象,请使用 destroy 函数。
let val = versioned::destroy(v);
总结
在本课程中,我们介绍了 Sui 框架提供的许多标准库。这些库涵盖了诸如 String、Option、FixedPoint32 等额外类型。还有数学库和其他库,帮助开发者在 Move 中编写安全且易于理解的代码。
大多数库可以在 std:: 下找到,但有一些例外,例如 bcs、url 或 versioned 在 sui:: 下。当编写 Move 代码时,开发者应尽可能使用提供的库,因为:
- 它们以高效的方式编写,并且可能在以后接收更多优化,例如转换为更高效的原生 Rust 代码。
- 它们经过广泛测试、审计和实战测试。这确保了不会因为小错误导致难以调试的问题。
我们介绍了以下库及其使用场景:
- 字符串(String) - 一种半原始类型,技术上是一个结构体,但可以作为交易的一部分自由传递给函数作为参数。
- Option - 一种辅助类型,用于表示可选值。不能通过交易传递。
- FixedPoint32 - 一种数字类型,在 Move 中表示分数数字很有用,因为 Move 没有对它们的原生支持。不能通过交易传递。
- 交易上下文 (Transaction context) - 一个系统对象,如果函数需要它,会自动作为最后一个参数传递给函数。可用于获取关于交易的信息,如发送者/摘要,或查询系统状态 - 当前纪元、时间戳或生成唯一 ID。
- 数学库 (Math) - 有用的数学函数。
- BCS - 可用于将结构体作为字节数组(vector
)传递,因为 Sui 默认不允许将自定义结构体作为参数传递。接收函数需要从给定的字节向量中手动逐个提取值。 - 加密函数 - 用于验证签名消息(使用不同的方案)或零知识证明。
- 类型名 (Type Name) - 用于在 Move 中对类型(结构体)进行字符串检查。
- Url 和 Versioned - 有用的库和类型,用于明确表示特殊值。
独立测试文件 - 为了简介起见
为什么要进行单元测试
单元测试是开发过程中的关键部分。它们帮助确保代码按预期运行,并且代码的更改不会破坏现有功能。
特别是在智能合约领域,错误可能会非常昂贵(涉及数百万美元!),单元测试是必须的。通常,建议开发人员编写覆盖以下内容的测试:
- 正常路径:覆盖代码预期行为的测试。
- 边缘情况:覆盖代码意外行为的测试,特别是导致错误的情况。
- 端到端测试(E2E):覆盖代码全流程的测试,包括与其他模块和区块链的交互。
除了单元测试,开发人员还可以编写集成测试,这些测试可以使用 TypeScript 进行,测试包括本地运行的区块链在内的一切端到端的交互。
Move中的单元测试
Move 中的单元测试被设计为直接在 Move 中编写。这允许与语言的深度集成,并能够测试 Move 代码的全部范围。Move 单元测试可以在与被测试模块相同的文件中编写。然而,建议在单独的文件中编写测试,以保持模块文件的简洁,专注于模块的实现。这也减少了为测试逻辑添加 #[test_only]
注释的数量。我们将在本课程的后续课程中对此进行更多讨论。Move 源代码目录的结构如下:
sources/
module1.move
module2.move
module3.move
tests/
module1_tests.move
module2_tests.move
module3_tests.move
Move.toml
每个模块的测试应有一个单独的文件。文件名应为模块名后加 _tests.move
。例如,如果模块名为 my_module
,测试文件应命名为 my_module_tests.move
。
每个测试模块如下所示:
#[test_only]
module my_package::my_module_tests {
}
#[test_only]
注释确保此模块在发布包时不会被部署。
编写测试
测试模块由辅助函数和测试函数组成。测试模块可以如下所示:
#[test_only]
module my_package::my_module_tests {
use std::string::{String, utf8};
use std::vector;
use my_package::my_module;
#[test]
fun my_test1() {
setup();
let my_struct = my_module::new_struct();
assert!(my_module.do_something(my_struct) == 0, 0);
}
#[test]
fun my_test2() {
setup();
let my_struct = MyStruct::new();
assert!(my_module.do_something(my_struct) == 0, 0);
}
fun setup() {
// 填写代码
}
}
在上面的例子中,我们有一个辅助设置函数,该函数在每个测试开始时被调用。请注意,
Move 没有自动设置函数,因此测试需要手动执行此操作。每个测试都带有 #[test]
注释,因此在开发者执行测试运行时会运行这些测试。
sui move test
在测试目录中。开发者还可以运行 sui move test --coverage
以获取覆盖率报告 - 测试覆盖了多少百分比的代码。
测试可以使用 assert! 语句来检查代码在测试中的预期行为。assert! 接受两个参数:
• 第一个是布尔表达式,如果测试通过,应该计算为 true
• 第二个是错误消息,以帮助指示哪个断言失败,以防测试中有很多断言
在同一模块中可以有任意数量的测试。测试模块还可以导入任何其他模块。请注意,在测试模块中编写的所有内容都不会部署到区块链, 因此开发者无需担心这里的安全问题。辅助函数也可以在没有实际测试的单独测试模块中创建:
#[test_only]
module my_package::test_helper {
public fun setup() {
// 填写代码
}
}
调用被测试模块中的私有函数
在某些情况下,开发者可能希望测试私有函数或使用私有函数创建私有数据结构。你可以通过将这些私有函数设置为 public(package)
,
并在被测试模块中将测试模块声明为 package
来实现。然而,赋予函数比必要更高的可见性并不是一个好习惯。相反,你可以使用 #[test_only]
属性来允许函数仅从测试模块调用。这个属性仅在测试模块中可用,而在生产代码中不可用。
module my_package::my_module {
fun private_function() {
// ...
}
#[test_only]
fun call_private_function() {
private_function();
}
}
module my_package::my_module_tests {
use my_package::my_module;
#[test]
fun test_private_function() {
my_module::call_private_function();
// ...
}
}
编写测试时需要记住的几点:
- #[test_only] 函数对于调用被测试模块的 init 函数特别有用,因为在运行测试时 init 函数默认不会被调用。必须显式调用它们。
- 确保不要拼错 #[test_only],例如拼成 #[testonly] 或 #[test_onlyy],因为这会使函数变为非测试函数!如果测试代码被部署,
- 例如一个铸币函数,这可能会是灾难性的。虽然编译器现在可以捕捉到这个错误,但确保注释正确仍然是一个好的实践。
- 如果没有必要,开发者不应该滥用 #[test_only] 函数。
- 如果在被测试模块中有仅在测试函数中使用的导入,可以在导入上使用 #[test_only] 注释。否则,编译器可能会警告有未使用的导入。
测试场景:管理交易
如果你还记得对象、&mut TxContext
和 tx_context
模块,你可能会问这些在测试中是如何设置的。
答案是默认情况下它们不是。这可能会在编写测试时导致令人困惑的失败,因为这些 tx_context 通常用于创建对象或确定交易的发送者。
Move 单元测试仅在Move VM 上运行,不包含区块链的其他组件。为了确保 tx_context 以及 Sui 系统的其他部分正常工作,开发者可以使用 test_scenario
:
use sui::test_scenario::{Self, Scenario};
#[test]
public fun my_test() {
let scenario_val = test_scenario::begin(@0x123);
let scenario = &mut scenario_val;
test_scenario::next_tx(scenario, @0x123);
// 测试代码
test_scenario::end(scenario_val);
}
在整个测试过程中,有 3 个函数可以从 test_scenario 调用:
test_scenario::begin
- 这个函数初始化测试场景并返回一个 Scenario 对象。这个对象用于跟踪测试场景的状态。test_scenario::next_tx
- 这个函数用于模拟一个交易。它接受一个 Scenario 对象和一个发送者地址作为参数。这个函数用于模拟特定发送者的交易。test_scenario::end
: 这个函数用于结束测试场景。它接受一个 Scenario 对象作为参数,并清理测试场景。
使用这些函数,测试所需的一切都应该已经设置好。在某些情况下,开发者可能会明确表示每个测试代码块的交易边界,使用块 { ... }
来实现:
#[test]
fun my_test {
// === 第三笔交易 ===
// 下一笔交易 - Fran 查看她的库存并找到了那本书
// 她决定将书还给 Manny,并自己再买一本
test_scenario::next_tx(&mut scenario, fran);
{
// 可以通过 ID 从发送者那里获取对象(如果有多个)
// 或者如果只有一个对象,可以使用:`take_from_sender<T>(&scenario)`
let book = test_scenario::take_from_sender_by_id<LittleBookOfCalm>(&scenario, book_id);
/// 将书送还给 Manny
sui::transfer::transfer(book, manny);
// 现在重复之前的步骤
let store = test_scenario::take_shared<BlackBooks>(&scenario);
let ctx = test_scenario::ctx(&mut scenario);
let coin = coin::mint_for_testing<SUI>(5_000_000_000, ctx);
// 与之前相同 - 购买这本书
let book = purchase(&mut store, coin, ctx);
sui::transfer::transfer(book, fran);
// 别忘了归还
test_scenario::return_shared(store);
};
// === 第四笔交易 ===
// 最后一笔交易 - Bernard 收取收益并将商店转让给 Fran
test_scenario::next_tx(&mut scenario, bernard);
{
let store = test_scenario::take_shared<BlackBooks>(&scenario);
let cap = test_scenario::take_from_sender<StoreOwnerCap>(&scenario);
let ctx = test_scenario::ctx(&mut scenario);
let coin = collect(&mut store, &cap, ctx);
sui::transfer::public_transfer(coin, bernard);
sui::transfer::transfer(cap, fran);
test_scenario::return_shared(store);
};
}
(测试)创建对象 - 归属对象、共享对象、系统对象
在许多情况下,当调用被测试的函数时,你还需要传递归属对象和共享对象。但是我们如何获取这些对象呢?Sui VM 在执行交易时会自动完成这一操作, 但这是在测试中。test_scenario 提供了一些函数来帮助完成这一操作:
test_scenario::take_shared
- 这个函数用于从交易的发送者那里获取一个共享对象。它接受一个 Scenario 对象作为参数,并返回共享对象。
let store = test_scenario::take_shared<BlackBooks>(&scenario);
test_scenario::take_from_sender
- 这个函数用于从交易的发送者那里获取一个归属对象。它接受一个 Scenario 对象作为参数,并返回归属对象。
let cap = test_scenario::take_from_sender<StoreOwnerCap>(&scenario);
这里需要注意的是,如果你获取了共享对象,在结束场景之前需要将它们返回:
test_scenario::return_shared(store);
你可能还需要将 TxContext 传递给测试函数。这也可以通过 test_scenario 生成:
let ctx = &mut tx_context::new_from_hint(
@0xC4AD, // 发送者
0u64, // 提示, 用以生成 tx hash
1, // epoch
0, // epoch_timestamp_ms
0, // `ids_created` (通常为 `0`)
);
通过这种方式,你还可以配置从 tx_context 模块返回的不同数据:
- sender:发送者
- hint:用于生成交易哈希
- epoch:纪元
- epoch timestamp:纪元时间戳,以毫秒为单位
- number of object ids already created so far:迄今为止已创建的对象 ID 数量。测试某些功能可能需要这个。
Clock,另一个系统对象,是一个共享对象,因此可以这样获取:
let clock = test::take_shared<Clock>(&test);
设置 SUI 代币和测试失败案例
在测试中创建 SUI 代币
一些测试在测试函数时可能还需要创建 SUI
代币。这可以通过已经在 coin 模块中定义的 #[test_only]
函数来完成。
use sui::coin;
#[test]
public fun test() {
let coins = coin::mint_for_testing(1000);
// 测试内容
}
对于自定义代币,开发者需要在初始化这些代币的模块中定义 test_only 函数,因为铸造自定义代币需要 TreasuryCap。
失败案例
在测试错误情况时,开发者可能希望编写预期失败的测试(例如,被测试函数由于无效输入正确地失败)。这可以通过 #[expected_failure]
注释来实现。
#[test]
#[expected_failure(abort_code = kiosk::royalty_rule::EInsufficientAmount)]
fun test_default_flow_0_invalid_amount_fail() {
}
我们可以使用 abort_code =
来指定我们期望测试返回的中止代码,而无需硬编码。这可能是目前在 Move 中最接近公共常量的做法。近期可能会有更新,增加对公共常量的支持。
调试
在单元测试中,开发者可能需要了解如何在测试失败时进行调试。这有助于解决难以调试的意外失败。 通常,开发者希望在测试的不同地方打印出不同的值,以便更好地了解发生了什么。在 Move 中,你也可以这样做:
#[test]
fun test_my_function() {
let a = 1;
let b = 2;
std::debug::print(&a);
std::debug::print(&b);
assert(a == b, 101);
}
std::debug::print
接受对任何值的引用并打印它们。请注意,要打印字符串字面量,你需要执行以下操作:
std::debug::print(&std::ascii::string(b"Hello, world!"));
std::debug::print
也可以打印整个结构体:
public struct MyStruct {
a: u64,
b: u64,
}
#[test]
fun test_my_function() {
let my_struct = MyStruct { a: 1, b: 2 };
std::debug::print(&my_struct);
}
编写参数化测试
参数化测试是用不同的输入值多次运行的测试。当你想用一系列不同的输入测试一个函数, 或用一系列不同的预期输出测试一个函数时,这非常有用。Sui 的单元测试尚不支持原生的参数化测试。 要实现这一点,我们需要编写一些样板代码,以使用不同的输入值多次运行相同的测试。
#[test]
fun test_add() {
assert_eq!(add(1, 2), 3);
assert_eq!(add(2, 2), 4);
assert_eq!(add(3, 2), 5);
}
可以重写为:
#[test]
fun test_add() {
let test_cases = vector[(1, 2, 3), (2, 2, 4), (3, 2, 5)];
let i = 0;
while (i < vector::length(test_cases)) {
assert_eq!(add(a, b), expected);
i = i + 1;
}
}
随着 Move 即将引入宏函数,这将变得更加容易。
#[test]
fun test_add() {
let test_cases = vector[(1, 2, 3), (2, 2, 4), (3, 2, 5)];
vector::for_each(test_cases, |test_case| {
let (a, b, expected) = test_case;
assert_eq!(add(a, b), expected);
}
}
总结
总之,单元测试可以很容易地在 Move 中编写:
- 在一个单独的文件/模块中
- 以 #[test_only] 开头
- 可以使用 test_scenario 来设置所有上下文,就像代码从交易中执行一样
- 可以获取归属对象和共享对象来调用被测试函数。在
test_scenario
结束或下一个(测试)交易开始之前,必须归还共享对象 - 可以创建系统对象 -
TxContext
和Clock
- 可以验证在调用被测试函数时发生中止并返回指定的错误代码
- 可以进行参数化,目前需要一些样板代码