LazorKit LogoLazorKit
React Native SDK

Getting Started

Wire up LazorKit in your React Native / Expo app in 5 minutes.

A complete working integration takes four steps: install dependencies, add polyfills, mount the provider, call the hook.

Zero-config defaults

Every provider prop is optional. If you leave them blank the SDK uses devnet defaults (api.devnet.solana.com, portal.lazor.sh, and kora.devnet.lazorkit.com, LazorKit's devnet relayer, set up for protocol v2). Ship to production by overriding rpcUrl + configPaymaster.

Mainnet

Mainnet still runs protocol v1, and the v2 mainnet program is not deployed yet. With adapter 2.x, existing v1 wallets work on mainnet (through a relayer that sponsors v1: v1ConfigPaymaster), but a new user cannot get a wallet there until v2 is deployed. See Networks & versions.

Prerequisites

  • React Native ≥ 0.70; Expo SDK 54 (the adapter depends on expo-web-browser 14.2 or 15 and expo-crypto 15; older Expo SDKs are untested)
  • A physical device or a simulator with biometrics enrolled (see Troubleshooting if you hit NotAllowedError)
  • A deep-link scheme for your app (e.g. myapp://)

Install

Install the SDK and peers

npm install @lazorkit/wallet-mobile-adapter @solana/web3.js \
  @react-native-async-storage/async-storage expo-web-browser \
  react-native-get-random-values react-native-url-polyfill buffer

In an Expo app, prefer npx expo install expo-web-browser @react-native-async-storage/async-storage so the native modules match your SDK. Install latest: the npm beta tag still points at an old pre-v2 build.

@solana/web3.js is a peer dependency. Install it explicitly so bundlers pick one version instead of duplicating.

Add polyfills

React Native is missing some Node globals that Solana libraries expect. Add these imports at the very top of your entry file (app/_layout.tsx, index.js, or App.tsx):

import 'react-native-get-random-values';
import 'react-native-url-polyfill/auto';
import { Buffer } from 'buffer';
global.Buffer = global.Buffer || Buffer;

The provider itself patches Buffer.prototype.subarray once mounted.

The portal returns signature payloads via a redirect. Your app needs to own a URL scheme.

app.json
{
  "expo": {
    "scheme": "myapp"
  }
}

In dev with Expo Go you can use exp://localhost:8081 (or your tunnel URL).

Add to ios/<YourApp>/Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
    </array>
  </dict>
</array>

Add to android/app/src/main/AndroidManifest.xml:

<intent-filter>
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="myapp" />
</intent-filter>

Android: prefer a link only your app can receive

Android lets more than one app claim the same custom scheme. An app that also claims yours sees the portal's redirects and could answer in the portal's place. On iOS the auth session hands the redirect to your app alone; on Android prefer a verified App Link as redirectUrl.

Wrap your app with the provider

app/_layout.tsx
import { LazorKitProvider } from '@lazorkit/wallet-mobile-adapter';
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <LazorKitProvider isDebug={__DEV__}>
      <Stack />
    </LazorKitProvider>
  );
}

That's the minimum. To override defaults:

<LazorKitProvider
  rpcUrl={process.env.EXPO_PUBLIC_RPC_URL}
  cluster="devnet"                 // needed when the RPC URL does not say which cluster
  portalUrl={process.env.EXPO_PUBLIC_PORTAL_URL}
  configPaymaster={{
    paymasterUrl: process.env.EXPO_PUBLIC_PAYMASTER_URL!,
    apiKey: process.env.EXPO_PUBLIC_PAYMASTER_KEY, // ships in the app: not a secret
  }}
>
  <Stack />
</LazorKitProvider>

If your app already has users on LazorKit v1, also set v1ConfigPaymaster; see Migrating from v1.


First flow: connect + send

Connect

app/index.tsx
import { useWallet } from '@lazorkit/wallet-mobile-adapter';
import { Button, Text } from 'react-native';

export default function HomeScreen() {
  const { connect, isConnected, isConnecting, smartWalletPubkey } = useWallet();

  if (isConnected) {
    return <Text>Connected: {smartWalletPubkey!.toBase58()}</Text>;
  }

  return (
    <Button
      title={isConnecting ? 'Connecting…' : 'Connect with Passkey'}
      onPress={() => connect({ redirectUrl: 'myapp://home' })}
    />
  );
}

smartWalletPubkey = vault

On mobile, smartWalletPubkey (alias vaultPubkey) is the vault PDA — the address that receives and sends funds. Use it for connection.getBalance, for QR codes, and wherever you show the user their wallet address. (On the web SDK the same name is the wallet PDA; vaultPubkey is the vault on both.)

The first connect opens the portal. For a returning user it may show a "Which wallet is yours?" chooser, and for an existing passkey that has no wallet yet it asks for one more signature to recover the passkey's key. See Wallet Confirmation.

Send a transaction

app/send.tsx
import { useWallet } from '@lazorkit/wallet-mobile-adapter';
import { LAMPORTS_PER_SOL, PublicKey, SystemProgram } from '@solana/web3.js';
import { Button } from 'react-native';

export default function SendScreen() {
  const { smartWalletPubkey, signAndSendTransaction } = useWallet();

  const send = async () => {
    if (!smartWalletPubkey) return;
    const ix = SystemProgram.transfer({
      fromPubkey: smartWalletPubkey,                        // vault
      toPubkey: new PublicKey('RECIPIENT_ADDRESS'),
      lamports: 0.01 * LAMPORTS_PER_SOL,
    });

    // Resolves once the transaction is confirmed; rejects if it failed on chain.
    const sig = await signAndSendTransaction(
      { instructions: [ix] },
      { redirectUrl: 'myapp://home' },
    );
    console.log('tx', sig);
  };

  return <Button title="Send 0.01 SOL" onPress={send} />;
}

What a send can reject with, and how callbacks and isSigning behave: Sending transactions.

(Optional) Use the vault balance

import { useEffect, useState } from 'react';
import { Text } from 'react-native';
import { LAMPORTS_PER_SOL } from '@solana/web3.js';
import { useWallet } from '@lazorkit/wallet-mobile-adapter';

export function Balance() {
  const { smartWalletPubkey, connection } = useWallet();
  const [sol, setSol] = useState(0);

  useEffect(() => {
    if (!smartWalletPubkey) return;
    connection.getBalance(smartWalletPubkey).then((l) => setSol(l / LAMPORTS_PER_SOL));
  }, [smartWalletPubkey?.toBase58()]);

  return <Text>{sol.toFixed(4)} SOL</Text>;
}

Deferred execution — split TX1 / TX2

For flows where TX1 and TX2 don't run in the same process — a relayer on your server, a scheduled job, a secondary device — use the split API instead of authorizeAndExecute:

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

// In the app — passkey-signed TX1 only, returns the payload
const { deferredPayload } = await authorizeDeferred(
  { instructions: jupiterSwapIxs, expiryOffset: 4_500 },   // slots after TX1; default 1500, 10–9000
  { redirectUrl: 'myapp://cb' },
);

// Send it to your relayer as a JSON-safe string
await fetch('https://your-backend.example.com/relayer', {
  method: 'POST',
  body: serializeDeferredPayload(deferredPayload),
});

The server has no hook: it sends TX2 with @lazorkit/sdk-legacy (no passkey needed):

import { Connection, Keypair, Transaction, sendAndConfirmTransaction } from '@solana/web3.js';
import { LazorKitClient, PROGRAM_ID_DEVNET, deserializeDeferredPayload } from '@lazorkit/sdk-legacy';

declare const relayer: Keypair; // the relayer's fee payer

export async function handle(req: Request) {
  const connection = new Connection('https://api.devnet.solana.com', 'confirmed');
  const client = new LazorKitClient(connection, PROGRAM_ID_DEVNET);
  const deferredPayload = deserializeDeferredPayload(await req.text());
  const { instructions } = await client.executeDeferredFromPayload({ payer: relayer.publicKey, deferredPayload });
  return sendAndConfirmTransaction(connection, new Transaction().add(...instructions), [relayer]);
}

TX2 must land within expiryOffset slots of TX1's slot. If an inner instruction pays the TX1 payer back, TX2 must be sent by the executor that TX1 named (the paymaster's fee payer by default); executeDeferredFromPayload refuses another sender for such a payload. If the authorization expires first, executeDeferred rejects with DeferredExpiredError, and the rent can be reclaimed (it goes back to the paymaster's fee payer, the only destination the program accepts):

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

See useWallet → Deferred execution for the full API.

Next level