Skip to main content

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.

resources/child-account-manager.jpg

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.

resources/account-hierarchy.jpg

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.

resources/user-account.jpg

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


_29
import MetadataViews from "../contracts/utility/MetadataViews.cdc"
_29
import NonFungibleToken from "../contracts/utility/NonFungibleToken.cdc"
_29
import 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
///
_29
pub 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.


_19
import LinkedAccounts from "../contracts/LinkedAccounts.cdc"
_19
_19
pub 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.

  1. Get all associated account addresses (see above)
  2. Looping over each associated account address client-side, get each address’s owned NFT metadata

_126
import NonFungibleToken from "../contracts/utility/NonFungibleToken.cdc"
_126
import MetadataViews from "../contracts/utility/MetadataViews.cdc"
_126
import LinkedAccounts from "../contracts/LinkedAccounts.cdc"
_126
_126
/// Custom struct to make interpretation of NFT & Collection data easy client side
_126
pub 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
///
_126
pub 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
///
_126
pub 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.

  1. Get all linked account addresses (see above)
  2. Looping over each associated account address client-side, get each address’s owned FungibleToken Vault metadata

_126
import FungibleToken from "../contracts/utility/FungibleToken.cdc"
_126
import FungibleTokenMetadataViews from "../contracts/utility/FungibleTokenMetadataViews.cdc"
_126
import MetadataViews from "../contracts/utility/MetadataViews.cdc"
_126
import LinkedAccounts from "../contracts/LinkedAccounts.cdc"
_126
_126
/// Custom struct to easily communicate vault data to a client
_126
pub 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
_126
pub 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
_126
pub 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
///
_126
pub 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.


_27
import FungibleToken from "../../contracts/utility/FungibleToken.cdc"
_27
import FlowToken from "../../contracts/FlowToken.cdc"
_27
import LinkedAccounts from "../../contracts/LinkedAccounts.cdc"
_27
_27
transaction(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.


_23
import 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
///
_23
transaction(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.