LazorKit LogoLazorKit
React Native SDK

Wallet Confirmation

How connect finds a returning user's wallet, when it asks the user to confirm it, and how to shape that step.

Version note

This page describes @lazorkit/wallet-mobile-adapter 2.0.0 and later (key recovery: 2.1.0 and later). Earlier versions — 1.x, and the 2.0.0 betas up to 2.0.0-beta.4 — have none of onConfirmWallet, confirmWallet, trustedAuthorities, watchMints, <WalletChooser /> or the built-in chooser.

A passkey's credential id is public — it sits in every account the passkey has touched — and anyone can create a wallet that lists it, add it to a wallet of their own, or hand it a wallet they have used first. So connect never takes "the wallet this credential is on" at face value.

What connect does

  1. Proves the passkey. The passkey signs a challenge the SDK chose — in the connect reply when the portal supports it, otherwise in one more portal prompt. Only wallets whose stored key verifies that signature count. (Mobile always proves: a deep link can be forged.)
  2. Uses a wallet on its own only when it is the one wallet this passkey has signed for, and nothing else can spend from it: no other authority, live session, pending transaction or token approval, and a vault that is still a plain system account.
  3. Otherwise asks the user which wallet is theirs — see onConfirmWallet.
  4. Creates a new (v2) wallet only when the passkey is proven on no live wallet, for the passkey's own key (below). It is saved as created, never looked up again. Until the v2 program is deployed on mainnet, this step fails there (Networks & versions).

This runs only on a fresh connect; a stored wallet and sign actions never come through it. While a wallet is connected, connect returns it without opening the portal — a confirmWallet naming another wallet throws, so disconnect first to connect another passkey's wallet. The lookup reads with getProgramAccounts, so rpcUrl must allow it; a failed read fails connect rather than counting as "no wallet".

A passkey that has no wallet yet

Every passkey starts without a v2 wallet, including one the user made on another device. Signing in never reveals a passkey's public key, so the portal's reply carries the key it has stored for it: none for a passkey made elsewhere, and possibly another passkey's. A wallet for a key the passkey does not hold could never sign, so the new wallet's key is always one a signature from this connect verifies against:

  • the reported key, once the proof from step 1 verifies against it;
  • otherwise the key recovered from two of the passkey's signatures over fresh challenges (resolvePasskeyPublicKey, @lazorkit/sdk-legacy 1.3.0): the step 1 proof and the connect reply's signature, or one more portal sign when the reply has none. That is one extra passkey prompt (a portal sign over a random challenge, no transaction).

Closing that prompt, or calling disconnect during it, rejects with PortalCancelledError. If the signatures do not settle on one key, connect throws an error that says so. A sign redirect that names another passkey than the one being connected also fails connect. In every case nothing is created.

Android: the redirect is the trust boundary

The signatures come back in redirects, over challenges the SDK sent only in the portal URL it opened. An app that merely fires a deep link into your scheme cannot sign them. But Android lets more than one app claim a custom scheme, and an app that can also receive your scheme's links sees the portal's redirects and could answer in the portal's place with a key of its own. On iOS the auth session hands the redirect to your app alone. On Android, prefer a redirect only your app can receive, such as a verified App Link.

A never-used wallet is confirmed once

A passkey signs only for a wallet its user chose, so a wallet it has never signed for is never used without asking — even when it is the only one, and even with trustedAuthorities. That includes the user's own wallet before its first transaction:

  • a user whose wallet was created but not used yet confirms it once if they connect afresh before its first transaction — on another device, or after disconnect (the connect that created it saves it directly, without asking);
  • a wallet made on the LazorKit migration page is confirmed once;
  • after the wallet's first transaction, it is used on its own.

The user is also asked whenever the passkey has signed for two wallets: signatures made before the v2 program bound the wallet into the passkey challenge could be replayed onto a planted copy.

Why a never-used wallet cannot be trusted by looking at it: its earlier holder could have moved the vault's SPL Token account for some mint to themselves before handing the wallet over, and nothing on chain leads back to that account. The SDK checks it for wSOL, USDC, USDT and devnet USDC, plus every mint in watchMints; for any other mint it cannot be seen.


Configure

<LazorKitProvider
  onConfirmWallet="builtin"                // default
  trustedAuthorities={[BACKEND_ADMIN_KEY]} // your own Ed25519 keys, base58
  watchMints={[MY_TOKEN_MINT]}             // SPL mints your app receives
>
  <App />
</LazorKitProvider>
PropTypeDefault
onConfirmWallet'builtin' | 'throw' | ConfirmWalletHandler'builtin'
trustedAuthoritiesreadonly string[] (base58 Ed25519 keys)none
watchMintsreadonly string[] (base58 SPL Token mints)none

connect({ redirectUrl, onConfirmWallet }) overrides it for one call. A malformed trustedAuthorities or watchMints entry fails every fresh connect, not only a returning user's.


onConfirmWallet

How connect asks the user when it will not pick a wallet on its own.

onConfirmWallet="builtin" — LazorKitProvider shows a chooser, "Which wallet is yours?", as a React Native Modal. Each wallet shows its vault address (selectable), SOL balance, a "Legacy (v1)" tag for a wallet not yet migrated, a note for a wallet not used with this passkey yet, and "Also controlled by: …" for anything else that can spend from it.

Nothing is pre-selected or marked as safe, and the order is not a recommendation. A wallet whose vault was handed to another program is shown with a warning and cannot be chosen there. "None of these" and the Android back button decline — connect rejects with WalletConfirmationDeclinedError, and no wallet is created.

Pass a function (ConfirmWalletHandler): (request) => { wallet } | null, or a promise of it. request.candidates are WalletChoices — vault, balance, signatureCount, vaultIsSystemAccount, every pending transaction, and every other authority, session and token grant, each marked trusted or not. Resolve with the chosen vault (or wallet), or null. Never pick for the user.

<LazorKitProvider
  onConfirmWallet={async ({ candidates }) => {
    const chosen = await myWalletPicker(candidates); // show c.vault
    return chosen ? { wallet: chosen.vault } : null;
  }}
>
  <App />
</LazorKitProvider>

onConfirmWallet="throw" — connect throws WalletNeedsConfirmationError with credentialId and candidates. Show them, then call connect({ redirectUrl, confirmWallet: choice.vault }). Within two minutes that adopts it without opening the portal again; an address that is none of them throws at once, and the candidates stay remembered. Disconnecting, or any connect that opens the portal, forgets them.

import { WalletNeedsConfirmationError } from '@lazorkit/wallet-mobile-adapter';

try {
  await connect({ redirectUrl: 'myapp://home', onConfirmWallet: 'throw' });
} catch (e) {
  if (e instanceof WalletNeedsConfirmationError) {
    const choice = await askUser(e.candidates);
    if (choice) await connect({ redirectUrl: 'myapp://home', confirmWallet: choice.vault });
  } else throw e;
}

While the chooser (or your function) waits for the user, isLoading is false and isConnecting stays true, so an overlay you show while loading does not cover it.

iOS shows one modal at a time

The built-in chooser is a React Native Modal that LazorKitProvider renders beside your app, and iOS will not present it over another modal that is open — your own <Modal>, or a screen presented modally (presentation: 'modal' in Expo Router / React Navigation). If you connect from inside one, render the chooser there as well; while it is mounted it draws instead of the provider's:

import { WalletChooser } from '@lazorkit/wallet-mobile-adapter';
import { Modal } from 'react-native';

export function SignInModal({ open, children }: { open: boolean; children: React.ReactNode }) {
  return (
    <Modal visible={open}>
      {children /* your sign-in screen, which calls connect() */}
      <WalletChooser />
    </Modal>
  );
}

If the chooser still is not on screen within a few seconds, connect rejects with WalletChooserNotShownError instead of waiting for an answer nobody can give. Android shows it over anything.

confirmWallet

The user's pick, by vault or wallet PDA. It must be a wallet this passkey is proven to hold a key of; any other address throws rather than being ignored. While a wallet is connected it must name that one.

trustedAuthorities and watchMints

  • trustedAuthorities — Ed25519 keys you control (a backend admin, session keys your app issues). An authority, session or token approval held by one of them does not stop a wallet from being used. Passkeys, pending transactions and a vault handed to another program are never waived.
  • watchMints — the vault's token account for each of these mints (on top of wSOL, USDC, USDT and devnet USDC) is checked for having been handed to someone else. A handed-away account for a mint nobody watches cannot be seen at all, which is why a wallet this passkey never signed for always goes to the user.

Errors

ErrorWhen
WalletNeedsConfirmationErroronConfirmWallet: 'throw' and the user has to choose.
WalletConfirmationDeclinedErrorThe user chose "None of these" (or your handler returned null). Nothing was saved, and no wallet was created.
PortalCancelledErrorThe user closed the portal (iOS cancel, or an Android Custom Tab dismissed without a redirect), or disconnect was called while connect ran — then that connect saves and remembers nothing, and its chooser closes. Also for sign actions.
WalletChooserNotShownErroriOS could not show the built-in chooser (another modal is open); see above. Nothing was saved.
LazorKitError with code: 'PORTAL_ERROR'The portal redirected with an error; its text is the message.

All are exported from @lazorkit/wallet-mobile-adapter.


Finding wallets yourself

Using @lazorkit/sdk-legacy directly? Do not take the first wallet findWalletsByAuthority(credentialIdHash) returns — anyone can plant one there. Use LazorKitClient.findOwnPasskeyWallet with a proof over createOwnershipChallenge(); see SDK (web3.js v1) › Look up a returning user.