本来我们来学习如何使用 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)