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.

The dual-key architecture stores the wallet’s private key encrypted twice:
  • Primary (encryptedPrivateKey) — encrypted with a passkey-derived key, unlocked by a WebAuthn ceremony.
  • Backup (encryptedPrivateKeyBackup) — encrypted with a user-chosen PIN, unlocked by typing it.
Both ciphertexts decrypt to the same private key. Day-to-day signing uses the passkey; if it fails (new device without the passkey, biometric sensor broken, browser extension uninstalled), the PIN fallback keeps the wallet recoverable without a recovery phrase.
WebAuthn / passkey ceremonies happen in the browser — outside the scope of @chipi-stack/backend. Use the React or Expo packages on the client to derive encryptKey from a passkey, then call this server-side flow with the derived value. The PIN backup half is fully server-driven.

Create a wallet with both keys

Pass both encryptKey (the passkey-derived primary) and encryptKeyBackup (the PIN), plus the auth metadata that lets your client know which credential to use later.
const wallet = await sdk.createWallet({
  params: {
    encryptKey: passkeyDerivedKey,        // from WebAuthn on the client
    externalUserId: "user-42",
    chain: "STARKNET",

    // Passkey + PIN backup
    encryptKeyBackup: userPin,            // user-chosen PIN — triggers dual-key wrapping
    authMethod: "passkey+pin",            // AuthMethod = "passkey" | "pin" | "passkey+pin"

    // Passkey credential metadata (for sign-in attribution)
    credentialId,                         // from WebAuthn registration response
    prfSupported: true,                   // whether the authenticator supports PRF
    deviceType: "web",                    // "web" | "ios" | "android"
  },
});
The response is the standard flat GetWalletResponse shape — store wallet.publicKey and both wallet.encryptedPrivateKey and wallet.encryptedPrivateKeyBackup against your user record.

Look up the wallet later

getWallet returns both ciphertexts plus the auth metadata, so the client knows which credential to prompt for.
const retrieved = await sdk.getWallet({ externalUserId: "user-42" });

retrieved.encryptedPrivateKey;       // primary (passkey)
retrieved.encryptedPrivateKeyBackup; // backup (PIN)
retrieved.authMethod;                // "passkey+pin"
retrieved.credentialId;              // WebAuthn credential to challenge
retrieved.prfSupported;              // true | false
retrieved.deviceType;                // "web" | "ios" | "android"
For wallets created without the dual-key fields (legacy PIN-only), encryptedPrivateKeyBackup, authMethod, credentialId, and prfSupported are null.

Get the credential metadata before sign-in

Sometimes you need just the WebAuthn credential ID to start a passkey ceremony, without exposing the encrypted private keys. Use getCredentialRecovery:
const recovery = await sdk.wallets.getCredentialRecovery(
  { externalUserId: "user-42" },
  process.env.CHIPI_SECRET_KEY!,
);

recovery.credentialId;   // string | null (null for legacy PIN-only wallets)
recovery.authMethod;     // "passkey" | "pin" | "passkey+pin" | null
recovery.prfSupported;   // true | false | null
recovery.deviceType;     // "web" | "ios" | "android" | null
If the user doesn’t exist, the call throws — wrap in a try/catch if you need to distinguish “no wallet” from “passkey not enabled”.

Sign with whichever key the user has

Both keys produce the same private key, so transfer / contract calls work the same way no matter which side you decrypt from. The only thing that changes is the wallet payload + encryptKey you pass:
// Path A — passkey unlock (day-to-day)
await sdk.transfer({
  params: {
    encryptKey: passkeyDerivedKey,
    wallet: {
      publicKey: retrieved.publicKey,
      encryptedPrivateKey: retrieved.encryptedPrivateKey,
    },
    token: "USDC",
    recipient,
    amount,
  },
});

// Path B — PIN backup (recovery / fallback)
await sdk.transfer({
  params: {
    encryptKey: userPin,
    wallet: {
      publicKey: retrieved.publicKey,
      encryptedPrivateKey: retrieved.encryptedPrivateKeyBackup, // <— note: backup
    },
    token: "USDC",
    recipient,
    amount,
  },
});
Both paths produce identical on-chain transactions; only the unlock UX differs.

Verify a wallet locally (debugging)

You can decrypt either ciphertext yourself to confirm the user’s keys produce a valid private key. Useful for migrations or debugging — not for production hot paths.
import { decryptPrivateKey } from "@chipi-stack/backend";

const fromPasskey = decryptPrivateKey(retrieved.encryptedPrivateKey, passkeyDerivedKey);
const fromPin = decryptPrivateKey(retrieved.encryptedPrivateKeyBackup, userPin);

console.log(fromPasskey === fromPin); // true — same private key, two locks
Wrong keys throw — never silently return garbage.

Migrate a legacy PIN-only wallet to passkey + PIN

For wallets created before passkey support, use the PIN rotation flow to add a passkey-derived backup. (Dedicated docs page coming next.)
Verified by staging-integration/staging-passkey-default.test.ts at commit 630f645 (2026-04-01). Runs in CI on every PR from stagingmain against live staging — covers wallet creation with both keys, the credential-recovery endpoint (including the legacy-wallet null-fields case and the 404 path), decryption with each key, equivalence of the two private keys, and a real on-chain transfer using the PIN backup.