Account Model & Implementation
This doc serves as developer guidance to support Hybrid Custody apps by leveraging Account Linking. While account linking as a feature is a language level API, supporting linked accounts such that users achieve Hybrid Custody has a bit more nuance, namely apps should build on the LinkedAccounts standard FLIP. Implementing this standard will allow dapps to facilitate a user experience based not on a single authenticated account, but on the global context of all accounts linked to the authenticated user.
We believe multi-account linking and management, technical initiatives in support of Walletless Onboarding, will enable in-dapp experiences far superior to the current Web3 status quo and allow for industry UX to finally reach parity with traditional Web2 authentication and onboarding flows, most notably on mobile.
A new user will no longer need a preconfigured wallet to interact with Flow. When they do decide to create a wallet and link with a dapp; however, the associated accounts and assets within them will need to be accessible the same as if they were in a single account.
In order to realize a multi-account world that makes sense to users - one where they don’t have to concern themselves with managing assets across their network of accounts - we’re relying on Flow builders to cast their abstractive magic. Consider this your grimoire, fellow builder, where we’ll continue from the perspective of a wallet or marketplace dapp seeking to facilitate a unified account experience, abstracting away the partitioned access between accounts into a single dashboard for user interactions on all their owned assets.
⚠️ Note that the documentation on Hybrid Custody covers the current state and will likely differ from the final implementation. Builders should be aware that breaking changes will deploy between current state and the stable version. Interested in shaping the conversation? Join in!
Objective
- Understand the linked account model
- Create a blockchain-native onboarding flow
- Link an existing app account as a child to a newly authenticated parent account
- Get your dapp to recognize “parent” accounts along with any associated “child” accounts
- View Fungible and NonFungible Token metadata relating to assets across all of a user’s associated accounts - their wallet-mediated “parent” account and any hybrid custody model “child” accounts
- Facilitate transactions acting on assets in child accounts
Design Overview
The basic idea in the (currently proposed) standard is relatively simple. A parent account is one that has delegated authority on another account. The account which has delegated authority over itself to the parent account is the child account.
In the Hybrid Custody Model, this child account would have shared access between the dapp which created the account and the linked parent account.
How does this delegation occur? Typically when we think of shared account access in crypto, we think keys. However, Cadence recently enabled an experimental feature whereby an account can link a Capability to its AuthAccount.
We’ve leveraged this feature in a (proposed) standard so that dapps can implement a hybrid custody model whereby the dapp creates an account it controls, then later delegates authority over that account to the user once they’ve authenticate with their wallet. All related constructs are defined in the LinkedAccounts
contract. The delegation of that account authority is mediated by the parent account's Collection
, and Handler
, residing in the linked child account.
Therefore, the presence of a Collection
in an account implies there are potentially associated accounts for which the owning account has delegated authority. This resource is intended to be configured with a pubic Capability enabling querying of an accounts child account addresses via getLinkedAccountAddresses()
.
A wallet or marketplace wishing to discover all of a user’s accounts and assets within them can do so by first looking to the user’s Collection
.
Identifying Account Hierarchy
To clarify, insofar as the standard is concerned, an account is a parent account if it contains a Collection
resource, and an account is a child account if it contains a Handler
resource.
We can see that the user’s Collection.linkedAccounts
point to the address of its child account. Likewise, the child account’s Handler.parentAddress
point to the user’s account as its parent address. This makes it easy to both identify whether an account is a parent, child, or both, and its associated parent/child account(s).
Consideration
Do note that this construction does not prevent an account from having multiple parent accounts or a child account from being the parent to other accounts. While initial intuition might lead one to believe that account associations are a tree with the user at the root, the graph of associated accounts among child accounts may lead to cycles of association.
We believe it would be unlikely for a use case to demand a user delegates authority over their main account (in fact we’d discourage such constructions), but delegating access between child accounts could be useful. As an example, consider a set of local game clients across mobile and web platforms, each with self-custodied app accounts having delegated authority to each other while both are child accounts of the user’s main account.
The user’s account is the root parent account while both child accounts have delegated access to each other. This allows assets to be easily transferable between dapp accounts without the need of a user signature to facilitate transfer.
Ultimately, it’ll be up to the implementing wallet/marketplace how far down the graph of account associations they’d want to traverse and display to the user.
Implementation
From the perspective of a wallet or marketplace dapp, some relevant things to know about the user are:
- Does this account have associated linked accounts?
- What are those associated linked accounts, if any?
- What NFTs are owned by this user across all associated accounts?
- What are the balances of all FungibleTokens across all associated accounts?
And with respect to acting on the assets of child accounts and managing child accounts themselves:
- Spending FungibleTokens from a linked account’s Vault
- Creating a user-funded linked account
- Removing a linked account
Examples
Query Whether an Address Has Associated Accounts
This script will return true
if a LinkedAccounts.Collection
is stored and false
otherwise
_29import MetadataViews from "../contracts/utility/MetadataViews.cdc"_29import NonFungibleToken from "../contracts/utility/NonFungibleToken.cdc"_29import LinkedAccounts from "../contracts/LinkedAccounts.cdc"_29_29/// This script allows one to determine if a given account has a LinkedAccounts.Collection configured as expected_29///_29/// @param address: The address to query against_29///_29/// @return True if the account has a LinkedAccounts.Collection configured at the canonical path, false otherwise_29///_29pub fun main(address: Address): Bool {_29 // Get the account_29 let account = getAuthAccount(address)_29 // Get the Collection's Metadata_29 let collectionView: MetadataViews.NFTCollectionData = (_29 LinkedAccounts.resolveView(Type<MetadataViews.NFTCollectionData>()) as! MetadataViews.NFTCollectionData?_29 )!_29 // Assign public & private capabilities from expected paths_29 let collectionPublicCap = account.getCapability<_29 &LinkedAccounts.Collection{LinkedAccounts.CollectionPublic, MetadataViews.ResolverCollection}_29 >(collectionView.publicPath)_29 let collectionPrivateCap = account.getCapability<_29 &LinkedAccounts.Collection{LinkedAccounts.CollectionPublic, NonFungibleToken.CollectionPublic, NonFungibleToken.Receiver, NonFungibleToken.Provider, MetadataViews.ResolverCollection}_29 >(collectionView.providerPath)_29 _29 // Return whether account is configured as expected_29 return account.type(at: collectionView.storagePath) == Type<@LinkedAccounts.Collection>() &&_29 collectionPublicCap.check() && collectionPrivateCap.check()_29}
Query All Accounts Associated with Address
The following script will return an array addresses associated with a given account’s address, inclusive of the provided address.
_19import LinkedAccounts from "../contracts/LinkedAccounts.cdc"_19_19pub fun main(address: Address): [Address] {_19 // Init return variable_19 let addresses: [Address] = [address]_19 // Get the AuthAccount of the specified Address_19 let account: AuthAccount = getAuthAccount(address)_19 // Get a reference to the account's Collection if it exists at the standard path_19 if let collectionRef = account.borrow<&LinkedAccounts.Collection>(_19 from: LinkedAccounts.CollectionStoragePath_19 ) {_19 // Append any child account addresses to the return value_19 addresses.appendAll(_19 collectionRef.getLinkedAccountAddresses()_19 )_19 }_19 // Return the final array, inclusive of specified Address_19 return addresses_19}
Query All Owned NFT Metadata
While it is possible to iterate over the storage of all associated accounts in a single script, memory limits prevent this approach from scaling well. Since some accounts hold thousands of NFTs, we recommend breaking up iteration, utilizing several queries to iterate over accounts and the storage of each account. Batching queries on individual accounts may even be required based on the number of NFTs held.
- Get all associated account addresses (see above)
- Looping over each associated account address client-side, get each address’s owned NFT metadata
_126import NonFungibleToken from "../contracts/utility/NonFungibleToken.cdc"_126import MetadataViews from "../contracts/utility/MetadataViews.cdc"_126import LinkedAccounts from "../contracts/LinkedAccounts.cdc"_126_126/// Custom struct to make interpretation of NFT & Collection data easy client side_126pub struct NFTData {_126 pub let name: String_126 pub let description: String_126 pub let thumbnail: String_126 pub let resourceID: UInt64_126 pub let ownerAddress: Address?_126 pub let collectionName: String?_126 pub let collectionDescription: String?_126 pub let collectionURL: String?_126 pub let collectionStoragePathIdentifier: String_126 pub let collectionPublicPathIdentifier: String?_126_126 init(_126 name: String,_126 description: String,_126 thumbnail: String,_126 resourceID: UInt64,_126 ownerAddress: Address?,_126 collectionName: String?,_126 collectionDescription: String?,_126 collectionURL: String?,_126 collectionStoragePathIdentifier: String,_126 collectionPublicPathIdentifier: String?_126 ) {_126 self.name = name_126 self.description = description_126 self.thumbnail = thumbnail_126 self.resourceID = resourceID_126 self.ownerAddress = ownerAddress_126 self.collectionName = collectionName_126 self.collectionDescription = collectionDescription_126 self.collectionURL = collectionURL_126 self.collectionStoragePathIdentifier = collectionStoragePathIdentifier_126 self.collectionPublicPathIdentifier = collectionPublicPathIdentifier_126 }_126}_126_126/// Helper function that retrieves data about all publicly accessible NFTs in an account_126///_126pub fun getAllViewsFromAddress(_ address: Address): [NFTData] {_126 // Get the account_126 let account: AuthAccount = getAuthAccount(address)_126 // Init for return value_126 let data: [NFTData] = []_126 // Assign the types we'll need_126 let collectionType: Type = Type<@{NonFungibleToken.CollectionPublic, MetadataViews.ResolverCollection}>()_126 let displayType: Type = Type<MetadataViews.Display>()_126 let collectionDisplayType: Type = Type<MetadataViews.NFTCollectionDisplay>()_126 let collectionDataType: Type = Type<MetadataViews.NFTCollectionData>()_126_126 // Iterate over each public path_126 account.forEachStored(fun (path: StoragePath, type: Type): Bool {_126 // Check if it's a Collection we're interested in, if so, get a reference_126 if type.isSubtype(of: collectionType) {_126 if let collectionRef = account.borrow<_126 &{NonFungibleToken.CollectionPublic, MetadataViews.ResolverCollection}_126 >(from: path) {_126 // Iterate over the Collection's NFTs, continuing if the NFT resolves the views we want_126 for id in collectionRef.getIDs() {_126 let resolverRef: &{MetadataViews.Resolver} = collectionRef.borrowViewResolver(id: id)_126 if let display = resolverRef.resolveView(displayType) as! MetadataViews.Display? {_126 let collectionDisplay = resolverRef.resolveView(collectionDisplayType) as! MetadataViews.NFTCollectionDisplay?_126 let collectionData = resolverRef.resolveView(collectionDataType) as! MetadataViews.NFTCollectionData?_126 // Build our NFTData struct from the metadata_126 let nftData = NFTData(_126 name: display.name,_126 description: display.description,_126 thumbnail: display.thumbnail.uri(),_126 resourceID: resolverRef.uuid,_126 ownerAddress: resolverRef.owner?.address,_126 collectionName: collectionDisplay?.name,_126 collectionDescription: collectionDisplay?.description,_126 collectionURL: collectionDisplay?.externalURL?.url,_126 collectionStoragePathIdentifier: path.toString(),_126 collectionPublicPathIdentifier: collectionData?.publicPath?.toString()_126 )_126 // Add it to our data_126 data.append(nftData)_126 }_126 }_126 }_126 }_126 return true_126 })_126 return data_126}_126_126/// Script that retrieve data about all publicly accessible NFTs in an account and any of its_126/// child accounts_126///_126/// Note that this script does not consider accounts with exceptionally large collections _126/// which would result in memory errors. To compose a script that does cover accounts with_126/// a large number of sub-accounts and/or NFTs within those accounts, see example 5 in_126/// the NFT Catalog's README: https://github.com/dapperlabs/nft-catalog and adapt for use_126/// with LinkedAccounts.Collection_126///_126pub fun main(address: Address): {Address: [NFTData]} {_126 let allNFTData: {Address: [NFTData]} = {}_126 _126 // Add all retrieved views to the running mapping indexed on address_126 allNFTData.insert(key: address, getAllViewsFromAddress(address))_126 _126 /* Iterate over any child accounts */ _126 //_126 // Get reference to LinkedAccounts.Collection if it exists_126 if let collectionRef = getAccount(address).getCapability<_126 &LinkedAccounts.Collection{LinkedAccounts.CollectionPublic}_126 >(_126 LinkedAccounts.CollectionPublicPath_126 ).borrow() {_126 // Iterate over each linked account in LinkedAccounts.Collection_126 for childAddress in collectionRef.getLinkedAccountAddresses() {_126 if !allNFTData.containsKey(childAddress) {_126 // Insert the NFT metadata for those NFTs in each child account_126 // indexing on the account's address_126 allNFTData.insert(key: childAddress, getAllViewsFromAddress(childAddress))_126 }_126 }_126 }_126 return allNFTData _126}
After iterating over all associated accounts, the client will have an array of NFTData
structs containing relevant information about each owned NFT including the address where the NFT resides. Note that this script does not take batching into consideration and assumes that each NFT resolves at minimum the MetadataViews.Display
view type.
Query All Account FungibleToken Balances
Similar to the previous example, we recommend breaking up this task due to memory limits.
- Get all linked account addresses (see above)
- Looping over each associated account address client-side, get each address’s owned FungibleToken Vault metadata
_126import FungibleToken from "../contracts/utility/FungibleToken.cdc"_126import FungibleTokenMetadataViews from "../contracts/utility/FungibleTokenMetadataViews.cdc"_126import MetadataViews from "../contracts/utility/MetadataViews.cdc"_126import LinkedAccounts from "../contracts/LinkedAccounts.cdc"_126_126/// Custom struct to easily communicate vault data to a client_126pub struct VaultInfo {_126 pub let name: String?_126 pub let symbol: String?_126 pub var balance: UFix64_126 pub let description: String?_126 pub let externalURL: String?_126 pub let logos: MetadataViews.Medias?_126 pub let storagePathIdentifier: String_126 pub let receiverPathIdentifier: String?_126 pub let providerPathIdentifier: String?_126_126 init(_126 name: String?,_126 symbol: String?,_126 balance: UFix64,_126 description: String?,_126 externalURL: String?,_126 logos: MetadataViews.Medias?,_126 storagePathIdentifier: String,_126 receiverPathIdentifier: String?,_126 providerPathIdentifier: String?_126 ) {_126 self.name = name_126 self.symbol = symbol_126 self.balance = balance_126 self.description = description_126 self.externalURL = externalURL_126 self.logos = logos_126 self.storagePathIdentifier = storagePathIdentifier_126 self.receiverPathIdentifier = receiverPathIdentifier_126 self.providerPathIdentifier = providerPathIdentifier_126 }_126_126 pub fun addBalance(_ addition: UFix64) {_126 self.balance = self.balance + addition_126 }_126}_126_126/// Returns a dictionary of VaultInfo indexed on the Type of Vault_126pub fun getAllVaultInfoInAddressStorage(_ address: Address): {Type: VaultInfo} {_126 // Get the account_126 let account: AuthAccount = getAuthAccount(address)_126 // Init for return value_126 let balances: {Type: VaultInfo} = {}_126 // Assign the type we'll need_126 let vaultType: Type = Type<@{FungibleToken.Balance, MetadataViews.Resolver}>()_126 let ftViewType: Type= Type<FungibleTokenMetadataViews.FTView>()_126 // Iterate over all stored items & get the path if the type is what we're looking for_126 account.forEachStored(fun (path: StoragePath, type: Type): Bool {_126 if type.isSubtype(of: vaultType) {_126 // Get a reference to the vault & its balance_126 if let vaultRef = account.borrow<&{FungibleToken.Balance, MetadataViews.Resolver}>(from: path) {_126 let balance = vaultRef.balance_126 // Attempt to resolve metadata on the vault_126 if let ftView = vaultRef.resolveView(ftViewType) as! FungibleTokenMetadataViews.FTView? {_126 // Insert a new info struct if it's the first time we've seen the vault type_126 if !balances.containsKey(type) {_126 let vaultInfo = VaultInfo(_126 name: ftView.ftDisplay?.name ?? vaultRef.getType().identifier,_126 symbol: ftView.ftDisplay?.symbol,_126 balance: balance,_126 description: ftView.ftDisplay?.description,_126 externalURL: ftView.ftDisplay?.externalURL?.url,_126 logos: ftView.ftDisplay?.logos,_126 storagePathIdentifier: path.toString(),_126 receiverPathIdentifier: ftView.ftVaultData?.receiverPath?.toString(),_126 providerPathIdentifier: ftView.ftVaultData?.providerPath?.toString()_126 )_126 balances.insert(key: type, vaultInfo)_126 } else {_126 // Otherwise just update the balance of the vault (unlikely we'll see the same type twice in_126 // the same account, but we want to cover the case)_126 balances[type]!.addBalance(balance)_126 }_126 }_126 }_126 }_126 return true_126 })_126 return balances_126}_126_126/// Takes two dictionaries containing VaultInfo structs indexed on the type of vault they represent &_126/// returns a single dictionary containg the summed balance of each respective vault type_126pub fun merge(_ d1: {Type: VaultInfo}, _ d2: {Type: VaultInfo}): {Type: VaultInfo} {_126 for type in d1.keys {_126 if d2.containsKey(type) {_126 d1[type]!.addBalance(d2[type]!.balance)_126 }_126 }_126_126 return d1_126}_126_126/// Queries for FT.Vault info of all FT.Vaults in the specified account and all of its linked accounts_126///_126/// @param address: Address of the account to query FT.Vault data_126///_126/// @return A mapping of VaultInfo struct indexed on the Type of Vault_126///_126pub fun main(address: Address): {Type: VaultInfo} {_126 // Get the balance info for the given address_126 var balances: {Type: VaultInfo} = getAllVaultInfoInAddressStorage(address)_126 _126 /* Iterate over any linked accounts */ _126 //_126 // Get reference to LinkedAccounts.Collection if it exists_126 if let collectionRef = getAccount(address).getCapability<_126 &LinkedAccounts.Collection{LinkedAccounts.CollectionPublic}_126 >(_126 LinkedAccounts.CollectionPublicPath_126 ).borrow() {_126 // Iterate over each linked account in Collection_126 for linkedAccount in collectionRef.getLinkedAccountAddresses() {_126 // Ensure all vault type balances are pooled across all addresses_126 balances = merge(balances, getAllVaultInfoInAddressStorage(linkedAccount))_126 }_126 }_126 return balances _126}
The above script returns a dictionary of VaultInfo
structs indexed on the Vault Type and containing relevant Vault metadata. If the Vault doesn’t resolve FungibleTokenMetadataViews
, your client will at least be guaranteed to receive the Type, storagePathIdentifier and balance of each Vault in storage.
The returned data at the end of address iteration should be sufficient to achieve a unified balance of all Vaults of similar types across all of a user’s associated account as well as a more granular per account view.
Use Child Account FungibleTokens Signing As Parent
A user with tokens in one of their linked accounts will likely want to utilize said tokens. In this example, the user will sign a transaction a transaction with their authenticated account that retrieves a reference to a linked account’s Flow Provider, enabling withdrawal from the linked account having signed with the main account.
_27import FungibleToken from "../../contracts/utility/FungibleToken.cdc"_27import FlowToken from "../../contracts/FlowToken.cdc"_27import LinkedAccounts from "../../contracts/LinkedAccounts.cdc"_27_27transaction(fundingChildAddress: Address, withdrawAmount: UFix64) {_27_27 let paymentVault: @FungibleToken.Vault_27_27 prepare(signer: AuthAccount) {_27 // Get a reference to the signer's LinkedAccounts.Collection from storage_27 let collectionRef: &LinkedAccounts.Collection = signer.borrow<&LinkedAccounts.Collection>(_27 from: LinkedAccounts.CollectionStoragePath_27 ) ?? panic("Could not borrow reference to LinkedAccounts.Collection in signer's account at expected path!")_27 // Borrow a reference to the signer's specified child account_27 let childAccount: &AuthAccount = collectionRef.getChildAccountRef(address: fundingChildAddress)_27 ?? panic("Signer does not have access to specified account")_27 // Get a reference to the child account's FlowToken Vault_27 let vaultRef: &TicketToken.Vault = childAccount.borrow<&FlowToken.Vault>(_27 from: /storage/flowToken_27 ) ?? panic("Could not borrow a reference to the child account's TicketToken Vault at expected path!")_27 self.paymentVault <-vaultRef.withdraw(amount: withdrawAmount)_27 }_27_27 execute {_27 // Do stuff with the vault...(e.g. mint NFT)_27 }_27}
At the end of this transaction, you’ve gotten a reference to the specified account’s Flow Provider. You could continue for a number of use cases - minting or purchasing an NFT with funds from the linked account, transfer between accounts, etc. A similar approach could get you reference to the linked account’s NonFungibleToken.Provider
, enabling NFT transfer, etc.
Revoking Secondary Access on a Linked Account
The expected uses of child accounts for progressive onboarding implies that they will be accounts with shared access. A user may decide that they no longer want secondary parties to have access to the child account.
There are two ways a party can have delegated access to an account - keys and AuthAccount Capability. To revoke access via keys, a user would iterate over account keys and revoke any that the user does not custody.
Things are not as straightforward respect to AuthAccount Capabilities, at least not until Capability Controllers enter the picture. This is discussed in more detail in the Flip. For now, we recommend that if users want to revoke secondary access, they transfer any assets from the relevant child account and remove it altogether.
Remove a Child Account
As mentioned above, if a user no longer wishes to share access with another party, it’s recommended that desired assets be transferred from that account to either their main account or other linked accounts and the linked account be removed from their LinkedAccounts.Collection
. Let’s see how to complete that removal.
_23import LinkedAccounts from "../../contracts/LinkedAccounts.cdc"_23_23/// This transaction removes access to a linked account from the signer's LinkedAccounts Collection._23/// **NOTE:** The signer will no longer have access to the removed child account via AuthAccount Capability, so care_23/// should be taken to ensure any assets in the child account have been first transferred as well as checking active_23/// keys that need to be revoked have been done so (a detail that will largely depend on you dApps custodial model)_23///_23transaction(childAddress: Address) {_23_23 let collectionRef: &LinkedAccounts.Collection_23 _23 prepare(signer: AuthAccount) {_23 // Assign a reference to signer's LinkedAccounts.Collection_23 self.collectionRef = signer.borrow<&LinkedAccounts.Collection>(_23 from: LinkedAccounts.CollectionStoragePath_23 ) ?? panic("Signer does not have a LinkedAccounts Collection configured!")_23 }_23_23 execute {_23 // Remove child account, revoking any granted Capabilities_23 self.collectionRef.removeLinkedAccount(withAddress: childAddress)_23 }_23}
After removal, the signer no longer has delegated access to the removed account via their Collection
. Also note that currently a user can grant their linked accounts generic Capabilities. During removal, those Capabilities are revoked, removing the linked account’s access via their Handler
.