Try   HackMD

本来我们来学习如何使用 Tact 编写 NFT 合约。在继续之前,建议先阅读上一篇关于编写 Jetton 的文章,有很多概念和细节我们在这篇文章就不再重复了。

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

每套 Collection 包含一个 Collection 合约和 N 个 Item 合约,每个 Item 合约拥有自己的 owner。

NFT 标准范式

TON 为 NFT 制定了一套标准,常用的是 TEP-62,我们在编写 NFT 时,都要遵循这个范式。

编写 NFT 合约

本文继续参考 Ton-Dynasty 的 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 的标准,Jetton 的 Metadata 也遵循该标准。

纵观 NFTCollectionStandard 合约,可以看到它其实主要是包含了一些 get 方法,方便为链下提供相关的数据。

我们以 get_collection_data 为例,看看其中的一些技术细节:

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

NFT 的 Owner 向 Collection 合约发送 Mint 消息,随后再流转到 Item 合约。Mint 接收方法如下:

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 时发送的消息要附带 codedata。如果该合约还没有被部署,则使用该 codedata 来部署合约示例,如果合约之前已经部署了,则忽略。

可以看到这里发送的是 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 也是使用的该方法。

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 的实现:

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

可以看到,用户 Transfer 时,只与 Item 合约交互。之后便向新 owner 的钱包发送 transfer notification 消息,以及返还 Gas。与 Jetton 的 Transfer 相比,简单许多。

回到上面 receive(msg: Transfer) 方法的 transfer 部分:

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 的运行方式有更深的理解。

关于我

欢迎和我交流