LazorKit LogoLazorKit
SDK (web3.js v1)

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-legacy 1.x and @solana/web3.js installed
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

  • unproven counts the wallets found by this credential under your rpId whose 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: 1 is a pre-v2 wallet on the v1 deployment paired with your program id (includeV1: false skips it); its funds have not been migrated yet — move them with migrateV1Wallet (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.
  • needsConfirmation is 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.
  • tokenGrants covers 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 clean tokenGrants proves 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 above

Freshness 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