Flowns 和 Lilico 基于 Cadence 可组合性的探索

Tags
Cadence
Tech
Flow
Published
Jul 11, 2022 01:21 PM
Language
ZH
notion image
近两个月,FlownsLilico 都在尝试将 Flowns 域名整合至 Lilico 非托管的钱包体系中,帮助用户提高更加流畅的转账和接受资产的体验。 Lilico 钱包使用 Flowns 域名,作为用户资产的 Inbox,资产的发送方和接收方都不需要担心账户初始化资源的问题,就能够像以太坊的地址那样,开启账户接受任意类型的 FT 和 NFT 资产的能力。

为什么需要这么做

Cadence 与其他智能合约语言的核心区别是面向资源的编程理念,也是其能够通过资源嵌套的方式实现更加复杂且灵活资产场景的优势。
也正是因为 Flow 面向资源的特性,用户无法在未初始化资产资源的情况下,接收来自发送方的资产转账,那么通过一个类似于万能的 Inbox 来做资产的缓存,就非常有必要。
Flowns 在设计之初,就把 Domain NFT 当做一个链上数据和资产存储的载体,希望通过资源的内嵌去满足更多产可组合性和可扩展性的场景的需求, 帮助用户缓存未初始化的资产资源是一个非常典型且需要解决的问题。
当然实现这个之前必须能够做到以下几点:
  • 内嵌资产的所有权归属于 Inbox 资产的所有权,也就是域名资产的拥有人
  • 内嵌资产只能由外部存入,且不能由拥有人之外的人取出
  • 内嵌资产只能由拥有人取出,且取出时需要支持同时初始化好资产存储所需要的资源
这么看来 Flowns 域名的资产内嵌比较符合这样的需求场景,且附带了额外的功能
  • 内嵌资产可以随着域名资产的转移而转移所有权
  • 内嵌资产在域名资产过期失效时,依然可以进行取出操作
  • 域名即便失效,或失效之后相同的域名被其他人注册,Inbox 依然保存在原有的拥有人账户下,且任何其他人都无权限使用该资源

Lilico 和 Flowns 做了什么

Lilico 通过 Flowns 域名的可组合性,为用户打造了一个能够接受任意资产转账的 Inbox,对于 Lilico 用户来说,资产的转账流程会多了一个后备的选项,如果接收方没有初始化资源,只要他拥有 Flowns 域名,那么就会启用 Inbox 的功能,帮助用户接受未初始化的资源。
整个流程将是这样:
  • 用户通过 Lilico 钱包离线生成助记词,并设置 Lilico 服务的用户名
  • Lilico 作为代理账户帮助用户创建主网和测试网的 Flow 地址
  • 用户通过 Claim .meow 的根域名获取和自己设置用户名对应的 Flowns 域名
    • 由用户发起,Lilico 签名和 Flowns 授权,三方完成授权多签并发行 .meow 域名给用户
    • 拿到域名之后,用户就具备了开启 Inbox 的能力
  • 任何第三方资产发送方,都会调用一个带有 Inbox 存入的判断脚本,可以将资产发送至未初始化接收资源用户的账户下
  • 如果是 Inbox 接收到了资产,用户可以通过查询域名状态看到资产信息, 可以选择将域名中的资产通过附带初始化的脚本转移到账户资源下。
看起来,是一个比较简单的功能整合,其中还涉及到很多技术的细节需要解决,比如如何让没有域名的新用户解锁 Inbox 功能,如何通过三方的签名去鉴权执行第一次的域名分发,如何能够动态的根据 FT 与 NFT 标准去支持未来的多种资产,以及如何防止薅羊毛等无效的注册和用户。
如果你对这其中的技术细节感兴趣,请看这篇

代码时间

前面我们已经把一些技术流程罗列清楚,不过还是要基于一些代码细节来了解如何实现了资产内嵌和 Inbox 的特性,这里包含了几个部分:
  • 基于 Flowns domain NFT 资产内嵌
  • Inbox 交易 Fallback 脚本的实现细节

基于 Flowns domain NFT 资产内嵌

在不久之前的 Cadence 大版本更新之后,Flowns 的合约也做了相应的更新,源码请看 Flowns contracts
Domains 合约中我们可以看到如下的代码:
pub resource NFT: DomainPublic, DomainPrivate, NonFungibleToken.INFT{ pub let id: UInt64 pub let name: String pub let nameHash: String pub let createdAt: UFix64 pub let parent: String pub var subdomainCount: UInt64 pub var receivable: Bool // .... // // Fungible Token 内嵌存储 access(self) var vaults: @{String: FungibleToken.Vault} // NonFungible Token 内嵌存储 access(self) var collections: @{String: NonFungibleToken.Collection} init(id: UInt64, name: String, nameHash: String, parent: String) { // ..../ } // .... //
这里我们看到 Domains NFT 资源中定义了两个 Mapping 的资源结构 vaultscollections,这两个结构是我们实现资源内嵌的基础,注意这里的修饰符使用了 access(self) , 是为了防止一些公开字段的安全隐患,只允许通过 NFT 资源自身的接口进行 Mapping 内容的修改。
Mapping 资源结构采用了 @{identifier : resource} 的形式来明确存储的类型,同时根据存储资源的「资源类型识别符」明确资源存储的位置。
关于 identifier 的信息,大家可以从这里了解更多细节,总得来说,Cadence 会根据资源的 identifier 来明确资产的类型,它作为资源的 ID 用来区分与其他资源的区别。
所以针对 FT 我们使用 Vault.getType().identifier 类似于这样的值 A.0ae53cb6e3f42a79.FlowToken.Vault 针对 NFT 我们使用 Collection.getType().identifier -> A.0afe396ebc8eee65.FLOAT.Collection 通过资源附带的信息作为唯一的 Key 来定位其在 Mapping 中的位置
这样做的好处在于,对于客户端或者查询者来说,他可以直接通过 Mapping key 中包含的信息,就明确的知道当前内嵌的资源来自什么地址,合约,和资源类型,便于第三方开发者识别出资产的归属。
既然有了存储的结构,当然少不了存取的方法

FT 资产的存入与取出

// 向 Domain NFT 资源中存入任意类型的 FT 资产 pub fun depositVault(from: @FungibleToken.Vault, senderRef: &{FungibleToken.Receiver}?) { pre { // 域名服务有效期的前置判断 !Domains.isExpired(self.nameHash) : Domains.domainExpiredTip !Domains.isDeprecated(nameHash: self.nameHash, domainId: self.id) : Domains.domainDeprecatedTip // 用户可以手动打开或关闭 Inbox, 默认开启状态 self.receivable : "Domain is not receivable" } // 通过资源类型获取 identifier let typeKey = from.getType().identifier let amount = from.balance let address = from.owner?.address // 根据 key 判断是否已经存在资源,多次接收的情况 if self.vaults[typeKey] == nil { // 首次存入 self.vaults[typeKey] <-! from } else { let vault = (&self.vaults[typeKey] as &FungibleToken.Vault?)! // 多次存入 vault.deposit(from: <- from) } emit DomainVaultDeposited(nameHash: self.nameHash, vaultType: typeKey, amount: amount, from: senderRef?.owner?.address ) } // 将 Domain NFT 资源中的资产提出 pub fun withdrawVault(key: String, amount: UFix64): @FungibleToken.Vault { // 前置条件判断 pre { self.vaults[key] != nil : "Vault not exsit..." } // 获取资源的引用 let vaultRef = (&self.vaults[key] as &FungibleToken.Vault?)! let balance = vaultRef.balance var withdrawAmount = amount // 判断提出的数额,为 0 则默认全部提出 if amount == 0.0 { withdrawAmount = balance } emit DomainVaultWithdrawn(nameHash: self.nameHash, vaultType: key, amount: balance, from: Domains.getRecords(self.nameHash)) // 返回 Vault 资源 return <- vaultRef.withdraw(amount: withdrawAmount) }
以上代码是处理 FT 的存入和取出的逻辑,具体的操作请看注释内容,这里两个方法都是 pub fun 的前缀,不过这里似乎是有一个权限的问题,我们后面会详细说明。
因为这里存入方法是公开的,所以存入时候使用资源获取 identifier 可以有效的避免被攻击和欺诈的风险,也保证了 Mapping 的 Key 都是唯一的,不会被恶意覆盖。

NFT 资产的存入和取出

// 初始化 NFT 集合资源 pub fun addCollection(collection: @NonFungibleToken.Collection) { // 同样是域名服务的有效性判断 pre { !Domains.isExpired(self.nameHash) : Domains.domainExpiredTip !Domains.isDeprecated(nameHash: self.nameHash, domainId: self.id) : Domains.domainDeprecatedTip self.receivable : "Domain is not receivable" } // 获取 Collection 的类型 identifier let typeKey = collection.getType().identifier // 判断是否是 Domain 类型的集合,不允许 Domain 内嵌 if collection.isInstance(Type<@Domains.Collection>()) { panic("Do not nest domain resource") } let address = collection.owner?.address // 判断是否是第一次初始化 Collection 资源 if self.collections[typeKey] == nil { self.collections[typeKey] <-! collection emit DomainCollectionAdded(nameHash: self.nameHash, collectionType: typeKey) } else { // 判断如果 Collection 中有 NFT 则抛出异常 if collection.getIDs().length > 0 { panic("collection not empty ") } // 销毁初始化的资源 destroy collection } } // 向 Domain NFT 存入 NFT 资产 pub fun depositNFT(key: String, token: @NonFungibleToken.NFT, senderRef: &{NonFungibleToken.CollectionPublic}?) { // 存储判断 pre { self.collections[key] != nil : "Cannot find NFT collection..." !Domains.isExpired(self.nameHash) : Domains.domainExpiredTip !Domains.isDeprecated(nameHash: self.nameHash, domainId: self.id) : Domains.domainDeprecatedTip } // 获得 Collection 的引用 let collectionRef = (&self.collections[key] as &NonFungibleToken.Collection?)! emit DomainCollectionDeposited(nameHash: self.nameHash, collectionType: key, itemId: token.id, from: senderRef?.owner?.address) // 存入 NFT 这里会自动的进行 key 和 NFT 资源类型的校验和判断 collectionRef.deposit(token: <- token) } // 从 Domain NFT 取出 NFT 资产 pub fun withdrawNFT(key: String, itemId: UInt64): @NonFungibleToken.NFT { pre { self.collections[key] != nil : "Cannot find NFT collection..." } // 获得引用 let collectionRef = (&self.collections[key] as &NonFungibleToken.Collection?)! emit DomainCollectionWithdrawn(nameHash: self.nameHash, collectionType: key, itemId: itemId, from: Domains.getRecords(self.nameHash)) // 提取出 NFT 资产 return <- collectionRef.withdraw(withdrawID: itemId) }
这里的 NFT 存取和 FT 略微有些不同,因为 FT 和 NFT 合约标准的差异,NFT 需要内嵌至 Collection 资源作为集合容器去管理,所以我们将存入的操作分成了两步,一个是初始化 Collection ,另一个是向 Collection 存入 NFT,这里其实也可以在脚本中合并到一起,把两步合并成一步。
为了安全起见,depositNFT 传入了 Collection type 的 identifier 作为 key, 但是这里没有做任何的校验,其实在调用 Collection deposit 函数的时候就会进行一次类型的比对,如果外部用户传入的 key 和实际的 token 资源不匹配,Cadence 会抛出执行异常,
函数的实现我们讲完了还有个问题没有解决 —— 同样都是 pub fun 存入取出的权限在哪里控制呢?
其实我们在定义资源接口的时候就把权限划分开了:
公开访问
// 对外暴露的公开可访问函数 pub resource interface DomainPublic { // ... // 存入 FT Vault pub fun depositVault(from: @FungibleToken.Vault, senderRef: &{FungibleToken.Receiver}?) // 初始化 Collection pub fun addCollection(collection: @NonFungibleToken.Collection) // 检查 Collection 初始化情况 pub fun checkCollection(key: String): Bool // 存入 NFT pub fun depositNFT(key: String, token:@NonFungibleToken.NFT, senderRef: &{NonFungibleToken.CollectionPublic}?) }
私有访问
// 只有拥有者可以调用的函数 pub resource interface DomainPrivate { // ... // 提取 FT pub fun withdrawVault(key: String, amount: UFix64): @FungibleToken.Vault // 提取 NFT pub fun withdrawNFT(key: String, itemId: UInt64): @NonFungibleToken.NFT // 开关 Inbox pub fun setReceivable(_ flag: Bool) }
这里我们看到 DomainPublicDomainPrivate 都进行了接口的划分而且通过不同的借用函数暴露出来,达到了不同的权限控制。
来自外部的用户只能访问到 DomainPublic 中的方法,而用户可访问的 borrowDomainPrivate 函数则通过存储在 private 路径中的 CollectionPrivate 访问到

Inbox 交易 Fallback 脚本的实现细节

接下来我们一起看看如何通过灵活的 Cadence 脚本来实现转账交易的 fallback Inbox 的功能。 老样子,先来看 FT 的交易脚本

FT 转账加 Inbox fallback

import FungibleToken from 0xFungibleToken import Domains from 0xFlowns import <Token> from <TokenAddress> transaction(amount: UFix64, recipient: Address) { // The Vault resource that holds the tokens that are being transfered let senderRef: &{FungibleToken.Receiver} let sentVault: @FungibleToken.Vault let sender: Address prepare(signer: AuthAccount) { // Get a reference to the signer's stored vault let vaultRef = signer.borrow<&<Token>.Vault>(from: <Token>.VaultStoragePath) ?? panic("Could not borrow reference to the owner's Vault!") self.senderRef = signer.getCapability(<Token>.ReceiverPublicPath) .borrow<&{FungibleToken.Receiver}>()! self.sender = vaultRef.owner!.address // Withdraw tokens from the signer's stored vault self.sentVault <- vaultRef.withdraw(amount: amount) } execute { // Get the recipient's public account object let recipientAccount = getAccount(recipient) // Get a reference to the recipient's Receiver let receiverRef = recipientAccount.getCapability(<Token>.ReceiverPublicPath) .borrow<&{FungibleToken.Receiver}>() // Use inbox as receiver when receiver has no resource if receiverRef == nil { // query Flowns domain Collection info let collectionCap = recipientAccount.getCapability<&{Domains.CollectionPublic}>(Domains.CollectionPublicPath) let collection = collectionCap.borrow()! var defaultDomain: &{Domains.DomainPublic}? = nil let ids = collection.getIDs() // panic if user has no domain if ids.length == 0 { panic("Recipient have no domain ") } // find default domain as Inbox defaultDomain = collection.borrowDomain(id: ids[0])! // check defualt domain for id in ids { let domain = collection.borrowDomain(id: id)! let isDefault = domain.getText(key: "isDefault") if isDefault == "true" { defaultDomain = domain } } // Deposit the withdrawn tokens in the recipient's domain inbox defaultDomain!.depositVault(from: <- self.sentVault, senderRef: self.senderRef) } else { // Deposit the withdrawn tokens in the recipient's receiver receiverRef!.deposit(from: <- self.sentVault) } } }
存入的思路比较简单,先查询用户是否已经初始化过接收资产的资源,如果没有,则查询 Flowns domain 的信息,如果有则将资产内嵌至用户默认的 Flowns domain 中。

将 FT 从域名中取出并初始化资源

import Domains from 0xDomains import FungibleToken from 0xFungibleToken import Flowns from 0xFlowns import <Token> from <TokenAddress> transaction(name: String, root:String, key:String, amount: UFix64) { var domain: &{Domains.DomainPrivate} var vaultRef: &<Token>.Vault prepare(account: AuthAccount) { // calculate domain namehash with Cadence let prefix = "0x" let rootHahsh = Flowns.hash(node: "", lable: root) let nameHash = prefix.concat(Flowns.hash(node: rootHahsh, lable: name)) let collectionCap = account.getCapability<&{Domains.CollectionPublic}>(Domains.CollectionPublicPath) let collection = collectionCap.borrow()! var domain: &{Domains.DomainPrivate}? = nil // here the only owner can borrow the `CollectionPrivate` resource interface to withdraw let collectionPrivate = account.borrow<&{Domains.CollectionPrivate}>(from: Domains.CollectionStoragePath) ?? panic("Could not find your domain collection cap") let ids = collection.getIDs() // get domain id with namehash let id = Domains.getDomainId(nameHash) if id != nil && !Domains.isDeprecated(nameHash: nameHash, domainId: id!) { domain = collectionPrivate.borrowDomainPrivate(id!) } self.domain = domain! // set up user's receiver resource befor withdraw from domain let vaultRef = account.borrow<&<Token>.Vault>(from: <TokenStoragePath>) if vaultRef == nil { account.save(<- <Token>.createEmptyVault(), to: <TokenStoragePath>) // linking account with resource account.link<&<Token>.Vault{FungibleToken.Receiver}>( <TokenReceiverPath>, target: <TokenStoragePath> ) account.link<&<Token>.Vault{FungibleToken.Balance}>( <TokenBalancePath>, target: <TokenStoragePath> ) self.vaultRef = account.borrow<&<Token>.Vault>(from: <TokenStoragePath>) ?? panic("Could not borrow reference to the owner's Vault!") } else { self.vaultRef = vaultRef! } } execute { self.vaultRef.deposit(from: <- self.domain.withdrawVault(key: key, amount: amount)) } }
这里我们为了方便第三方的整合,使用了 <Token> <TokenAddress> 作为占位符来方便未来进行不同资产类型的替换

NFT 转账加 Inbox fallback

import NonFungibleToken from 0xNonFungibleToken import Domains from 0xDomains import <NFT> from <NFTAddress> // This transaction is for transferring and NFT from // one account to another transaction(recipient: Address, withdrawID: UInt64) { prepare(signer: AuthAccount) { // get the recipients public account object let recipient = getAccount(recipient) // borrow a reference to the signer's NFT collection let collectionRef = signer .borrow<&NonFungibleToken.Collection>(from: <NFT>.CollectionStoragePath) ?? panic("Could not borrow a reference to the owner's collection") let senderRef = signer .getCapability(<NFT>.CollectionPublicPath) .borrow<&{NonFungibleToken.CollectionPublic}>() // borrow a public reference to the receivers collection let recipientRef = recipient .getCapability(<NFT>.CollectionPublicPath) .borrow<&{<NFT>.CollectionPublic}>() if recipientRef == nil { let collectionCap = recipient.getCapability<&{Domains.CollectionPublic}>(Domains.CollectionPublicPath) let collection = collectionCap.borrow()! var defaultDomain: &{Domains.DomainPublic}? = nil let ids = collection.getIDs() if ids.length == 0 { panic("Recipient have no domain ") } // check defualt domain defaultDomain = collection.borrowDomain(id: ids[0])! // check defualt domain for id in ids { let domain = collection.borrowDomain(id: id)! let isDefault = domain.getText(key: "isDefault") if isDefault == "true" { defaultDomain = domain } } let typeKey = collectionRef.getType().identifier // withdraw the NFT from the owner's collection let nft <- collectionRef.withdraw(withdrawID: withdrawID) if defaultDomain!.checkCollection(key: typeKey) == false { let collection <- <NFT>.createEmptyCollection() defaultDomain!.addCollection(collection: <- collection) } defaultDomain!.depositNFT(key: typeKey, token: <- NFT, senderRef: senderRef ) } else { // withdraw the NFT from the owner's collection let nft <- collectionRef.withdraw(withdrawID: withdrawID) // Deposit the NFT in the recipient's collection recipientRef!.deposit(token: <-nft) } } }
存入流程与 FT 相似,不再赘述

NFT 从域名中取出

import Domains from 0xDomains import NonFungibleToken from 0xNonFungibleToken import <NFT> from <NFTAddress> // key will be like 'A.f8d6e0586b0a20c7.Domains.Collection' of a NFT collection transaction(name: String, root:String, key: String, itemId: UInt64) { var domain: &{Domains.DomainPrivate} var collectionRef: &Domains.Collection prepare(account: AuthAccount) { var domain: &{Domains.DomainPrivate}? = nil // here the only owner can borrow the `CollectionPrivate` resource interface to withdraw let collectionPrivate = account.borrow<&{Domains.CollectionPrivate}>(from: Domains.CollectionStoragePath) ?? panic("Could not find your domain collection cap") // calculate domain namehash with Cadence let prefix = "0x" let rootHahsh = Flowns.hash(node: "", lable: root) let nameHash = prefix.concat(Flowns.hash(node: rootHahsh, lable: name)) // get domain id let id = Domains.getDomainId(nameHash) if id !=nil { domain = collectionPrivate.borrowDomainPrivate(id!) } self.domain = domain! // get receiver resource let collectionRef = account.borrow<&<NFT>.Collection>(from: <NFT>.CollectionStoragePath) // If receiver is nil, init collection resource before withdraw NFT if collectionRef == nil { account.save<@NonFungibleToken.Collection>(<- <NFT>.createEmptyCollection(), to: <NFT>.CollectionStoragePath) account.link<&<NFT>.Collection{NonFungibleToken.CollectionPublic, NonFungibleToken.Receiver, <NFT>.CollectionPublic}>(<NFT>.CollectionPublicPath, target: <NFT>.CollectionStoragePath) account.link<&<NFT>.Collection>(<NFT>.CollectionPrivatePath, target: <NFT>.CollectionStoragePath) // get receiver after init resource self.collectionRef = account.borrow<&<NFT>.Collection>(from: <NFT>.CollectionStoragePath)?? panic("Can not borrow collection") } else { self.collectionRef = collectionRef! } } execute { self.collectionRef.deposit(token: <- self.domain.withdrawNFT(key: key, itemId: itemId)) } }
这里所有的脚本都按照整合的方式进行了组装,也兼容 Flow FT 与 NFT 的标准。
我们可以看到取出 FT 和 NFT 的脚本中,我们都用到了 CollectionPrivate 的资源接口,这里只有 Collection 的所有者才能访问,从而获得 DomainPrivate 的资源接口,所以在权限和存储的层面上,所以之前带有 pub fun 注释的提取 FT 和 NFT 的函数是权限安全的。
最终的效果我们可以通过 Flowns 的 api 查到
{ "id": "23447", "owner": "0x7e5d2312899dcf9f", "name": "switchtest1.meow", "nameHash": "0xb7d21b40e4014cfbc7687935474ef78c6569957bbc6ef8ef8035134b630a8291", "expiredAt": "4807525926.00000000", "addresses": {}, "texts": {}, "parentName": "meow", "subdomainCount": "0", "subdomains": {}, "createdAt": "1653925926.00000000", "vaultBalances": {"A.7e60df042a9c0868.FlowToken.Vault": "1.00000000"}, "collections": {"A.0afe396ebc8eee65.FLOAT.Collection": [11238]}, "receivable": true, "deprecated": false, "isExpired": false, "mediaUrl": "<https://testnet.flowns.org/api/data/image/switchtest1.meow>", "isDefault": false }
这里 vaultBalancescollections 就是包含了对应 FT 和 NFT 的资产信息,也可以通过查询 Flowns NFT 的信息查到。
// query domain detail pub fun main(nameHash: String): Domains.DomainDetail? { let address = Domains.getRecords(nameHash) ?? panic("Domain not exist") let account = getAccount(address) let collectionCap = account.getCapability<&{Domains.CollectionPublic}>(Domains.CollectionPublicPath) let collection = collectionCap.borrow()! var detail: Domains.DomainDetail? = nil let id = Domains.getDomainId(nameHash) if id != nil && !Domains.isDeprecated(nameHash: nameHash, domainId: id!) { let domain = collection.borrowDomain(id: id!) detail = domain.getDetail() } return detail }
到这里,内嵌 FT 和 NFT 的一些技术细节就总结完了,希望对大家理解 Cadence 的资源模型有所帮助。

最后

Inbox 只是 Flowns 使用 Cadence 资源模型实现的一个微小的场景,除了作为 Inbox ,域名 NFT 资源可以承载更多的资产类型和使用场景,目前 Flow 生态中的项目都还局限在标准的 NFT 玩法上,但也有类似于可组合的 NFT 项目在探索相关的应用,7 月 6 号开放了无许可权限之后,Flow 会迎来更多的创新和可能性,Cadence 基于 NFT 的嵌套,和可组合性就能发挥出更大的优势,Flowns 也会持续在 NFT 可组合性上进行探索与实践,将域名 NFT 扩展到更多的应用场景中。
除此之外,Flowns 还支持用户自定义链上数据,通过 texts 字段的维护,可以让域名拥有者解锁自定义域名内容的能力(链上 Profile),在 Flow 网络中,域名的存储和数据的写入成本需要域名的拥有人负担,但整体的网络存储成本非常低,可以忽略不计,也能够支持我们在更多的场景去将数据写入到域名中,成为用户链上身份数据的一部分。
和 Lilico 的这次深度整合只是一个开始,借助 B 端的业务场景,Flowns 作为资产与数据协议的潜力会逐渐发挥出来,Cadence 的灵活性也满足了未来可能会出现的一些应用层的创新,帮助 Cadence 开发者从容的应对复杂的业务场景,在降低资产去中心化的同时,兼顾效率与性能。
最后,感谢 Lilico 团队的支持和帮助,Flowns domain 的内嵌能力才得以被大家看到,进入到应用的阶段,未来 FLowns 和 Lilico 将会继续在 NFT 内嵌领域进行深度合作,探索更多的场景,为 Lilico 和 Flowns 用户提供更加多样的服务,再次感谢。