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-browser14.2 or 15 andexpo-crypto15; 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 bufferIn 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.
Register your deep-link scheme
The portal returns signature payloads via a redirect. Your app needs to own a URL scheme.
{
"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
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
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
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
Session keys
One passkey prompt → many prompt-free sends, within on-chain spending limits.
Deferred execution
Authorize once, execute payloads too big for a single tx (Jupiter swaps, batches).
Authorities
Add Ed25519 keys as Admin, or as a Delegate bounded by a spending policy.
Sending transactions
Confirmation, callbacks, sequencing and the errors a send can reject with.
Troubleshooting
Common errors: NotAllowedError, Network request failed, 0x2, 0xbd0, deep-link mismatches.