Troubleshooting
Common errors, program error codes, and fixes for LazorKit SDKs.
A reference for the issues you'll hit most often. Every entry has symptoms, root cause, and a concrete fix. Every error class and program code is listed on Errors.
Enable debug logging first
Before anything else, set isDebug={true} on <LazorKitProvider> (React Native) to
stream SDK logs to the console. For the React SDK, read useWallet().error and the
browser console.
Section map
| Topic | Symptoms include |
|---|---|
| Setup | Cannot resolve module, ERESOLVE, Buffer is not defined, SSR crashes |
| Wallet address | Funds sent to the wrong address, balance stuck at 0 |
| Passkey / WebAuthn | NotAllowedError, "user denied permission", simulator issues, portal closed, a message signature that does not verify |
| Sending transactions | 3006, outcome unknown, "Already signing", deferred expiry |
| Sessions and kept keys | 3037 / 3038, KeyWalletMismatchError, createSession refusing its limits, a sign-out through wallet-adapter |
| Network / relay | Network request failed, paymaster errors, RPC refusals |
| Deep linking | Portal never returns, redirect drops signature |
| v1 and v2 | 4018, 4008, 3005, migrated wallets, cluster detection |
| Program errors | 0xbd0 ActionSolLimitExceeded, auth payload, replay |
Setup
Cannot resolve module '@solana/web3.js'
@solana/web3.js is a peer dependency.npm install @solana/web3.jsThen restart your bundler / Metro to clear module caches.
npm ERESOLVE installing @lazorkit/wallet
@lazorkit/wallet 3.x needs @solana/web3.js 1.99 or later (its peer
@solana/wallet-adapter-base 0.9.28 does). An app pinned to 1.98.x cannot resolve it:
npm install @solana/web3.js@^1.99.0peer @solana/kit@"^5.0" from @solana-program/token@0.9.0 in an app on @solana/kit 6, 7
or 8: 3.3.0 and earlier declared @solana/kit ^5, @solana/kora and
@solana-program/token as peers, though nothing in the wallet loads them. 3.3.1 dropped
them; install it instead of using --legacy-peer-deps:
npm install @lazorkit/wallet@^3.3.1Buffer is not defined
@lazorkit/wallet 3.0.2 and later, and @lazorkit/sdk-legacy 0.3.2 / 1.x, need no global
Buffer. If another library in your app does, polyfill it per environment:
At the very top of your entry file (app/_layout.tsx, index.js, App.tsx):
import 'react-native-get-random-values';
import 'react-native-url-polyfill/auto';
import { Buffer } from 'buffer';
global.Buffer = global.Buffer || Buffer;In your client providers file:
'use client';
import { Buffer } from 'buffer';
if (typeof window !== 'undefined') (window as any).Buffer ??= Buffer;import { defineConfig } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
export default defineConfig({ plugins: [nodePolyfills()] });Install the buffer package and register it once:
import { Buffer } from 'buffer';
(window as any).Buffer = (window as any).Buffer ?? Buffer;Import ./polyfills before everything else in src/index.tsx.
"Window is not defined" / SSR crashes
The React SDK reads localStorage, opens the portal, and calls navigator.credentials —
all client-only. In Next.js App Router, mount the provider inside a "use client"
boundary:
'use client';
import { LazorkitProvider } from '@lazorkit/wallet';
export function Providers({ children }: { children: React.ReactNode }) {
return <LazorkitProvider>{children}</LazorkitProvider>;
}Never call useWallet from a server component.
Random-values crash (Expo)
If crypto.getRandomValues throws at startup, react-native-get-random-values was not
imported first. Make import 'react-native-get-random-values'; the first line of your
entry file. The adapter needs it for Keypair.generate() and ownership challenges.
Wallet address
Which address do I show and fund?
The vault. The SDKs name it differently:
| SDK | Vault (show, fund, fromPubkey) | Wallet PDA (internal — never send funds) |
|---|---|---|
React (@lazorkit/wallet) | vaultPubkey, wallet.vaultPda | smartWalletPubkey, wallet.smartWallet |
React Native (@lazorkit/wallet-mobile-adapter) | smartWalletPubkey (= vaultPubkey), wallet.smartWallet | walletPdaPubkey, wallet.walletPda |
| Wallet adapter / Wallet Standard | publicKey (3.0.0+) | — |
@lazorkit/sdk-legacy, @lazorkit/sdk | vaultPda | walletPda |
vaultPubkey is the vault on both UI SDKs.
Funds sent to the wallet PDA are lost
On the web SDK, smartWalletPubkey / wallet.smartWallet is the wallet PDA. Nothing
can sign for it, so SOL or tokens sent there cannot be recovered. Show and fund
vaultPubkey.
"Insufficient funds" on the vault
A transaction that spends SOL from the vault needs the vault to hold enough. Fund it by sending SOL to the vault:
import { useWallet } from '@lazorkit/wallet'; // or '@lazorkit/wallet-mobile-adapter'
const { vaultPubkey } = useWallet();
console.log('receive SOL at:', vaultPubkey?.toBase58()); // the vault, on web and mobileBalance shows 0
You are reading the wallet PDA instead of the vault, or deriving the vault yourself.
findVaultPda derives a v2 address at the v2 mainnet id, wrong for a v1 wallet and
for devnet. Read the vault the SDK stored:
// before: await connection.getBalance(findVaultPda(walletPda)[0])
await connection.getBalance(vaultPubkey!);A React Native app that upgraded from adapter 1.x had smartWallet holding the wallet PDA;
the store migrates persisted records to the vault on load (its own storage format, v0 →
v1).
Passkey / WebAuthn
NotAllowedError: The request is not allowed by the user agent or platform
The standard WebAuthn denial. Covers user cancellation, missing biometric, and environments without a platform authenticator.
Likely causes
- User tapped Cancel on the Face ID / Touch ID sheet.
- Device has no biometric or screen lock enrolled — there's no platform authenticator to prompt.
- iOS Simulator: Face ID not enrolled (
Features → Face ID → Enrolled). - Android Emulator without Play Services, or without a screen lock / biometric set.
- iOS < 16 or Android < 9 — passkey-class authenticators are unavailable.
Fix checklist
- Test on a physical device with Face ID / Touch ID / screen lock enrolled.
- For iOS simulators, enable Face ID enrollment in the Simulator menu.
- For Android emulators, pick a Pixel image with Google Play + set a screen lock.
- Ensure iCloud Keychain (iOS) or Google Password Manager (Android) is signed in — passkeys require the platform's credential store.
- Open
https://portal.lazor.shdirectly in the device's browser to confirm passkey works outside your app.
PortalCancelledError
The user closed the portal — the dialog's X, Escape, a click outside, the popup (web), or
dismissed the browser (mobile) — before it answered; or disconnect was called during
connect. Nothing was signed. Treat it as a cancel. Since @lazorkit/wallet 3.0.0 /
adapter 2.0.0 this is raised at once instead of after a 60-second timeout.
connect asks for one more signature
An existing passkey that has no wallet yet (made on another device or browser) does not
reveal its public key on sign-in. Since 3.1.0 / 2.1.0, connect recovers the key from two
of its signatures, which costs one extra prompt. If the user closes it, connect rejects
with PortalCancelledError; if the signatures do not settle on one key, it throws an
error that says so. Nothing is created in either case. See
Wallet Confirmation.
WalletChooserNotShownError (iOS)
The built-in chooser is a React Native Modal and iOS shows one modal at a time. Mount
<WalletChooser /> inside your own modal, or close it before connecting. See
Wallet Confirmation › iOS.
A message signature does not verify
verifyWalletMessage resolves false when any of these differ from what the passkey
signed, and verifySignedMessage likewise:
- The signature was made by
@lazorkit/wallet3.3.0 / adapter 2.3.0 or earlier, over the message's own bytes. Since 3.3.1 / 2.3.1 the passkey signssignedMessageChallenge(message); ask the user to sign again. messageis not the exact string (or bytes) that was signed.rpIdis not the portal the passkey was created under (portal.lazor.shby default), ororigin, when you pass it, is not the portal's origin.walletorcredentialIdnames another wallet or passkey, or the passkey is not an Owner of that wallet.- An adapter or Wallet Standard signature was passed as it came: it is UTF-8 JSON; pass
JSON.parse(new TextDecoder().decode(signature)).
It rejects, rather than resolving false, when it cannot read the chain: use an RPC
endpoint that allows getProgramAccounts. Never fall back to the deprecated
verifyMessage / verifySignatureBrowser: they do not check which message was signed.
rpId mismatch / "credentials don't match"
rpId is baked into every passkey — and into the wallet
A passkey is bound to a specific Relying Party ID, and each passkey authority stores
sha256(rpId) on chain. If you change rpId after a user has registered, their
existing passkeys won't match and every connect will fail.
Keep rpId stable across environments (or scope it per-env and migrate users
explicitly). The default portal.lazor.sh works for most integrations. Moving users to a
new rpId means adding their new passkey as an authority with a transaction signed by
the old one.
Portal opens but closes immediately
Usually a portal URL mismatch — the WebBrowser session returns dismiss without
a redirect payload. Check:
portalUrlresolves over HTTPS (WebAuthn requires a secure context).- Device has network connectivity to
portal.lazor.sh. - Portal page loads without ad-blocker interference.
Sending transactions
Since @lazorkit/wallet 3.1.0 / adapter 2.1.0, every send resolves only once its
transaction is confirmed. See React /
React Native › Sending transactions.
SignatureReusedError / 0xbbe (3006)
The passkey signed a counter that another transaction of the same passkey had already used. Causes, most likely first:
- The same passkey signing twice at once — two tabs, two devices, or an app and a wallet. The SDK serialises calls within a page, not across them.
- Back-to-back sends on an old SDK. Before wallet 3.1.0 / adapter 2.1.0 /
sdk-legacy 1.3.0, the second challenge could read the counter before the first
transaction executed. Upgrade; with
@lazorkit/sdk-legacy, passminContextSlot(Passkey signing). - Not LazorKit's at all: an inner Anchor program's
AccountNotMutableis also 3006. The firstProgram <id> failedlog line names the program; the UI SDKs check it and report such a failure as it is.
The signature can never become valid. The SDK does not resend it or open a new prompt on its own: ask the user to approve again.
TransactionOutcomeUnknownError / ConfirmationTimeoutError
Whether the transaction landed is not known — the paymaster's answer was lost, or no
outcome within two minutes. Do not resend blindly: check error.signature (when set),
or the balance or state the transaction would change, first.
PreviousTransactionPendingError
Nothing was signed or sent: the passkey's previous transaction still has no known outcome, and a new signature could reuse its counter. Wait, then try again.
"Already signing" (web) / SigningError (mobile)
Another call is still running: isSigning stays true until the transaction is
confirmed. Disable buttons while isSigning, and await one send before starting the
next. A send started from an action's onSuccess runs (web 3.3.0+, mobile 2.2.0+); on
web 3.2.1 and earlier it was refused this way too.
DeferredExpiredError / 0xbc6 (3014)
TX2 of a deferred execution came after its window (expiryOffset slots after TX1; default
1500 in the UI SDKs, 300 in @lazorkit/sdk-legacy). Nothing ran; ask the user to approve
again. If you split TX1 and TX2, send TX2 sooner or pass a larger expiryOffset (at most
9000). An inner program's 3014 (Anchor's AccountNotAssociatedTokenAccount) is a
different failure — usually a missing token account — and since 3.2.1 / 2.2.1 the SDKs
report it as it came.
InvalidSignatureAge / 0xbbf (3007)
The challenge's slot is more than 150 slots old by the time the transaction runs — the
user took too long to approve, or (with commitment: 'finalized') the slot was already old
when the prompt opened. Prepare again.
Sessions and kept keys
UnlistedSolOutflowError / 0xbdd (3037)
"This session is not allowed to spend SOL" ("This key …" for a Delegate). The transaction would have lowered the wallet's SOL balance, and the session's limits (the Delegate's policy) name no SOL. Only the v2 program release that adds errors 3037 and 3038 returns it; that release is not deployed yet (Networks & versions). Nothing in the transaction ran, and the SDK does not resend it. Causes:
- No SOL limit, and the transaction moves SOL. A session made with token limits only spends no SOL.
- Rent. Creating an account the vault pays for (a recipient's token account, a swap's output account) lowers the vault's SOL. Give the session a SOL limit that covers the rent, or create the account first in an instruction the fee payer funds.
- Wrapping SOL. Wrapping spends SOL; wSOL is a mint of its own.
Limits cannot be changed: revoke the session (remove the Delegate) and register one with a
SOL limit (solPerTxMax, solLifetimeCap or solRecurring on web; an Actions.sol*
action otherwise). See
Session Keys › What a policy bounds.
UnlistedTokenOutflowError / 0xbde (3038)
"This session is not allowed to spend this token". A token would have left the wallet whose
mint no limit names, from the same program release. A session made with SOL limits only,
as every SpendingLimits session before @lazorkit/wallet 3.4.0, spends no token; wSOL
counts as a token. Register a session whose limits name each mint it spends:
spendingLimits.tokens on web, an Actions.token* action on mobile and in the contract
SDKs.
A raw 3037 or 3038 whose logs name another program as the first to fail is that program's,
not LazorKit's; isUnlistedSolOutflowError / isUnlistedTokenOutflowError tell them apart.
createSession throws before the prompt (web)
Since 3.4.0 createSession checks spendingLimits before anything is read or the passkey
is asked. It throws on a tokens entry with no limit, or with a mint that is not an
address; a mint named twice; an amount outside a u64; a window of 0 slots; more than 16
actions; or "spendingLimits makes N bytes of actions; at most 244 fit in the transaction
beside the passkey's response". A SOL limit takes 19 bytes (solRecurring 43), a token's
lifetimeCap or perTxMax 51, its recurring 75: drop a limit, or a mint. The same
budget applies to a Delegate policy and to a React Native session's actions, but there
nothing checks it before the prompt, and a policy that does not fit fails after the user
approved.
createSession returns the session my sessionKey already had (web)
For a sessionKey of your own that already has a session, createSession resolves with
that session as it was made, whatever limits you pass: the program does not create a
second session for the same key. To change its limits, revoke it
(revokeSession({ sessionPda })) and register it again, or register a new key. Since 3.4.0
the limits are checked first, so a call without valid spendingLimits (and not
unrestricted) throws instead of resolving.
KeyWalletMismatchError with reason: 'disconnected' (web)
The wallet was disconnected while this signAndSendWithSession or
signAndSendWithAuthority was running, by disconnect(), wallet-adapter or the Wallet
Standard, and the same wallet is connected again by now. A send started before a sign-out
is not finished after it (3.4.0). Nothing was sent: the message ends "Nothing was signed or
sent." or, when the key had already signed, "The transaction it had signed was not sent."
Send again. reason: 'no-wallet' is the same refusal with no wallet connected. When an
earlier attempt of the same send may have reached the chain, the send rejects with
TransactionOutcomeUnknownError instead: check before sending again.
useWallet() shows no wallet after a wallet-adapter Disconnect
Since 3.4.0 LazorkitWalletAdapter.disconnect() and standard:disconnect disconnect the
React SDK's store too, as its own disconnect() does: the stored wallet is shared. Connect
again. See Wallet Standard › Disconnecting.
Network / relay
TypeError: Network request failed
A fetch call inside the SDK failed — typically getPayerSigner or
signAndSendTransaction against the paymaster.
Most common cause (React Native): the paymasterUrl resolved to undefined because
your env var wasn't set. Guard the env var, or rely on the SDK default:
import { LazorKitProvider } from '@lazorkit/wallet-mobile-adapter';
const PAYMASTER_URL = process.env.EXPO_PUBLIC_PAYMASTER_URL;
<LazorKitProvider
{...(PAYMASTER_URL ? { configPaymaster: { paymasterUrl: PAYMASTER_URL } } : {})}
>
<App />
</LazorKitProvider>PaymasterError
The paymaster refused the request. code and data carry its JSON-RPC error; httpStatus
a non-success HTTP status.
- Unauthorized / 401: the relayer requires an API key (
apiKey, sent asx-api-key) or another check (reCAPTCHA) your app does not pass. - Program not allowed: the relayer's
allowed_programslacks the LazorKit program for this wallet's protocol, or the Secp256r1 precompile. LazorKit's v2 relayer does not sponsor v1 wallets: setv1PaymasterConfig/v1ConfigPaymaster. - Simulation failed: the instruction set reverts.
datausually holds the program logs; see Errors › Program error codes.
getProgramAccounts refused, or connect fails reading the chain
A fresh connect looks the passkey's wallets up with getProgramAccounts, which many
public or rate-limited RPC endpoints refuse. Use an RPC that allows it. A failed read
fails connect; it is never taken as "no wallet", so no wallet is created by mistake.
MinContextSlotNotReachedError
A challenge read with minContextSlot found no RPC node at that slot within 10 s (30 s
at finalized). The endpoint is behind: retry, or use an RPC that has caught up.
Deep linking (React Native only)
Portal redirects, but the app never wakes up
The portal sent the signature back, but the OS didn't route it to your app.
Fix
- Confirm your scheme is registered in
app.json/Info.plist/AndroidManifest.xml. - The
redirectUrlpassed into every mutation method must match a registered scheme exactly — case-sensitive, no trailing slash surprises. - In Expo dev with Expo Go, use
exp://<your-tunnel>orexp://localhost:8081. - On Android, check
adb logcat | grep -i intentwhile the redirect fires — if the intent never reaches your app, the scheme isn't registered correctly. - On Android, another installed app may also claim your custom scheme. Prefer a verified
App Link as
redirectUrl.
Redirect URL arrives without signature fields
The URL came back like myapp://cb?type=error&error=.... The adapter rejects with a
LazorKitError whose code is 'PORTAL_ERROR' and whose message is the portal's text.
Common triggers:
NotAllowedErrorfrom WebAuthn (see above) — user cancelled or environment denied.- Portal timeout — user took too long to authenticate.
- Portal received an invalid challenge payload — inspect the SDK debug logs.
Handle it like any rejection, or in the onFail callback:
await signAndSendTransaction(
{ instructions: [ix] },
{
redirectUrl: 'myapp://cb',
onFail: (err) => console.warn('portal failed:', err.message),
},
);v1 and v2
V1WalletRetiredError / 0xfb2 (4018)
The wallet is on LazorKit v1, and v1 has been retired to its sunset binary. The funds are
safe; the wallet must move to v2 with migrateV1Wallet or the LazorKit migration page.
See Migrating from v1.
V1WalletMigratedError
The stored v1 wallet was migrated (or closed). Call connect again; it finds the
passkey's v2 wallet.
FeeAccountsRequired / 0xfa8 (4008)
A v2 CreateWallet, Execute or ExecuteDeferred came without the four protocol-fee
accounts. v2 requires them even when no fee is charged. You are using an SDK older than
the v2 release, or building instructions by hand: use the LazorKitClient methods of
@lazorkit/sdk-legacy 1.x.
InvalidMessageHash / 0xbbd (3005) on every passkey transaction
The v2 program names the wallet in the passkey challenge. A client that builds the
challenge without it (an SDK from before that change, or a hand-built flow without the
wallet argument) fails every passkey transaction with 3005. Upgrade to the current
packages.
LazorKitClient: cannot infer program ID from RPC endpoint
@lazorkit/sdk-legacy reads the cluster from the RPC URL and throws when the URL names
none. Pass the program id: new LazorKitClient(connection, PROGRAM_ID_DEVNET). The UI
SDKs take the cluster prop instead (they treat an unknown URL as mainnet).
A new user cannot get a wallet on mainnet
New wallets are created on protocol v2, whose mainnet program is not deployed yet. See Networks & versions.
Precompile errors (0x0 – 0x5)
Low-numbered error codes are from Solana's native precompile programs, not LazorKit. The most common one you'll see with passkey signing:
| Hex | Source program | Cause |
|---|---|---|
0x2 | Secp256r1SigVerify111... | InvalidSignature — the precompile couldn't verify the r/s/pubkey/message tuple. |
Debugging 0x2 InvalidSignature
The secp256r1 precompile verifies sig against (publicKey, authenticatorData ‖ sha256(clientDataJSON)).
Any of these being wrong produces 0x2:
-
Stale cached pubkey. The client signed with a
publicKeyBytesthat doesn't match the on-chain authority — for example after cross-device passkey use. The current SDKs read the key from the on-chain authority before every passkey transaction; upgrade, anddisconnect+connectonce to refresh the stored record. -
A signature not in the program's format. The precompile takes a 64-byte
r‖swith a low S. The browser returns DER; convert it (see Passkey signing). -
authenticatorDatafrom the wrong authenticator. Make sure the portal returns bytes from the same credential the user just touched, not a cached response. -
clientDataJSONtampered. The program hashes the raw bytes; any proxy that rewrites headers or trims whitespace breaks the signature.
Log the prepared challenge, the raw clientDataJSON, and the compressed pubkey —
comparing them against the on-chain authority usually pinpoints the desync in seconds.
Program errors (0xbd0, 0xbd1, …)
Custom program errors come back from Solana as hex codes inside error messages and logs
(custom program error: 0xbd0), or as { "Custom": 3024 }. The full table — 3001–3038
and 4001–4018, with what each means — is on
Errors › Program error codes. The ones you will meet most:
| Hex | Dec | Name | Fix |
|---|---|---|---|
0xbd0 | 3024 | ActionSolLimitExceeded | Lifetime SOL cap spent. Revoke and create a new session. |
0xbd1 | 3025 | ActionSolRecurringLimitExceeded | Window cap hit. Wait for the reset. |
0xbcd | 3021 | ActionProgramNotWhitelisted | The call targets a program outside the whitelist. |
0xbc1 | 3009 | SessionExpired | Create a new session. |
0xbbe | 3006 | SignatureReused | See above. |
0xbc6 | 3014 | DeferredAuthorizationExpired | See above. |
0xbd9 | 3033 | DelegateRequiresPolicy | Pass a policy when adding ROLE_SPENDER. |
0xbdd | 3037 | ActionUnlistedSolOutflow | See above. |
0xbde | 3038 | ActionUnlistedTokenOutflow | See above. |
0xfb2 | 4018 | RetiredDeployment | See v1 and v2. |
Check which program failed
Programs your instructions call can use the same numbers (Anchor's account errors are
3000–3017). The first log line Program <id> failed: custom program error: 0x… names the
program; only when it is LazorKit's does this table apply.
Decoding helper
const match = String(err).match(/custom program error: 0x([0-9a-f]+)/i);
if (match) {
const code = parseInt(match[1], 16);
console.log('program error:', code); // e.g. 3024
}Both UI SDKs and @lazorkit/sdk-legacy also ship this helper:
import { extractErrorCode, errorFromCode } from '@lazorkit/wallet';
// or '@lazorkit/wallet-mobile-adapter' / '@lazorkit/sdk-legacy'
const code = extractErrorCode(err); // 3024, or null
const name = code === null ? undefined : errorFromCode(code); // 'ActionSolLimitExceeded'Session exhaustion pattern
0xbd0 / 0xbd1 / 0xbd3 all mean "session hit a spending limit". Because actions are
immutable, the only way forward is to revoke and recreate (React Native shown):
import { Actions } from '@lazorkit/wallet-mobile-adapter';
await revokeSession({ sessionPda }, { redirectUrl });
const { sessionPda: newPda } = await createSession(
{
sessionKey: freshKp.publicKey,
expiresAtSlot: currentSlot + 216_000n,
actions: [Actions.solRecurringLimit({ limit: 2_000_000_000n, window: 216_000n })],
},
{ redirectUrl },
);Still stuck?
- GitHub issues (React and React Native SDKs) — github.com/lazor-kit/lazor-kit/issues
- GitHub issues (protocol and contract SDKs) — github.com/lazor-kit/lazorkit-protocol/issues
- Telegram — t.me/lazorkit
- Twitter — twitter.com/lazorkit
When filing an issue, include:
- SDK version (
npm ls @lazorkit/wallet @lazorkit/wallet-mobile-adapter @lazorkit/sdk-legacy) - Platform + OS version (iOS 17.4, Android 14, Chrome 130, etc.)
- Debug logs (
isDebug={true}) - The exact
redirectUrl/portalUrl/paymasterUrlused, and the cluster - Program error code and transaction logs if applicable