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: UFix64init(balance: UFix64)/// withdraw subtracts `amount` from the Vault's balance/// and returns a new Vault with the subtracted balancepub fun withdraw(amount: UFix64): {//...}/// deposit takes a Vault and adds its balance to the balance of this Vaultpub 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): {//...}}pub resource interface Receiver {pub fun deposit(from: @Vault)}pub resource interface Balance {pub var balance: UFix64init(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 supplylet vault <- create Vault(balance: self.totalSupply)// 2. save resource to user's storage pathself.account.save(<-vault, to: /storage/exampleTokenVault)// 3. link Receiver interface to public pathself.account.link<&{FungibleToken.Receiver}>(/public/exampleTokenReceiver,target: /storage/exampleTokenVault)// 4. link Balance interface to public pathself.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 0xFUNGIBLETOKENADDRESSimport ExampleToken from 0xTOKENADDRESStransaction(amount: UFix64, to: Address) {// The Vault resource that holds the tokens that are being transferredlet sentVault: .Vaultprepare(signer: AuthAccount) {// Get a reference to the signer's stored vault// 1. Get vault resource reference from sender, need authlet 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 vaultself.sentVault <- vaultRef.withdraw(amount: amount)}execute {// Get the recipient's public account objectlet recipient = getAccount(to)// Get a reference to the recipient's Receiver// 3. Get pub receiver reference from receiver's account public pathlet 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 functionreceiverRef.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 resourcepub resource NFT {pub let id: UInt64pub 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 mappub var ownedNFTs: @{UInt64: NFT}// withdraw removes an NFT from the collection and moves it to the caller// 2. Withdraw NFT resource form ownedNFTspub fun withdraw(withdrawID: UInt64):// deposit takes a NFT and adds it to the collections dictionary// and adds the ID to the id array// 3. Deposit NFT resource to ownedNFTspub fun deposit(token: @NFT)// ...}
标准合约在 Collection 资源中定义了一个嵌套的结构,用 ownedNFTs 来存储用户账户下的 NFT 资产。同样提供了集合的接口去实现 NFT 资产的转入和转出。
这里就使用到了资源的嵌套,Collection 作为一个集合资源,会管理内嵌的 NFT 资源,并提供接口方便第三方查询和所有者授权调用,资源转移的代码如下:
// withdraw removes an NFT from the collection and moves it to the callerpub fun withdraw(withdrawID: UInt64): .NFT {// 1. Take NFT resource from ownedNFTslet token <- self.ownedNFTs.remove(key: withdrawID) ?? panic("missing NFT")emit Withdraw(id: token.id, from: self.owner?.address)// 2. return NFT resourcereturn <-token}// deposit takes a NFT and adds it to the collections dictionary// and adds the ID to the id arraypub fun deposit(token: @NonFungibleToken.NFT) {// 3. Convert type from super resource to resourcelet token <- token as! .NFTlet id: UInt64 = token.id// add the new token to the dictionary which removes the old one// 4. Save NFT resource to ownedNFTslet oldToken <- self.ownedNFTs[id] <- tokenemit 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 fieldpub 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 arraypub fun deposit(token: @NonFungibleToken.NFT) {// 1. Force down convertlet token <- token as! .NFTlet id: UInt64 = token.id// add the new token to the dictionary which removes the old onelet oldToken <- self.ownedNFTs[id] <- tokenemit 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 methodspub fun borrowNFT(id: UInt64): &NonFungibleToken.NFT {// 2. Up convertreturn &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 authlet ref = &self.ownedNFTs[id] as auth &NonFungibleToken.NFTreturn 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 haspub let id: UInt64}// Requirement that all conforming NFT smart contracts have// to define a resource called NFT that conforms to INFTpub 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): {// 1. Check balance enough or not before transferpre {self.balance >= amount:"Amount withdrawn must be less than or equal than the balance of the Vault"}// 2. Check balance after trasferpost {// use the special function `before` to get the value of the `balance` field// at the beginning of the function executionself.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 Vaultpub 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 depositpre {from.isInstance(self.getType()):"Cannot deposit an incompatible token type"}// 4. Check balance after depositepost {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(): {// 5. Check vault balance after resource initpost {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 是赋能下一代开发者的技术社区,通过聚集、培育、输送开发者到各开放网络,共同定义并构建未来。