Linking Accounts

If you’ve built dApps on Flow, or any blockchain for that matter, you’re painfully aware of the user onboarding process and successive pain of prompting user signatures for on-chain interactions. As a developer, this leaves you with two options - custody the user’s private key and act on their behalf or go with the Web3 status quo, hope your new users are native to Flow and authenticate them via their existing wallet. Either choice will force significant compromise, fragmenting user experience and leaving much to be desired compared to the broader status quo of Web2 identity authentication and single-click onboarding flow.

In this doc, we’ll dive into a progressive onboarding flow, including the Cadence scripts & transactions that go into its implementation in your dApp. These components will enable any implementing dApp to create a custodial account, intermediate the user’s on-chain actions on their behalf, and later delegate control of that dApp-created account to the user’s wallet-mediated account. We’ll refer to this custodial pattern as the Hybrid Account Model and the process of delegating control of the dApp account as Account Linking.

Objectives

  • Establish a walletless onboarding flow
  • 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

Terminology

Parent-Child accounts - For the moment, we’ll call the account created by the dApp the “child” account and the account receiving its AuthAccount Capability the “parent” account. Existing methods of account access & delegation (i.e. keys) still imply ownership over the account, but insofar as linked accounts are concerned, the account to which both the user and the dApp share access via AuthAccount Capability will be considered the “child” account. This naming is a topic of community discussion and may be subject to change.

Walletless onboarding - An onboarding flow whereby a dApp creates an account for a user, onboarding them to the dApp, obviating the need for user wallet authentication.

Blockchain-native onboarding - Similar to the already familiar Web3 onboarding flow where a user authenticates with their existing wallet, a dApp onboards a user via wallet authentication while additionally creating a dApp account and linking it with the authenticated account, resulting in a hybrid custody model.

Hybrid Custody Model - A custodial pattern in which a dApp and a user maintain access to a dApp created account and user access to that account has been mediated by account linking.

Account Linking - Technically speaking, account linking in our context consists of giving some other account an AuthAccount Capability from the granting account. This Capability is maintained in (soon to be standard) resource called a ChildAccountManager, providing its owning user access to any and all of their linked accounts.

Progressive Onboarding - An onboarding flow that walks a user up to self-custodial ownership, starting with walletless onboarding and later linking the dApp account with the user’s authenticated wallet once the user chooses to do so.

Onboarding Flows

Given the ability to establish an account and later delegate access to a user, dApps are freed from the constraints of dichotomous custodial & self-custodial patterns. A developer can choose to onboard a user via traditional Web2 identity and later delegate access to the user’s wallet account. Alternatively, a dApp can enable wallet authentication at the outset, creating a dApp specific account & linking with the user’s wallet account. As specified above, these two flows are known as walletless and blockchain-native respectively. Developers can choose to implement one for simplicity or both for user flexibility.

Walletless Onboarding

Account Creation

walletless-account-creation.png

In this account creation scenario, a local dApp makes an API call to its backend account providing a public key along with the call. The backend account creates a new account using its ChildAccountCreator resource, funding its creation, and adding the provided public key to the new account.

The following transaction creates an account using the signer's ChildAccountCreator, funding creation via the signing account and adding the provided public key. A ChildAccountTag resource is saved in the new account, identifying it as an account created under this construction. This resource also holds metadata related to the purpose of this account. Additionally, the ChildAccountCreator maintains a mapping of addresses created by it indexed on the originatingpublic key. This enables dApps to lookup the address for which they hold a public key.

Note that this is just one way to make an account on Flow, and your dApp doesn't necessarily have to implement a ChildAccountCreator to support a walletless onboarding flow. Any account can be linked in the

1
import ChildAccount from "../contracts/ChildAccount.cdc"
2
import MetadataViews from "../../contracts/utility/MetadataViews.cdc"
3
4
transaction(
5
pubKey: String,
6
fundingAmt: UFix64,
7
childAccountName: String,
8
childAccountDescription: String,
9
clientIconURL: String,
10
clientExternalURL: String
11
) {
12
13
prepare(signer: AuthAccount) {
14
// Save a ChildAccountCreator if none exists
15
if signer.borrow<&ChildAccount.ChildAccountCreator>(from: ChildAccount.ChildAccountCreatorStoragePath) == nil {
16
signer.save(<-ChildAccount.createChildAccountCreator(), to: ChildAccount.ChildAccountCreatorStoragePath)
17
}
18
// Link the public Capability so signer can query address on public key
19
if !signer.getCapability<
20
&ChildAccount.ChildAccountCreator{ChildAccount.ChildAccountCreatorPublic}
21
>(ChildAccount.ChildAccountCreatorPublicPath).check() {
22
// Unlink & Link
23
signer.unlink(ChildAccount.ChildAccountCreatorPublicPath)
24
signer.link<
25
&ChildAccount.ChildAccountCreator{ChildAccount.ChildAccountCreatorPublic}
26
>(
27
ChildAccount.ChildAccountCreatorPublicPath,
28
target: ChildAccount.ChildAccountCreatorStoragePath
29
)
30
}
31
// Get a reference to the ChildAccountCreator
32
let creatorRef = signer.borrow<&ChildAccount.ChildAccountCreator>(
33
from: ChildAccount.ChildAccountCreatorStoragePath
34
) ?? panic("Problem getting a ChildAccountCreator reference!")
35
// Construct the ChildAccountInfo metadata struct
36
let info = ChildAccount.ChildAccountInfo(
37
name: childAccountName,
38
description: childAccountDescription,
39
clientIconURL: MetadataViews.HTTPFile(url: clientIconURL),
40
clienExternalURL: MetadataViews.ExternalURL(clientExternalURL),
41
originatingPublicKey: pubKey
42
)
43
// Create the account, passing signer AuthAccount to fund account creation
44
// and add initialFundingAmount in Flow if desired
45
let newAccount: AuthAccount = creatorRef.createChildAccount(
46
signer: signer,
47
initialFundingAmount: fundingAmt,
48
childAccountInfo: info
49
)
50
// At this point, the newAccount can further be configured as suitable for
51
// use in your dApp (e.g. Setup a Collection, Mint NFT, Configure Vault, etc.)
52
// ...
53
}
54
}

Query Account Address

As mentioned above, the address of any accounts created via the ChildAccountCreator are saved in its mapping, indexed on the originating public key. This feature was included because there is not currently a mechanism to determine a new account’s address without listening for account creation events.

With the ChildAccountCreatorPublic Capability configured, we can query the creating ChildAccountCreator with the public key we have to determine the corresponding account’s address. For a dApp querying for an address corresponding to a custodied key pair, it’s also relevant to know whether the key is still active (in other words has not been revoked). This is relevant in a situation where another party shares access to the account and has the ability to revoke the dApp’s keys.

1
import ChildAccount from "../contracts/ChildAccount.cdc"
2
3
/// Returns the child address associated with a public key if account
4
/// was created by the ChildAccountCreator at the specified Address and
5
/// the provided public key is still active on the account.
6
///
7
pub fun main(creatorAddress: Address, pubKey: String): Address? {
8
// Get a reference to the ChildAccountCreatorPublic Capability from creatorAddress
9
if let creatorRef = getAccount(creatorAddress).getCapability<
10
&ChildAccount.ChildAccountCreator{ChildAccount.ChildAccountCreatorPublic}
11
>(ChildAccount.ChildAccountCreatorPublicPath).borrow() {
12
// Get the address created by the given public key if it exists
13
if let address = creatorRef.getAddressFromPublicKey(publicKey: pubKey) {
14
// Also check that the given key has not been revoked
15
if ChildAccount.isKeyActiveOnAccount(publicKey: pubKey, address: address) {
16
return address
17
}
18
}
19
return nil
20
}
21
return nil
22
}

Blockchain-Native Onboarding

Compared to walletless onboarding where a user does not have a Flow account, blockchain-native onboarding assumes a user already has a wallet configured and immediately links it with a newly created dApp account. This enables the dApp to sign transactions on the user's behalf via the new child account while immediately delegating control of that account to the onboarding user's main account.

Account Creation & Linking

1
import ChildAccount from "../contracts/ChildAccount.cdc"
2
import MetadataViews from "../contracts/utility/MetadataViews.cdc"
3
4
/// This transaction creates an account using the client's ChildAccountCreator,
5
/// funding creation via the signing account and adding the provided public key.
6
/// A ChildAccountTag resource is saved in the new account, identifying it as an
7
/// account created under this construction. This resource also holds metadata
8
/// related to the purpose of this account.
9
/// Additionally, the ChildAccountCreator maintains a mapping of addresses created
10
/// by it indexed on the originatingpublic key. This enables dApps to lookup the
11
/// address for which they hold a public key.
12
///
13
transaction(
14
pubKey: String,
15
fundingAmt: UFix64,
16
childAccountName: String,
17
childAccountDescription: String,
18
clientIconURL: String,
19
clientExternalURL: String
20
) {
21
22
let managerRef: &ChildAccount.ChildAccountManager
23
let info: ChildAccount.ChildAccountInfo
24
let childAccountCap: Capability<&AuthAccount>
25
26
prepare(parent: AuthAccount, client: AuthAccount) {
27
28
/* --- Get a ChildAccountCreator reference from client's account --- */
29
//
30
// Save a ChildAccountCreator if none exists
31
if client.borrow<&ChildAccount.ChildAccountCreator>(from: ChildAccount.ChildAccountCreatorStoragePath) == nil {
32
client.save(<-ChildAccount.createChildAccountCreator(), to: ChildAccount.ChildAccountCreatorStoragePath)
33
}
34
// Link the public Capability so signer can query address on public key
35
if !client.getCapability<
36
&ChildAccount.ChildAccountCreator{ChildAccount.ChildAccountCreatorPublic}
37
>(ChildAccount.ChildAccountCreatorPublicPath).check() {
38
// Link Cap
39
client.unlink(ChildAccount.ChildAccountCreatorPublicPath)
40
client.link<
41
&ChildAccount.ChildAccountCreator{ChildAccount.ChildAccountCreatorPublic}
42
>(
43
ChildAccount.ChildAccountCreatorPublicPath,
44
target: ChildAccount.ChildAccountCreatorStoragePath
45
)
46
}
47
// Get a reference to the ChildAccountCreator
48
let creatorRef = client.borrow<&ChildAccount.ChildAccountCreator>(
49
from: ChildAccount.ChildAccountCreatorStoragePath
50
) ?? panic("Problem getting a ChildAccountCreator reference!")
51
52
/* --- Create the new account --- */
53
//
54
// Construct the ChildAccountInfo metadata struct
55
self.info = ChildAccount.ChildAccountInfo(
56
name: childAccountName,
57
description: childAccountDescription,
58
clientIconURL: MetadataViews.HTTPFile(url: clientIconURL),
59
clienExternalURL: MetadataViews.ExternalURL(clientExternalURL),
60
originatingPublicKey: pubKey
61
)
62
// Create the account, passing signer AuthAccount to fund account creation
63
// and add initialFundingAmount in Flow if desired
64
let newAccount: AuthAccount = creatorRef.createChildAccount(
65
signer: client,
66
initialFundingAmount: fundingAmt,
67
childAccountInfo: info
68
)
69
// At this point, the newAccount can further be configured as suitable for
70
// use in your dApp (e.g. Setup a Collection, Mint NFT, Configure Vault, etc.)
71
// ...
72
73
/* --- Setup parent's ChildAccountManager --- */
74
//
75
// Check the parent account for a ChildAccountManager
76
if parent.borrow<
77
&ChildAccount.ChildAccountManager
78
>(from: ChildAccount.ChildAccountManagerStoragePath) == nil {
79
// Save a ChildAccountManager to the signer's account
80
parent.save(<-ChildAccount.createChildAccountManager(), to: ChildAccount.ChildAccountManagerStoragePath)
81
}
82
// Ensure ChildAccountManagerViewer is linked properly
83
if !parent.getCapability<
84
&ChildAccount.ChildAccountManager{ChildAccount.ChildAccountManagerViewer}
85
>(ChildAccount.ChildAccountManagerPublicPath).check() {
86
// Link Cap
87
parent.unlink(ChildAccount.ChildAccountManagerPublicPath)
88
parent.link<
89
&ChildAccount.ChildAccountManager{ChildAccount.ChildAccountManagerViewer}
90
>(
91
ChildAccount.ChildAccountManagerPublicPath,
92
target: ChildAccount.ChildAccountManagerStoragePath
93
)
94
}
95
// Get ChildAccountManager reference from signer
96
self.managerRef = parent.borrow<
97
&ChildAccount.ChildAccountManager
98
>(from: ChildAccount.ChildAccountManagerStoragePath)!
99
// Link the new account's AuthAccount Capability
100
self.childAccountCap = newAccount.linkAccount(ChildAccount.AuthAccountCapabilityPath)
101
}
102
103
execute {
104
/* --- Link the parent & child accounts --- */
105
//
106
// Add account as child to the ChildAccountManager
107
self.managerRef.addAsChildAccount(childAccountCap: self.childAccountCap, childAccountInfo: self.info)
108
}
109
110
post {
111
// Make sure new account was linked to parent's successfully
112
self.managerRef.getChildAccountAddresses().contains(self.newAccountAddress):
113
"Problem linking accounts!"
114
}
115
}

Account Linking

Linking an account is the process of delegating account access via AuthAccount Capability. Of course, we want to do this in a way that allows the receiving account to maintain that Capability and allows easy identification of the accounts on either end of the linkage - the parent & child accounts. This is accomplished in the (still in flux) ChildAccount contract which we'll continue to use in this guidance.

linked-accounts-diagram.png

In this scenario, a user custodies a key for their main account which has a ChildAccountManager within it. Their ChildAccountManager maintains an AuthAccount Capability to the child account, which the dApp maintains access to via the account’s key.

Linking accounts can be done in one of two ways. Put simply, the child account needs to get the parent account its AuthAccount Capability, and the parent needs to save that Capability in its ChildAccountManager in a manner that represents the linked accounts and their relative associations. We can achieve this in a multisig transaction signed by both the the child account & the parent account, or we can leverage Cadence’s AuthAccount.Inbox to publish the Capability from the child account & have the parent claim the Capability in a separate transaction. Let’s take a look at both.

A consideration during the linking process is whether you would like the parent account to be configured with some resources or Capabilities relevant to your dApp. For example, if your dApp deals with specific NFTs, you may want to configure the parent account with Collections for those NFTs so the user can easily transfer them between their linked accounts.

Multisig Transaction

1
import ChildAccount from "../../contracts/ChildAccount.cdc"
2
3
/// Adds the labeled child account as a Child Account in the parent accounts'
4
/// ChildAccountManager resource. The parent maintains an AuthAccount Capability
5
/// on the child's account.
6
/// Note that this transaction assumes we're linking an account created by a
7
/// ChildAccountCreator and the child account already has a ChildAccountTag.
8
///
9
transaction {
10
11
let authAccountCap: Capability<&AuthAccount>
12
let managerRef: &ChildAccount.ChildAccountManager
13
let info: ChildAccount.ChildAccountInfo
14
15
prepare(parent: AuthAccount, child: AuthAccount) {
16
17
/* --- Configure parent's ChildAccountManager --- */
18
//
19
// Get ChildAccountManager Capability, linking if necessary
20
if parent.borrow<
21
&ChildAccount.ChildAccountManager
22
>(from: ChildAccount.ChildAccountManagerStoragePath) == nil {
23
// Save
24
parent.save(<-ChildAccount.createChildAccountManager(), to: ChildAccount.ChildAccountManagerStoragePath)
25
}
26
// Ensure ChildAccountManagerViewer is linked properly
27
if !parent.getCapability<
28
&ChildAccount.ChildAccountManager{ChildAccount.ChildAccountManagerViewer}
29
>(ChildAccount.ChildAccountManagerPublicPath).check() {
30
parent.unlink(ChildAccount.ChildAccountManagerPublicPath)
31
// Link
32
parent.link<
33
&ChildAccount.ChildAccountManager{ChildAccount.ChildAccountManagerViewer}
34
>(
35
ChildAccount.ChildAccountManagerPublicPath,
36
target: ChildAccount.ChildAccountManagerStoragePath
37
)
38
}
39
// Get a reference to the ChildAccountManager resource
40
self.managerRef = parent.borrow<
41
&ChildAccount.ChildAccountManager
42
>(from: ChildAccount.ChildAccountManagerStoragePath)!
43
44
/* --- Link the child account's AuthAccount Capability & assign --- */
45
//
46
// Get the AuthAccount Capability, linking if necessary
47
if !child.getCapability<&AuthAccount>(ChildAccount.AuthAccountCapabilityPath).check() {
48
// Unlink any Capability that may be there
49
child.unlink(ChildAccount.AuthAccountCapabilityPath)
50
// Link & assign the AuthAccount Capability
51
self.authAccountCap = child.linkAccount(
52
ChildAccount.AuthAccountCapabilityPath
53
)!
54
} else {
55
// Assign the AuthAccount Capability
56
self.authAccountCap = child.getCapability<&AuthAccount>(ChildAccount.AuthAccountCapabilityPath)
57
}
58
59
// Get the child account's Metadata which should have been configured on
60
// creation in context of this dApp
61
let childTagRef = child.borrow<
62
&ChildAccount.ChildAccountTag
63
>(
64
from: ChildAccount.ChildAccountTagStoragePath
65
) ?? panic("Could not borrow reference to ChildAccountTag in account ".concat(child.address.toString()))
66
self.info = childTagRef.info
67
68
execute {
69
// Add child account if it's parent-child accounts aren't already linked
70
let childAddress = self.authAccountCap.borrow()!.address
71
if !self.managerRef.getChildAccountAddresses().contains(childAddress) {
72
// Add the child account
73
self.managerRef.addAsChildAccount(
74
childAccountCap: self.authAccountCap,
75
childAccountInfo: self.info
76
)
77
}
78
}
79
}

Publish & Claim

Publish

Here, the account delegating access to itself links its AuthAccount Capability, and publishes it to be claimed by the account it will be linked to.

1
import ChildAccount from "../../contracts/ChildAccount.cdc"
2
3
/// Signing account publishes a Capability to its AuthAccount for
4
/// the specified parentAddress to claim
5
///
6
transaction(parentAddress: Address) {
7
8
let authAccountCap: Capability<&AuthAccount>
9
10
prepare(signer: AuthAccount) {
11
// Get the AuthAccount Capability, linking if necessary
12
if !signer.getCapability<&AuthAccount>(ChildAccount.AuthAccountCapabilityPath).check() {
13
self.authAccountCap = signer.linkAccount(ChildAccount.AuthAccountCapabilityPath)!
14
} else {
15
self.authAccountCap = signer.getCapability<&AuthAccount>(ChildAccount.AuthAccountCapabilityPath)
16
}
17
// Publish for the specified Address
18
signer.inbox.publish(self.authAccountCap!, name: "AuthAccountCapability", recipient: parentAddress)
19
}
20
}

Claim

On the other side, the receiving account claims the published AuthAccount Capability, adding it to the signer's ChildAccountManager.

1
import ChildAccount from "../../contracts/ChildAccount.cdc"
2
import MetadataViews from "../../contracts/utility/MetadataViews.cdc"
3
4
/// Signing account claims a Capability to specified Address's AuthAccount
5
/// and adds it as a child account in its ChildAccountManager, allowing it
6
/// to maintain the claimed Capability
7
/// Note that this transaction assumes we're linking an account created by a
8
/// ChildAccountCreator and the child account already has a ChildAccountTag.
9
///
10
transaction(
11
pubKey: String,
12
childAddress: Address,
13
childAccontName: String,
14
childAccountDescription: String,
15
clientIconURL: String,
16
clientExternalURL: String
17
) {
18
19
let managerRef: &ChildAccount.ChildAccountManager
20
let info: ChildAccount.ChildAccountInfo
21
let childAccountCap: Capability<&AuthAccount>
22
23
prepare(signer: AuthAccount) {
24
// Get ChildAccountManager Capability, linking if necessary
25
if signer.borrow<
26
&ChildAccount.ChildAccountManager
27
>(
28
from: ChildAccount.ChildAccountManagerStoragePath
29
) == nil {
30
// Save a ChildAccountManager to the signer's account
31
signer.save(
32
<-ChildAccount.createChildAccountManager(),
33
to: ChildAccount.ChildAccountManagerStoragePath
34
)
35
}
36
// Ensure ChildAccountManagerViewer is linked properly
37
if !signer.getCapability<
38
&ChildAccount.ChildAccountManager{ChildAccount.ChildAccountManagerViewer}
39
>(ChildAccount.ChildAccountManagerPublicPath).check() {
40
// Link
41
signer.link<
42
&ChildAccount.ChildAccountManager{ChildAccount.ChildAccountManagerViewer}
43
>(
44
ChildAccount.ChildAccountManagerPublicPath,
45
target: ChildAccount.ChildAccountManagerStoragePath
46
)
47
}
48
// Get ChildAccountManager reference from signer
49
self.managerRef = signer.borrow<
50
&ChildAccount.ChildAccountManager
51
>(from: ChildAccount.ChildAccountManagerStoragePath)!
52
// Claim the previously published AuthAccount Capability from the given Address
53
self.childAccountCap = signer.inbox.claim<&AuthAccount>(
54
"AuthAccountCapability",
55
provider: childAddress
56
) ?? panic(
57
"No AuthAccount Capability available from given provider"
58
.concat(childAddress.toString())
59
.concat(" with name ")
60
.concat("AuthAccountCapability")
61
)
62
// Construct ChildAccountInfo struct from given arguments
63
self.info = ChildAccount.ChildAccountInfo(
64
name: childAccountName,
65
description: childAccountDescription,
66
clientIconURL: MetadataViews.HTTPFile(url: clientIconURL),
67
clienExternalURL: MetadataViews.ExternalURL(clientExternalURL),
68
originatingPublicKey: pubKey
69
)
70
}
71
72
execute {
73
// Add account as child to the ChildAccountManager
74
self.managerRef.addAsChildAccount(childAccountCap: self.childAccountCap, childAccountInfo: self.info)
75
}
76
}

Funding & Custody Patterns

Aside from implementing onboarding flows & account linking, you'll want to also consider the account funding & custodial pattern appropriate for the dApp you're building. The only one compatible with walletless onboarding (and therefore the only one showcased above) is one in which the dApp custodies the child account's key, funds account creation and uses its ChildAccountCreator resource to initiate account creation.

In general, the funding pattern for account creation will determine to some extent the backend infrastructure needed to support your dApp and the onboarding flow your dApp can support. For example, if you want to to create a service-less client (a totally local dApp without backend infrastructure), you could forego walletless onboarding in favor of a user-funded blockchain-native onboarding to achieve a hybrid custody model. Your dApp maintains the keys to the dApp account to sign on behalf of the user, and the user funds the creation of the the account, linking to their main account on account creation. This would be a user-funded, dApp custodied pattern.

Here are the patterns you might consider:

DApp-Funded, DApp-Custodied

If you want to implement walletless onboarding, you can stop here as this is the only compatible pattern. In this scenario, a backend dApp account funds the creation of a new account and the dApp custodies the key for said account either on the user's device or some backend KMS. Creation can occur the same as any Flow account or with the help of the ChildAccountCreator resource.

DApp-Funded, User-Custodied

In this case, the backend dApp account funds account creation, but adds a key to the account which the user custodies. In order for the dApp to act on the user's behalf, it has to be delegated access via AuthAccount Capability which the backend dApp account would maintain in a ChildAccountManager. This means that the new account would have two parent accounts - the user's and the dApp. While not comparatively useful now, once SuperAuthAccount is ironed out and implemented, this pattern will be the most secure in that the custodying user will have ultimate authority over the child account. Also note that this and the following patterns are incompatible with walletless onboarding in that the user must have a wallet.

User-Funded, DApp-Custodied

As mentioned above, this pattern unlocks totally service-less architectures - just a local client & smart contracts. An authenticated user signs a transaction creating an account, adding the key provided by the client, and linking the account as a child account. At the end of the transaction, hybrid custody is achieved and the dApp can sign with the custodied key on the user's behalf using the newly created account.

User-Funded, User-Custodied

While perhaps not useful for most dApps, this pattern may be desirable for advanced users who wish to create a shared access account themselves. The user funds account creation, adding keys they custody, and delegates secondary access to some other account. As covered above in account linking, this can be done via multisig or the publish & claim mechanism.

Additional Resources

You can find additional Cadence examples in context at the following repos: