NFT底层技术必读:Flow FT 与 NFT 标准中的最佳实践
TinTin Meeting 由 TinTinLand 新设的线上栏目,聚焦区块链技术领域,邀请行业技术专家以及参与者共同探讨区块链技术的实践经验及建设成效,为开发者提供新思路、新方案。
第 5 期的 TinTinMeeting 技术分享主题是「Flow FT 与 NFT 标准中的最佳实践」。分享嘉宾为 Flow 中国区技术大使 Caos,同时他也是 TinTinLand 与 Flow 合作推出的「Flow DApp 开发入门课程—— 从初识 Cadence 到搭建 Marketplace」的课程助教。这次活动吸引了众多对NFT感兴趣的开发者,特别是新手开发者,他们表示从技术层面对于 NFT 和 FT 有了更系统的认知。
会上对于为什么要学习标准合约以及 FT 和 NFT 的标准合约是什么,分享嘉宾 Caos 从抽象、聚合和解耦三个角度给出了非常实用的解读和分析。同时针对 Flow 为数字资产打造的智能合约编程语言 “Cadence” ,Caos 也进行了实战演练和分享。
以下是分享的全部:
在之前写过一篇关于 Flow NFT metadata 标准的文章,发现很有必要写一篇关于 FT 和 NFT 标准合约介绍,于是就有了这篇,不过本文并不是帮助读者能够零门槛学习标准合约,而是假设读者具备智能合约的开发经验,或者了解 Cadence 的基础语法知识。
为什么要学习标准合约
为什么要学习标准合约
定义标准合约的目的是为了对智能合约中基础合约和资产的规范,在以太坊智能合约体系里,ERC20、ERC721/1155 等资产标准被使用的最为广泛,这些标准也在 DeFi 和 NFT 发展中起到了巨大的推动作用。
Flow 中的 FT 与 NFT 也是如此,但因 Cadence 面向资源的编程思想,其实现与以太坊完全不同,需要通过 Cadence 语言实现的资产标准合约来完成同样的事情。
不论是在以太坊还是 Flow 亦或是具备其他智能合约引擎的公链,他们都会有自己的资产标准,标准的起草和设计通常会经历非常多的考究和讨论,力求用最为简介的方式解决通用和复杂的需求,不仅考虑现在已知的需求也要考虑未来可能的扩展,在软件设计领域,我们都会尝试通过分层设计来拆解复杂需求,在智能合约领域因为受合约部署后不可变的限制,对标准合约的设计会要求会更高,且更加偏向于单一职责标准合约可组合设计,也更加注重未来扩展。
这在面向对象的编程语言中较为常用的模式,子合约通过继承标准合约就拥有了与其兄弟合约相同的行为,但却有不同实现。
标准合约定义了一系列的资源和接口,根据不同的权限和访问控制需求将接口分离,并将分离的资源和接口聚合在资源中。
对实现标准的开发者来说,只需要继承标准合约的声明,且完成相应的实现,就完成了标准的引入,同样可以基于标准的接口添加不同的业务代码和逻辑,在同样的标准中实现不同的业务。
标准实现合约根据自身的需要设计新的接口并聚合在资源中,以达到满足自身业务需求的目的。
智能合约的升级是一件非常复杂且敏感的事情,不同于以太坊文件引用的方式,Flow 网络中的合约引入是依赖链上部署的合约代码,所以升级已经部署的标准是非常审慎且复杂的过程。虽然目前 Flow 网络支持合约的升级,但涉及到存储和数据结构的变化,依然会造成问题导致升级失败。
那么为了能够在未来方便升级,标准合约需要拥有单一职责,且保持解耦,正如之前 Metadata 合约的升级,在不破坏主网中现有标准实现的 NFT 合约的前提下,以无侵入的方式能够让合约扩展和升级就尤为重要。
这也是我们在学习标准合约中需要理解且留意的重点,也是所有标准设计者需要思考并考虑的核心设计原则。
那么接下来我们将从 Flow 标准合约中学习那些 Cadence 编写智能合约的最佳实践。
最佳实践
最佳实践
权限控制
因为面向资源的特性,Cadence 中的函数和方法会定义在资源中,如此一来,权限控制的粒度也需要细化到资源的方法级别,我们来看 FT 标准合约的代码。
pub resource Vault: Provider, Receiver, Balance {
pub var balance: UFix64
init(balance: UFix64)
/// withdraw subtracts `amount` from the Vault's balance
/// and returns a new Vault with the subtracted balance
pub fun withdraw(amount: UFix64): @Vault {
//...
}
/// deposit takes a Vault and adds its balance to the balance of this Vault
pub fun deposit(from: @Vault) {
// Assert that the concrete type of the deposited vault is the same
// as the vault that is accepting the deposit
}
}
这里定义了一个名为 Vault
的资源用来当做合约代币持有人的「钱包」,在资源里存储了一个公开的余额字段(可读不可写),一个初始化函数和两个 token 转账需要用到的核心函数 withdraw
与 deposit
。
这些核心函数和变量其实是继承自 Provider
, Receiver
, Balance
三个资源接口:
pub resource interface Provider {
pub fun withdraw(amount: UFix64): @Vault {
//...
}
}
pub resource interface Receiver {
pub fun deposit(from: @Vault)
}
pub resource interface Balance {
pub var balance: UFix64
init(balance: UFix64) {
// ...
}
}
熟悉 Cadence 权限控制的读者应该不难发现,这里的资源接口权限修饰符都是 pub
,那么如何控制权限的呢,如果任意第三方获取到用户的 Vault
资源是不是可以直接调用 withdraw
函数完成提款呢?
其实在标准合约里并没有涉及到权限控制的代码,但不代表其无法进行权限控制,我们看标准实现的示例合约代码:
pub resource Vault: FungibleToken.Provider, FungibleToken.Receiver, FungibleToken.Balance {
// ...
}
init() {
self.totalSupply = 1000.0
// 1. init vault with total supply
let vault <- create Vault(balance: self.totalSupply)
// 2. save resource to user's storage path
self.account.save(<-vault, to: /storage/exampleTokenVault)
// 3. link Receiver interface to public path
self.account.link<&{FungibleToken.Receiver}>(
/public/exampleTokenReceiver,
target: /storage/exampleTokenVault
)
// 4. link Balance interface to public path
self.account.link<&ExampleToken.Vault{FungibleToken.Balance}>(
/public/exampleTokenBalance,
target: /storage/exampleTokenVault
)
}
Vault
资源继承了之前提到的标准合约 FungibleToken
的标准接口,同时也实现了其接口对应的函数(这里做了省略处理)。
注释1位置的代码初始化了 Vault
资源并在注释2位置把它通过 self.account.save()
存入了只有用户自己可以访问的 /storage/exampleTokenVault
私有 Storage Path 中,关于用户账户的资源路径,可以看这篇文档。
注释3和4位置将 FungibleToken.Receiver
与 FungibleToken.Balance
两种类型的资源接口 link 到了当前账户的 /public/
公开 Public Path 中
这样外部使用者只能通过公开的 Path 获取到 Vault
资源的 Receiver
与 Balance
两个资源接口类型,对应的充值和查询余额的函数。
而 withdraw
函数因为没有被 link 到任何的公开 path 中,所以也只能由 Vault
资源的持有人进行调用,这样就保证了权限和资产安全。
这里我们附上 Token 转账的交易脚本来看看转账的过程是如何操作资源和权限控制的:
import FungibleToken from 0xFUNGIBLETOKENADDRESS
import ExampleToken from 0xTOKENADDRESS
transaction(amount: UFix64, to: Address) {
// The Vault resource that holds the tokens that are being transferred
let sentVault: @FungibleToken.Vault
prepare(signer: AuthAccount) {
// Get a reference to the signer's stored vault
// 1. Get vault resource reference from sender, need auth
let vaultRef = signer.borrow<&ExampleToken.Vault>(from: /storage/exampleTokenVault)
?? panic("Could not borrow reference to the owner's Vault!")
// Withdraw tokens from the signer's stored vault
// 2. Set vault resource reference with withdraw vault
self.sentVault <- vaultRef.withdraw(amount: amount)
}
execute {
// Get the recipient's public account object
let recipient = getAccount(to)
// Get a reference to the recipient's Receiver
// 3. Get pub receiver reference from receiver's account public path
let receiverRef = recipient.getCapability(/public/exampleTokenReceiver)
.borrow<&{FungibleToken.Receiver}>()
?? panic("Could not borrow receiver reference to the recipient's Vault")
// Deposit the withdrawn tokens in the recipient's receiver
// 4. Call receiver's vault deposit function
receiverRef.deposit(from: <-self.sentVault)
}
}
上面的代码实现了转账的交易脚本,根据注释的编号,分为以下部分:
从发送者 Sender(交易授权者)的账户中获得
/storage/
path 的Vault
资源,这个步骤是权限受限的。调用
Vault
资源中的withdraw
方法提取出对应包含转账金额的临时Vault
资源。在执行环节根据接收者 Receiver(无需权限)的地址获得他的公开账户,然后根据
/public/
path 中的FungibleToken.Receiver
类型的接口借出接收人公开的Vault
资源引用(这里的Vault
和上一个不同,是通过公开的接口暴露出来的,Receiver
类型的接口只有deposit
方法)。调用接收方资源的
deposit
函数,完成转账操作。
注意:这里的转账脚本并不是操作合约的代码完成,而是调用发送者授权资源的提现方法和接收者公开资源的充值方法,以资源为中心,点对点的方式完成了代币的转移。
这里我们可以发现,标准合约并不会控制权限,而是在实现层面给权限控制预留空间,这里我们就要引入接口分离的实践。
接口分离
接口分离
上文提到权限控制的核心在于标准实现的合约把资源中的接口拆分成不同的权限接口(interface)存储在用户账户不同的 Path 中完成。
Provider(需授权调用)
Receiver(无需授权)
Balance(无需授权)
这里方便大家理解,我把资源和接口的结构用示意图表示:
上图中在 Private path 中的资源只允许资源所有人可以访问调用,其中 Vault
资源的 Provider
接口提供了 Withdraw
方法,其他的两种公开的接口 Receiver
和 Balance
则通过 link 方法存储到 PublicPath
中,供外部访问。
那么标准合约中将三类接口分开定义,又在标准的实现中用 Vault
分别继承的原因就显而易见了。
这里我们再看一下在 FT 和 NFT 发生资产转移的时候的示意图:
不论是 Vault 还是 Collection 资源,都继承了权限分离的接口,根据权限存储在用户不同的 path 下,在转账的时候通过授权方授权接口的调用,和接收方公开接口的调用,完成资源点对点的转移。
这就是分离接口的优势,在适当的情况下,将权限控制在不同的接口中,可以在不牺牲安全的前提下,提高接口的扩展性。
资源嵌套
资源嵌套
资源嵌套是 Cadence 中非常重要的特性,也是区别与以太坊合约的核心特性,读者可以从 How Cadence and Flow will revolutionize smart contract programming 这篇文章了解其中的细节,简单来说资源它们是真实的东西 —— 一个代币的金库,一个 NBA Topshot 瞬间而且它们存储在所有者的帐户中,如上图中的 Vault
和 Collection
资源。
包括单个的 NFT 资产也是资源:
// NFT resource
pub resource NFT {
pub let id: UInt64
pub var metadata: {String: String}
...
}
从技术的角度来说,资源类型类似于类 —— 它们表示数据和函数的集合。但它们对开发人员如何处理它们引入了严格的规则:
资源在同一时间只能存在于一个确切的位置
资源无法被复制
资源必须被明确的销毁
这可以防止资源的有害复制和意外删除,使其非常适合区块链应用程序。移动操作符是用于传输资源的特殊操作符,它在处理资源时提供直观的视觉提示。
资源的嵌套在 NFT 标准合约中的实践是这样:
pub resource Collection: Provider, Receiver, CollectionPublic {
// Dictionary to hold the NFTs in the Collection
// 1. Store user's NFTs as a map
pub var ownedNFTs: @{UInt64: NFT}
// withdraw removes an NFT from the collection and moves it to the caller
// 2. Withdraw NFT resource form ownedNFTs
pub fun withdraw(withdrawID: UInt64): @NFT
// deposit takes a NFT and adds it to the collections dictionary
// and adds the ID to the id array
// 3. Deposit NFT resource to ownedNFTs
pub fun deposit(token: @NFT)
// ...
}
标准合约在 Collection
资源中定义了一个嵌套的结构,用 ownedNFTs
来存储用户账户下的 NFT 资产。同样提供了集合的接口去实现 NFT 资产的转入和转出。
这里就使用到了资源的嵌套,Collection
作为一个集合资源,会管理内嵌的 NFT 资源,并提供接口方便第三方查询和所有者授权调用,资源转移的代码如下:
// withdraw removes an NFT from the collection and moves it to the caller
pub fun withdraw(withdrawID: UInt64): @NonFungibleToken.NFT {
// 1. Take NFT resource from ownedNFTs
let token <- self.ownedNFTs.remove(key: withdrawID) ?? panic("missing NFT")
emit Withdraw(id: token.id, from: self.owner?.address)
// 2. return NFT resource
return <-token
}
// deposit takes a NFT and adds it to the collections dictionary
// and adds the ID to the id array
pub fun deposit(token: @NonFungibleToken.NFT) {
// 3. Convert type from super resource to resource
let token <- token as! @ExampleNFT.NFT
let id: UInt64 = token.id
// add the new token to the dictionary which removes the old one
// 4. Save NFT resource to ownedNFTs
let oldToken <- self.ownedNFTs[id] <- token
emit Deposit(id: id, to: self.owner?.address)
destroy oldToken
}
具体 NFT 的转移可以参考之前的图例和代码注释中的描述。这里我们注意到注释 3 的位置进行的变量的类型转换,也引出我们对类型转换的实践。
类型转换
类型转换
类型转换中包含了资源 (Resource) 类型的转换,和资源中能力 (Capability) 类型的转换,上文中在 NFT 转账的代码里因为在标准合约的抽象接口中,其类型声明是@NonFungibleToken.NFT
而实际在实现了标准的合约中,我们需要将类型进行转换,尤其是实现了标准且在自己 NFT 合约中添加了自定义字段的 NFT 资源。
这里的资源是为了将标准中定义的函数所传递的类型进行转换,并存储:
pub resource Collection: ExampleNFTCollectionPublic, NonFungibleToken.Provider, NonFungibleToken.Receiver, NonFungibleToken.CollectionPublic, MetadataViews.ResolverCollection {
// dictionary of NFT conforming tokens
// NFT is a resource type with an `UInt64` ID field
pub var ownedNFTs: @{UInt64: NonFungibleToken.NFT}
init () {
self.ownedNFTs <- {}
}
// ...
// deposit takes a NFT and adds it to the collections dictionary
// and adds the ID to the id array
pub fun deposit(token: @NonFungibleToken.NFT) {
// 1. Force down convert
let token <- token as! @ExampleNFT.NFT
let id: UInt64 = token.id
// add the new token to the dictionary which removes the old one
let oldToken <- self.ownedNFTs[id] <- token
emit Deposit(id: id, to: self.owner?.address)
destroy oldToken
}
// borrowNFT gets a reference to an NFT in the collection
// so that the caller can read its metadata and call its methods
pub fun borrowNFT(id: UInt64): &NonFungibleToken.NFT {
// 2. Up convert
return &self.ownedNFTs[id] as &NonFungibleToken.NFT
}
pub fun borrowExampleNFT(id: UInt64): &ExampleNFT.NFT? {
if self.ownedNFTs[id] != nil {
// Create an authorized reference to allow downcasting
// 3. convert with owner's auth
let ref = &self.ownedNFTs[id] as auth &NonFungibleToken.NFT
return ref as! &ExampleNFT.NFT
}
return nil
}
//...
}
上述代码分为不同形式的转换:
注释一:向下转换,将抽象的资源类型,强制转换成实现的资源类型,因为
@ExampleNFT.NFT
与@NonFungibleToken.NFT
都实现了相同的父类型资源@NonFungibleToken.INFT
所以他们之间是可以正常的强制转换。
// Interface that the NFTs have to conform to
//
pub resource interface INFT {
// The unique ID that each NFT has
pub let id: UInt64
}
// Requirement that all conforming NFT smart contracts have
// to define a resource called NFT that conforms to INFT
pub resource NFT: INFT {
pub let id: UInt64
}
pub resource NFT: NonFungibleToken.INFT, MetadataViews.Resolver {
pub let id: UInt64
// ...
}
注释二:因为我们在存储的时候使用的向下转换,那么为了满足标准接口的需求,我们必须在通过
borrow
函数获得引用之后再进行一次向上类型转换注释三:资源引用的授权转换,这里因为会直接返回资源的引用类型,能够有权限调用资源中的函数,那么就需要使用
auth
修饰符,声明需要owner
授权才可以完成转换。
当然我们也可以再资源的返回值上定义资源所实现的接口类型(&{Capability}),来完成自动的类型转换,适用于对添加了资源的接口和能力的情况,这里就不过多的描述,感兴趣的同学可以参考 FLowns 的 Domains NFT 实现。
条件判断
条件判断是合约中非常重要的环节,他会帮助我们检查代码执行的前置和后置状态,已达到合约函数级别的安全,同时结合 Cadence 中 Assert 内置函数,可以大大提高开发和调试的效率。
这里我们看标准合约中的一些前置和后置的条件判断:
pub fun withdraw(amount: UFix64): @Vault {
// 1. Check balance enough or not before transfer
pre {
self.balance >= amount:
"Amount withdrawn must be less than or equal than the balance of the Vault"
}
// 2. Check balance after trasfer
post {
// use the special function `before` to get the value of the `balance` field
// at the beginning of the function execution
self.balance == before(self.balance) - amount:
"New Vault balance must be the difference of the previous balance and the withdrawn Vault"
}
}
/// deposit takes a Vault and adds its balance to the balance of this Vault
pub fun deposit(from: @Vault) {
// Assert that the concrete type of the deposited vault is the same
// as the vault that is accepting the deposit\\
// 3. Check Vault type before deposit
pre {
from.isInstance(self.getType()):
"Cannot deposit an incompatible token type"
}
// 4. Check balance after deposite
post {
self.balance == before(self.balance) + before(from.balance):
"New Vault balance must be the sum of the previous balance and the deposited Vault"
}
}
pub fun createEmptyVault(): @Vault {
// 5. Check vault balance after resource init
post {
result.balance == 0.0: "The newly created Vault must have zero balance"
}
}
在标准合约中定义的 Pre
与 Post
检查块,也会被继承了相同函数的子合约执行,所以实现了标准的合约,可以不需要关注可能会导致漏洞的检查,同时也可以根据自身的需求在函数中添加自己的 Pre
和 Post
条件,并不会覆盖标准合约的检查。
总结
总结
FT 和 NFT 的标准合约代码是值得我们在 Cadence 开发的各个阶段去学习的优秀示例,标准合约作为资产合约的基础,也是我们需要着重去学习和理解的基础模块,可以在此基础上进行扩展和改写,也帮助我们更加深入的理解 Cadence 和面向资源的编程思想,充分理解 Cadence 的特性,它能做什么,不能做什么,当我们将其内化为自己的知识之后,设计合约乐高和更加复杂的业务就会得心应手。
目前 TinTinLand 与 Flow 合作的「Flow DApp 开发入门课程—— 从初识 Cadence 到搭建 Marketplace」开发课程已正式上线,有 110 名学员参与了本次的课程。关于这门课程大家可以通过 Flow 课程的开营仪式获得更多的了解(https://www.bilibili.com/video/BV1qa411h7g6?spm_id_from=333.999.0.0)。
课程班级讨论在 Discord 上进行,欢迎大家加入 TinTinLand Discord 的 announcement 频道里和 Flow 的开发小伙伴们一起参与技术讨论——https://discord.com/invite/kmPnTDSFu8。
往期精彩
数字资产理想模型|Cadence 面向资源的编程范式基础介绍
关于我们
ABOUT US
TinTinLand 是赋能下一代开发者的技术社区,通过聚集、培育、输送开发者到各开放网络,共同定义并构建未来。
Discord: https://discord.gg/kmPnTDSFu8
Twitter: https://twitter.com/Tintinland2021
Bilibili: https://space.bilibili.com/1152852334
Medium: https://medium.com/@tintin.land2021
YouTube: https://www.youtube.com/channel/UCDpcMcnfYHHdvn8ym10cGlA