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.
- 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
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.
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.
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
1import ChildAccount from "../contracts/ChildAccount.cdc"2import MetadataViews from "../../contracts/utility/MetadataViews.cdc"34transaction(5pubKey: String,6fundingAmt: UFix64,7childAccountName: String,8childAccountDescription: String,9clientIconURL: String,10clientExternalURL: String11) {1213prepare(signer: AuthAccount) {14// Save a ChildAccountCreator if none exists15if signer.borrow<&ChildAccount.ChildAccountCreator>(from: ChildAccount.ChildAccountCreatorStoragePath) == nil {16signer.save(<-ChildAccount.createChildAccountCreator(), to: ChildAccount.ChildAccountCreatorStoragePath)17}18// Link the public Capability so signer can query address on public key19if !signer.getCapability<20&ChildAccount.ChildAccountCreator{ChildAccount.ChildAccountCreatorPublic}21>(ChildAccount.ChildAccountCreatorPublicPath).check() {22// Unlink & Link23signer.unlink(ChildAccount.ChildAccountCreatorPublicPath)24signer.link<25&ChildAccount.ChildAccountCreator{ChildAccount.ChildAccountCreatorPublic}26>(27ChildAccount.ChildAccountCreatorPublicPath,28target: ChildAccount.ChildAccountCreatorStoragePath29)30}31// Get a reference to the ChildAccountCreator32let creatorRef = signer.borrow<&ChildAccount.ChildAccountCreator>(33from: ChildAccount.ChildAccountCreatorStoragePath34) ?? panic("Problem getting a ChildAccountCreator reference!")35// Construct the ChildAccountInfo metadata struct36let info = ChildAccount.ChildAccountInfo(37name: childAccountName,38description: childAccountDescription,39clientIconURL: MetadataViews.HTTPFile(url: clientIconURL),40clienExternalURL: MetadataViews.ExternalURL(clientExternalURL),41originatingPublicKey: pubKey42)43// Create the account, passing signer AuthAccount to fund account creation44// and add initialFundingAmount in Flow if desired45let newAccount: AuthAccount = creatorRef.createChildAccount(46signer: signer,47initialFundingAmount: fundingAmt,48childAccountInfo: info49)50// At this point, the newAccount can further be configured as suitable for51// use in your dApp (e.g. Setup a Collection, Mint NFT, Configure Vault, etc.)52// ...53}54}
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.
1import ChildAccount from "../contracts/ChildAccount.cdc"23/// Returns the child address associated with a public key if account4/// was created by the ChildAccountCreator at the specified Address and5/// the provided public key is still active on the account.6///7pub fun main(creatorAddress: Address, pubKey: String): Address? {8// Get a reference to the ChildAccountCreatorPublic Capability from creatorAddress9if 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 exists13if let address = creatorRef.getAddressFromPublicKey(publicKey: pubKey) {14// Also check that the given key has not been revoked15if ChildAccount.isKeyActiveOnAccount(publicKey: pubKey, address: address) {16return address17}18}19return nil20}21return nil22}
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.
1import ChildAccount from "../contracts/ChildAccount.cdc"2import MetadataViews from "../contracts/utility/MetadataViews.cdc"34/// 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 an7/// account created under this construction. This resource also holds metadata8/// related to the purpose of this account.9/// Additionally, the ChildAccountCreator maintains a mapping of addresses created10/// by it indexed on the originatingpublic key. This enables dApps to lookup the11/// address for which they hold a public key.12///13transaction(14pubKey: String,15fundingAmt: UFix64,16childAccountName: String,17childAccountDescription: String,18clientIconURL: String,19clientExternalURL: String20) {2122let managerRef: &ChildAccount.ChildAccountManager23let info: ChildAccount.ChildAccountInfo24let childAccountCap: Capability<&AuthAccount>2526prepare(parent: AuthAccount, client: AuthAccount) {2728/* --- Get a ChildAccountCreator reference from client's account --- */29//30// Save a ChildAccountCreator if none exists31if client.borrow<&ChildAccount.ChildAccountCreator>(from: ChildAccount.ChildAccountCreatorStoragePath) == nil {32client.save(<-ChildAccount.createChildAccountCreator(), to: ChildAccount.ChildAccountCreatorStoragePath)33}34// Link the public Capability so signer can query address on public key35if !client.getCapability<36&ChildAccount.ChildAccountCreator{ChildAccount.ChildAccountCreatorPublic}37>(ChildAccount.ChildAccountCreatorPublicPath).check() {38// Link Cap39client.unlink(ChildAccount.ChildAccountCreatorPublicPath)40client.link<41&ChildAccount.ChildAccountCreator{ChildAccount.ChildAccountCreatorPublic}42>(43ChildAccount.ChildAccountCreatorPublicPath,44target: ChildAccount.ChildAccountCreatorStoragePath45)46}47// Get a reference to the ChildAccountCreator48let creatorRef = client.borrow<&ChildAccount.ChildAccountCreator>(49from: ChildAccount.ChildAccountCreatorStoragePath50) ?? panic("Problem getting a ChildAccountCreator reference!")5152/* --- Create the new account --- */53//54// Construct the ChildAccountInfo metadata struct55self.info = ChildAccount.ChildAccountInfo(56name: childAccountName,57description: childAccountDescription,58clientIconURL: MetadataViews.HTTPFile(url: clientIconURL),59clienExternalURL: MetadataViews.ExternalURL(clientExternalURL),60originatingPublicKey: pubKey61)62// Create the account, passing signer AuthAccount to fund account creation63// and add initialFundingAmount in Flow if desired64let newAccount: AuthAccount = creatorRef.createChildAccount(65signer: client,66initialFundingAmount: fundingAmt,67childAccountInfo: info68)69// At this point, the newAccount can further be configured as suitable for70// use in your dApp (e.g. Setup a Collection, Mint NFT, Configure Vault, etc.)71// ...7273/* --- Setup parent's ChildAccountManager --- */74//75// Check the parent account for a ChildAccountManager76if parent.borrow<77&ChildAccount.ChildAccountManager78>(from: ChildAccount.ChildAccountManagerStoragePath) == nil {79// Save a ChildAccountManager to the signer's account80parent.save(<-ChildAccount.createChildAccountManager(), to: ChildAccount.ChildAccountManagerStoragePath)81}82// Ensure ChildAccountManagerViewer is linked properly83if !parent.getCapability<84&ChildAccount.ChildAccountManager{ChildAccount.ChildAccountManagerViewer}85>(ChildAccount.ChildAccountManagerPublicPath).check() {86// Link Cap87parent.unlink(ChildAccount.ChildAccountManagerPublicPath)88parent.link<89&ChildAccount.ChildAccountManager{ChildAccount.ChildAccountManagerViewer}90>(91ChildAccount.ChildAccountManagerPublicPath,92target: ChildAccount.ChildAccountManagerStoragePath93)94}95// Get ChildAccountManager reference from signer96self.managerRef = parent.borrow<97&ChildAccount.ChildAccountManager98>(from: ChildAccount.ChildAccountManagerStoragePath)!99// Link the new account's AuthAccount Capability100self.childAccountCap = newAccount.linkAccount(ChildAccount.AuthAccountCapabilityPath)101}102103execute {104/* --- Link the parent & child accounts --- */105//106// Add account as child to the ChildAccountManager107self.managerRef.addAsChildAccount(childAccountCap: self.childAccountCap, childAccountInfo: self.info)108}109110post {111// Make sure new account was linked to parent's successfully112self.managerRef.getChildAccountAddresses().contains(self.newAccountAddress):113"Problem linking accounts!"114}115}
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.
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.
1import ChildAccount from "../../contracts/ChildAccount.cdc"23/// Adds the labeled child account as a Child Account in the parent accounts'4/// ChildAccountManager resource. The parent maintains an AuthAccount Capability5/// on the child's account.6/// Note that this transaction assumes we're linking an account created by a7/// ChildAccountCreator and the child account already has a ChildAccountTag.8///9transaction {1011let authAccountCap: Capability<&AuthAccount>12let managerRef: &ChildAccount.ChildAccountManager13let info: ChildAccount.ChildAccountInfo1415prepare(parent: AuthAccount, child: AuthAccount) {1617/* --- Configure parent's ChildAccountManager --- */18//19// Get ChildAccountManager Capability, linking if necessary20if parent.borrow<21&ChildAccount.ChildAccountManager22>(from: ChildAccount.ChildAccountManagerStoragePath) == nil {23// Save24parent.save(<-ChildAccount.createChildAccountManager(), to: ChildAccount.ChildAccountManagerStoragePath)25}26// Ensure ChildAccountManagerViewer is linked properly27if !parent.getCapability<28&ChildAccount.ChildAccountManager{ChildAccount.ChildAccountManagerViewer}29>(ChildAccount.ChildAccountManagerPublicPath).check() {30parent.unlink(ChildAccount.ChildAccountManagerPublicPath)31// Link32parent.link<33&ChildAccount.ChildAccountManager{ChildAccount.ChildAccountManagerViewer}34>(35ChildAccount.ChildAccountManagerPublicPath,36target: ChildAccount.ChildAccountManagerStoragePath37)38}39// Get a reference to the ChildAccountManager resource40self.managerRef = parent.borrow<41&ChildAccount.ChildAccountManager42>(from: ChildAccount.ChildAccountManagerStoragePath)!4344/* --- Link the child account's AuthAccount Capability & assign --- */45//46// Get the AuthAccount Capability, linking if necessary47if !child.getCapability<&AuthAccount>(ChildAccount.AuthAccountCapabilityPath).check() {48// Unlink any Capability that may be there49child.unlink(ChildAccount.AuthAccountCapabilityPath)50// Link & assign the AuthAccount Capability51self.authAccountCap = child.linkAccount(52ChildAccount.AuthAccountCapabilityPath53)!54} else {55// Assign the AuthAccount Capability56self.authAccountCap = child.getCapability<&AuthAccount>(ChildAccount.AuthAccountCapabilityPath)57}5859// Get the child account's Metadata which should have been configured on60// creation in context of this dApp61let childTagRef = child.borrow<62&ChildAccount.ChildAccountTag63>(64from: ChildAccount.ChildAccountTagStoragePath65) ?? panic("Could not borrow reference to ChildAccountTag in account ".concat(child.address.toString()))66self.info = childTagRef.info6768execute {69// Add child account if it's parent-child accounts aren't already linked70let childAddress = self.authAccountCap.borrow()!.address71if !self.managerRef.getChildAccountAddresses().contains(childAddress) {72// Add the child account73self.managerRef.addAsChildAccount(74childAccountCap: self.authAccountCap,75childAccountInfo: self.info76)77}78}79}
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.
1import ChildAccount from "../../contracts/ChildAccount.cdc"23/// Signing account publishes a Capability to its AuthAccount for4/// the specified parentAddress to claim5///6transaction(parentAddress: Address) {78let authAccountCap: Capability<&AuthAccount>910prepare(signer: AuthAccount) {11// Get the AuthAccount Capability, linking if necessary12if !signer.getCapability<&AuthAccount>(ChildAccount.AuthAccountCapabilityPath).check() {13self.authAccountCap = signer.linkAccount(ChildAccount.AuthAccountCapabilityPath)!14} else {15self.authAccountCap = signer.getCapability<&AuthAccount>(ChildAccount.AuthAccountCapabilityPath)16}17// Publish for the specified Address18signer.inbox.publish(self.authAccountCap!, name: "AuthAccountCapability", recipient: parentAddress)19}20}
On the other side, the receiving account claims the published AuthAccount Capability, adding it to the signer's ChildAccountManager
.
1import ChildAccount from "../../contracts/ChildAccount.cdc"2import MetadataViews from "../../contracts/utility/MetadataViews.cdc"34/// Signing account claims a Capability to specified Address's AuthAccount5/// and adds it as a child account in its ChildAccountManager, allowing it6/// to maintain the claimed Capability7/// Note that this transaction assumes we're linking an account created by a8/// ChildAccountCreator and the child account already has a ChildAccountTag.9///10transaction(11pubKey: String,12childAddress: Address,13childAccontName: String,14childAccountDescription: String,15clientIconURL: String,16clientExternalURL: String17) {1819let managerRef: &ChildAccount.ChildAccountManager20let info: ChildAccount.ChildAccountInfo21let childAccountCap: Capability<&AuthAccount>2223prepare(signer: AuthAccount) {24// Get ChildAccountManager Capability, linking if necessary25if signer.borrow<26&ChildAccount.ChildAccountManager27>(28from: ChildAccount.ChildAccountManagerStoragePath29) == nil {30// Save a ChildAccountManager to the signer's account31signer.save(32<-ChildAccount.createChildAccountManager(),33to: ChildAccount.ChildAccountManagerStoragePath34)35}36// Ensure ChildAccountManagerViewer is linked properly37if !signer.getCapability<38&ChildAccount.ChildAccountManager{ChildAccount.ChildAccountManagerViewer}39>(ChildAccount.ChildAccountManagerPublicPath).check() {40// Link41signer.link<42&ChildAccount.ChildAccountManager{ChildAccount.ChildAccountManagerViewer}43>(44ChildAccount.ChildAccountManagerPublicPath,45target: ChildAccount.ChildAccountManagerStoragePath46)47}48// Get ChildAccountManager reference from signer49self.managerRef = signer.borrow<50&ChildAccount.ChildAccountManager51>(from: ChildAccount.ChildAccountManagerStoragePath)!52// Claim the previously published AuthAccount Capability from the given Address53self.childAccountCap = signer.inbox.claim<&AuthAccount>(54"AuthAccountCapability",55provider: childAddress56) ?? 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 arguments63self.info = ChildAccount.ChildAccountInfo(64name: childAccountName,65description: childAccountDescription,66clientIconURL: MetadataViews.HTTPFile(url: clientIconURL),67clienExternalURL: MetadataViews.ExternalURL(clientExternalURL),68originatingPublicKey: pubKey69)70}7172execute {73// Add account as child to the ChildAccountManager74self.managerRef.addAsChildAccount(childAccountCap: self.childAccountCap, childAccountInfo: self.info)75}76}
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:
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.
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.
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.
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.
You can find additional Cadence examples in context at the following repos: