Back up a wallet

This guide will walk you through how backups of a user's wallet are set up with Portal.

MPC backups allow your users to recover their MPC wallets in the event that their device is lost, stolen, or bricked.

Backups are handled in two pieces: user backup shares and custodian backup shares. Your infrastructure will store both of them.

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().
model UserBackupShare {
  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.
model CustodianBackupShare {
  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.
model User {
  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:

  1. A user has many raw custodian backup shares unique by backup method.

  2. A user has many encrypted user backup shares unique by backup method.

    1. 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:

  1. You must configure 1 or many backup methods when initializing Portal.

  2. You must be able to store the encrypted user backup share by backup method.

  3. 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.

You can use @portal-hq/gdrive-storage or @portal-hq/icloud-storage (iOS only).

Storing User Backup Shares

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) => {
    const backupMethod = req.body['backupMethod']
    validateBackupMethod(backupMethod)

    const cipherText = req.body['cipherText']
    validateCipherText(cipherText)

    const userId = req.params['userId']
    const user = await db.user.findById(userId)

    // Remove any existing user backup shares for this backup method.
    await db.userBackupShare.deleteMany({
      where: {
        backupMethod,
        userId: user.id,
      },
    })

    // Store the user backup share.
    await db.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:

  1. Generate an encrypted user backup share using portal.backupWallet().

  2. Send the encrypted user backup share to your API and store it.

Ensure you have created and connected the UI element in the storyboard to the action.

import PortalSwift

class ViewController: UIViewController {
  public var portal: Portal?
  public var yourApiUrl: String = "https://YOUR_API_URL.com"
  
  @IBAction func handlePasswordBackup(_: UIButton!) {
    Task {
      do {
        guard let portal = self.portal else {
          throw PortalExampleAppError.portalNotInitialized()
        }

        // Obtain the user's password.
        guard let enteredPassword = await requestPasswordFromUser() else {
          return
        }

        // Set the user's password.
        try portal.setPassword(enteredPassword)

        // Run backup.
        _ = try await self.backup(clientId, withMethod: .Password)
      } catch {
        // Handle any errors during the password backup flow.
      }
    }
  }

  public func backup(_ userId: String, withMethod: BackupMethods) async throws -> String {
    guard let portal else {
      throw PortalExampleAppError.portalNotInitialized()
    }

    guard let config else {
      throw PortalExampleAppError.configurationNotSet()
    }

    // Run password backup.
    let (encryptedClientBackupShare, storageCallback) = try await portal.backupWallet(withMethod) { status in
      // (Optional) Create a progress indicator here in the progress callback.
    }

    // Obtain your API's URL for storing the encrypted user backup share.
    guard let url = URL(string: "\(yourApiUrl)/users/\(userId)/store-encrypted-user-backup-share") else {
      throw URLError(.badURL)
    }

    // Store the encrypted user backup share on your API.
    try await requests.post(
      url,
      andPayload: [
        "backupMethod": withMethod.rawValue,
        "encryptedClientBackupShare": encryptedClientBackupShare,
      ]
    )

    // Call the storageCallback to notify Portal you stored the user backup share successfully.
    try await storageCallback()
    
    // ✅ The user has now backed up with their password successfully!
  }
}

Status Flow

case readingShare = "Reading share"
case generatingShare = "Generating share"
case parsingShare = "Parsing share" 
case encryptingShare = "Encrypting share"
case storingShare = "Storing share"
case done = "Done"

Custodian Backup Shares

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:

  1. Register a webhook on the Portal Admin Dashboard for your environment (development or production).

  2. 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
app.post('/webhook/backup', async (req, res) => {
  const backupMethod = req.body['backupMethod']
  validateBackupMethod(backupMethod)
  
  const share = req.body['share']
  validateShare(share)

  const clientId = req.body['clientId']
  const user = await this.getUserByClientId(clientId)

  // Remove any existing custodian backup shares for this backup method + user.
  await db.custodianBackupShare.deleteMany({
    where: {
      backupMethod,
      userId: user.id,
    },
  })

  // Store the custodian backup share.
  await db.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.

Last updated