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
| Method | Portal prompt | Use for |
|---|---|---|
connect | yes | Authenticate + create/load the smart wallet |
disconnect | no | Clear local state |
signAndSendTransaction | yes | Single-transaction Execute |
signMessage | yes | Off-chain passkey signature; check it with verifyWalletMessage |
createSession | yes | Register an Ed25519 session key with its limits |
signAndSendWithSession | no | Send a tx signed by your session keypair |
revokeSession | yes | Close a session, refund rent |
addAuthorityEd25519 | yes | Add an Ed25519 key as Admin, or as a Delegate with a policy |
removeAuthority | yes | Remove any authority by PDA |
listAuthorities | no | Read all authorities on the wallet |
authorizeAndExecute | yes (×1) | Deferred 2-tx flow for oversized payloads (atomic, bundled) |
authorizeDeferred | yes | TX1 only — returns DeferredPayload to run TX2 later elsewhere |
executeDeferred | no | TX2 only — submit from a previously-authorized payload |
reclaimDeferred | no | Close an expired DeferredExec and reclaim its rent |
transferSol | yes | Convenience — SOL transfer from the vault |
State
| Field | Type | Notes |
|---|---|---|
smartWalletPubkey | PublicKey | null | Vault PDA — where funds live. Use for balances, QRs. |
vaultPubkey | PublicKey | null | Alias of smartWalletPubkey. |
walletPdaPubkey | PublicKey | null | Wallet metadata PDA — only for raw LazorKitClient. |
passkeyPubkey | number[] | null | 33-byte compressed secp256r1 pubkey. |
protocolVersion | 1 | 2 | null | The connected wallet's protocol; null while disconnected (2.0.0+). |
isConnected | boolean | |
isLoading | boolean | Any async op in progress; false while the wallet chooser waits for the user. |
isConnecting | boolean | |
isSigning | boolean | From the portal round-trip until the transaction is confirmed. |
error | Error | null | |
connection | Connection | The @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 | 2Returns 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' });| Option | Type | Notes |
|---|---|---|
redirectUrl | string | Your app's deep link. |
confirmWallet | string | Vault (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' | ConfirmWalletHandler | Overrides 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:
| Rank | Constant | May add and remove | Spends |
|---|---|---|---|
| Owner | ROLE_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. |
| Admin | ROLE_ADMIN (1) | Delegates only | without limit |
| Delegate | ROLE_SPENDER (2) | nothing | only 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 base64The 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>:
| Field | What it is |
|---|---|
signature | The P-256 signature, 64 bytes (r || s, low-S), base64. |
signedPayload | What the passkey signed: authenticatorData || SHA-256(clientDataJSON), base64. |
clientDataJsonBase64 | The WebAuthn clientDataJSON, base64. Its challenge is the base64url of the challenge above. |
authenticatorDataBase64 | The 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.