本来我们来学习如何使用 Tact 编写 NFT 合约。在继续之前,建议先阅读上一篇关于编写 Jetton 的[文章](https://hackmd.io/@xyymeeth/Hy6UfF9rye),有很多概念和细节我们在这篇文章就不再重复了。
## NFT 架构
TON 上的 NFT 与 Jetton 相同,不能使用无界数据结构。Jetton 中存在大量的用户,因此需要将用户的数据部分独立拆分出来。NFT 中可能存在成千上万个 Item(即 TokenId),因此也需要将 NFT 的合约拆分。
NFT 的合约拆分成了如下两部分:
+ Collection
+ Item
Collection 合约类似于 Jetton 的 Master 合约,主要存储 NFT 的核心信息,例如 Metadata。Item 则以 NFT 的 TokenId 做区分,每一个 TokenId 是一个 Item,其拥有各自独立的合约。一些基础操作例如 `transfer` 等都是在 Item 合约上操作。
NFT 与 Jetton 相比,合约架构拆分的角度不同。对于 Jetton 来说,可能用无数个用户,每个用户的余额不尽相同,因此以用户维度来拆分。
对于 NFT 来说,以 Item 维度拆分更加合适。假设一共 Mint 了一万个 Item,那么就有一万个 Item 合约,每个 Item 都有自己的 owner,token uri 等。这些信息都存储在 Item 合约中,每个 Item 都是独立的。
NFT 的架构图例如下:
![image](https://hackmd.io/_uploads/HJkKvfg8yx.png)
每套 Collection 包含一个 Collection 合约和 N 个 Item 合约,每个 Item 合约拥有自己的 owner。
## NFT 标准范式
TON 为 NFT 制定了一套标准,常用的是 [TEP-62](https://github.com/ton-blockchain/TEPs/blob/master/text/0062-nft-standard.md),我们在编写 NFT 时,都要遵循这个范式。
## 编写 NFT 合约
本文继续参考 [Ton-Dynasty](https://github.com/Ton-Dynasty/tondynasty-contracts) 的 NFT 实现。
在合约库中找到这几个文件(二级菜单是文件中包含的合约名):
+ NFTCollection.tact
+ NFTCollectionStandard
+ NFTItem.tact
+ NFTItemStandard
+ nft_example.tact
+ ExampleNFTCollection
+ ExampleNFTItem
前两个文件对应的分别是 Collection 和 Item 合约,但是它们目前还是抽象基类,第三个 `nft_example.tact` 包含的是对 Collection 和 Item 的实现。
### NFTCollectionStandard
`NFTCollectionStandard` 中包含三个重要的变量,分别是:
+ next_item_index,下一个 TokenId
+ collection_content,该 NFT 的 Metadata
+ owner_address,该 NFT 的 owner 地址
其中 `collection_content` 包含该 NFT 的 Metadata,遵循 [TEP-64](https://github.com/ton-blockchain/TEPs/blob/master/text/0064-token-data-standard.md) 的标准,Jetton 的 Metadata 也遵循该标准。
纵观 `NFTCollectionStandard` 合约,可以看到它其实主要是包含了一些 get 方法,方便为链下提供相关的数据。
我们以 `get_collection_data` 为例,看看其中的一些技术细节:
```tact=
get fun get_collection_data(): CollectionData {
return self._get_collection_data();
}
virtual inline fun _get_collection_data(): CollectionData {
let builder: StringBuilder = beginString();
let urlPrefix: String = self.collection_content.asSlice().asString();
builder.append(urlPrefix);
builder.append(self.NFT_COLLECTION_STANDARD_METADATA);
return CollectionData {
next_item_index: self.next_item_index,
collection_content: builder.toCell(),
owner_address: self.owner_address
};
}
```
`beginString()` 创建并返回一个新的 `StringBuilder`,它的作用与其它语言中的类似,可以存储字符串。
我们之前说过 TON 上的所有数据都存储的 Cell 结构中,Storage 变量 `collection_content` 同样存在于 Cell 中,我们想要读取它,但是 Cell 不能直接被读取。Slice 是一个可以帮助我们读取 Cell 的中间介质,需要使用 `asSlice()` 将 Cell 转化为 Slice 类型,然后再使用 `asString()` 将其转化为字符串。
随后将相关的字符串数据都存放在 `builder` 变量中,最后返回 `CollectionData` 类型数据。注意 Tact 中不支持方法返回多个变量,因此如果希望返回多个变量,需要额外定义结构体,将相关的数据都放进去。`CollectionData` 中的 `collection_content` 是 Cell 类型,因此需要将 `builder` 通过 `toCell()` 转化为 Cell 类型。
我们现在来看 `nft_example.tact` 中的 `ExampleNFTCollection` 合约,它继承了 `NFTCollectionStandard`,重点来看其中的 `Mint` 接收方法。
Mint NFT 的流程如下:
![image](https://hackmd.io/_uploads/Syi-q0kLyl.png)
NFT 的 Owner 向 Collection 合约发送 Mint 消息,随后再流转到 Item 合约。Mint 接收方法如下:
```tact=
receive("Mint") {
let ctx: Context = context();
let nftItemInit: StateInit =
self._get_nft_item_state_init(self.next_item_index);
send(SendParameters {
to: contractAddress(nftItemInit),
value: self.estimate_rest_value(ctx),
bounce: false,
mode: SendIgnoreErrors,
body: Transfer {
query_id: 0,
new_owner: ctx.sender,
response_destination: ctx.sender,
custom_payload: emptyCell(),
forward_amount: 0,
forward_payload: emptySlice()
}.toCell(),
code: nftItemInit.code,
data: nftItemInit.data
});
self.next_item_index = self.next_item_index + 1;
}
```
`_get_nft_item_state_init` 方法获取即将 Mint 的 Item 的合约示例。与 Jetton 中的 Mint 类似,Mint 时发送的消息要附带 `code` 和 `data`。如果该合约还没有被部署,则使用该 `code` 和 `data` 来部署合约示例,如果合约之前已经部署了,则忽略。
可以看到这里发送的是 `Transfer` 消息,与 EVM 中的 NFT Mint 类似,这里可以理解成 0 地址向某地址转账了一个 NFT。
### NFTItemStandard
先来看看 Item 合约中都有什么 Storage 变量:
+ collection_address,Collection 的合约地址
+ index,即 TokenId
+ owner,该 Item 的 owner
+ individual_content,即 Token URI
+ is_initialized,该 Item 合约是否已经初始化
接着继续来看 Mint 过程在 Item 中的部分,由于 Collection 合约中的 Mint 向 Item 合约发送了 `Transfer` 消息,因此我们需要看 `Transfer` 接收方法,但是该方法不仅用于 Mint,也用于正常的 Transfer 操作,也就是说用户的正常 Transfer 也是使用的该方法。
```tact=
receive(msg: Transfer){
let ctx: Context = context();
let remain: Int = self._transfer_estimate_rest_value(ctx);
self._transfer_validate(ctx, msg, remain);
if (self.is_initialized == false) {
self.mint(ctx, msg);
} else {
self.transfer(ctx, msg, remain);
}
}
```
`_transfer_estimate_rest_value` 方法用于计算一些预留的 Storage Gas 费用。
`_transfer_validate` 方法校验该 `Transfer` 消息的 sender 必须是 Collection 合约或者该 Item 的 owner。当 sender 是 `Collection` 合约时,为 Mint 操作,当 sender 是 Item 的 owner 时,为 Transfer 操作。
`is_initialized` 字段代表该 Item 合约是否已经初始化,也就是是否已经部署,默认是 false。如果为 false,那么说明当前操作是 Mint,需要调用 `mint` 方法。如果为 true,说明当前操作是 Transfer。我们来看 `mint` 的实现:
```tact=
virtual inline fun mint(ctx: Context, msg: Transfer) {
require(ctx.sender == self.collection_address, "NFTItemStandard: Only the collection can initialize the NFT item");
self.is_initialized = true;
self.owner = msg.new_owner;
send(SendParameters{
to: msg.response_destination,
value: 0,
mode: SendIgnoreErrors + SendRemainingValue,
body: Excesses { query_id: msg.query_id }.toCell()
});
}
```
Mint 操作要求当前的 sender 是 Collection 合约。随后将 `is_initialized` 字段置为 true,说明后续的 Transfer 接收方法都是转账的场景。设置 owner。最后将剩余的 Gas 返还给初始 sender。
到此 Mint 操作就已经完成,大家可以与前面的流程图做对比,看看是否对应。
最后来看 Transfer 部分,Transfer 的流程图如下:
![image](https://hackmd.io/_uploads/rkLgsJeU1x.png)
可以看到,用户 Transfer 时,只与 Item 合约交互。之后便向新 owner 的钱包发送 `transfer notification` 消息,以及返还 Gas。与 Jetton 的 Transfer 相比,简单许多。
回到上面 `receive(msg: Transfer)` 方法的 `transfer` 部分:
```tact=
virtual inline fun transfer(ctx: Context, msg: Transfer, remain: Int) {
self.owner = msg.new_owner;
if (msg.forward_amount > 0) {
send(SendParameters{
to: msg.new_owner,
value: msg.forward_amount,
mode: SendIgnoreErrors,
bounce: false,
body: OwnershipAssigned{
query_id: msg.query_id,
prev_owner: ctx.sender,
forward_payload: msg.forward_payload
}.toCell()
});
}
remain = remain - ctx.readForwardFee();
if (
msg.response_destination != newAddress(0, 0)
&& remain > msg.forward_amount
) {
send(SendParameters{
to: msg.response_destination,
value: remain - msg.forward_amount,
mode: SendPayGasSeparately,
body: Excesses { query_id: msg.query_id }.toCell()
});
}
}
```
该方法主要有两部分。第一部分是向新的 owner 钱包发送 `transfer notification` 消息,第二部分是返还 Gas。
与 Jetton 的 notification 类似,当消息中的 `forward_amount` 字段大于 0 时,说明需要向 owner 钱包发送通知消息,附带的 TON 数量是 `msg.forward_amount`。
我们重点来看 `remain` 变量,它是通过前面的 `_transfer_estimate_rest_value` 方法计算出来的,代表的是当前合约运行中去除各种费用之后的剩余 TON 数量,也就是可以发送出去的数量。
进入到 `transfer` 方法之后,`remain` 部分首先拆分出去了 `msg.forward_amount`,在第一个 `if` 结束时,实际剩余的 TON 数量为 `remain - msg.forward_amount`。如果剩余的数量大于 0 则需要返还,小于 0 则无需返还。由于在发送 `Excesses` 时使用的是 `SendPayGasSeparately` 机制,因此发送该消息的***邮费***需要额外支付,这部分***邮费***就是 `ctx.readForwardFee()`,所以最后在 `remain - msg.forward_amount` 数量的基础上,仍然需要留有 `ctx.readForwardFee()` 的数量才能将消息发送出去。
综上所述,最终仍需发送剩余 Gas 的前提是:
> remain - msg.forward_amount - ctx.readForwardFee() > 0
理解了这个不等式,就可以理解代码中 `remain` 相关的计算。
至此,NFT 代码的核心逻辑就已经讲完,还有一些比较简单的方法大家可以自行学习理解。
## 总结
本文介绍了 TON 上 NFT 的 Tact 实现。TON 上的 NFT 需要将 Collection 和 Item 拆分成两个合约,NFT 的每个 TokenId 就是一个 Item。用户对于 NFT 的转账操作只需要与 Item 合约交互即可,相对 Jetton 的转账逻辑比较简单。大家对于这部分还是要多看多思考,这样可以对 TON 上 NFT 的运行方式有更深的理解。
## 关于我
欢迎[和我交流](https://linktr.ee/xyymeeth)