LazorKit LogoLazorKit
React Native SDK

useWallet

Hook API — connect, sign, manage sessions & authorities.

import { useWallet } from '@lazorkit/wallet-mobile-adapter';
// Back-compat alias — both exports point to the same hook:
import { useLazorWallet } from '@lazorkit/wallet-mobile-adapter';

useWallet returns the wallet state plus every mutation method. Each passkey mutation opens the portal, signs the challenge with a passkey, relays the resulting transaction through the paymaster, and resolves once it is confirmed — see Sending transactions.

Every mutation takes options

Passkey-signed methods require a trailing options: SignOptions argument with your redirectUrl and optional onSuccess / onFail callbacks. Session-signed methods (no portal) only need callbacks.

Quick reference

MethodPortal promptUse for
connectyesAuthenticate + create/load the smart wallet
disconnectnoClear local state
signAndSendTransactionyesSingle-transaction Execute
signMessageyesOff-chain passkey signature; check it with verifyWalletMessage
createSessionyesRegister an Ed25519 session key with its limits
signAndSendWithSessionnoSend a tx signed by your session keypair
revokeSessionyesClose a session, refund rent
addAuthorityEd25519yesAdd an Ed25519 key as Admin, or as a Delegate with a policy
removeAuthorityyesRemove any authority by PDA
listAuthoritiesnoRead all authorities on the wallet
authorizeAndExecuteyes (×1)Deferred 2-tx flow for oversized payloads (atomic, bundled)
authorizeDeferredyesTX1 only — returns DeferredPayload to run TX2 later elsewhere
executeDeferrednoTX2 only — submit from a previously-authorized payload
reclaimDeferrednoClose an expired DeferredExec and reclaim its rent
transferSolyesConvenience — SOL transfer from the vault

State

FieldTypeNotes
smartWalletPubkeyPublicKey | nullVault PDA — where funds live. Use for balances, QRs.
vaultPubkeyPublicKey | nullAlias of smartWalletPubkey.
walletPdaPubkeyPublicKey | nullWallet metadata PDA — only for raw LazorKitClient.
passkeyPubkeynumber[] | null33-byte compressed secp256r1 pubkey.
protocolVersion1 | 2 | nullThe connected wallet's protocol; null while disconnected (2.0.0+).
isConnectedboolean
isLoadingbooleanAny async op in progress; false while the wallet chooser waits for the user.
isConnectingboolean
isSigningbooleanFrom the portal round-trip until the transaction is confirmed.
errorError | null
connectionConnectionThe @solana/web3.js connection from the provider.

Option shapes

// Passkey-signed flows (open the portal). Every mutation that prompts the user uses this.
interface SignOptions {
  redirectUrl: string;                  // your app's deep-link scheme
  onSuccess?: (result: any) => void;
  onFail?: (error: Error) => void;
}

// No-passkey flows (session / executeDeferred / reclaimDeferred). No redirectUrl needed.
interface TxCallbacks {
  onSuccess?: (signature: string) => void;
  onFail?: (error: Error) => void;
}

Each call's promise settles, and its onSuccess or onFail runs, once isSigning (or isConnecting for connect) is false again. So await signAndSendTransaction(a); await signAndSendTransaction(b) runs both, and so does a second call made from onSuccess. A call made while another is still running rejects with SigningError and calls its onFail at once. A callback that throws is logged and changes nothing, on every action, connect and disconnect included.

Changed in 2.3.0

In 2.2.1 a throwing onSuccess on connect or disconnect made the call reject, the store's connect ignored its callbacks, and transferSol with no wallet connected rejected without calling onFail or setting error.


Connection

connect

Open the portal, authenticate the user with their passkey, and either load the existing smart wallet or create a fresh one on-chain.

const wallet = await connect({ redirectUrl: 'myapp://home' });
// wallet.smartWallet      → vault PDA (base58)
// wallet.walletPda        → metadata PDA
// wallet.walletDevice     → authority PDA for this passkey
// wallet.protocolVersion  → 1 | 2

Returns Promise<WalletInfo>.

An existing passkey with no wallet yet (made on another device) costs one extra portal prompt (2.1.0+): its public key is recovered from two of its signatures, so the new wallet is created for the right key. See Wallet Confirmation.

Confirming a returning user's wallet

Version note

confirmWallet and onConfirmWallet are in @lazorkit/wallet-mobile-adapter 2.0.0 and later. Earlier versions — 1.x, and the 2.0.0 betas up to 2.0.0-beta.4 — do not have them.

With no stored wallet, connect proves the passkey and 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. Otherwise it asks the user — by default with a built-in "Which wallet is yours?" chooser. A user's own wallet that has never been used is confirmed once. While a wallet is connected, connect returns it without opening the portal.

// After catching WalletNeedsConfirmationError (onConfirmWallet: 'throw'):
await connect({ redirectUrl: 'myapp://home', confirmWallet: 'CHOSEN_VAULT_ADDRESS' });
OptionTypeNotes
redirectUrlstringYour app's deep link.
confirmWalletstringVault (or wallet PDA) the user chose — a wallet the passkey is proven to hold; any other address throws. Within two minutes of the error, no second portal round-trip.
onConfirmWallet'builtin' | 'throw' | ConfirmWalletHandlerOverrides the provider's for this call.

connect can reject with WalletNeedsConfirmationError, WalletConfirmationDeclinedError, PortalCancelledError or WalletChooserNotShownError. Full flow: Wallet Confirmation.

disconnect

await disconnect({
  onSuccess: () => console.log('disconnected'),
  onFail: (error) => console.warn(error),
});

Clears the persisted state. The on-chain wallet is untouched — reconnect any time. onSuccess / onFail run once the disconnect is over, on the hook and on the store (2.3.0+); a throwing onSuccess changes nothing. The adapter keeps no session key, so delete the one your app keeps here (see createSession).

From 2.0.0 on, a connect still running when you call it is abandoned: it rejects with PortalCancelledError and connects nothing.


Transactions

signAndSendTransaction

Single-transaction execution, for inner instructions of up to about 380 bytes without a lookup table: about 340 with computeUnitLimit, 109 fewer when Chrome pads the passkey's clientDataJSON. Anything larger goes through authorizeAndExecute, and in practice so does a Jupiter swap: a single-hop SOL→USDC route, with Jupiter's lookup tables and compute-budget instructions, came to 1207 bytes, which Chrome's padding pushes over the 1232-byte limit. Measured 2026-09-29 with @lazorkit/sdk-legacy 1.2.0 and the portal's clientDataJSON from a browser tab.

const sig = await signAndSendTransaction(
  {
    instructions: [ix],
    transactionOptions: {
      feeToken: 'USDC',              // optional: pay fees in any SPL mint
      computeUnitLimit: 400_000,
      addressLookupTableAccounts: [alt],
      clusterSimulation: 'devnet',   // portal-side preview cluster
    },
  },
  { redirectUrl: 'myapp://callback' },
);

Returns Promise<string> — the signature, once the transaction is confirmed. Rejects with TransactionFailedError, TransactionExpiredError, TransactionOutcomeUnknownError (ConfirmationTimeoutError), PreviousTransactionPendingError, SignatureReusedError, PaymasterError, PortalCancelledError or SigningError — see Errors.

feeToken is forwarded to the paymaster as fee_token (honoured only if your relayer supports it); computeUnitLimit adds a compute-unit-limit instruction.

authorizeAndExecute

Two-transaction deferred flow for payloads that don't fit in a single transaction (Jupiter swaps with routes + ATAs, multi-CPI batches). One passkey prompt covers both txs, and the SDK submits them back-to-back. Use this when everything runs in the same process.

const sig = await authorizeAndExecute(
  {
    instructions: jupiterSwapIxs,
    // expiryOffset: slots after TX1 that TX2 is still accepted. Default 1500.
    transactionOptions: { clusterSimulation: 'devnet' },
  },
  { redirectUrl: 'myapp://callback' },
);

How it works

TX1 Authorize records a SHA-256 hash of your instruction set. TX2 ExecuteDeferred replays those exact instructions — the program rejects any mismatch. authorizeAndExecute submits both transactions automatically and returns TX2's signature.

The window is counted in slots

The program accepts TX2 for expiryOffset slots after the slot TX1 landed in (10 to 9000; the adapter's default is 1500). A slot's length depends on the cluster and its load, so do not convert it to minutes with a fixed figure. The adapter may wait up to two minutes for TX1 to be confirmed before it sends TX2, so do not pass a small value. If the window has passed, TX2 is not sent and the call rejects with DeferredExpiredError: nothing in the payload ran, the passkey approval is spent, and deferredExecPda still holds the paymaster's rent until reclaimDeferred closes it. Until it expires, an unused approval stays executable: nothing can cancel it. Since 2.2.1 an inner program's 3014 is thrown as it came, and every TX2 error carries deferredExecPda, authorizeSignature and expiresAtSlot (DeferredFailureContext).


authorizeDeferred

TX1 only. Passkey-signed Authorize. Returns the payload needed for TX2 so you can run it elsewhere — a server relayer, another device, a scheduled job.

const { signature, deferredPayload, deferredExecPda, counter } = await authorizeDeferred(
  {
    instructions: jupiterSwapIxs,
    expiryOffset: 4_500,                     // slots after TX1 (default 1500, max 9000)
    transactionOptions: { clusterSimulation: 'devnet' },
  },
  { redirectUrl: 'myapp://callback' },
);

TX2 must land within expiryOffset slots of TX1's slot, not of when this call resolves. The program's maximum, 9000 slots, is tens of minutes, not hours.

Returns Promise<AuthorizeResult>.

executeDeferred

TX2 only. No passkey prompt. Submit ExecuteDeferred from a payload produced by a prior authorizeDeferred call (either from this device, or deserialized from a wire format — see serializeDeferredPayload).

// Same machine — use the object directly:
const txSig = await executeDeferred({
  deferredPayload,                           // from authorizeDeferred result
  transactionOptions: { clusterSimulation: 'devnet' },
});

The rent goes back to the Authorize payer (the paymaster's fee payer): the program refuses any other refundDestination, so leave it unset. An authorization that has expired is not sent; the call rejects with DeferredExpiredError.

reclaimDeferred

No passkey prompt. Close an expired DeferredExec PDA and recover its rent. Gated on the original payer: the paymaster's fee payer signs, and the rent goes back to it. The program allows it only once the authorization has expired.

await reclaimDeferred({
  deferredExecPda,                           // from authorizeDeferred, or DeferredExpiredError
});

Cross-machine deferred flows

Any party holding a serialized deferredPayload can submit TX2 — the program verifies the instruction / accounts hash. Use serializeDeferredPayload() to get a JSON-safe string; deserializeDeferredPayload() to reconstruct it before calling executeDeferred.

import {
  serializeDeferredPayload,
  deserializeDeferredPayload,
} from '@lazorkit/wallet-mobile-adapter';

// Device A:
const { deferredPayload } = await authorizeDeferred(
  { instructions: jupiterSwapIxs },
  { redirectUrl: 'myapp://callback' },
);
await fetch('https://your-backend.example.com/relayer', {
  method: 'POST',
  body: serializeDeferredPayload(deferredPayload),
});

// Device B (another app instance), with the string it received:
const payload = deserializeDeferredPayload(await req.text());
const sig = await executeDeferred({ deferredPayload: payload });

A server has no hook: it sends TX2 with LazorKitClient.executeDeferredFromPayload from @lazorkit/sdk-legacy — see Getting Started › Deferred execution.


transferSol

Shortcut — SystemProgram.transfer from the vault.

import { PublicKey } from '@solana/web3.js';

await transferSol(
  { recipient: new PublicKey('RECIPIENT_ADDRESS'), lamports: 50_000_000 },
  { redirectUrl: 'myapp://callback' },
);

Session keys

Sessions let you skip the passkey prompt for a scoped set of operations. Create once with a passkey, then sign many transactions with a local Ed25519 key.

createSession

import { Actions } from '@lazorkit/wallet-mobile-adapter';
import { Keypair } from '@solana/web3.js';

const sessionKp = Keypair.generate();
const currentSlot = BigInt(await connection.getSlot());

const { signature, sessionPda } = await createSession(
  {
    sessionKey: sessionKp.publicKey,
    expiresAtSlot: currentSlot + 216_000n,    // ~24h on mainnet
    actions: [
      Actions.solRecurringLimit({ limit: 1_000_000_000n, window: 216_000n }),
      Actions.solMaxPerTx(500_000_000n),      // rent the vault pays counts as SOL
      Actions.tokenMaxPerTx({ mint: USDC_MINT, max: 50_000_000n }), // each mint it may spend
      Actions.programWhitelist(JUPITER_PROGRAM_ID),
    ],
  },
  { redirectUrl: 'myapp://callback' },
);

Persist sessionKp.secretKey securely (e.g. expo-secure-store) along with sessionPda and the wallet it belongs to; use it only while that wallet is connected, and delete it at disconnect and once the session is revoked or expired. The adapter never stores it. The package README's Session keys section has a worked example.

actions is required: without it createSession throws, unless you pass unrestricted: true for a session that can spend the whole vault through any program until it expires. The program allows at most 6,480,000 slots of expiry.

Name every asset the session may spend. From the v2 program release that adds errors 3037 and 3038, SOL leaves the wallet only with an Actions.sol* action (solMaxPerTx, solLimit, solRecurringLimit), rent the vault pays for a new account included, and a token only with an Actions.token* action (tokenMaxPerTx, tokenLimit, tokenRecurringLimit) for its mint. wSOL is a mint of its own. That release is not deployed yet; until it is, an asset the actions do not name is not bounded at all. See Session Keys › What a policy bounds.

Keep the actions within 244 bytes, at most 16 of them and one of each kind per mint: they share the transaction that registers them with the passkey's response. A sol* action takes 19 bytes (solRecurringLimit 43), tokenMaxPerTx and tokenLimit 51, tokenRecurringLimit 75: solMaxPerTx with tokenMaxPerTx and tokenLimit for 2 mints, or with tokenMaxPerTx for 4. Nothing checks this before the portal opens: actions that do not fit fail after the user approved.

Actions are immutable

Once a session is created, its action list can't be modified. To change limits, revoke the session and create a new one. The program does not create a second session for a key while the first exists: revoke first, or use a new key. A session whose actions name SOL only moves no token once the program release above is deployed.

Returns Promise<{ signature: string; sessionPda: PublicKey }>.

signAndSendWithSession

Send a transaction using the stored session keypair. No portal prompt.

const sig = await signAndSendWithSession({
  sessionKeypair: sessionKp,
  sessionPda,
  instructions: [ix],
  transactionOptions: { clusterSimulation: 'devnet' },
});

Fails with 0xbd0 (ActionSolLimitExceeded) or related action errors once limits are hit. See Troubleshooting. From the program release that adds errors 3037 and 3038, a transaction that would move SOL, or a token, that the session's actions do not name rejects with UnlistedSolOutflowError ("This session is not allowed to spend SOL") or UnlistedTokenOutflowError ("This session is not allowed to spend this token") (2.4.0), with the original error as cause; nothing in it ran. See Errors › Assets a policy does not name.

revokeSession

const signature = await revokeSession(
  {
    sessionPda,
    refundDestination: smartWalletPubkey!,  // send rent back to the vault
  },
  { redirectUrl: 'myapp://callback' },
);

After a session expires, anyone may close it with CloseExpiredSession and keep the rent; revoke it yourself if you want the rent back.


Authorities

Give additional keys access to the wallet — typically an Ed25519 key for a backend, a desktop or a hardware companion. See Ranks & policies.

addAuthorityEd25519

import { Actions, ROLE_ADMIN, ROLE_SPENDER, serializeActions } from '@lazorkit/wallet-mobile-adapter';

// A Delegate: spends within its policy, manages nothing. The policy is required on v2.
// `role` is required: there is no default.
const { signature, newAuthorityPda } = await addAuthorityEd25519(
  {
    newEd25519Pubkey: deviceKp.publicKey,
    role: ROLE_SPENDER,
    // Up to 1 SOL in all and 5 USDC a transaction; no other token can leave.
    policy: serializeActions([
      Actions.solLimit(1_000_000_000n),
      Actions.tokenMaxPerTx({ mint: USDC_MINT, max: 5_000_000n }),
    ]),
  },
  { redirectUrl: 'myapp://callback' },
);

// An Admin: manages Delegates and sessions, spends without limits.
await addAuthorityEd25519(
  { newEd25519Pubkey: deviceKp.publicKey, role: ROLE_ADMIN },
  { redirectUrl: 'myapp://callback' },
);

role is required: there is no default. A call without it, or with a value that is not a rank, throws before anything is read or the portal opens, with a message that says what each rank may do:

RankConstantMay add and removeSpends
OwnerROLE_OWNER (0)any authority, other Owners included (never the last Owner, never itself)without limit. The adapter does not pass allowOwner, so on a v2 wallet it refuses this rank before anything is read or the portal opens. On a v1 wallet it adds one.
AdminROLE_ADMIN (1)Delegates onlywithout limit
DelegateROLE_SPENDER (2)nothingonly within its policy, which v2 requires

For a key your app or backend holds, use ROLE_SPENDER with a policy. On a v2 wallet a Delegate throws without a policy (on chain: 3033), and a policy on any other rank is refused (3035). A policy names what may leave the wallet, as a session's actions do, and must fit in the same 244 bytes; see What a policy bounds. On a v1 wallet, pass unrestricted: true and no policy: v1 never checked rank at Execute, so any key you add can spend the whole vault.

Changed in 2.3.0: role is required

Breaking. In 2.2.1 role was optional and defaulted to ROLE_SPENDER. role: ROLE_SPENDER with a policy keeps that behaviour.

removeAuthority

await removeAuthority(
  { targetAuthorityPda, refundDestination: smartWalletPubkey! },
  { redirectUrl: 'myapp://callback' },
);

You cannot remove the last Owner, nor the passkey you sign with; an Admin can remove only Delegates. transferOwnership isn't exposed through the hook — use LazorKitClient directly.

listAuthorities

Read all authority accounts on the connected wallet. No passkey prompt.

const authorities = await listAuthorities();
// [{
//   authorityPda, authorityType, role,   // role: 0 Owner, 1 Admin, 2 Delegate
//   credential, secp256r1Pubkey?
// }, …]

Messages

signMessage

Off-chain passkey signature over a string: a sign-in, or any statement your server checks.

const result = await signMessage(
  'Sign in to example.com\nNonce: 8f2c…',
  { redirectUrl: 'myapp://callback' },
);
// { signature, signedPayload, clientDataJsonBase64, authenticatorDataBase64 }, all base64

The passkey never signs the message's bytes as its WebAuthn challenge. It signs a challenge made from them, with a tag no other kind of passkey challenge carries (format v1, the same as @lazorkit/wallet's):

tag       = UTF-8 "LazorKit signed message v1"     (26 bytes)
challenge = tag || SHA-256(tag || message)        (58 bytes)

The message is signed as its UTF-8 bytes. signedMessageChallenge(message) computes the challenge and SIGNED_MESSAGE_DOMAIN is the tag. A transaction challenge is a 32-byte hash and an ownership proof (connect) is 59 bytes tagged LazorKit ownership proof v1, so no challenge of one kind can be another. The portal gets the challenge as message and the text as displayMessage; the adapter checks the redirect, and a reply over any other challenge rejects with "The portal did not sign this message: its reply is over another challenge."

Returns Promise<SignMessageResult>:

FieldWhat it is
signatureThe P-256 signature, 64 bytes (r || s, low-S), base64.
signedPayloadWhat the passkey signed: authenticatorData || SHA-256(clientDataJSON), base64.
clientDataJsonBase64The WebAuthn clientDataJSON, base64. Its challenge is the base64url of the challenge above.
authenticatorDataBase64The WebAuthn authenticatorData, base64.

Changed in 2.3.1

Message signatures can no longer be confused with transaction approvals. In 2.3.0 and earlier the passkey signed the message itself as its challenge (the base64 decoding of the text rather than the text), and the result had only signature and signedPayload. A signature made by an earlier release does not verify: ask the user to sign again.

Verifying a message signature

A message signature proves that a passkey's key signed the message, not which wallet that key belongs to. To authenticate a wallet, read the key from the chain, never from the client: a server that takes the key from the request accepts anyone's passkey for any wallet they name. Send the result to your server with the wallet's address (smartWalletPubkey) and wallet.credentialId, and check it there with verifyWalletMessage:

import { Connection } from '@solana/web3.js';
import { verifyWalletMessage } from '@lazorkit/wallet-mobile-adapter';
// or, on a server without React Native: from '@lazorkit/wallet'

const ok = await verifyWalletMessage({
  connection: new Connection(RPC_URL),
  cluster: 'devnet',                   // when RPC_URL does not say which cluster
  wallet: body.wallet,                 // the wallet the client claims: vault or wallet PDA
  credentialId: body.credentialId,     // the passkey's credential id, base64
  rpId: 'portal.lazor.sh',             // the passkey's relying party: the provider's rpId
  message,                             // the message you issued, with your domain and nonce
  signature: body.signature,
  clientDataJsonBase64: body.clientDataJsonBase64,
  authenticatorDataBase64: body.authenticatorDataBase64,
  signedPayload: body.signedPayload,   // optional
});

It is true only when the signature is over signedMessageChallenge(message) (a webauthn.get with the user present, under rpId), verifies against the key stored on chain in an Owner authority of wallet for credentialId (v2 or v1), and the wallet account still exists. It reads the chain with one getProgramAccounts per program, as connect does, and rejects when the chain cannot be read: treat that as not verified. Check the domain and the nonce in the message yourself. The parameters are the same as on the web: React SDK › Verifying a message signature.

verifySignedMessage({ message, publicKey, ...result }) is the offline part of that check, with no RPC: true only when publicKey's passkey signed signedMessageChallenge(message), and false for any other challenge, the raw message bytes included. It never throws. Use it alone only with a key you read from the claimed wallet's authority on chain yourself: wallet.passkeyPubkey comes from the client, and proves nothing to a server.


Low-level escape hatch

The SDK re-exports everything from the program module — use these for flows the hook doesn't cover (admin protocol ops, manual prepare/finalize, custom signer layouts):

import {
  LazorKitClient,
  PROGRAM_ID_DEVNET,
  // PDAs (v2 addresses, at the v2 mainnet id unless you pass one)
  findWalletPda, findVaultPda, findAuthorityPda,
  findSessionPda, findDeferredExecPda,
  // On-chain readers
  readAuthorityCounter, readAuthorityPubkey,
  // Session / policy builders
  Actions, serializeActions,
  // Constants
  ROLE_OWNER, ROLE_ADMIN, ROLE_SPENDER,
  AUTH_TYPE_ED25519, AUTH_TYPE_SECP256R1,
  // Cross-machine deferred helpers
  serializeDeferredPayload, deserializeDeferredPayload,
} from '@lazorkit/wallet-mobile-adapter';

const client = new LazorKitClient(connection, PROGRAM_ID_DEVNET);
const { walletPdaPubkey } = useWallet();
const authorities = walletPdaPubkey ? await client.findAuthoritiesByWallet(walletPdaPubkey) : [];

Never derive a user's address with the PDA helpers: use smartWalletPubkey. For your own prepare/finalize loop, use @lazorkit/sdk-legacy's prepare* methods with their minContextSlot / commitment options (Passkey signing). readAuthorityState is still exported for compatibility, but it reads with no freshness floor.


v1 wallets

protocolVersion is 1 for a wallet made before LazorKit v2; every method routes to the v1 program for it, through v1ConfigPaymaster. After LazorKit retires v1, its actions reject with V1WalletRetiredError (4018): the funds are safe and the wallet must move to v2. A stored v1 wallet that has been migrated rejects with V1WalletMigratedError; call connect again. See Migrating from v1.