Getting Started
Create a wallet, execute a transaction, find a returning passkey user's wallet.
Ship the core flows with @lazorkit/sdk-legacy. Every client.* call returns
TransactionInstruction[] — you wrap them into a Transaction, sign, and submit.
Vault PDA holds funds
createWallet returns vaultPda — the address where SOL and tokens live. Send/receive
funds there. walletPda is the metadata PDA, used internally.
Prerequisites
- Node.js 18+, or a browser / React Native bundle (no Node polyfills needed)
@lazorkit/sdk-legacy1.x and@solana/web3.jsinstalled
npm install @lazorkit/sdk-legacy@next @solana/web3.js@next matters: the latest tag is 0.3.2, the protocol v1 line, which has none of the
APIs on this page. Protocol v2 runs on devnet today; see Networks & versions.
1. Initialize the client
import { Connection } from '@solana/web3.js';
import { LazorKitClient } from '@lazorkit/sdk-legacy';
const connection = new Connection('https://api.devnet.solana.com', 'confirmed');
const client = new LazorKitClient(connection);The program id comes from the RPC URL; for a URL that does not name its cluster, pass it:
new LazorKitClient(connection, PROGRAM_ID_DEVNET).
2. Create a wallet
import { Keypair } from '@solana/web3.js';
import * as crypto from 'crypto';
const ownerKp = Keypair.generate();
const { instructions, walletPda, vaultPda, authorityPda } = await client.createWallet({
payer: payer.publicKey,
userSeed: crypto.randomBytes(32),
owner: { type: 'ed25519', publicKey: ownerKp.publicKey },
});import * as crypto from 'crypto';
const { instructions, walletPda, vaultPda, authorityPda } = await client.createWallet({
payer: payer.publicKey,
userSeed: crypto.randomBytes(32),
owner: {
type: 'secp256r1',
credentialIdHash, // 32-byte SHA256 of WebAuthn credential ID
compressedPubkey, // 33-byte compressed Secp256r1 public key
rpId: 'your-app.com',
},
});compressedPubkey must be the passkey's own key: registration
(navigator.credentials.create) returns it; a sign-in assertion does not. For an existing
passkey, recover it from two signatures —
Passkey signing › A passkey with no wallet.
A wallet created for the wrong key can never sign.
Send the returned instructions as a single transaction. vaultPda (also returned as
depositAddress) is the address your users send funds to; walletPda is their metadata
PDA anchor — never send funds there.
When the protocol fee is configured, the first fee-paying transaction of a payer also
carries a permissionless RegisterPayer instruction that the client prepends (about
0.00112 SOL of FeeRecord rent, once); see Paymaster.
3. Execute a transaction
import { SystemProgram } from '@solana/web3.js';
import { ed25519 } from '@lazorkit/sdk-legacy';
const { instructions } = await client.execute({
payer: payer.publicKey,
walletPda,
signer: ed25519(ownerKp.publicKey),
instructions: [
SystemProgram.transfer({
fromPubkey: vaultPda, // funds live at the vault
toPubkey: recipient,
lamports: 1_000_000n,
}),
],
});ownerKp signs at transaction level alongside the fee_payer.
Passkey signing is two-phase — the authenticator can only sign after the challenge is computed:
import { SystemProgram } from '@solana/web3.js';
// 1. Compute the challenge
const prepared = await client.prepareExecute({
payer: payer.publicKey,
walletPda,
secp256r1: { credentialIdHash, publicKeyBytes: compressedPubkey, authorityPda },
instructions: [
SystemProgram.transfer({
fromPubkey: vaultPda,
toPubkey: recipient,
lamports: 1_000_000n,
}),
],
});
// 2. Authenticator signs the challenge
const webauthnResponse = await getWebAuthnResponse(
prepared.challenge,
'your-app.com',
credentialId,
);
// 3. Build the final transaction
const { instructions } = client.finalizeExecute(prepared, webauthnResponse);getWebAuthnResponse turns the browser's assertion into the SDK's WebAuthnResponse
(DER → low-S r‖s, plus the clientDataJSON hash): copy it from
Passkey signing. For a second
passkey transaction right after this one, pass this one's slot as minContextSlot, or it
can fail with SignatureReused (3006) — see
Two passkey transactions in a row.
4. Look up a returning user
Version note
This section describes @lazorkit/sdk-legacy 1.2.0 and later (protocol v2; next
is 1.4.0). 0.3.x (latest) and 1.1.x have no findOwnPasskeyWallet. The warning
below applies to every version.
A returning user signs in with their passkey, and you need their wallet. 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 hash is public — it sits in every authority account the passkey has — and
CreateWallet / AddAuthority take any key without its consent. Anyone can create a
wallet listing a user's credential next to their own public key, or add the user's real
passkey to a wallet they control, and a lookup by hash returns both.
findOwnPasskeyWallet takes one assertion over a challenge you generated, and adopts a
wallet only when
- the passkey is an Owner on it, created under your
rpId, and the public key stored there is the one that just signed; and - 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
(nor its wSOL / USDC / USDT account handed to someone else), and a vault that is still a
plain system account — apart from Ed25519 keys you list in
trustedKeys(a backend admin, session keys you issued, your own delegate); and - it is the one wallet the passkey has signed for (
signatureCount > 0, the replay counter on its authority, which only a signature by the key it stores advances).
import {
createTaggedOwnershipChallenge,
selectWalletByAddress,
verifyOwnershipProof,
} from '@lazorkit/sdk-legacy';
const rpId = 'your-app.com';
const challenge = createTaggedOwnershipChallenge(); // fresh for every sign-in, never reused
const credential = (await navigator.credentials.get({
// A copy: WebAuthn's BufferSource type wants a Uint8Array backed by an ArrayBuffer.
publicKey: { challenge: new Uint8Array(challenge), rpId, userVerification: 'preferred' },
})) as PublicKeyCredential;
const response = credential.response as AuthenticatorAssertionResponse;
const credentialIdHash = new Uint8Array(await crypto.subtle.digest('SHA-256', credential.rawId));
const proof = {
challenge,
signature: new Uint8Array(response.signature), // DER, as the browser returns it
authenticatorData: new Uint8Array(response.authenticatorData),
clientDataJson: new Uint8Array(response.clientDataJSON),
};
const { adopt, needsConfirmation, unproven } = await client.findOwnPasskeyWallet({
credentialIdHash,
rpId,
proof,
trustedKeys: [backendAdmin.publicKey], // optional: your own Ed25519 keys on users' wallets
watchMints: [yourAppsMint], // optional: SPL Token mints your app receives
});
let wallet;
if (adopt) {
// The passkey has used it, and only it (and your trusted keys) can spend from it.
wallet = adopt;
} else if (needsConfirmation.length > 0) {
// The passkey owns these, but has never signed for them, or has signed for more than
// one, or something else can spend from them too. Show each vault address, its balance
// (`lamports`) and who else controls it (`otherAuthorities`, `liveSessions`,
// `pendingDeferred`, `tokenGrants`, `vaultIsSystemAccount`), and let the user choose.
// Pre-select nothing. "None of these" means stop — not create.
const chosen = await askUserToChoose(needsConfirmation); // a vault address, or null
if (!chosen) throw new Error('No wallet confirmed');
wallet = selectWalletByAddress(needsConfirmation, chosen); // vault or wallet address
} else {
// No wallet is proven to be this passkey's. That shows it owns none only if the proof is
// good: a proof that fails (a reused challenge, another rpId) proves nothing, and the
// passkey's own wallets then count in `unproven`. So check the proof against the public
// key you are about to create the wallet for. It comes from the passkey's registration
// (navigator.credentials.create) — an assertion carries none.
if (verifyOwnershipProof([{ publicKey: compressedPubkey }], proof, rpId).length === 0) {
throw new Error('The proof does not verify against this key: no wallet created');
}
// Now this passkey provably owns no live wallet: create one (step 2), for the key the
// proof verified against.
}
// wallet: { walletPda, vaultPda, authorityPda, publicKey, version, lamports, ... }createTaggedOwnershipChallenge() (1.4.0) returns LazorKit ownership proof v1 followed by
32 random bytes (59 bytes), the same form as the wallet packages' own ownership challenge,
so no transaction challenge (32 bytes) can ever equal it. On 1.2.x and 1.3.x, use
createOwnershipChallenge(). It is unchanged in 1.4.0, 32 bare random bytes, and
deprecated there as a passkey challenge, since those bytes look exactly like a transaction
challenge to whatever is asked to sign them. verifyOwnershipProof and
findOwnPasskeyWallet check a proof over exactly the challenge it carries, so proofs over
either form verify, and the client that asks for the proof and the server that checks it
can move to the tagged form in either order. See
Passkey signing › Messages and ownership proofs.
askUserToChoose is your UI. A wallet in needsConfirmation is a
WalletFacts: everything the user needs to
recognise it, and everything besides this passkey that can spend from it.
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
(signatureCount: 0) is never adopted — not even the only one, and not with
trustedKeys. That includes the user's own wallet before its first transaction: they
confirm it once, and once it has been used it is adopted on its own. Say it gently — for
example "This wallet has not been used with your passkey yet. Continue only if you created
it." A wallet your app has just created or migrated into, you already know: keep its
address rather than looking it up.
The reason is what cannot be read back. TransferOwnership hands a wallet to a passkey
without asking it (its public key is on chain), and before that its Owner could move the
vault's SPL Token account for any mint to themselves with SetAuthority. That account
stops listing as the vault's, nothing on chain leads back to it, and every later payment of
that mint to the vault's address lands in it. The SDK checks the canonical account for
wSOL, USDC, USDT and devnet USDC, plus every mint in watchMints — pass the mints your app
receives. For any other mint such a wallet looks spotless.
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: whoever planted a wallet for a passkey could take the signature from the user's
first CreateSession, AddAuthority, TransferOwnership or Authorize and submit it
again on theirs, within about 150 slots, through the same fee payer (a relayer signs for
anyone) — raising its counter too. Those counts are still on chain, so when two wallets
have been signed for, neither is adopted and the user chooses.
Reading the result
unprovencounts the wallets found by this credential under yourrpIdwhose stored public key the proof did not verify against. With a good proof those are wallets someone planted with another key; with a proof that fails, it is all of them, the passkey's own included. Either way they are ignored — so both lists empty means no wallet is proven, and you create one only once the proof verifies against the new wallet's key, as above.version: 1is a pre-v2 wallet on the v1 deployment paired with your program id (includeV1: falseskips it); its funds have not been migrated yet — move them withmigrateV1Wallet(Migrating from v1). It gets no precedence of its own: when exactly one wallet has been signed for and nothing untrusted can spend from it, that one is adopted, v1 or v2.needsConfirmationis ordered signed-for first, then — among wallets equally signed for, or not — v1 before v2, then the fuller vault, then the wallet address. Anyone can change the balance order by funding a vault: it is not a recommendation.- A wallet whose account no longer exists (a migrated v1 wallet leaves other passkeys' authorities behind) is left out.
tokenGrantscovers SPL Token and Token-2022 accounts the vault owns (any mint), plus the vault's SPL Token account for each watched mint. For other mints only accounts the vault still owns are seen, so on a wallet never signed for, a cleantokenGrantsproves nothing about them.
RPC requirements
Every wallet described costs three getProgramAccounts calls (authorities, sessions,
deferred executions), two getTokenAccountsByOwner, and one read of six accounts (one
more per watchMints entry) — use an RPC endpoint that allows them. Each step asks for
state at least as new as the one before (minContextSlot), so a transaction landing
mid-read cannot be half-seen; a node that has not caught up is asked again, up to five
times. A failed read throws rather than guess.
Split across client and server
The steps are public on their own, for a flow that collects the assertion in one place
and decides in another — say, a browser collects it and your server decides.
findOwnPasskeyWallet runs these four in order:
import { verifyOwnershipProof, pickOwnWallet } from '@lazorkit/sdk-legacy';
const candidates = await client.findPasskeyWalletCandidates({ credentialIdHash, rpId });
const proven = verifyOwnershipProof(candidates, proof, rpId); // pure; [] if the proof fails
const facts = await client.describeWalletCandidates(proven, { trustedKeys, watchMints });
const { adopt, needsConfirmation } = pickOwnWallet(facts); // pure: the rule aboveFreshness is yours: generate the challenge with createTaggedOwnershipChallenge() for each
proof and never reuse one. An empty proven is also what a proof that fails gives — a
challenge the browser did not sign, say — so before creating a wallet, check the proof
against its key with verifyOwnershipProof([{ publicKey }], proof, rpId), as the example
above does.
Ed25519 owners
findWalletsByAuthority(publicKey.toBytes(), 'ed25519') lists every wallet the key is on,
including any a stranger added it to. Check role and the wallet's other authorities
(findAuthoritiesByWallet) before using one.
5. Transfer SOL (shorthand)
transferSol wraps execute with a SystemProgram.transfer from the vault:
import { ed25519 } from '@lazorkit/sdk-legacy';
const { instructions } = await client.transferSol({
payer: payer.publicKey,
walletPda,
signer: ed25519(ownerKp.publicKey),
recipient,
lamports: 500_000_000n,
});6. Program constants
import {
PROGRAM_ID_DEVNET, // v2 devnet: 57bTNWqtYTJbWuLWASKo6GqUTAK6oFDUR5c6hEc6V8nv
PROGRAM_ID_MAINNET, // v2 mainnet: LazorFroiVuAjcwwQ2me83vTr5nc5NRxSaTg3pmEXC8 (not deployed yet)
PROGRAM_ID_DEVNET_V1, // v1 devnet: 4h3XoNReAgEcHVxcZ8sw2aufi9MTr7BbvYYjzjWDyDxS
PROGRAM_ID_MAINNET_V1, // v1 mainnet: LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi
} from '@lazorkit/sdk-legacy';
console.log(PROGRAM_ID_DEVNET.toBase58(), PROGRAM_ID_MAINNET.toBase58());
console.log(PROGRAM_ID_DEVNET_V1.toBase58(), PROGRAM_ID_MAINNET_V1.toBase58());There is no PROGRAM_ID export in @lazorkit/sdk-legacy. See
Networks & versions for what is deployed where.
Next steps
Passkey signing
The WebAuthnResponse helper, minContextSlot, one flow per authority, key recovery.
Session Keys
Delegate execution to a scoped Ed25519 signer with on-chain spending limits.
Types
Signer helpers, PDA derivation, account readers, error utilities.
Smart Wallet
How vault / wallet / authority PDAs fit together.
Overview
LazorKit SDK for @solana/web3.js v1 — direct contract access via LazorKitClient.
Passkey signing
The prepare / finalize flow, turning a WebAuthn assertion into a WebAuthnResponse, challenge-read options (minContextSlot, commitment), one flow per authority, and recovering a passkey's public key.