If you're set up to use Portal-managed backups, your application will not require the backend implementation described in this guide. Instead, you can jump straight to Backup methods.
MPC backups allow your users to recover their MPC wallets in the event that their device is lost, stolen, or bricked.
At the time of recovery, a user and a custodian backup share are used together to generate new signing shares, which are then stored on the user's device. This allows the user to migrate their wallet to a new device seamlessly.
Additionally, a user can back up their wallet once per backup method. That means they can potentially have "GDRIVE", "ICLOUD", "PASSWORD", and "PASSKEY" backups simultaneously, which would allow them to easily recover their wallet using any of those backup methods.
Your Database
Before we dive into the backup shares' implementations, we should go over your database schema. Multi-backup requires storing both custodian and user backup shares by their respective backup method in your own database. We recommend the following database model structure:
// Encrypted backup share received from portal.backup().// Encrypted backup share retrieved from your API to be used in portal.recover().modelUserBackupShare { backupMethod String @default("UNKNOWN") // (String) The method used for the backup. Defaults to "UNKNOWN" if not specified.
createdAt DateTime @default(now()) // (DateTime) The timestamp when the backup share was created.
id String@id@default(cuid()) // (String) A unique identifier for each backup share cipherText String// (String) The encrypted backup share data. userId String // (Int) The identifier of the user to whom the backup share belongs.
user User @relation(fields: [userId], references: [id]) // (User) A relation field that links to the corresponding user in the User model.
@@unique([userId, backupMethod]) // A unique constraint ensuring that each user has only one backup share per method.
}// Raw backup share received from the POST /backup webhook.// Raw backup share retrieved from the POST /backup/fetch webhook.modelCustodianBackupShare { backupMethod String @default("UNKNOWN") // (String) The method used for the backup. Defaults to "UNKNOWN" if not specified.
createdAt DateTime @default(now()) // (DateTime) The timestamp when the backup share was created.
id String@id@default(cuid()) // (String) A unique identifier for each backup share share String// (String) The raw backup share data. userId String // (String) The identifier of the user to whom the backup share belongs.
user User @relation(fields: [userId], references: [id]) // (User) A relation field that links to the corresponding user in the User model.
@@unique([userId, backupMethod]) // A unique constraint ensuring that each user has only one backup share per method.
}// User model, often referred to as "Client" in our documentation.modelUser { clientApiKey String@unique// (String) The unique Client API Key assigned to the user. clientId String@unique// (String) The unique identifier for the client that Portal uses. id String@id@default(cuid()) // (String) The primary key for the user userBackupShares UserBackupShare[] // (UserBackupShare[]) A list of the user backup shares related to the user.
custodianBackupShares CustodianBackupShare[] // (CustodianBackupShare[]) A list of the custodian backup shares related to the user.
}
We won't go into depth just yet, but it's worth calling out that you can see a couple associations:
A user has many raw custodian backup shares unique by backup method.
A user has many encrypted user backup shares unique by backup method.
The encryption key is stored differently depending on the backup method used.
Perfect, let's dive right into user backup shares.
User Backup Shares
There's a few steps to store your user's backup shares. First, our SDK creates the raw user backup share and then encrypts it. Next, the encryption key is stored using the backup method you specify (e.g. "GDRIVE", "ICLOUD", "PASSWORD", "PASSKEY", etc.). Finally, the encrypted user backup share must then be saved in your infrastructure. Be sure to store it for the user by backup method, as a user can only have 1 backup per backup method.
In order to support user backup shares, three main dependencies must be met:
You must configure 1 or many backup methods when initializing Portal.
You must be able to store the encrypted user backup share by backup method.
You must be able to retrieve the encrypted user backup share by backup method.
Using Google Drive or iCloud Backups
In order to enable Google Drive or iCloud backups, you must first configure one or more cloud storage adapters with your Portal instance.
After running portal.backupWallet(), you will receive a cipherText as a return value (we will get to that part in a moment). This is the encrypted user backup share. On your own API server, we recommend creating an endpoint to accept the encrypted user backup share and store it.
server.ts
/* * This endpoint can be defined by you however you want */app.post('/users/:userId/user-backup-share',async (req, res) => {constbackupMethod=req.body['backupMethod']validateBackupMethod(backupMethod)constcipherText=req.body['cipherText']validateCipherText(cipherText)constuserId=req.params['userId']constuser=awaitdb.user.findById(userId)// Remove any existing user backup shares for this backup method.awaitdb.userBackupShare.deleteMany({ where: { backupMethod, userId:user.id, }, })// Store the user backup share.awaitdb.userBackupShare.create({ data: { backupMethod, cipherText, userId:user.id, }, })res.status(200).json({ message:'Successfully stored client backup share' })})
Sending shares to your server
In order to add support for user backup shares to your mobile app, you must perform three steps:
Generate an encrypted user backup share using portal.backupWallet().
Send the encrypted user backup share to your API and store it.
Call portal.api.storedClientBackupShare() to notify Portal that the encrypted user backup share was saved successfully. (Alternatively you can make an HTTP request to our API directly.)
package io.portal.android.app// Imports...classMainActivity : AppCompatActivity() {lateinitvar portal: Portallateinitvar backupButton: Button// Your API instance.privateval exchangeApi: Api=Api()// The user from your API instance.lateinitvar user: UseroverridefunonCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_main) backupButton =findViewById(R.id.backupButton) backupButton.setOnClickListener { handleBackup() } }overridefunonActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {super.onActivityResult(requestCode, resultCode, data)if (portal.backup.drive.auth.onActivityResult !=null) { portal.backup.drive.auth.onActivityResult!!(requestCode, resultCode, data) } }privatefunhandleBackup() {CoroutineScope(Dispatchers.IO).launch {try {// Get an encryped client backup share.val cipherText = portal.backupWallet() { status ->// Do something with the status, such as update a progress bar// or log the progress Log.println(Log.INFO, "[PortalEx]", "Backup status: ${status.status} is done: ${status.done}") }// Send the backup share to your API and store it. exchangeApi.storeCipherText(user.id, backupShare, "GDRIVE")// ✅ Notify Portal that the backup share was stored! 🙌 portal.api.storedClientBackupShare(true, "GDRIVE") } catch (err: Throwable) {// ❌ Notify Portal that the backup share was not stored. portal.api.storedClientBackupShare(false, "GDRIVE") } } }}
Storing a custodian backup share is done by Portal generating a custodian backup share and sending the share – via webhook – to be stored within your infrastructure.
In order to support custodian backup shares, two main dependencies must be met:
Register a webhook on the Portal Admin Dashboard for your environment (development or production).
Implement the /backup webhook endpoint on your server to store the custodian backup shares.
Registering Your Webhook
You can register your webhook for both /backup and /backup/fetch endpoints in the Portal Admin Dashboard. Reach out to us if you still need to be invited to your organization's dashboard and we can help.
Implementing the /backup Webhook
Portal will send the raw custodian backup share to POST[webhookBaseURL]/backup. The request will include a X-Webhook-Secret header (you can set this in the Portal Admin Dashboard). The body of this POST request will contain three fields:
backupMethod - The backup method and curve used to create the backup share.
clientId - The Portal ID of the user. We recommend keeping track of this.
share - A JSON stringified version of the custodian backup share.
server.ts
import express from'express'constapp=express()app.post('/webhook/backup',async (req, res) => {constbackupMethod=req.body['backupMethod']validateBackupMethod(backupMethod)constshare=req.body['share']validateShare(share)constclientId=req.body['clientId']constuser=awaitthis.getUserByClientId(clientId)// Remove any existing custodian backup shares for this backup method + user.awaitdb.custodianBackupShare.deleteMany({ where: { backupMethod, userId:user.id, }, })// Store the custodian backup share.awaitdb.custodianBackupShare.create({ data: { backupMethod, share, userId:user.id, }, })res.status(204).send()})
When Portal makes a request to your /backup webhook, another immediate request is made to /backup/fetch right after in order to validate the custodian backup share was stored successfully. The /backup/fetch webhook is explained in the "Handling recovery" doc.
Next steps
Great! You have now set up backups for your users. Before we talk about how to put those backups to use, let's cover all the backup methods your users can make use of.