Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.chipipay.com/llms.txt

Use this file to discover all available pages before exploring further.

Why migrate

PIN-encrypted wallets are the shortest path to a working integration, but they are not the right default for production. A 6-digit PIN has roughly 20 bits of entropy — an attacker who obtains the encrypted ciphertext (e.g. via a leaky metadata field, a stolen backup, or any other ciphertext exposure) can brute-force it offline in seconds. Passkey authentication removes this vector:
  • The encryption key is derived from a hardware-backed WebAuthn credential (Face ID, Touch ID, Windows Hello, security key).
  • It never leaves the device’s secure element.
  • There is no low-entropy secret to crack.
This guide walks through migrating an existing PIN-only wallet to a passkey wallet using useMigrateWalletToPasskey.
Recommended for all production wallets. New wallets should be created with usePasskey: true from the start (see the Passkeys guide). This guide covers existing wallets that were created PIN-only.

Prerequisites

  • Wallet was created with a PIN (encryptKey) and currently has only encryptedPrivateKey set.
  • User is on a device that supports WebAuthn PRF (most modern browsers + Face ID / Touch ID / Windows Hello).
  • App is using @chipi-stack/nextjs ≥ v14.3.0 (which ships dual-key support for new wallets) or @chipi-stack/chipi-react ≥ v14.3.0.
  • @chipi-stack/chipi-passkey is installed.
npm install @chipi-stack/chipi-passkey@latest

How migration works

useMigrateWalletToPasskey performs an atomic key swap:
  1. The user’s current PIN is used to decrypt encryptedPrivateKey locally — if the PIN is wrong, migration aborts before touching anything else.
  2. A new passkey is created via WebAuthn (biometric prompt).
  3. The decrypted private key is re-encrypted using the passkey-derived key.
  4. The new ciphertext is sent to the backend via PATCH /chipi-wallets/update-encryption-details.
  5. The hook returns the updated wallet and the credentialIdpersist both.
The on-chain wallet address does not change. The same private key continues to control the same Starknet account; only the encryption envelope on the backend is replaced.

Migration component

"use client";

import { useState } from "react";
import { useAuth } from "@clerk/nextjs";
import { useChipiWallet, useMigrateWalletToPasskey } from "@chipi-stack/nextjs";

export function MigrateToPasskey({ userId }: { userId: string }) {
  const { getToken } = useAuth();
  const { wallet } = useChipiWallet({
    externalUserId: userId,
    getBearerToken: getToken,
  });

  const { migrateWalletToPasskeyAsync, isLoading, error } = useMigrateWalletToPasskey();
  const [pin, setPin] = useState("");
  const [done, setDone] = useState(false);

  const handleMigrate = async () => {
    try {
      if (!wallet) return;
      const bearerToken = await getToken();
      if (!bearerToken) throw new Error("Missing bearer token");

      const result = await migrateWalletToPasskeyAsync({
        wallet,
        oldEncryptKey: pin,
        externalUserId: userId,
        bearerToken,
      });

      // Persist the new wallet and credentialId — these replace the old PIN-encrypted state.
      // localStorage is used here for example simplicity. In production, store both
      // values in a durable backend (your DB, or Clerk privateMetadata via a
      // server-side route) — see "After migration" below.
      localStorage.setItem("wallet", JSON.stringify(result.wallet));
      localStorage.setItem("credentialId", result.credentialId);
      setDone(true);
    } catch (e) {
      console.error("Migration failed", e);
    }
  };

  if (done) {
    return <p>✓ Migrated to passkey. You can now sign transactions with biometrics.</p>;
  }

  return (
    <div>
      <p>Upgrade your wallet to passkey-based security.</p>
      <label htmlFor="current-pin">Current PIN</label>
      <input
        id="current-pin"
        type="password"
        placeholder="Enter your current PIN"
        value={pin}
        onChange={(e) => setPin(e.target.value)}
      />
      <button onClick={handleMigrate} disabled={isLoading || !pin}>
        {isLoading ? "Migrating..." : "Migrate to Passkey"}
      </button>
      {error && <p style={{ color: "red" }}>{error.message}</p>}
    </div>
  );
}

After migration

Once migration succeeds:
  • wallet.encryptedPrivateKey is now passkey-encrypted.
  • All subsequent signing flows must use usePasskey: true. For example:
import { useTransfer, ChainToken } from "@chipi-stack/nextjs";

const { transferAsync } = useTransfer();

await transferAsync({
  params: {
    wallet,
    token: ChainToken.USDC,
    recipient,
    amount,
    usePasskey: true,           // required after migration
    externalUserId: userId,
  },
  bearerToken,
});
  • Store credentialId somewhere durable (your DB, Clerk privateMetadata via a server route, etc.). If localStorage is cleared, the SDK can recover the credential from the backend via /chipi-wallets/credential-recovery.

A note on PIN backup

useMigrateWalletToPasskey performs a single-key swap — after migration, the backend stores only the passkey-encrypted ciphertext. A wallet that was created PIN-only does not gain a PIN backup column through migration. If you want true dual-key (passkey primary, PIN backup), the path is to create new wallets with usePasskey: true from the start (see Passkeys → Create Wallet with Passkey + PIN). This sets both encryptedPrivateKey (passkey) and encryptedPrivateKeyBackup (PIN) at creation time, and the SDK’s useTransfer will automatically fall back to PIN if the passkey is unavailable. For migrated wallets, plan for passkey recovery via WebAuthn discoverable credentials and credentialId recovery rather than a PIN fallback.

Rollout strategy

A staged rollout reduces risk:
  1. Detect. On wallet load, check whether the wallet has a credentialId / passkey-encrypted state. If not, surface a one-time migration prompt.
  2. Prompt at a low-stakes moment. Don’t block the user mid-transfer. Show the upgrade flow on the next wallet screen visit, or after a successful (non-critical) action.
  3. Make it non-mandatory at first. Let users skip and re-prompt later. Track adoption.
  4. Mandate for high-value flows. Once adoption is high, gate sensitive flows (large transfers, treasury actions) behind passkey-only wallets.
  5. Monitor. Log migration attempts, successes, failures, and PRF-not-supported events so you can size the long tail.

Errors and edge cases

ErrorCauseWhat to do
Failed to decrypt wallet with provided encryptKeyUser entered the wrong PINRe-prompt; do not retry the passkey creation step
WebAuthn aborted / cancelledUser dismissed the biometric promptNo state changed — safe to retry
Backend rejected wallet encryption updateNetwork or auth failure mid-updateThe backend write is atomic; retry the full migration
PRF not supportedDevice/browser does not support WebAuthn PRFKeep the user on PIN; surface the migration option only when PRF is detected