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 3.0.0 and later (key recovery: 3.1.0 and
later). Earlier versions (2.x and before) have none of onConfirmWallet,
confirmWallet, trustedAuthorities, watchMints or the built-in chooser.
When nothing is stored yet, connect looks the passkey's wallet up on chain. Looking it
up is not enough: the credential-id hash that finds it is public, and CreateWallet /
AddAuthority / TransferOwnership take any owner without that owner's consent. Anyone
can list a user's passkey on a wallet they still control, or hand them one after quietly
keeping a way into its vault.
What connect does
- Proves the passkey. It keeps only the wallets whose stored key the passkey is proven to hold — the key the portal reports, or a signature over a fresh challenge (at most one extra passkey prompt per connect).
- 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 deferred transaction or token approval, and a vault still owned by the System Program.
- Otherwise asks the user which wallet is theirs — see
onConfirmWallet. - Creates a new (v2) wallet only when the passkey holds none, for the passkey's own key (below). A wallet it just created is saved as it is, never looked up again. Until the v2 program is deployed on mainnet, this step fails there (Networks & versions).
A stored wallet is used as stored; none of this runs for it, or for signing. If the chain
cannot be read, connect fails — that is never taken as "no wallet", so it never creates
a wallet by mistake. The lookup uses getProgramAccounts, so rpcUrl must allow it.
A passkey that has no wallet yet
Every passkey starts without a v2 wallet, including one the user made long ago on another
device or browser. Signing in never reveals a passkey's public key, so the portal reports
the key it has stored for that passkey: none for a passkey made elsewhere, and possibly
another passkey's. A wallet created for a key the passkey does not hold could never sign,
and whatever reached its vault would be stuck. So the key a new wallet gets is always one
that a signature from this connect verifies against:
- a passkey the portal registered just now: the key it reports (no extra prompt);
- otherwise the reported key, once the ownership proof verifies against it;
- otherwise the key recovered from two of the passkey's signatures over fresh
challenges (
resolvePasskeyPublicKey,@lazorkit/sdk-legacy1.3.0). That costs one extra passkey prompt: a portal sign over a random challenge, with no transaction.
Closing that extra prompt rejects connect with PortalCancelledError. If the signatures
do not settle on one key, connect throws an error that says so. A portal sign reply that
names another passkey than the one being connected also fails connect. In every case
nothing is created.
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
disconnector cleared storage (theconnectthat 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, so either count may be the 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: the SDK's chooser
trustedAuthorities={[BACKEND_ADMIN]} // your own Ed25519 keys, base58
watchMints={[MY_TOKEN_MINT]} // SPL mints your app receives
>
{children}
</LazorkitProvider>| Prop | Type | Default |
|---|---|---|
onConfirmWallet | 'builtin' | 'throw' | ConfirmWalletHandler | 'builtin' |
trustedAuthorities | string[] (base58 Ed25519 keys) | none |
watchMints | string[] (base58 SPL Token mints) | none |
Each call can override the provider: connect({ onConfirmWallet }). 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" — a "Which wallet is yours?" dialog, drawn in the portal
dialog's frame (keyboard accessible, light and dark). Each row shows:
- the vault address — selectable in full, with a copy button — and its SOL balance;
- "Legacy (v1)" for a wallet not yet migrated to v2;
- "Not used with this passkey yet…" for a wallet the passkey has never signed for;
- "Also controlled by: …" for everything untrusted that can spend from it — other passkeys, backend keys with their role, session keys and until when, pending transactions, token approvals, token accounts handed to someone else — with the full addresses under "Addresses".
A vault handed to another program gets a warning of its own and no "Use this wallet"
button. Rows come in no meaningful order, nothing is pre-selected, and no row is called
safe: the SDK cannot see everything a former holder of a wallet may have left on its
vault. "None of these", the X, Escape or a click outside declines — connect rejects
with WalletConfirmationDeclinedError, and no wallet is created.
Pass a function (ConfirmWalletHandler). It gets
{ credentialId, candidates } (a
ConfirmWalletRequest) and returns
{ wallet } — the chosen vault (or wallet PDA) — or null when the user recognises
none:
<LazorkitProvider
onConfirmWallet={async ({ candidates }) => {
const chosen = await myWalletPicker(candidates); // show c.vault; never pick for the user
return chosen ? { wallet: chosen.vault } : null;
}}
>
{children}
</LazorkitProvider>Each candidate is a WalletChoice: vault, balance,
signatureCount, vaultIsSystemAccount, every pending transaction, and every other
authority, session and token grant, each marked trusted or not. The order is not a
recommendation — pre-select nothing. Returning null rejects connect with
WalletConfirmationDeclinedError; a throw is passed on as connect's error.
onConfirmWallet="throw" — connect throws WalletNeedsConfirmationError, whose
candidates are the same
WalletChoice[]. Once the user picks one, call connect({ confirmWallet: choice.vault }).
Within 2 minutes that adopts it without a second passkey prompt; after that, or after
disconnect, the portal opens again.
import { WalletNeedsConfirmationError } from '@lazorkit/wallet';
try {
await connect({ onConfirmWallet: 'throw' }); // or set it on <LazorkitProvider>
} catch (e) {
if (e instanceof WalletNeedsConfirmationError) {
const choice = await askUser(e.candidates);
if (choice) await connect({ confirmWallet: choice.vault });
} else throw e;
}confirmWallet
connect({ confirmWallet }) takes the vault or the wallet PDA of a wallet the passkey
is proven to hold — including one the built-in chooser would not offer a button for. One
that names none of them throws an error naming it; it is never ignored. After a
'throw', one that names none of the offered wallets throws at once, without opening the
portal, and the offer stays open.
While a wallet is connected, a confirmWallet naming another one throws too:
disconnect first.
trustedAuthorities and watchMints
trustedAuthorities— your own Ed25519 keys: a backend admin, session keys you issue. An authority, session or token approval held by one of them does not stop a wallet from being used on its own, and is not listed in the chooser. Passkeys cannot be trusted this way, nor can a pending deferred transaction or a vault handed to another program.watchMints— your app's SPL Token mints, added to the ones whose vault token account is checked for having been handed to someone else (wSOL, USDC, USDT and devnet USDC always are). An account moved away for any other mint cannot be found.
Errors
| Error | When |
|---|---|
WalletNeedsConfirmationError | onConfirmWallet: 'throw' and the wallet needs the user. Carries credentialId and candidates: WalletChoice[]. |
WalletConfirmationDeclinedError | The user chose none ("None of these", or your handler returned null). Nothing was saved. |
PortalCancelledError | The user closed the portal dialog (X, Escape, a click outside) or its popup window before it answered — on connect and on every signing action. Raised at once, not after the 60 s timeout. Also what a connect still running rejects with when disconnect is called: its portal or chooser closes and it connects nothing. |
All three are exported from @lazorkit/wallet. Portal errors carry the portal's own
message.
Wallet adapter and Wallet Standard
LazorkitWalletAdapter takes the same options in its config, and per call:
import { LazorkitWalletAdapter } from '@lazorkit/wallet';
const adapter = new LazorkitWalletAdapter({
rpcUrl: 'https://api.devnet.solana.com',
portalUrl: 'https://portal.lazor.sh',
paymasterConfig: { paymasterUrl: 'https://kora.devnet.lazorkit.com' },
onConfirmWallet: 'throw',
trustedAuthorities: [BACKEND_ADMIN_KEY],
watchMints: [MY_TOKEN_MINT],
});
// Per call, after the user picked a wallet from WalletNeedsConfirmationError.candidates:
await adapter.connect({ confirmWallet: 'CHOSEN_VAULT_ADDRESS' });With 'throw', catch the error (wallet-adapter's onError), then set
adapter.confirmWallet = choice.vault and connect again. The adapter clears
confirmWallet once a wallet is connected, and on disconnect. A second connect without
options while one is running waits for it rather than opening another portal. One that
passes confirmWallet or onConfirmWallet throws WalletConnectionError (and emits it as
error) until the first finishes.
registerLazorkitWallet takes the same options except
onConfirmWallet: 'throw', which it rejects: standard:connect cannot pass
confirmWallet, so the user could never finish. Use 'builtin' or your own handler there.
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.
createOwnershipChallenge, verifyOwnershipProof, pickOwnWallet and
selectWalletByAddress are re-exported from @lazorkit/wallet.