LazorKit LogoLazorKit
Concepts

Smart Wallet

How LazorKit smart wallets work — PDA structure, vault vs wallet PDA, authentication, ranks, lifecycle.

A LazorKit smart wallet is a cluster of on-chain accounts controlled by the LazorKit program. The owner authenticates via WebAuthn (passkey) or Ed25519 — no seed phrase, no browser extension, no private key storage in your app.

This page describes protocol v2 (devnet today; mainnet pending — see Networks & versions). Where v1 differs, it says so.

Two PDAs — and the SDKs name them differently

The vault PDA holds SOL and tokens. It is the address users see, share and receive funds at. The wallet PDA is an internal identity account; nothing can spend from it.

SDKVaultWallet PDA
React (@lazorkit/wallet)vaultPubkey, wallet.vaultPdasmartWalletPubkey, wallet.smartWallet
React Native (@lazorkit/wallet-mobile-adapter)smartWalletPubkey (= vaultPubkey), wallet.smartWalletwalletPdaPubkey, wallet.walletPda
@lazorkit/sdk-legacy, @lazorkit/sdkvaultPdawalletPda

vaultPubkey means the vault in both UI SDKs. Funds sent to the wallet PDA cannot be recovered.


Account structure

AccountSeeds (v2)SizeWhat it holds
Wallet PDA["lk2:wallet", user_seed]8 bytesIdentity anchor: bump, version, and owner_count, which stops the last Owner from being removed.
Vault PDA["lk2:vault", wallet]0 bytesHolds all SOL and tokens. Program signs via PDA seeds.
Authority PDA["lk2:authority", wallet, id_hash]80 (Ed25519) / 145 (Secp256r1), plus 0–2048 bytes of policyPer-key auth record, replay counter, optional spending policy.
Session PDA["lk2:session", wallet, session_key]80 bytes + actionsEphemeral signer + immutable action buffer.
DeferredExec["lk2:deferred", wallet, authority, counter]176 bytesA pending deferred authorization (hashes, expiry, payer).

v1 used the same seeds without the lk2: prefix, so every v2 address differs from the v1 address for the same user_seed. id_hash is the credential-id hash for a passkey and the public key for an Ed25519 key.

Wallet PDA

Minimal 8-byte account. Chosen by a 32-byte user_seed at creation. A wallet your app has just created, you already know — keep its address. To find a returning passkey user's wallet, prove the passkey and check who else can spend from it (see Look up a returning user); a lookup by credential hash alone can return a wallet someone else planted.

Vault PDA

Zero-data account that holds SOL and tokens. The program signs for it during Execute. No rent deposit required — the vault is itself a PDA with no allocated data.

Send funds to the vault — not the wallet PDA

Funds sent to the wallet PDA cannot be spent by anyone: the program only spends from the vault. On the web SDK, use vaultPubkey; see the table above.

Authority PDA

One PDA per authorised key. A 48-byte header, the key material, then an optional policy:

  • authority_type — Ed25519 (0) or Secp256r1 (1)
  • role — the rank: Owner (0) / Admin (1) / Delegate (2; the SDKs call it ROLE_SPENDER)
  • counter — monotonic u32 for Secp256r1 replay protection
  • policy_len — length of the spending policy that follows the key material; 0 = unbounded
  • wallet — the wallet this authority belongs to (bytes 16..48), which the passkey challenge hashes
  • key material — 32-byte pubkey for Ed25519; credential_id_hash(32) + compressed_pubkey(33) + rpId_hash(32) for Secp256r1

Secp256r1 authorities store sha256(rpId) rather than the rpId string, so every one is 145 bytes before its policy. Clients still send rpId as a string when creating a wallet or adding an authority; the program hashes it once.

Each authority has its own PDA, and an Execute writes only the caller's own authority (its counter) — but it also takes the vault as a writable account. So transactions of one wallet are scheduled one after another, whichever authority signs them; different wallets run in parallel.


Authentication

Secp256r1 (Passkey)

WebAuthn credentials held by the device's authenticator (Face ID, Touch ID, Windows Hello, security keys; often synced by iCloud Keychain or Google Password Manager). On-chain verification uses the native Secp256r1 precompile and enforces:

  • Odometer counter — monotonic u32 per authority. The client signs stored_counter + 1; the program commits it only after successful verification. A signature for a used counter fails with SignatureReused (3006).
  • Slot freshness — the challenge's slot must be at most 150 slots old (InvalidSignatureAge, 3007).
  • CPI protection — passkey authentication refuses to run below the top level.
  • Accounts hash — the signed payload binds every account's key and its signer/writable flags, preventing account swaps.
  • Wallet binding — the challenge names the wallet, so an assertion made for one wallet does not verify on another (InvalidMessageHash, 3005). v1 did not do this.

How a client builds and signs the challenge: Passkey signing.

Off-chain signatures. The wallet SDKs never have a passkey sign bytes an app chose as its challenge. A message signature (signMessage) is over a 58-byte challenge tagged LazorKit signed message v1, and connect's ownership proof over a 59-byte challenge tagged LazorKit ownership proof v1, while every transaction challenge the program accepts is a 32-byte hash, so no signature of one kind can be taken for another (@lazorkit/wallet 3.3.1, @lazorkit/wallet-mobile-adapter 2.3.1). The contract SDKs give the same bytes since @lazorkit/sdk-legacy 1.4.0 / @lazorkit/sdk 1.0.0-rc.6: signedMessageChallenge and createTaggedOwnershipChallenge; their older createOwnershipChallenge() still returns 32 bare random bytes and is deprecated as a passkey challenge. To check which wallet signed a message, use verifyWalletMessage, which reads the passkey's key from the wallet's Owner authority on chain: Verifying a message signature.

Ed25519

Standard Solana keypair. Verified by the Solana runtime. No counter required — Solana's tx-level duplicate detection handles replay.


Ranks

RankValueMay addMay removeSpends
Owner0Owner, Admin, DelegateOwner (not the last, not itself), Admin, Delegatewithout limits
Admin1DelegateDelegatewithout limits
Delegate2nothingnothingonly within its required policy

Owners and Admins also create and revoke sessions; only a passkey Owner or Admin may Authorize a deferred execution. A wallet may have several Owners — one passkey per device, so a surviving device can remove a lost one.

In v1, Execute never checked rank: a v1 "Spender" could spend the whole vault. See Ranks & policies for the full rules.


Wallet lifecycle

Create

import { randomBytes } from 'crypto';

const { instructions, walletPda, vaultPda, authorityPda } = await client.createWallet({
  payer: payer.publicKey,
  userSeed: randomBytes(32),
  owner: {
    type: 'secp256r1',
    credentialIdHash,     // 32-byte SHA-256 of the WebAuthn credential ID
    compressedPubkey,     // 33-byte compressed Secp256r1 public key
    rpId: 'your-app.com',
  },
});

Creates Wallet PDA, Vault PDA, and the first Authority PDA in one transaction. client is a LazorKitClient from @lazorkit/sdk-legacy 1.x.

compressedPubkey must be the passkey's real key. Registration (navigator.credentials.create) returns it; a sign-in assertion does not. For a passkey that already exists, recover the key from two of its signatures — Passkey signing › A passkey with no wallet. A wallet created for the wrong key can never sign, and its vault's funds are stuck.

Look up a returning user

Version note

findOwnPasskeyWallet is in @lazorkit/sdk-legacy 1.2.0 and later. The React SDK (@lazorkit/wallet ≥ 3.0.0) and React Native SDK (@lazorkit/wallet-mobile-adapter ≥ 2.0.0) apply the same rule inside connect. Earlier versions — sdk-legacy 0.3.x and 1.1.x, wallet 2.x, wallet-mobile-adapter 1.x and the 2.0.0 betas — do not have it.

Do not take the first wallet that lists the passkey's credential-id hash:

// Unsafe: the first match can be a wallet someone planted for this passkey.
const [wallet] = await client.findWalletsByAuthority(credentialIdHash);

The credential-id hash is public — it sits in every authority account the passkey has — and CreateWallet, AddAuthority and TransferOwnership take any key without that key's consent. So anyone can create a wallet that lists a user's credential next to their own public key, add the user's real passkey to a wallet they control, or hand the user a wallet after quietly keeping a way into its vault. A lookup by hash returns all of them.

findOwnPasskeyWallet takes one passkey assertion over a challenge you generated, and uses a wallet on its own only when all three hold:

  1. Proven. The passkey is an Owner on it, created under your rpId, and the public key stored there is the one that just signed.
  2. Nothing untrusted can spend from it. No other authority, no live session, no pending deferred execution, no delegate or foreign close authority on the vault's token accounts, and a vault that is still a plain system account — apart from Ed25519 keys you declare trusted (a backend admin, session keys you issue).
  3. The one wallet this passkey has signed for. The replay counter on its authority is above zero — only a signature by the key it stores advances it — and no other wallet of this passkey's has one.

Anything else goes to the user to confirm, by vault address:

const { adopt, needsConfirmation, unproven } = await client.findOwnPasskeyWallet({
  credentialIdHash,
  rpId,
  proof, // an assertion over createTaggedOwnershipChallenge()
});
// adopt → use it · needsConfirmation → let the user choose
// both empty → none proven: create a wallet once the proof verifies against its key
// (a proof that fails proves nothing, and also leaves both empty)

Why "signed for": TransferOwnership hands a wallet to a passkey without asking it, and not everything its earlier Owner did through the vault can be read back — an SPL Token account moved off the vault with SetAuthority is only found for a few watched mints (wSOL, USDC, USDT, and any you add). A passkey signs only for a wallet its user chose. That includes the user's own wallet before its first transaction: it has never been signed for, so the user confirms it once, and after its first transaction it is used without asking.

Why "the one": the v2 program names the wallet in the passkey challenge, so a signature made now verifies only on the wallet it was made for. v1 never did, nor did v2 before that change, and counters raised then are still on chain — a user's signature could have been replayed onto a planted wallet. When two wallets have been signed for, neither is adopted.

The full flow is in SDK (web3.js v1) › Look up a returning user. The React SDK and React Native SDK apply the same rule inside connect and ask the user with a built-in chooser.

Add authority

Adds another key. An Owner may add any rank — the SDKs require allowOwner: true to add an Owner — and an Admin only Delegates. A Delegate needs a policy (serializeActions([...])); an Owner or Admin may not carry one. An authority that carries a policy may not add authorities at all. See Ranks & policies.

Remove authority

Closes the authority PDA and refunds rent to a specified destination. Refuses self-removal and the removal of the last Owner. An Admin may remove only Delegates.

Transfer ownership

Atomically closes the old owner PDA and creates a new owner PDA (the Owner count is unchanged). Refunds the old owner's rent to an explicit destination. It is the way to hand ownership to a key that does not exist yet.

Sessions

An Owner or Admin creates sessions; see Session Keys. After a session expires, anyone may close it with CloseExpiredSession and keep its rent.

Move from v1

A v1 wallet moves its funds to a v2 wallet with one Owner-signed MigrateWallet. See Migrating from v1.


Costs

OperationCost
Wallet creation (Ed25519)0.002399 SOL
Wallet creation (Secp256r1)0.002713 SOL
Execute (per tx)0.000005 SOL
Session setup (one-time)0.001453 SOL

Network fees and rent, measured on protocol v1 and not re-measured on v2. In v2, a fee payer's first fee-paying transaction also creates its FeeRecord (about 0.00112 SOL of rent, once), and each CreateWallet / Execute / ExecuteDeferred pays the protocol fee when one is configured (none on devnet today). A Delegate's policy adds its bytes to the authority's rent.