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.

Sometimes a user wants to change their PIN — they forgot it and want to set a new one (with the old one available for derivation), or you’re upgrading them from PIN-only to passkey + PIN. The flow is client-side re-encryption + a single backend call:
  1. Decrypt the existing private key with the old PIN.
  2. Re-encrypt that same private key with the new PIN.
  3. Call updateWalletEncryption to store the new ciphertext.
The wallet’s public address never changes; the on-chain account is the same. Only the encryption wrapper around the private key rotates.
Step 1 requires the user’s current PIN. If the user has lost it, this flow can’t recover the wallet — that’s a different recovery path (passkey backup, social recovery, etc.).

Initialise

import { ChipiServerSDK } from "@chipi-stack/backend";

const sdk = new ChipiServerSDK({
  apiPublicKey: process.env.CHIPI_PUBLIC_KEY!,
  apiSecretKey: process.env.CHIPI_SECRET_KEY!,
});

Rotate

import {
  encryptPrivateKey,
  decryptPrivateKey,
} from "@chipi-stack/backend";

async function rotatePin(
  sdk,
  externalUserId: string,
  oldPin: string,
  newPin: string,
) {
  // 1. Pull the current encrypted key
  const wallet = await sdk.getWallet({ externalUserId });
  if (!wallet) throw new Error("Wallet not found");

  // 2. Re-wrap the same private key with the new PIN
  const rawPrivateKey = decryptPrivateKey(wallet.encryptedPrivateKey, oldPin);
  const newEncryptedPrivateKey = encryptPrivateKey(rawPrivateKey, newPin);

  // 3. Persist
  const result = await sdk.wallets.updateWalletEncryption(
    {
      externalUserId,
      newEncryptedPrivateKey,
      publicKey: wallet.publicKey, // optional but disambiguates if user has multiple wallets
    },
    process.env.CHIPI_SECRET_KEY!,
  );

  // success is typed as `boolean | undefined` (the field is optional on the
  // response). Treat only an explicit `false` as a failure — `undefined` means
  // the call resolved without an error and we should trust it.
  if (result.success === false) throw new Error("Rotation failed");
}
updateWalletEncryption returns { success?: boolean }. After it resolves, getWallet returns the new ciphertext, the old PIN no longer decrypts, and signing requires the new PIN.

Verifying the rotation

After rotating, you can confirm the wallet still works by re-fetching and decrypting:
const refreshed = await sdk.getWallet({ externalUserId });
const verified = decryptPrivateKey(refreshed.encryptedPrivateKey, newPin);
console.log(verified.startsWith("0x")); // true — the same private key, new lock
The old PIN now throws on decrypt:
import { decryptPrivateKey } from "@chipi-stack/backend";

try {
  decryptPrivateKey(refreshed.encryptedPrivateKey, oldPin);
} catch (e) {
  console.log("Old PIN no longer decrypts ✅");
}

Putting it together

End-to-end rotation with a transfer-with-new-PIN sanity check:
import {
  ChipiServerSDK,
  Chain,
  ChainToken,
  encryptPrivateKey,
  decryptPrivateKey,
} from "@chipi-stack/backend";

async function rotateAndVerify(
  sdk: ChipiServerSDK,
  externalUserId: string,
  oldPin: string,
  newPin: string,
) {
  const wallet = await sdk.getWallet({ externalUserId });
  if (!wallet) throw new Error("Wallet not found");

  // Rotate
  const rawPrivateKey = decryptPrivateKey(wallet.encryptedPrivateKey, oldPin);
  const newCiphertext = encryptPrivateKey(rawPrivateKey, newPin);
  await sdk.wallets.updateWalletEncryption(
    {
      externalUserId,
      newEncryptedPrivateKey: newCiphertext,
      publicKey: wallet.publicKey,
    },
    process.env.CHIPI_SECRET_KEY!,
  );

  // Sanity-check: small self-transfer using the new PIN proves the wallet
  // still signs after the rotation
  const refreshed = await sdk.getWallet({ externalUserId });
  const tx = await sdk.transfer({
    params: {
      encryptKey: newPin,
      wallet: {
        publicKey: refreshed!.publicKey,
        encryptedPrivateKey: refreshed!.encryptedPrivateKey,
      },
      token: ChainToken.USDC,
      recipient: refreshed!.publicKey, // self
      amount: 0n,                       // a no-op approve-style call
    },
  });

  return { rotated: true, verifyTx: tx };
}
Verified by staging-integration/staging-update-encryption.test.ts at commit b5ccbfe (2026-03-31). Runs in CI on every PR from stagingmain against live staging — covers rotation end-to-end including a real on-chain transfer signed with the rotated key.

What’s next

  • For passkey + PIN migrations specifically, see the Passkey + PIN backup guide.
  • The same updateWalletEncryption endpoint backs both flows; the client-side re-encryption step is what changes (passkey-derived key vs raw PIN string).