Flow FT 与 NFT 标准合约中的最佳实践
在之前写过一篇关于 Flow NFT metadata 标准的文章,发现很有必要写一篇关于 FT 和 NFT 标准合约介绍,于是就有了这篇,不过本文并不是帮助读者能够零门槛学习标准合约,而是假设读者具备智能合约的开发经验,或者了解 Cadence 的基础语法知识。
为什么要学习标准合约
定义标准合约的目的是为了规范智能合约中基础合约和资产的规范,在以太坊智能合约体系里,ERC20 ERC721/1155 等资产标准被使用的最为广泛,这些标准也在 DeFi 和 NFT 发展中起到了巨大的推动作用。
不论是在以太坊还是 Flow 亦或是具备其他智能合约引擎的公链,他们都会有自己的资产标准,标准的起草和设计通常会经历非常多的考究和讨论,力求用最为简洁的方式解决通用和复杂的需求,不仅考虑现在已知的需求也要考虑未来可能的扩展,在软件设计领域,我们都会尝试通过分层设计来拆解复杂需求,在智能合约领域因为受合约部署后不可变的限制,对标准合约的设计会要求会更高,且更加偏向于单一职责标准合约可组合设计,也更加注重未来扩展。
下面我们就智能合约设计通用的特性来讲述标准的设计原则。
抽象 —— 简化复杂需求
抽象是标准合约的核心,通过抽象的方式来定义资源和数据,能够让标准合约在未来更具备组合性,被其他的合约引用且通过抽象的接口完成对实现(子合约)接口的调用。
这在面向对象的编程语言中较为常用的模式,子合约通过继承标准合约就拥有了与其兄弟合约相同的行为,但却有不同实现。
这对于想要基于标准合约开发第三方应用或基础设施类型的项目极其重要,开发者不需要关注实现标准合约的子合约的细节,只关注核心抽象的接口接入,降低了协作的复杂度,也是构建合约可组合的基础。
聚合 —— 统一标准接口
标准合约定义了一系列的资源和接口,根据不同的权限和访问控制需求将接口分离,并将分离的资源和接口聚合在资源中。
对实现标准的开发者来说,只需要继承标准合约的声明,且完成相应的实现,就完成了标准的引入,同样可以基于标准的接口添加不同的业务代码和逻辑,在同样的标准中实现不同的业务。
标准实现合约根据自身的需要设计新的接口并聚合在资源中,以达到满足自身业务需求的目的。
解耦 —— 灵活可扩展
智能合约的升级是一件非常复杂且敏感的事情,不同于以太坊文件引用的方式,Flow 网络中的合约引入是依赖链上部署的合约代码,所以升级已经部署的标准是非常审慎且复杂的过程。虽然目前 Flow 网络支持合约的升级,但涉及到存储和数据结构的变化,依然会造成问题导致升级失败。
那么为了能够在未来方便升级,标准合约需要拥有单一职责,且保持解耦,正如之前 Metadata 合约的升级,在不破坏主网中现有标准实现的 NFT 合约的前提下,以无侵入的方式能够让合约扩展和升级就尤为重要。
这也是我们在学习标准合约中需要理解且留意的重点,也是所有标准设计者需要思考并考虑的核心设计原则。
那么接下来的文章我们将从 Flow 标准合约中学习那些 Cadence 编写智能合约的最佳实践。
最佳实践
抛开标准设计和 Cadence 语法不谈,这里我们着重根据未来 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(无需授权)
这里方便大家理解,我把资源和接口的结构用示意图表示:
上图中在 Storage 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 实现。
条件判断
这里我们看标准合约中的一些前置和后置的条件判断:
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 的特性,它能做什么,不能做什么,当我们将其内化为自己的知识之后,设计合约乐高和更加复杂的业务就会得心应手。
2022-03-10