LazorKit LogoLazorKit

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.

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, open DevTools — the store logs all state transitions.

Section map

TopicSymptoms include
SetupCannot resolve module, Buffer is not defined, SSR crashes
Passkey / WebAuthnNotAllowedError, "user denied permission", simulator issues
Network / relayNetwork request failed, paymaster 500s, fetch(undefined)
Deep linkingPortal never returns, redirect drops signature
Program errors0xbd0 ActionSolLimitExceeded, auth payload, replay
SDK migration (v2)smartWallet points to wrong PDA, balance stuck at 0

Setup

Cannot resolve module '@solana/web3.js'

@solana/web3.js is a peer dependency.
npm install @solana/web3.js

Then restart your bundler / Metro to clear module caches.

Buffer is not defined

Solana libraries assume Node's Buffer global. Polyfill 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:

app/providers.tsx
'use client';
import { Buffer } from 'buffer';
if (typeof window !== 'undefined') (window as any).Buffer ??= Buffer;
vite.config.ts
import { nodePolyfills } from 'vite-plugin-node-polyfills';
export default defineConfig({ plugins: [nodePolyfills()] });

Install the buffer package and register it once:

src/polyfills.ts
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 and calls navigator.credentials — both are client-only. In Next.js App Router, mount the provider inside a "use client" boundary:

app/providers.tsx
'use client';
import { LazorkitProvider } from '@lazorkit/wallet';
export function Providers({ children }) {
  return <LazorkitProvider>{children}</LazorkitProvider>;
}

Never call useWallet from a server component.

Random-values crash (Expo)

If crypto.getRandomValues throws at startup, install expo-crypto:

npx expo install expo-crypto

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.sh directly in the device's browser to confirm passkey works outside your app.

rpId mismatch / "credentials don't match"

rpId is baked into every passkey

A passkey is bound to a specific Relying Party ID. 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.

Portal opens but closes immediately

Usually a portal URL mismatch — the WebBrowser session returns dismiss without a redirect payload. Check:

  • portalUrl resolves over HTTPS (WebAuthn requires a secure context).
  • Device has network connectivity to portal.lazor.sh.
  • Portal page loads without ad-blocker interference.

Network / relay

TypeError: Network request failed

A fetch call inside the SDK failed — typically getFeePayer() or signAndSendTransaction() against the paymaster.

Most common cause (React Native): the paymasterUrl resolved to undefined because your env var wasn't set. The provider passes { paymasterUrl: undefined }, and the SDK's default only kicks in when the whole configPaymaster prop is missing.

Fix (React Native) — guard the env var, or rely on the SDK default:

const PAYMASTER_URL = process.env.EXPO_PUBLIC_PAYMASTER_URL;

<LazorKitProvider
  {...(PAYMASTER_URL ? { configPaymaster: { paymasterUrl: PAYMASTER_URL } } : {})}
>
  <App />
</LazorKitProvider>

Recent SDK versions (≥ 2.0.0) also defend against this internally — upgrade if you're on an older release.

Paymaster returns 4xx / 5xx

RPC error: Signer not authorized
RPC error: Transaction simulation failed
  • Signer not authorized: the paymaster's allow-list doesn't include your portalUrl / API key. Confirm with the paymaster operator.
  • Simulation failed: the instruction set reverts on-chain. Log instructions and run simulation locally (connection.simulateTransaction(tx)) to get the full program log.

"Insufficient funds" on the vault

A transaction that reads SOL from the vault needs the vault to hold enough. Fund it by sending SOL to smartWalletPubkey (the vault PDA).

// `smartWalletPubkey` IS the vault — funds sent here are spendable by the wallet.
console.log('receive SOL at:', wallet.smartWallet);

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 redirectUrl passed 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> or exp://localhost:8081.
  • On Android, check adb logcat | grep -i intent while the redirect fires — if the intent never reaches your app, the scheme isn't registered correctly.

Redirect URL arrives without signature fields

The URL came back like myapp://cb?type=error&error=.... Treat this as a portal-side failure. Common triggers:

  • NotAllowedError from 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 via the onFail callback on every mutation:

await signAndSendTransaction(payload, {
  redirectUrl: 'myapp://cb',
  onFail: (err) => console.warn('portal failed:', err.message),
});

Precompile errors (0x00x5)

Low-numbered error codes are from Solana's native precompile programs, not LazorKit. The most common one you'll see with passkey signing:

HexSource programCause
0x2Secp256r1SigVerify111...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 SDK signed with an old publicKeyBytes that doesn't match the on-chain Authority. Common trigger: cross-device passkey recovery (user signs in on a second device with an iCloud-synced passkey, the portal-reported pubkey drifts from what was stored on-chain).

    Fix (RN SDK ≥ 2.0.0-beta.2): buildSecp256r1Params no longer forwards the cached wallet.passkeyPubkey — the client reads the authoritative pubkey off-chain via readAuthorityPubkey, and saveWallet refreshes the cached pubkey on every recovery. Upgrade and disconnect + connect once to re-hydrate.

  • authenticatorData from the wrong authenticator. Make sure the portal returns bytes from the same credential the user just touched, not a cached response.

  • clientDataJSON tampered. 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 SendTransactionError.message. Decode by converting hex → decimal and matching against the table below.

Error map

HexDecNameCause
0xbb93001InvalidAuthorityPayloadAuth payload structure rejected — mismatched authenticatorData / clientDataJSON.
0xbba3002PermissionDeniedCaller's role doesn't allow this instruction.
0xbbb3003InvalidInstructionInstruction data couldn't be decoded.
0xbbc3004InvalidPubkeyPubkey didn't match expected PDA / authority.
0xbbd3005InvalidMessageHashChallenge hash mismatch — challenge the client signed differs from what the program recomputed.
0xbbe3006SignatureReusedOdometer counter replay — the passkey signed with a stale counter.
0xbbf3007InvalidSignatureAgeAuth payload slot is outside the 150-slot freshness window.
0xbc03008InvalidSessionDurationexpiresAtSlot out of bounds.
0xbc13009SessionExpiredSession's expiry slot has passed.
0xbc23010AuthorityDoesNotSupportSessionNon-Admin/Owner tried to create or revoke a session.
0xbc33011InvalidAuthenticationKindAuthentication type (Ed25519 / Secp256r1) doesn't match the authority.
0xbc43012InvalidMessageSigned payload couldn't be parsed.
0xbc53013SelfReentrancyNotAllowedTransaction attempted a CPI back into the LazorKit program.
0xbc63014DeferredAuthorizationExpiredTX2 ExecuteDeferred after the authorization window expired.
0xbc73015DeferredHashMismatchTX2 instruction/accounts don't match the hash from TX1.
0xbc83016InvalidExpiryWindowexpiryOffset out of bounds.
0xbc93017UnauthorizedReclaimNon-payer tried to reclaim a deferred exec account.
0xbca3018DeferredAuthorizationNotExpiredTried to reclaim before the auth window elapsed.
0xbcb3019InvalidSessionAccountSession PDA doesn't match the provided session key.
0xbcc3020ActionBufferInvalidActions buffer malformed — check Actions.* call sites.
0xbcd3021ActionProgramNotWhitelistedCPI target isn't in the session's program whitelist.
0xbce3022ActionProgramBlacklistedCPI target is blacklisted by the session.
0xbcf3023ActionSolMaxPerTxExceededSingle-tx SOL outflow exceeds solMaxPerTx.
0xbd03024ActionSolLimitExceededSession's lifetime SOL cap exhausted. Revoke + create a new session.
0xbd13025ActionSolRecurringLimitExceededSOL recurring window cap hit — wait for reset or increase limit.
0xbd23026ActionTokenLimitExceededToken lifetime cap exhausted.
0xbd33027ActionTokenRecurringLimitExceededToken recurring window cap hit.
0xbd43028ActionWhitelistBlacklistConflictSame program listed in both whitelist and blacklist actions.
0xbd53029ActionTokenMaxPerTxExceededSingle-tx token outflow exceeds tokenMaxPerTx.

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 SDKs also ship this helper:

import { extractErrorCode, errorFromCode } from '@lazorkit/wallet';
// or '@lazorkit/wallet-mobile-adapter'

const code = extractErrorCode(err);            // 3024
const name = 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:

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 },
);

SDK migration (v2)

smartWallet changed meaning — what do I do?

In SDK v2, WalletInfo.smartWallet now points to the vault PDA (where funds live), not the wallet metadata PDA.

  • Display: show smartWallet or smartWalletPubkey as the user's wallet address.
  • Receive SOL/tokens: send to smartWallet.
  • Instructions: use smartWalletPubkey as fromPubkey for transfers.
  • Raw SDK: use walletPda (new field) or walletPdaPubkey (from the hook) when calling LazorKitClient directly.

Automatic migration: the React Native store bumps version: 0 → 1 and migrates persisted wallets transparently. No user action needed.

If you have custom storage or cached references:

import { findVaultPda, PublicKey } from '@lazorkit/wallet-mobile-adapter';

const legacyWalletPda = new PublicKey(oldCachedSmartWallet); // old = wallet PDA
const [vaultPda] = findVaultPda(legacyWalletPda);
// store vaultPda as the new smartWallet, keep legacyWalletPda as walletPda

Balance shows 0 after upgrade

You're still querying the wallet metadata PDA instead of the vault. Swap to smartWalletPubkey:

// before: await connection.getBalance(walletPdaPubkey)
await connection.getBalance(smartWalletPubkey);

Still stuck?

When filing an issue, include:

  1. SDK version (npm ls @lazorkit/wallet-mobile-adapter)
  2. Platform + OS version (iOS 17.4, Android 14, Chrome 130, etc.)
  3. Debug logs (isDebug={true})
  4. The exact redirectUrl / portalUrl / paymasterUrl used
  5. Program error code if applicable