Skip to content

Latest commit

 

History

History
834 lines (681 loc) · 24.5 KB

File metadata and controls

834 lines (681 loc) · 24.5 KB

Tutorial 1: Creating a Passkey-Based Wallet

Time to complete: 15-20 minutes

Learn how to implement passwordless wallet authentication using LazorKit's passkey integration. By the end of this tutorial, you'll understand how passkeys work and have a fully functional wallet connection flow.


📚 Table of Contents

  1. What are Passkeys?
  2. How LazorKit Passkeys Work
  3. Prerequisites
  4. Step 1: Setup the Provider
  5. Step 2: Create the Connection Screen
  6. Step 3: Implement Connect Function
  7. Step 4: Display Wallet Information
  8. Step 5: Handle Disconnect
  9. Complete Code Example
  10. How It Works Under the Hood
  11. Testing Your Implementation

What are Passkeys?

Passkeys are a modern authentication standard (WebAuthn) that replaces passwords and seed phrases with biometric authentication:

Traditional Wallet Passkey Wallet
12-24 word seed phrase Device biometrics (FaceID/TouchID)
Write down and store securely Stored in device Secure Enclave
Can be lost or stolen Bound to your biometrics
Same phrase on all devices Synced via iCloud/Google
5+ minute setup 30 second setup

Why This Matters

  • Users don't need to understand crypto - They just use their fingerprint
  • No seed phrase anxiety - Nothing to write down or lose
  • Hardware-level security - Private keys never leave the Secure Enclave
  • Cross-device sync - Passkeys sync via iCloud Keychain / Google Password Manager

How LazorKit Passkeys Work

┌─────────────────────────────────────────────────────────────────────────────┐
│                         PASSKEY AUTHENTICATION FLOW                          │
└─────────────────────────────────────────────────────────────────────────────┘

    Your App                    LazorKit Portal                  Device
       │                              │                             │
       │  1. connect()                │                             │
       │─────────────────────────────>│                             │
       │                              │                             │
       │                              │  2. Request biometric       │
       │                              │─────────────────────────────>│
       │                              │                             │
       │                              │  3. User authenticates      │
       │                              │     (FaceID/TouchID)        │
       │                              │<─────────────────────────────│
       │                              │                             │
       │  4. Redirect with wallet     │                             │
       │<─────────────────────────────│                             │
       │                              │                             │
       │  5. smartWalletPubkey ready! │                             │
       ▼                              ▼                             ▼

Key Concepts

Concept Description
Smart Wallet A Program Derived Address (PDA) controlled by your passkey
Portal LazorKit's web interface for authentication
Redirect URL Deep link back to your app after authentication
Credential ID Unique identifier for the passkey

Prerequisites

Before starting this tutorial, ensure you have:

  • ✅ Completed the Installation Guide
  • LazorKitProvider wrapping your app
  • ✅ Polyfills configured correctly
  • ✅ Deep linking scheme configured in app.json

Step 1: Setup the Provider

First, ensure your root layout has the LazorKitProvider:

// app/_layout.tsx
import "../polyfills"; // ⚠️ MUST be first!

import { LazorKitProvider } from "@lazorkit/wallet-mobile-adapter";
import { Stack } from "expo-router";
import * as WebBrowser from "expo-web-browser";

// Required for completing the auth session
WebBrowser.maybeCompleteAuthSession();

const LAZORKIT_CONFIG = {
  rpcUrl: "https://api.devnet.solana.com",
  portalUrl: "https://portal.lazor.sh",
  configPaymaster: {
    paymasterUrl: "https://kora.devnet.lazorkit.com",
  },
};

export default function RootLayout() {
  return (
    <LazorKitProvider
      rpcUrl={LAZORKIT_CONFIG.rpcUrl}
      portalUrl={LAZORKIT_CONFIG.portalUrl}
      configPaymaster={LAZORKIT_CONFIG.configPaymaster}
      isDebug={true}
    >
      <Stack>
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      </Stack>
    </LazorKitProvider>
  );
}

Listing 1-1: Root layout configuration with LazorKitProvider

This code sets up the foundation for passkey authentication in React Native. Let's break it down:

import "../polyfills"; // ⚠️ MUST be first!

The polyfills import must come before anything else. Solana's web3.js library expects Node.js APIs like Buffer and crypto that don't exist in React Native. Our polyfill file provides these implementations.

import * as WebBrowser from "expo-web-browser";
WebBrowser.maybeCompleteAuthSession();

When users authenticate with their passkey, they're redirected to LazorKit's portal in a web browser. After authentication, the browser redirects back to your app via deep link. maybeCompleteAuthSession() handles this return—it closes the browser and resumes your app with the authentication result.

const LAZORKIT_CONFIG = {
  rpcUrl: "https://api.devnet.solana.com",
  portalUrl: "https://portal.lazor.sh",
  configPaymaster: {
    paymasterUrl: "https://kora.devnet.lazorkit.com",
  },
};

The configuration object specifies three essential endpoints:

  • rpcUrl: Where to send Solana RPC requests (we use Devnet for testing)
  • portalUrl: LazorKit's authentication portal
  • paymasterUrl: The service that sponsors gas fees for gasless transactions
isDebug={true}

The isDebug flag enables verbose logging during development. You'll see authentication flow details and transaction information in the console. Set this to false in production.

Why maybeCompleteAuthSession()?

When the user returns from the LazorKit portal (browser), Expo needs to know the auth session is complete. This function handles that cleanup.


Step 2: Create the Connection Screen

Create a new screen for wallet connection:

// app/(tabs)/index.tsx
import { useWallet } from "@lazorkit/wallet-mobile-adapter";
import { useState } from "react";
import {
  ActivityIndicator,
  Alert,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from "react-native";

export default function WalletScreen() {
  const {
    connect, // Function to initiate connection
    disconnect, // Function to disconnect
    isConnected, // Boolean: is wallet connected?
    smartWalletPubkey, // PublicKey of the smart wallet
    isConnecting, // Boolean: is connection in progress?
  } = useWallet();

  const [isLoading, setIsLoading] = useState(false);

  // We'll implement these next...
  const handleConnect = async () => {
    /* ... */
  };
  const handleDisconnect = async () => {
    /* ... */
  };

  return <View style={styles.container}>{/* UI will go here */}</View>;
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0a0a0a",
    padding: 20,
  },
});

Listing 1-2: Basic wallet screen structure with useWallet hook

This code creates the foundation for wallet connection. Let's examine the key parts:

const { connect, disconnect, isConnected, smartWalletPubkey, isConnecting } =
  useWallet();

The useWallet hook provides everything needed for wallet management:

  • connect: Opens the authentication flow in the browser
  • disconnect: Clears the wallet session
  • isConnected: Whether we have an active wallet session
  • smartWalletPubkey: A Solana PublicKey object representing your wallet address
  • isConnecting: True while the browser flow is in progress
const [isLoading, setIsLoading] = useState(false);

We maintain our own loading state in addition to isConnecting. This gives us finer control—isConnecting covers the browser flow, while isLoading can cover post-connection setup like fetching balances.

The useWallet Hook Returns

Property Type Description
connect function Initiates passkey authentication
disconnect function Clears the wallet session
isConnected boolean Whether a wallet is connected
smartWalletPubkey PublicKey | null The wallet's Solana address
isConnecting boolean Loading state during connection
signMessage function Signs arbitrary messages
signAndSendTransaction function Signs and broadcasts transactions

Step 3: Implement Connect Function

Now implement the connection logic:

import { getRedirectUrl } from "@/utils/redirect-url";

const handleConnect = async () => {
  // Prevent multiple connection attempts
  if (isConnecting || isLoading) return;

  try {
    setIsLoading(true);

    await connect({
      // redirectUrl tells LazorKit where to return after auth
      // This MUST match your app's URL scheme
      redirectUrl: getRedirectUrl(),

      // Called when authentication succeeds
      onSuccess: (wallet) => {
        console.log("✅ Connected successfully!");
        console.log("Smart Wallet:", wallet.smartWallet);
        console.log("Credential ID:", wallet.credentialId);
        console.log("Platform:", wallet.platform);
        setIsLoading(false);
      },

      // Called when authentication fails
      onFail: (error) => {
        console.error("❌ Connection failed:", error);
        Alert.alert(
          "Connection Failed",
          error?.message || "Unable to connect wallet"
        );
        setIsLoading(false);
      },
    });
  } catch (error: any) {
    console.error("Error during connect:", error);
    Alert.alert("Error", error?.message || "Failed to connect");
    setIsLoading(false);
  }
};

Listing 1-3: The handleConnect function with callbacks

This function orchestrates the entire connection flow. Let's examine each part:

if (isConnecting || isLoading) return;

Guard against double-taps. If authentication is already in progress, we ignore additional presses. This prevents confusing race conditions.

await connect({
  redirectUrl: getRedirectUrl(),
  // ...
});

The redirectUrl is crucial for mobile apps. After the user authenticates in the browser, this URL tells the browser how to return to your app. It must match the URL scheme configured in your app.json (e.g., passpay://).

onSuccess: (wallet) => {
  console.log("Smart Wallet:", wallet.smartWallet);
  console.log("Credential ID:", wallet.credentialId);
  console.log("Platform:", wallet.platform);
  setIsLoading(false);
},

The onSuccess callback receives a WalletInfo object with the wallet's details. The smartWallet address is what you'll use for receiving funds and displaying to users. The same passkey always produces the same wallet address.

onFail: (error) => {
  Alert.alert("Connection Failed", error?.message || "Unable to connect wallet");
  setIsLoading(false);
},

The onFail callback handles authentication failures—user cancelled, network issues, or browser problems. We show a native alert for immediate feedback.

The connect Options

interface ConnectOptions {
  // REQUIRED: Deep link URL to return to your app
  redirectUrl: string;

  // OPTIONAL: Called with wallet info on success
  onSuccess?: (wallet: WalletInfo) => void;

  // OPTIONAL: Called with error on failure
  onFail?: (error: Error) => void;
}

The WalletInfo Object

When connection succeeds, you receive:

interface WalletInfo {
  // Unique WebAuthn credential ID (Base64)
  credentialId: string;

  // Raw public key bytes of the passkey
  passkeyPubkey: number[];

  // ⭐ YOUR SOLANA WALLET ADDRESS (Base58)
  // Use this to receive funds and display to users
  smartWallet: string;

  // Internal PDA for device management
  walletDevice: string;

  // Origin platform ('android' | 'ios')
  platform: string;
}

Step 4: Display Wallet Information

Show the connected wallet state:

return (
  <View style={styles.container}>
    <Text style={styles.title}>PassPay Wallet</Text>

    {isConnected && smartWalletPubkey ? (
      // ✅ CONNECTED STATE
      <View style={styles.walletCard}>
        <Text style={styles.label}>Your Wallet Address</Text>
        <Text style={styles.address} numberOfLines={1} ellipsizeMode="middle">
          {smartWalletPubkey.toBase58()}
        </Text>
        <Text style={styles.successBadge}> Connected with Passkey</Text>

        <TouchableOpacity
          style={styles.disconnectButton}
          onPress={handleDisconnect}
        >
          <Text style={styles.disconnectText}>Disconnect</Text>
        </TouchableOpacity>
      </View>
    ) : (
      // ❌ NOT CONNECTED STATE
      <View style={styles.connectContainer}>
        <Text style={styles.description}>
          Create or connect your wallet using biometric authentication (FaceID,
          TouchID, or fingerprint)
        </Text>

        <TouchableOpacity
          style={[
            styles.connectButton,
            (isConnecting || isLoading) && styles.buttonDisabled,
          ]}
          onPress={handleConnect}
          disabled={isConnecting || isLoading}
        >
          {isConnecting || isLoading ? (
            <ActivityIndicator color="white" />
          ) : (
            <Text style={styles.connectButtonText}>
              Connect with Passkey 🔐
            </Text>
          )}
        </TouchableOpacity>
      </View>
    )}
  </View>
);

Display Address Helpers

Create a utility to truncate long addresses:

// utils/helpers.ts
export function truncateAddress(
  address: string,
  startChars: number = 4,
  endChars: number = 4
): string {
  if (!address) return "";
  if (address.length <= startChars + endChars) return address;
  return `${address.slice(0, startChars)}...${address.slice(-endChars)}`;
}

// Usage:
// truncateAddress("4UjfJZ8K1234567890abcdefghijklmnopqrstuvwxyz")
// Returns: "4Ujf...wxyz"

Step 5: Handle Disconnect

Implement the disconnect function:

const handleDisconnect = async () => {
  try {
    await disconnect({
      onSuccess: () => {
        console.log("👋 Disconnected successfully");
      },
      onFail: (error) => {
        console.error("Disconnect failed:", error);
        Alert.alert("Error", "Failed to disconnect");
      },
    });
  } catch (error: any) {
    console.error("Error during disconnect:", error);
    Alert.alert("Error", error?.message || "Failed to disconnect");
  }
};

What Disconnect Does

  • Clears the local wallet session
  • Resets isConnected to false
  • Resets smartWalletPubkey to null
  • Does NOT delete the passkey (user can reconnect later)

Complete Code Example

Here's the full implementation from PassPay:

// app/(tabs)/index.tsx
import { useWallet } from "@lazorkit/wallet-mobile-adapter";
import { useState } from "react";
import {
  ActivityIndicator,
  Alert,
  ScrollView,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from "react-native";
import { getRedirectUrl } from "@/utils/redirect-url";

export default function HomeScreen() {
  const { connect, isConnected, smartWalletPubkey, disconnect, isConnecting } =
    useWallet();
  const [isLoading, setIsLoading] = useState(false);

  const handleConnect = async () => {
    if (isConnecting || isLoading) return;

    try {
      setIsLoading(true);
      await connect({
        redirectUrl: getRedirectUrl(),
        onSuccess: (wallet) => {
          console.log("Connected:", wallet.smartWallet);
          setIsLoading(false);
        },
        onFail: (error) => {
          console.error("Connection failed:", error);
          Alert.alert("Connection Failed", error?.message || "Unknown error");
          setIsLoading(false);
        },
      });
    } catch (error: any) {
      console.error("Error connecting:", error);
      Alert.alert("Error", error?.message || "Failed to connect");
      setIsLoading(false);
    }
  };

  const handleDisconnect = async () => {
    try {
      await disconnect({
        onSuccess: () => console.log("Disconnected"),
        onFail: (error) => {
          console.error("Disconnect failed:", error);
          Alert.alert("Error", "Failed to disconnect");
        },
      });
    } catch (error: any) {
      Alert.alert("Error", error?.message || "Failed to disconnect");
    }
  };

  return (
    <ScrollView style={styles.container}>
      <View style={styles.content}>
        <Text style={styles.title}>PassPay</Text>
        <Text style={styles.subtitle}>Passkey-Powered Solana Wallet</Text>

        {isConnected && smartWalletPubkey ? (
          <View style={styles.walletContainer}>
            <View style={styles.walletCard}>
              <Text style={styles.label}>Wallet Address</Text>
              <Text
                style={styles.address}
                numberOfLines={1}
                ellipsizeMode="middle"
              >
                {smartWalletPubkey.toBase58()}
              </Text>
              <Text style={styles.successText}> Connected with Passkey</Text>
            </View>

            <TouchableOpacity
              style={styles.disconnectButton}
              onPress={handleDisconnect}
            >
              <Text style={styles.disconnectText}>Disconnect</Text>
            </TouchableOpacity>
          </View>
        ) : (
          <View style={styles.connectContainer}>
            <Text style={styles.description}>
              Create or connect your wallet using biometric authentication
            </Text>

            <TouchableOpacity
              style={[
                styles.connectButton,
                (isConnecting || isLoading) && styles.buttonDisabled,
              ]}
              onPress={handleConnect}
              disabled={isConnecting || isLoading}
            >
              {isConnecting || isLoading ? (
                <ActivityIndicator color="white" />
              ) : (
                <Text style={styles.connectButtonText}>
                  Connect with Passkey 🔐
                </Text>
              )}
            </TouchableOpacity>
          </View>
        )}
      </View>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0a0a0a",
  },
  content: {
    padding: 20,
    paddingTop: 60,
  },
  title: {
    fontSize: 32,
    fontWeight: "bold",
    color: "#fff",
    textAlign: "center",
  },
  subtitle: {
    fontSize: 16,
    color: "#888",
    textAlign: "center",
    marginTop: 8,
    marginBottom: 32,
  },
  walletContainer: {
    gap: 16,
  },
  walletCard: {
    backgroundColor: "#1a1a1a",
    borderRadius: 16,
    padding: 20,
    borderWidth: 1,
    borderColor: "#333",
  },
  label: {
    fontSize: 12,
    color: "#888",
    marginBottom: 8,
    textTransform: "uppercase",
  },
  address: {
    fontSize: 14,
    color: "#fff",
    fontFamily: "monospace",
  },
  successText: {
    color: "#14F195",
    marginTop: 12,
    fontSize: 14,
  },
  connectContainer: {
    alignItems: "center",
    gap: 24,
  },
  description: {
    fontSize: 16,
    color: "#888",
    textAlign: "center",
    lineHeight: 24,
  },
  connectButton: {
    backgroundColor: "#9945FF",
    paddingVertical: 16,
    paddingHorizontal: 32,
    borderRadius: 12,
    width: "100%",
    alignItems: "center",
  },
  buttonDisabled: {
    opacity: 0.6,
  },
  connectButtonText: {
    color: "#fff",
    fontSize: 18,
    fontWeight: "600",
  },
  disconnectButton: {
    backgroundColor: "transparent",
    borderWidth: 1,
    borderColor: "#ff4444",
    paddingVertical: 12,
    borderRadius: 12,
    alignItems: "center",
  },
  disconnectText: {
    color: "#ff4444",
    fontSize: 16,
    fontWeight: "500",
  },
});

How It Works Under the Hood

1. Passkey Creation (First Time)

User taps "Connect"
    ↓
Browser opens LazorKit Portal
    ↓
Portal calls navigator.credentials.create()
    ↓
Device shows biometric prompt
    ↓
Secure Enclave generates keypair
    ↓
Public key sent to LazorKit
    ↓
Smart Wallet PDA created on-chain
    ↓
Redirect back to app with wallet info

2. Passkey Authentication (Returning User)

User taps "Connect"
    ↓
Browser opens LazorKit Portal
    ↓
Portal calls navigator.credentials.get()
    ↓
Device shows biometric prompt
    ↓
Secure Enclave signs challenge
    ↓
Signature verified
    ↓
Redirect back to app with wallet info

3. The Smart Wallet

LazorKit creates a Program Derived Address (PDA) for each passkey:

┌─────────────────────────────────────────┐
│           SMART WALLET (PDA)             │
├─────────────────────────────────────────┤
│ • Controlled by LazorKit program        │
│ • Authorized by your passkey            │
│ • Can hold SOL and tokens               │
│ • Supports gasless transactions         │
│ • Can be recovered with passkey sync    │
└─────────────────────────────────────────┘

Testing Your Implementation

Test on Physical Device (Required)

Passkeys require biometric hardware:

  • iOS: FaceID or TouchID
  • Android: Fingerprint scanner

⚠️ Passkeys do NOT work in simulators/emulators!

Test Checklist

  • Connect button opens browser
  • Biometric prompt appears
  • Successful redirect back to app
  • Wallet address displays correctly
  • Disconnect clears the session
  • Reconnecting uses existing passkey

Debug Tips

  1. Enable debug mode in LazorKitProvider:

    <LazorKitProvider isDebug={true} ... />
  2. Check redirect URL matches your scheme:

    console.log("Redirect URL:", getRedirectUrl());
  3. Monitor Metro logs for connection events


Complete Example

See the full implementation on PassPay in app/welcome.tsx.

📁 Key Files
├── app/welcome.tsx                          ← Welcome/connect screen
├── app/(tabs)/_layout.tsx                   ← Wallet provider setup
├── hooks/
│   └── use-wallet-guard.ts                  ← Connection helper hook
└── components/common/
    └── Logo.tsx                             ← UI components

🎉 What You've Learned

  • ✅ How passkeys replace seed phrases
  • ✅ The authentication flow between your app and LazorKit
  • ✅ Implementing connect() with proper callbacks
  • ✅ Displaying wallet information
  • ✅ Handling disconnect
  • ✅ The smart wallet architecture

Next Steps

Continue to Tutorial 2: Gasless Transactions to learn how to send SOL without paying gas fees!