PollarPollar
Guides

Passkeys and wallet recovery

Passkeys play two roles in Pollar:

  • Smart wallet login. A passkey is the signer of a Soroban smart account (a C... address). See Smart Wallets.
  • Wallet recovery. A passkey is one of the ways a user gets back into a Turnkey wallet (co-signed or self-custody) from a new device. This guide covers this role.

Requires @pollar/core / @pollar/react / @pollar/react-native 0.12.0 or later.


Why recovery is required

A Turnkey wallet signs with a key on the user's device (see Co-signed Wallets). Pollar holds no copy of it. If the user loses the device and the wallet has no other way in, nobody can move its funds again.

So a Turnkey wallet needs a recovery method before it signs. Until it has one, every signature other than the wallet's own setup (account creation, trustlines, a new chain) and on-ramps is refused with 409 SDK_WALLET_RECOVERY_REQUIRED, on testnet and mainnet. A ramp order that reaches a step needing the wallet waits until a method is added.

A wallet counts as recoverable when it has at least one of:

MethodHow the user adds itHow it recovers
PasskeyRegisters a passkey (synced by the platform: iCloud Keychain, Google Password Manager)Signs in with the passkey on the new device
EmailEnters an email and confirms it with a code Turnkey sendsReceives a code at that email on the new device
Second deviceSigns in on another device and approves it from the firstThe other device approves the new one

The user adds every method directly with Turnkey, from their own device. Pollar never sets a recovery method, never confirms one and never receives the codes: the Turnkey wallet is created with no email, and the OTP codes go from Turnkey to the user. This keeps the wallet out of Pollar's reach, which matters most for a co-signed wallet, where Pollar holds the other vote.


With @pollar/react

The provider handles the whole flow:

  • recoveryLocked on usePollar() is true while the wallet has no method. The wallet menu (<WalletButton>) shows the lock with a way to fix it, and the receive modal hides the address until a method exists, so nobody sends funds to a wallet that cannot be recovered.
  • openRecoveryModal() opens <RecoveryModal>: add or remove passkeys, confirm an email, approve or reject a device that asks to join, and export the recovery phrase.
  • After a custody migration moves the user's wallet to Turnkey, the provider opens <RecoveryModal> right away.
  • The ramp widget offers the same setup when an order waits on it.
'use client';
import { usePollar } from '@pollar/react';

export function RecoveryBanner() {
  const { recoveryLocked, openRecoveryModal } = usePollar();
  if (!recoveryLocked) return null;
  return (
    <button onClick={openRecoveryModal}>
      Set up a way to recover your wallet
    </button>
  );
}

recovery on usePollar() holds the full state (RecoveryState): required (the user has a Turnkey wallet), configured, email (address, verified), passkeys and devices. refreshRecovery() reads it again.

On the web the provider installs browserRecoveryPasskey, the WebAuthn ceremony for recovery passkeys. Its relying party is the page's hostname, so a passkey created on your app's domain recovers the wallet on that domain.

On React Native pass your own ceremony as recoveryPasskey in the client config; see @pollar/react-native. Without one the passkey options are unavailable (SDK_COSIGN_PASSKEY_UNAVAILABLE) and the user recovers with an email code or another device.


Headless: client.cosign.recovery

Every method runs on the user's device and talks to Turnkey through Pollar's relay; Pollar sees no secret.

State

MethodReturnsWhat it does
status()RecoveryStateWhether the wallet can be recovered without this device, and the methods it has
passkeyAvailablebooleanWhether a passkey ceremony is configured on this client

Adding methods (on a device that has access)

MethodReturnsWhat it does
addPasskey({ name? })RecoveryStateRegisters a passkey as a way back in
removePasskey(authenticatorId)RecoveryStateRemoves one
startEmail(email)voidTurnkey emails a code to confirm email
confirmEmail(code)RecoveryStateConfirms the email with that code; it counts as a method from now on. An email the wallet already holds unconfirmed is replaced
approveDevice(enrollmentId)CosignDevicesContentAdds a device that asked to join (client.cosign.devices() lists the open enrollments)
rejectDevice(enrollmentId)voidRefuses it

Recovering (on a new device)

MethodReturnsWhat it does
recoverWithPasskey()RecoveryStateOne of the user's passkeys adds this device's key to their Turnkey user
startEmailRecovery()voidTurnkey emails a code to the confirmed address
recoverWithEmailCode(code)RecoveryStateThe code opens a short Turnkey session that adds this device's key
requestDeviceEnrollment(label?){ id, expiresAt }Asks an enrolled device to add this one; the user approves it there

The user signs in to the app on the new device first (social, email or wallet login, as usual); recovery then adds the device to their Turnkey wallet.

Export

exportWallet() returns the wallet's recovery phrase, decrypted on this device: Turnkey encrypts it to a key that exists only for that call, and nothing readable crosses Pollar. Whoever holds the phrase controls every account of the wallet, so show it only on an explicit user action.

const state = await client.cosign.recovery.status();
if (state.required && !state.configured) {
  await client.cosign.recovery.startEmail('[email protected]');
  // ... the user types the code Turnkey sent
  await client.cosign.recovery.confirmEmail(code);
}

Errors

CodeMeaning
SDK_WALLET_RECOVERY_REQUIRED (409)The wallet has no recovery method; add one, then retry. The SDK also throws it from recoverWithPasskey() / startEmailRecovery() when the wallet has no passkey / no confirmed email to recover with
SDK_COSIGN_PASSKEY_UNAVAILABLESDK only: no passkey ceremony on this client
SDK_COSIGN_TURNKEY_REJECTED (502)Turnkey refused the request; cosignRefusalMessage(err) gives the text with Turnkey's reason
SDK_COSIGN_UPSTREAM_ERROR (502)Turnkey or the relay failed, or a code did not verify; retry
SDK_COSIGN_DEVICE_NOT_ENROLLED (403)This device is not one of the user's; recover it first

See Error Codes for the rest.


Browser and device support

Recovery passkeys use WebAuthn, supported on:

  • iOS 16+ (Face ID, Touch ID)
  • Android 9+ (fingerprint, face unlock)
  • macOS with Touch ID
  • Windows Hello
  • Any device with a FIDO2 hardware key

On this page

Was this helpful?