Exploring Mobile Wallet Adapter

Summary #

  • Wallets are just wrappers around a keypair, but they're essential for secure key management
  • Mobile and Web dApps handle their wallet-app connection differently
  • MWA handles all of its wallet interaction by wrapping all the wallet's functionalities within the transact function for easier intergration.
  • Solana Mobile's walletlib does the heavy lifting for surfacing wallet requests to wallet apps

Lesson #

Wallets exist to protect your secret keys. While some applications might have app-specific keys, many crypto use cases rely on a single identity used across multiple apps. In these cases, you very much want to be careful about how you expose signing across these apps. You don't want to share your secret key with all of them, which means you need a standard for allowing apps to submit transactions for signature to a secure wallet app that holds your secret key. This is where the Mobile Wallet Adapter (MWA) comes in. It's the transport layer to connect your mobile dApps to your wallet.

What is MWA #

Mobile Wallet Adapter (MWA) is the mobile connection between dApps and wallets. Much like the wallet adapter we're used to on the web, MWA allows us to create native mobile dApps. However, since the web and mobile are different platforms, we have to approach the app-wallet connection differently.

At its core, a wallet app is fairly straightforward. It's a secure wrapper around your keypair. External applications can request that the wallet sign transactions without ever having access to your private key. Both the web and mobile wallet adapters define this interaction for their respective platforms.

How does a web wallet work? #

A web wallet is simply a browser extension that stores keypairs and allows the browser to request access to its functions. It's the wallet's job to follow the wallet standard, which defines what functions should be available to the browser:

  • registerWallet
  • getWallets
  • signAndSendTransaction
  • signIn
  • signTransaction
  • signMessage

These functions are all available to the browser through the global window object. The browser extension registers itself as a wallet. The wallet adapter looks for these registered wallets and allows the client to connect and interact with them.

A browser extension wallet can run isolated JavaScript. This means it can inject functions into the browser's window object. Effectively, the transport layer here is just extra JavaScript code as far as the browser is concerned.

If you're curious to know more about how browser extensions work, take a look at some open-source browser extensions.

How MWA is different from web wallets #

Mobile Wallet Adapter (MWA) is different. In the web world, we just need to inject some code into the window object to access our wallets. Mobile apps, however, are sandboxed. This means that the code for each app is isolated from other apps. There's no shared state between apps that would be analogous to a browser's window object. This poses a problem for wallet signing since a mobile wallet and a mobile dApp exist in isolated environments.

However, there are ways to facilitate communication if you're willing to get creative. On Android, basic inter-app communication is done through Intents. An Android Intent is a messaging object used to request an action from another app component.

This particular communication is one-way, whereas the interface for wallet functionality requires two-way communication. MWA gets around this by using an intent from the requesting app to trigger the wallet app opening up two-way communication using WebSockets.

The rest of this lesson will focus on the MWA interface and functionality rather than the low-level mechanisms underpinning inter-app communication. However, if you want to know the nitty gritty, read the MWA specs.

How to work with MWA #

The differences between MWA and the traditional wallet adapter require slight modifications to how you program your apps.

Connect to a wallet #

By way of comparison, look at the example of connecting to a wallet with React vs with React Native.

On the web, you wrap the application with WalletProvider, and then children access the wallet through the useWallet hook. From there, children can view, select, connect, and interact with wallets.

// Parent
<WalletProvider wallets={wallets}>{children}</WalletProvider>;
 
// Child
const { wallets, select, connect } = useWallet();
const wallet = wallets[0]; // choose a wallet
select(wallet); // select the wallet
connect(); // connect

In React Native, using MWA, this looks a little different. In this case, providers aren't needed. Rather, wallet context is provided through the transact function from the MWA package. Behind the scenes, this function searches the devices for active Solana wallets. It surfaces these wallets to the user through a partial selection modal. Once the user selects a wallet, that wallet is provided as an argument to the transact callback. Your code can then interact with the wallet directly.

transact(async (wallet: Web3MobileWallet) => {
  // returns you the context of the user selected wallet
});

Authorize a wallet #

On the web, the first time you connect a wallet to a site in your browser, the wallet prompts you to authorize the site. Similarly, on mobile, the requesting app needs to be authorized before it can request privileged methods like signing a transaction.

Your code can trigger this authorization process by calling wallet.authorize(). The user will be prompted to accept or reject the authorization request. The returned AuthorizationResult will indicate the user's acceptance or rejection. If accepted, this result object provides you with the user's account as well as an auth_token you can use in wallet.reauthorize() for subsequent calls. This auth token ensures that other apps can't pretend to be your app. The auth token is generated during the authorize() call, and subsequent requests from the dApp can use the reauthorize() method with the stored token to maintain secure communication without repeatedly prompting the user.

transact(async (wallet: Web3MobileWallet) => {
  const authResult = wallet.authorize({
    cluster: "devnet",
    identity: { name: "Solana Counter Incrementor" },
  }); // Authorizes the wallet
 
  const authToken = authResult.auth_token; // save this for the wallet.reauthorize() function
  const publicKey = authResult.selectedAccount.publicKey;
});

It's worth noting that all methods except authorize and deauthorize are privileged methods. So you'll want to track if a wallet is authorized or not and call wallet.reauthorize() when it is. Below is a simple example that tracks the authorization state:

const APP_IDENTITY = { name: "Solana Counter Incrementor" };
const [auth, setAuth] = useState<string | null>(null);
 
transact(async (wallet: Web3MobileWallet) => {
  let authResult;
 
  if (auth) {
    authResult = wallet.reauthorize({
      auth_token: auth,
      identity: APP_IDENTITY,
    });
  } else {
    authResult = wallet.authorize({
      cluster: "devnet",
      identity: APP_IDENTITY,
    });
 
    setAuth(authResult.auth_token);
  }
 
  const publicKey = authResult.selectedAccount.publicKey;
});

Note that the above example does not handle errors or user rejections. In production, it's a good idea to wrap the authorization state and methods with a custom useAuthorization hook. For reference, we built this in the previous lesson.

Interact with a wallet #

Unlike connecting and authorizing wallets, requesting methods like signAndSendTransactions, signMessages, and signTransactions is virtually the same between web and mobile.

On the web, you can access these methods with the useWallet hook. You just have to make sure you're connected before calling them:

const { connected, signAllTransactions, signMessage, sendTransaction } = useWallet();
 
if ( connected ) {
  signAllTransactions(...);
  signMessage(...);
  sendTransaction(...);

For MWA, simply call the functions on the wallet context provided by the transact callback:

const APP_IDENTITY = {name: 'Solana Counter Incrementor'}
const [auth, setAuth] = useState<string | null>(null)
 
transact(async (wallet: Web3MobileWallet) => {
  let authResult;
 
  if ( auth ) {
    authResult = wallet.reauthorize({
          auth_token: auth,
          identity: APP_IDENTITY,
    })
  } else {
    authResult = wallet.authorize(
      {
        cluster: "devnet",
        identity: APP_IDENTITY
      }
    );
    setAuth(authResult.auth_token)
  }
 
  const publicKey = authResult.selectedAccount.publicKey
 
  // Choose your interaction...
  wallet.signAndSendTransactions(...)
  wallet.signMessages(...)
  wallet.signTransactions(...)
});

Every time you want to call these methods, you will have to call wallet.authorize() or wallet.reauthorize().

When invoking wallet.signAndSendTransactions(...), it's essential to handle transaction failures gracefully. Transactions can fail due to various reasons such as network issues, signature mismatches, or insufficient funds. Proper error handling ensures a smooth user experience, even when the transaction process encounters issues:

transact(async (wallet: Web3MobileWallet) => {
    try {
      const result = await wallet.signAndSendTransactions(...);
      // Handle success
    } catch (error) {
      console.error("Failed to sign and send transactions:", error);
      // Implement error handling logic
    }
  });

And that's it! You should have enough information to get started. The Solana mobile team has put in a lot of work to make the development experience as seamless as possible between the two.

What MWA is doing wallet-side #

This lesson has talked mostly about what MWA is doing in dApps, but a huge portion of MWA functionality happens in wallets. Whether you want to create your own wallet or simply understand the system better, it's worth discussing what MWA-compatible wallets are doing at a high level. For most readers, it's not essential to feel like you can create a wallet after reading through these sections; simply try to get a sense of the overall flow.

Introduction to the walletlib #

Solana Mobile has done the vast majority of the heavy lifting by creating the mobile-wallet-adapter-walletlib. This library handles all the low-level communication between dApps and wallets:

npm i @solana-mobile/mobile-wallet-adapter-walletlib
Info

This package is still in alpha and is not production ready. However, the API is stable and will not change drastically, so you can begin integration with your wallet.

However, walletlib doesn't provide UI for you or determine the outcome of requests. Rather, it exposes a hook allowing the wallet code to receive and resolve requests. The wallet developer is responsible for displaying the appropriate UI, managing the wallet behavior, and appropriately resolving each request.

How wallets use the walletlib #

At its core, wallets use walletlib by calling a single function: useMobileWalletAdapterSession. When calling this function, wallets provide the following:

  1. The wallet name
  2. A configuration object of type MobileWalletAdapterConfig
  3. A handler for requests
  4. A handler for sessions

Below is an example component that shows the scaffold of how wallets connect to the walletlib:

import { useCallback, useMemo } from "react";
import { Text } from "react-native";
import { WalletProvider } from "./components/WalletProvider";
import {
  MWARequest,
  MWASessionEvent,
  MobileWalletAdapterConfig,
  useMobileWalletAdapterSession,
} from "./lib/mobile-wallet-adapter-walletlib/src";
 
function MWAApp() {
  const config: MobileWalletAdapterConfig = useMemo(() => {
    return {
      supportsSignAndSendTransactions: true,
      maxTransactionsPerSigningRequest: 10,
      maxMessagesPerSigningRequest: 10,
      supportedTransactionVersions: [0, "legacy"],
      noConnectionWarningTimeoutMs: 3000,
    };
  }, []);
 
  const handleRequest = useCallback((request: MWARequest) => {}, []);
 
  const handleSessionEvent = useCallback(
    (sessionEvent: MWASessionEvent) => {},
    [],
  );
 
  useMobileWalletAdapterSession(
    "React Native Fake Wallet",
    config,
    handleRequest,
    handleSessionEvent,
  );
 
  return <Text>I'm a wallet!</Text>;
}
 
export default MWAApp;

If you were to create your own wallet, you would modify the config object and implement the handleRequest and handleSessionEvent handlers accordingly. While all of these are required and all are important, the primary element is the request handler. This is where wallets provide the implementation logic for each request, e.g. how to handle when a dApp requests authorization or requests that the wallet sign and send a transaction.

For example, if the request is of type MWARequestType.SignAndSendTransactionsRequest, then your code would use the user's secret key to sign the transaction provided by the request, send the request to an RPC provider, and then respond to the requesting dApp using a resolve function.

The resolve function simply tells the dApp what happened and closes the session. The resolve function takes two arguments: request and response. The types of request and response are different depending on what the original request was. So in the example of MWARequestType.SignAndSendTransactionsRequest, you would use the following resolve function:

export function resolve(
  request: SignAndSendTransactionsRequest,
  response: SignAndSendTransactionsResponse,
): void;

The SignAndSendTransactionsResponse type is defined as follows:

export type SignAndSendTransactionsCompleteResponse = Readonly<{
  signedTransactions: Uint8Array[];
}>;
export type SignAndSendTransactionsResponse =
  | SignAndSendTransactionsCompleteResponse
  | UserDeclinedResponse
  | TooManyPayloadsResponse
  | AuthorizationNotValidResponse
  | InvalidSignaturesResponse;

Which response you send would depend on the result of attempting to sign and send the transaction.

You can dig into the walletlib source if you'd like to know all of the types associated with resolve.

One final point is that the component used for interacting with walletlib also needs to be registered in the app's index.js as the MWA entry point for the app.

import { AppRegistry } from "react-native";
import App from "./App";
import { name as appName } from "./app.json";
import MWAApp from "./MWAApp";
 
// Mock event listener functions to prevent them from fataling.
window.addEventListener = () => {};
window.removeEventListener = () => {};
 
AppRegistry.registerComponent(appName, () => App);
 
// Register the MWA component
AppRegistry.registerComponent("MobileWalletAdapterEntrypoint", () => MWAApp);

Conclusion #

While the MWA is slightly different than the web wallet standard, once you understand the nuances between them it becomes fairly straightforward to implement mobile wallet interaction. This becomes especially true when you understand what MWA is doing not only in your dApp but also in wallets. If anything remains unclear to you, be sure to spend time familiarizing yourself with both sides of the equation.

Lab #

Now let's do some hands-on practice by building a mobile wallet. The goal here is to see what happens on both sides of the MWA process to demystify the app-wallet relationship.

0. Set up development environment if needed #

Before we start programming our wallet, we need to do some setup. You will need a React Native development environment and a Solana dApp to test on. If you have completed the Introduction to Solana Mobile lab, both of these requirements should be met and the counter app installed on your Android device/emulator.

If you haven't completed/done the intro to solana mobile you will need to:

  1. Setup an Android React Native developer environment with a device or emulator
  2. Install a Devnet Solana dApp by doing the following steps in your terminal:
git clone https://github.com/solana-developers/react-native-fake-wallet
cd solana-react-native-counter
npm run install

1. Planning out the app's structure #

We are making the wallet from scratch, so let's look at our major building blocks.

First, we'll make the actual wallet app (popup not included). This will include:

  • Creating a WalletProvider.tsx
  • Modifying the MainScreen.tsx
  • Modifying App.tsx

Next, we'll make a boilerplate MWA app that displays 'Im a Wallet' anytime the wallet is requested from a different dApp. This will include:

  • Creating a MWAApp.tsx
  • Modifying index.js

Then we'll set up all of our UI and request routing. This will mean:

  • Modifying the MWAApp.tsx
  • Creating a ButtonGroup.tsx
  • Creating a AppInfo.tsx

Finally, we'll implement two actual request functions, authorize and sign and send transactions. This entails creating the following:

  • AuthorizeDappRequestScreen.tsx
  • SignAndSendTransactionScreen.tsx

2. Scaffold the Wallet app #

Let's scaffold the app with:

npx react-native@latest init wallet --npm
cd wallet

Now, let's install our dependencies. These are the same dependencies from our Introduction to Solana Mobile lab with two additions:

  • @react-native-async-storage/async-storage: provides access to on-device storage
  • fast-text-encoding: a polyfill for text encoding

We will be using async-storage to store our keypair so that the wallet will stay persistent through multiple sessions. It is important to note that async-storage is NOT a safe place to keep your keys in production. Again, DO NOT use this in production. Instead, take a look at Android's keystore system.

Install these dependencies with the following command:

npm install \
  @solana/web3.js \
  @solana-mobile/mobile-wallet-adapter-protocol-web3js \
  @solana-mobile/mobile-wallet-adapter-protocol \
  react-native-get-random-values \
  buffer \
  @coral-xyz/anchor \
  assert \
  bs58 \
  @react-native-async-storage/async-storage \
  fast-text-encoding

We need to depend on Solana's mobile-wallet-adapter-walletlib package, which handles all of the low-level communication.

Info

Note: A reminder that this package is still in alpha and is not production ready. However, the API is stable and will not change drastically, so you can begin integration with your wallet.

Let's install the package in a new folder lib:

npm i @solana-mobile/mobile-wallet-adapter-walletlib

Next, in android/build.gradle, change the minSdkVersion to version 23.

  minSdkVersion = 23

Finally, finish the initial setup by building the app. You should get the default React Native app showing up on your device./environment-setup?os=linux&platform=android&guide=native#jdk-studio

npm run android

If you get any errors make sure you double-check you've followed all of the steps above.

3. Create the main wallet app #

There are two parts to the wallet application we'll be building:

  1. The UI to be displayed when you manually open the wallet application
  2. The UI to be displayed as a bottom sheet when a separate app requests to use the wallet

Throughout this lab, we'll be calling these the "main wallet app" and "wallet popup," respectively.

  • Generate a Keypair when the app first loads
  • Display the address and Devnet SOL balance
  • Allow users to airdrop some Devnet SOL to their wallet

This can all be accomplished by creating two files:

  • WalletProvider.tsx - Generates a Keypair and stores it in async-storage, then fetches the keypair on subsequent sessions. It also provides the Solana Connection
  • MainScreen.tsx - Shows the wallet, its balance, and an airdrop button

Let's start with the WalletProvider.tsx. This file will use async-storage to store a base58 encoded version of a Keypair. The provider will check the storage key of @my_fake_wallet_keypair_key. If nothing returns, then the provider should generate and store a keypair. The WalletProvider will then return its context including the wallet and connection. The rest of the app can access this context using the useWallet() hook.

AGAIN, async storage is not fit to store private keys in production. Please use something like Android's keystore system.

Let's create the WalletProvider.tsx within a new directory named components:

import AsyncStorage from "@react-native-async-storage/async-storage";
import { Connection, Keypair } from "@solana/web3.js";
import { encode, decode } from "bs58";
import {
  ReactNode,
  createContext,
  useContext,
  useEffect,
  useState,
} from "react";
 
const ASYNC_STORAGE_KEY = "@my_fake_wallet_keypair_key";
 
interface EncodedKeypair {
  publicKeyBase58: string;
  secretKeyBase58: string;
}
 
function encodeKeypair(keypair: Keypair): EncodedKeypair {
  return {
    publicKeyBase58: keypair.publicKey.toBase58(),
    secretKeyBase58: encode(keypair.secretKey),
  };
}
 
function decodeKeypair(encodedKeypair: EncodedKeypair): Keypair {
  const secretKey = decode(encodedKeypair.secretKeyBase58);
  return Keypair.fromSecretKey(secretKey);
}
 
export interface WalletContextData {
  wallet: Keypair | null;
  connection: Connection;
}
 
const WalletContext = createContext<WalletContextData>({
  wallet: null,
  connection: new Connection("https://api.devnet.solana.com"),
});
 
export const useWallet = () => useContext(WalletContext);
 
export interface WalletProviderProps {
  rpcUrl?: string;
  children: ReactNode;
}
 
export function WalletProvider(props: WalletProviderProps) {
  const { rpcUrl, children } = props;
  const [keyPair, setKeyPair] = useState<Keypair | null>(null);
 
  const fetchOrGenerateKeypair = async () => {
    try {
      const storedKey = await AsyncStorage.getItem(ASYNC_STORAGE_KEY);
      let keyPair;
      if (storedKey) {
        const encodedKeypair: EncodedKeypair = JSON.parse(storedKey);
        keyPair = decodeKeypair(encodedKeypair);
      } else {
        // Generate a new random pair of keys and store them in local storage for later retrieval
        // This is not secure! Async storage is used for demo purpose. Never store keys like this!
        keyPair = Keypair.generate();
        await AsyncStorage.setItem(
          ASYNC_STORAGE_KEY,
          JSON.stringify(encodeKeypair(keyPair)),
        );
      }
      setKeyPair(keyPair);
    } catch (error) {
      console.log("error getting keypair: ", error);
    }
  };
 
  useEffect(() => {
    fetchOrGenerateKeypair();
  }, []);
 
  const connection = useMemo(
    () => new Connection(rpcUrl ?? "https://api.devnet.solana.com"),
    [rpcUrl],
  );
 
  const value = {
    wallet: keyPair,
    connection,
  };
 
  return (
    <WalletContext.Provider value={value}>{children}</WalletContext.Provider>
  );
}

Note that we are defaulting our rpcUrl to Devnet.

Now let's make the MainScreen.tsx. It should simply grab the wallet and connection from useWallet(), and then display the address and balance. Additionally, since all transactions require a transaction fee in SOL, we'll also include an airdrop button.

Create a new directory called screens and a new file called MainScreen.tsx inside of it:

import { Button, StyleSheet, Text, View } from "react-native";
import { useWallet } from "../components/WalletProvider";
import { useEffect, useState } from "react";
import { LAMPORTS_PER_SOL } from "@solana/web3.js";
 
const styles = StyleSheet.create({
  container: {
    width: "100%",
    height: "100%",
    justifyContent: "center", // Centers children along the main axis (vertically for column)
    alignItems: "center", // Centers children along the cross axis (horizontally for column)
  },
});
 
function MainScreen() {
  const [isLoading, setIsLoading] = useState(false);
  const [balance, setBalance] = useState<null | number>(null);
  const { wallet, connection } = useWallet();
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
 
  useEffect(() => {
    updateBalance();
  }, [wallet]);
 
  const updateBalance = async () => {
    if (wallet) {
      try {
        const lamports = await connection.getBalance(wallet.publicKey);
        setBalance(lamports / LAMPORTS_PER_SOL);
      } catch (error) {
        console.error("Failed to fetch / update balance:", error);
        setErrorMessage("Failed to fetch balance");
      }
    }
  };
 
  const airdrop = async () => {
    if (wallet && !isLoading) {
      setIsLoading(true);
      try {
        const signature = await connection.requestAirdrop(
          wallet.publicKey,
          LAMPORTS_PER_SOL,
        );
        await connection.confirmTransaction(signature, "max");
        await updateBalance();
      } catch (error) {
        console.log("error requesting airdrop", error);
        setErrorMessage("Airdrop failed");
      }
 
      setIsLoading(false);
    }
  };
 
  return (
    <View style={styles.container}>
      <Text>Wallet:</Text>
      <Text>{wallet?.publicKey.toString() ?? "No Wallet"}</Text>
      <Text>Balance:</Text>
      <Text>{balance?.toFixed(5) ?? ""}</Text>
      {isLoading && <Text>Loading...</Text>}
      {errorMessage && <Text style={{ color: "red" }}>{errorMessage}</Text>}
      {balance !== null && !isLoading && balance < 0.005 && (
        <Button title="Airdrop 1 SOL" onPress={airdrop} />
      )}
    </View>
  );
}
 
export default MainScreen;

Lastly, let's edit the App.tsx file to complete the 'app' section of our wallet:

import { SafeAreaView, Text, View } from "react-native";
import MainScreen from "./screens/MainScreen";
import "react-native-get-random-values";
import { WalletProvider } from "./components/WalletProvider";
import React from "react";
 
function App(): JSX.Element {
  return (
    <SafeAreaView>
      <WalletProvider>
        <MainScreen />
      </WalletProvider>
    </SafeAreaView>
  );
}
 
export default App;

Make sure everything is working by building and deploying:

npm run android

4. Create helper components #

Now let's take a brief detour and create some helper UI components that we'll need for the wallet popup. We'll define a layout for some text with AppInfo.tsx and some buttons in ButtonGroup.tsx.

First, AppInfo.tsx will show us relevant information coming from the dApp requesting a wallet connection. Go ahead and create the following as components/AppInfo.tsx:

import { Text, View } from "react-native";
 
interface AppInfoProps {
  title?: string;
  cluster?: string;
  appName?: string;
  scope?: string;
}
 
function AppInfo(props: AppInfoProps) {
  const { title, cluster, appName, scope } = props;
  return (
    <>
      <Text>{title}</Text>
      <View>
        <Text>Request Metadata</Text>
        <Text>Cluster: {cluster ? cluster : "NA"}</Text>
        <Text>App name: {appName ? appName : "NA"}</Text>
        <Text>Scope: {scope ? scope : "NA"}</Text>
      </View>
    </>
  );
}
 
export default AppInfo;

Second, let's create a component that groups an "accept" and "reject" button as components/ButtonGroup.tsx:

import { Button, Dimensions, StyleSheet, View } from "react-native";
 
const styles = StyleSheet.create({
  button: { flex: 1, marginHorizontal: 8 },
  buttonGroup: {
    width: Dimensions.get("window").width,
    display: "flex",
    flexDirection: "row",
    marginVertical: 16,
  },
});
 
interface ButtonGroupProps {
  positiveOnClick: () => any;
  negativeOnClick: () => any;
  positiveButtonText: string;
  negativeButtonText: string;
}
const ButtonGroup = (props: ButtonGroupProps) => {
  return (
    <View style={styles.buttonGroup}>
      <Button
        onPress={props.positiveOnClick}
        title={props.positiveButtonText}
      />
      <Button
        onPress={props.negativeOnClick}
        title={props.negativeButtonText}
      />
    </View>
  );
};
 
export default ButtonGroup;

5. Create the wallet popup boilerplate #

The wallet popup is what is seen when a Solana dApp sends out an intent for solana-wallet://. Our wallet will listen for this, establish a connection, and render the popup.

Fortunately, we don't have to implement anything low-level. Solana has done the hard work for us in the mobile-wallet-adapter-walletlib library. All we have to do is create the view and handle the requests.

Let's start with the absolute bare bones of the popup. All it will do is pop up when a dApp connects to it and simply say "I'm a wallet".

To make this pop up when a Solana dApp requests access, we'll need the useMobileWalletAdapterSession from the walletlib. This requires four things:

  • walletName - the name of the wallet
  • config - some simple wallet configurations of type MobileWalletAdapterConfig
  • handleRequest - a callback function to handle requests from the dApp
  • handleSessionEvent - a callback function to handle session events

Here is an example of the minimum setup to satisfy useMobileWalletAdapterSession:

const config: MobileWalletAdapterConfig = useMemo(() => {
  return {
    supportsSignAndSendTransactions: true,
    maxTransactionsPerSigningRequest: 10,
    maxMessagesPerSigningRequest: 10,
    supportedTransactionVersions: [0, "legacy"],
    noConnectionWarningTimeoutMs: 3000,
  };
}, []);
 
const handleRequest = useCallback((request: MWARequest) => {}, []);
 
const handleSessionEvent = useCallback(
  (sessionEvent: MWASessionEvent) => {},
  [],
);
 
useMobileWalletAdapterSession(
  "React Native Fake Wallet",
  config,
  handleRequest,
  handleSessionEvent,
);

We will be implementing handleRequest and handleSessionEvent soon, but let's make the bare-bones popup work first.

Create a new file in the root of your project called MWAApp.tsx:

import { useCallback, useMemo } from "react";
import { SafeAreaView, StyleSheet, Text, View } from "react-native";
import { WalletProvider } from "./components/WalletProvider";
import {
  MWARequest,
  MWASessionEvent,
  MobileWalletAdapterConfig,
  useMobileWalletAdapterSession,
} from "./lib/mobile-wallet-adapter-walletlib/src";
 
const styles = StyleSheet.create({
  container: {
    margin: 0,
    bottom: 0,
    width: "100%",
    backgroundColor: "black",
  },
});
 
function MWAApp() {
  const config: MobileWalletAdapterConfig = useMemo(() => {
    return {
      supportsSignAndSendTransactions: true,
      maxTransactionsPerSigningRequest: 10,
      maxMessagesPerSigningRequest: 10,
      supportedTransactionVersions: [0, "legacy"],
      noConnectionWarningTimeoutMs: 3000,
    };
  }, []);
 
  const handleRequest = useCallback((request: MWARequest) => {}, []);
 
  const handleSessionEvent = useCallback(
    (sessionEvent: MWASessionEvent) => {},
    [],
  );
 
  useMobileWalletAdapterSession(
    "React Native Fake Wallet",
    config,
    handleRequest,
    handleSessionEvent,
  );
 
  return (
    <SafeAreaView>
      <WalletProvider>
        <View style={styles.container}>
          <Text style={{ fontSize: 50 }}>I'm a wallet!</Text>
        </View>
      </WalletProvider>
    </SafeAreaView>
  );
}
 
export default MWAApp;

The last thing we need to do is to register our MWA app as an entry point in index.js under the name MobileWalletAdapterEntrypoint.

Change index.js to reflect the following:

import { AppRegistry } from "react-native";
import App from "./App";
import { name as appName } from "./app.json";
import MWAApp from "./MWAApp";
 
// Mock event listener functions to prevent them from fataling.
window.addEventListener = () => {};
window.removeEventListener = () => {};
 
AppRegistry.registerComponent(appName, () => App);
 
// Register the MWA component
AppRegistry.registerComponent("MobileWalletAdapterEntrypoint", () => MWAApp);

Go ahead and test this out to make sure it works. First

npm run android

Open your Devnet Solana dApp, ideally the counter app from the previous lesson, then make a request.

You should see a sheet present from the bottom of the screen that says "I'm a wallet."

6. Create the MWA scaffolding #

Let's flesh out MWAApp.tsx to scaffold out some of the architecture that will later allow users to connect, sign, and send transactions. For now, we'll only do this for two of the MWA functions: authorize and signAndSendTransaction.

To start, we'll add a few things in MWAApp.tsx:

  1. Do some lifecycle management by saving the currentRequest and currentSession in a useState. This will allow us to track the life cycle of a connection.
  2. Add a hardwareBackPress listener in a useEffect to gracefully handle closing out the popup. This should call resolve with MWARequestFailReason.UserDeclined.
  3. Listen for a SessionTerminatedEvent in a useEffect to close out the popup. This should call exitApp on the BackHandler. We'll be doing this in a helper function to keep functionality contained.
  4. Listen for a ReauthorizeDappRequest request type in a useEffect and automatically resolve it.
  5. Render appropriate content for the different types of requests with renderRequest(). This should be a switch statement that will route to different UI based on the request type.

Change your MWAApp.tsx to reflect the following:

import { useCallback, useEffect, useMemo, useState } from "react";
import {
  BackHandler,
  SafeAreaView,
  StyleSheet,
  Text,
  View,
} from "react-native";
import { WalletProvider } from "./components/WalletProvider";
import {
  AuthorizeDappRequest,
  MWARequest,
  MWARequestFailReason,
  MWARequestType,
  MWASessionEvent,
  MWASessionEventType,
  MobileWalletAdapterConfig,
  ReauthorizeDappCompleteResponse,
  ReauthorizeDappResponse,
  SignAndSendTransactionsRequest,
  getCallingPackage,
  resolve,
  useMobileWalletAdapterSession,
} from "./lib/mobile-wallet-adapter-walletlib/src";
 
const styles = StyleSheet.create({
  container: {
    margin: 0,
    width: "100%",
    backgroundColor: "black",
    color: "black",
  },
});
 
function MWAApp() {
  const [currentRequest, setCurrentRequest] = useState<MWARequest | null>(null);
  const [currentSession, setCurrentSession] = useState<MWASessionEvent | null>(
    null,
  );
  // ------------------- FUNCTIONS --------------------
 
  const endWalletSession = useCallback(() => {
    setTimeout(() => {
      BackHandler.exitApp();
    }, 200);
  }, []);
 
  const handleRequest = useCallback((request: MWARequest) => {
    setCurrentRequest(request);
  }, []);
 
  const handleSessionEvent = useCallback((sessionEvent: MWASessionEvent) => {
    setCurrentSession(sessionEvent);
  }, []);
 
  // ------------------- EFFECTS --------------------
 
  useEffect(() => {
    const backHandler = BackHandler.addEventListener(
      "hardwareBackPress",
      () => {
        if (currentRequest) {
          switch (currentRequest.__type) {
            case MWARequestType.AuthorizeDappRequest:
            case MWARequestType.SignAndSendTransactionsRequest:
            case MWARequestType.SignMessagesRequest:
            case MWARequestType.SignTransactionsRequest:
              resolve(currentRequest, {
                failReason: MWARequestFailReason.UserDeclined,
              });
              break;
            default:
              console.warn("Unhandled request type");
          }
        }
        return true; // Prevents default back button behavior
      },
    );
    return () => backHandler.remove();
  }, [currentRequest]);
 
  useEffect(() => {
    if (currentSession?.__type === MWASessionEventType.SessionTerminatedEvent) {
      endWalletSession();
    }
  }, [currentSession]);
 
  useEffect(() => {
    if (!currentRequest) {
      return;
    }
 
    if (currentRequest.__type === MWARequestType.ReauthorizeDappRequest) {
      resolve(currentRequest, {
        authorizationScope: new TextEncoder().encode("app"),
      });
    }
  }, [currentRequest, endWalletSession]);
 
  // ------------------- MWA --------------------
 
  const config: MobileWalletAdapterConfig = useMemo(() => {
    return {
      supportsSignAndSendTransactions: true,
      maxTransactionsPerSigningRequest: 10,
      maxMessagesPerSigningRequest: 10,
      supportedTransactionVersions: [0, "legacy"],
      noConnectionWarningTimeoutMs: 3000,
    };
  }, []);
 
  useMobileWalletAdapterSession(
    "React Native Fake Wallet",
    config,
    handleRequest,
    handleSessionEvent,
  );
 
  // ------------------- RENDER --------------------
 
  const renderRequest = () => {
    if (!currentRequest) {
      return <Text>No request</Text>;
    }
 
    switch (currentRequest?.__type) {
      case MWARequestType.AuthorizeDappRequest:
      case MWARequestType.SignAndSendTransactionsRequest:
      case MWARequestType.SignMessagesRequest:
      case MWARequestType.SignTransactionsRequest:
      default:
        return <Text>TODO Show screen for {currentRequest?.__type}</Text>;
    }
  };
 
  // ------------------- RENDER --------------------
 
  return (
    <SafeAreaView>
      <WalletProvider>
        <View style={styles.container}>
          <Text>REQUEST: {currentRequest?.__type.toString()}</Text>
          {renderRequest()}
        </View>
      </WalletProvider>
    </SafeAreaView>
  );
}
 
export default MWAApp;

Note that renderRequest is not rendering anything useful yet. We still need to handle the different requests.

7. Implement the authorization popup #

Let's put together our first screen to handle new authorizations. This screen's only job is to show what app wants authorization and allow the user to accept or deny the request using the resolve function from the walletlib.

We'll use our AppInfo and ButtonGroup to compose our entire UI here. All we have to do is plug in the right information and write the logic for accepting and rejecting the request.

For authorization, the resolve function we'll use is the one using the AuthorizeDappRequest and AuthorizeDappResponse types. AuthorizeDappResponse is a union of types AuthorizeDappCompleteResponse and UserDeclinedResponse. The definition for each is shown below:

export type AuthorizeDappResponse =
  | AuthorizeDappCompleteResponse
  | UserDeclinedResponse;
 
export type AuthorizeDappCompleteResponse = Readonly<{
  publicKey: Uint8Array;
  accountLabel?: string;
  walletUriBase?: string;
  authorizationScope?: Uint8Array;
}>;
 
export type UserDeclinedResponse = Readonly<{
  failReason: MWARequestFailReason.UserDeclined;
}>;

Our logic will determine which of these to use when resolving the request.

Now that we have all that context, we can put everything together in a new file called screens/AuthorizeDappRequestScreen.tsx:

import "fast-text-encoding";
import React from "react";
import { useWallet } from "../components/WalletProvider";
import {
  AuthorizeDappCompleteResponse,
  AuthorizeDappRequest,
  MWARequestFailReason,
  resolve,
} from "../lib/mobile-wallet-adapter-walletlib/src";
import AppInfo from "../components/AppInfo";
import ButtonGroup from "../components/ButtonGroup";
import { Text, View } from "react-native";
 
export interface AuthorizeDappRequestScreenProps {
  request: AuthorizeDappRequest;
}
 
function AuthorizeDappRequestScreen(props: AuthorizeDappRequestScreenProps) {
  const { request } = props;
  const { wallet } = useWallet();
 
  if (!wallet) {
    throw new Error("No wallet found");
  }
 
  const authorize = () => {
    resolve(request, {
      publicKey: wallet?.publicKey.toBytes(),
      authorizationScope: new TextEncoder().encode("app"),
    } as AuthorizeDappCompleteResponse);
  };
 
  const reject = () => {
    resolve(request, {
      failReason: MWARequestFailReason.UserDeclined,
    });
  };
 
  return (
    <View>
      <AppInfo
        title="Authorize Dapp"
        appName={request.appIdentity?.identityName}
        cluster={request.cluster}
        scope={"app"}
      />
 
      <ButtonGroup
        positiveButtonText="Authorize"
        negativeButtonText="Decline"
        positiveOnClick={authorize}
        negativeOnClick={reject}
      />
    </View>
  );
}
 
export default AuthorizeDappRequestScreen;

Now let's update our MWAApp.tsx to handle this situation by adding to our renderRequest switch statement:

switch (currentRequest?.__type) {
  case MWARequestType.AuthorizeDappRequest:
    return (
      <AuthorizeDappRequestScreen
        request={currentRequest as AuthorizeDappRequest}
      />
    );
  case MWARequestType.SignAndSendTransactionsRequest:
  case MWARequestType.SignMessagesRequest:
  case MWARequestType.SignTransactionsRequest:
  default:
    return <Text>TODO Show screen for {currentRequest?.__type}</Text>;
}

Feel free to build and run the wallet again. When you first interact with another Solana app, our new authorization screen will now appear.

8. Implement the sign-and-send popup #

Let's finish up our wallet app with the sign and send transaction screen. Here, we need to grab the transactions from the request, sign them with our secret key from our WalletProvider, and then send them to an RPC.

The UI will look very similar to our authorization page. We'll provide some info about the app with AppInfo and some buttons with ButtonGroup. This time, we will fulfill the SignAndSendTransactionsRequest and SignAndSendTransactionsResponse for our resolve function.

export function resolve(
  request: SignAndSendTransactionsRequest,
  response: SignAndSendTransactionsResponse,
): void;

More specifically, we'll have to adhere to what SignAndSendTransactionsResponse is unioned with:

export type SignAndSendTransactionsCompleteResponse = Readonly<{
  signedTransactions: Uint8Array[];
}>;
export type SignAndSendTransactionsResponse =
  | SignAndSendTransactionsCompleteResponse
  | UserDeclinedResponse
  | TooManyPayloadsResponse
  | AuthorizationNotValidResponse
  | InvalidSignaturesResponse;

We are only going to cover the SignAndSendTransactionsCompleteResponse, InvalidSignaturesResponse, and UserDeclinedResponse.

Most notably, we'll have to adhere to InvalidSignaturesResponse:

export type InvalidSignaturesResponse = Readonly<{
  failReason: MWARequestFailReason.InvalidSignatures;
  valid: boolean[];
}>;

The InvalidSignaturesResponse is unique because it requires an array of booleans, each of which corresponds to a failed transaction. So we'll have to keep track of that.

As for signing and sending, we'll have to do some work. Since we are sending transactions over sockets, the transaction data is serialized into bytes. We'll have to deserialize the transactions before we sign them.

We can do this in two functions:

  • signTransactionPayloads: returns the signed transactions along with a 1-to-1 valid boolean array. We'll check that to see if a signature has failed.
  • sendSignedTransactions: takes the signed transactions and sends them out to the RPC. Similarly, it keeps an array of valid booleans to know which transactions failed.

Let's put that all together in a new file called screens/SignAndSendTransactionScreen.tsx:

import {
  Connection,
  Keypair,
  TransactionSignature,
  VersionedTransaction,
} from "@solana/web3.js";
import { useState } from "react";
import {
  MWARequestFailReason,
  SignAndSendTransactionsRequest,
  resolve,
} from "../lib/mobile-wallet-adapter-walletlib/src";
 
import { useWallet } from "../components/WalletProvider";
import { Text, View } from "react-native";
import AppInfo from "../components/AppInfo";
import ButtonGroup from "../components/ButtonGroup";
import { decode } from "bs58";
 
export async function sendSignedTransactions(
  signedTransactions: Array<Uint8Array>,
  minContextSlot: number | undefined,
  connection: Connection,
): Promise<[boolean[], Uint8Array[]]> {
  const valid = signedTransactions.map(_ => true);
  const signatures: (Uint8Array | null)[] = await Promise.all(
    signedTransactions.map(async (byteArray, index) => {
      try {
        const transaction: VersionedTransaction =
          VersionedTransaction.deserialize(byteArray);
 
        const signature: TransactionSignature =
          await connection.sendTransaction(transaction, {
            minContextSlot: minContextSlot,
            preflightCommitment: "finalized",
            skipPreflight: true,
          });
 
        const response = await connection.confirmTransaction(
          signature,
          "confirmed",
        );
 
        return decode(signature);
      } catch (error) {
        console.log("Failed sending transaction " + error);
        valid[index] = false;
        return null;
      }
    }),
  );
 
  return [valid, signatures as Uint8Array[]];
}
 
export function signTransactionPayloads(
  wallet: Keypair,
  payloads: Uint8Array[],
): [boolean[], Uint8Array[]] {
  const valid = payloads.map(_ => true);
 
  const signedPayloads = payloads.map((payload, index) => {
    try {
      const transaction: VersionedTransaction =
        VersionedTransaction.deserialize(new Uint8Array(payload));
 
      transaction.sign([
        {
          publicKey: wallet.publicKey,
          secretKey: wallet.secretKey,
        },
      ]);
      return transaction.serialize();
    } catch (error) {
      console.log("sign error: " + error);
      valid[index] = false;
      return new Uint8Array([]);
    }
  });
 
  return [valid, signedPayloads];
}
 
export interface SignAndSendTransactionScreenProps {
  request: SignAndSendTransactionsRequest;
}
 
function SignAndSendTransactionScreen(
  props: SignAndSendTransactionScreenProps,
) {
  const { request } = props;
  const { wallet, connection } = useWallet();
  const [loading, setLoading] = useState(false);
 
  if (!wallet) {
    throw new Error("Wallet is null or undefined");
  }
 
  const signAndSendTransaction = async (
    wallet: Keypair,
    connection: Connection,
    request: SignAndSendTransactionsRequest,
  ) => {
    const [validSignatures, signedTransactions] = signTransactionPayloads(
      wallet,
      request.payloads,
    );
 
    if (validSignatures.includes(false)) {
      resolve(request, {
        failReason: MWARequestFailReason.InvalidSignatures,
        valid: validSignatures,
      });
      return;
    }
 
    const [validTransactions, transactionSignatures] =
      await sendSignedTransactions(
        signedTransactions,
        request.minContextSlot ? request.minContextSlot : undefined,
        connection,
      );
 
    if (validTransactions.includes(false)) {
      resolve(request, {
        failReason: MWARequestFailReason.InvalidSignatures,
        valid: validTransactions,
      });
      return;
    }
 
    resolve(request, { signedTransactions: transactionSignatures });
  };
 
  const signAndSend = async () => {
    if (loading) return;
    setLoading(true);
    try {
      await signAndSendTransaction(wallet, connection, request);
    } catch (error) {
      const valid = request.payloads.map(() => false);
      resolve(request, {
        failReason: MWARequestFailReason.InvalidSignatures,
        valid,
      });
      console.error("Transaction failed:", error);
    } finally {
      setLoading(false);
    }
  };
 
  const reject = () => {
    resolve(request, { failReason: MWARequestFailReason.UserDeclined });
  };
 
  return (
    <View>
      <AppInfo
        title="Sign and Send Transaction"
        appName={request.appIdentity?.identityName}
        cluster={request.cluster}
        scope={"app"}
      />
      <Text>Payloads</Text>
      <Text>
        This request has {request.payloads.length}{" "}
        {request.payloads.length > 1 ? "payloads" : "payload"} to sign.
      </Text>
      <ButtonGroup
        positiveButtonText="Sign and Send"
        negativeButtonText="Reject"
        positiveOnClick={signAndSend}
        negativeOnClick={reject}
      />
      {loading && <Text>Loading...</Text>}
    </View>
  );
}
 
export default SignAndSendTransactionScreen;

Finally, let's edit MWAApp.tsx and add our new screen to the switch statement:

switch (currentRequest?.__type) {
  case MWARequestType.AuthorizeDappRequest:
    return (
      <AuthorizeDappRequestScreen
        request={currentRequest as AuthorizeDappRequest}
      />
    );
  case MWARequestType.SignAndSendTransactionsRequest:
    return (
      <SignAndSendTransactionScreen
        request={currentRequest as SignAndSendTransactionsRequest}
      />
    );
  case MWARequestType.SignMessagesRequest:
  case MWARequestType.SignTransactionsRequest:
  default:
    return <Text>TODO Show screen for {currentRequest?.__type}</Text>;
}

Go ahead and build and run your wallet app. You should now be able to authorize your dApp and sign and send transactions. Note that we left SignMessagesRequest and SignTransactionsRequest empty so you can do it in the Challenge.

Nice work! Creating a wallet, even a "fake" version, is no small feat. If you got stuck anywhere, make sure to go back through it until you understand what's happening. Also, feel free to look through the lab's solution code on the main branch.

Challenge #

Now it's your turn to practice independently. Try and implement the last two request types: SignMessagesRequest and SignTransactionsRequest.

Try to do this without help as it's great practice, but if you get stuck, check out the solution code on the solution branch.

Completed the lab?

Push your code to GitHub and tell us what you thought of this lesson!