BEGINNERS' GUIDE TO MOBILE DEVELOPMENT ON SOLANA
An in-depth introduction to the Solana Mobile Stack
Overview
In order to foster web3 adoption and security, Solana Labs introduced the SAGA, an Android mobile phone with features tightly integrated with the Solana blockchain making web3 transactions easier. This also birthed the Solana Mobile Stack (SMS) which is a collection of libraries for building mobile apps that seamlessly interact with the Solana blockchain. What is truly remarkable is that SMS is not confined to the Saga alone, these apps work seamlessly on other smartphones as well.
Though the Solana Mobile Stack has been out for quite a long time now, we haven't seen many apps integrating it hence the need for a step by step in-depth tutorial targeted at developers that would love to work with the Solana blockchain on mobile. Whether you're a seasoned developer or just starting your journey into blockchain or mobile development, this step-by-step tutorial will provide you with the knowledge you need to start building web3 applications for mobile. In this tutorial, we would be creating a simple React Native mobile application that connects to Solana Wallets and displays information about the wallet and the tokens in it.
What You Will Need
Go through this guide before starting the tutorial. You'll need
React Native, you can follow this guide to install.
a running Android emulator or device to build and launch your app.
an MWA-compatible wallet installed on the same device.
an IDE/Editor of your choice.
Clone The Tutorial Template
We would be building off the starter repo I created so we can easily follow along with the rest of the tutorial. Don’t worry it only has the setup for installing the packages we would be using, the folders we would be working with are empty as the tutorial would follow a hands-on approach. In future projects, feel free to use the official DApp scaffold as it is a ready-to-go template already structured out.
$ git clone https://github.com/Vida-TG/solana-mobile-stack-template
$ cd solana-mobile-stack-template
$ yarn install
Here, I am cloning the template, entering the directory and installing the project dependencies.
We have the usual package.json
file that states the general configuration of our app; most importantly it is where installed packages and their versions are specified.
There's app.json
where you'll find key-value pairs related to the app name and display name. It can also hold the app version, icon, splash screen, and more.
index.js
is the root of our app, there we are
importing our app name alongside another important component called App and registering them to React Native. App.tsx
is the entry point of our app. It's where we will start writing our app's code.
In App.tsx
, we are importing another important component from screens called the MainScreen and when you open up MainScreen.tsx
you would notice that all we are currently doing is importing a view from React Native and returning a not-so-good-looking `Hello`.
Boring right? Relax it only gets better from here
If you open up util
, there is an interesting file called alertAndLog.ts
. All it does is alert us or log out important info.
Let's run the app now. To do that, make sure your emulator or device is running then build and launch the app
$ npx react-native run-android
If you are seeing errors about missing/undefined methods, double check you installed the polyfills correctly. Boom you should have your app built with the boring Hello sitting on the screen.
How would our app work?
We connect our wallet with a button interfacing the Mobile Wallet Adapter SDK. This would allow us connect to any installed mobile wallet
Connect to the Solana RPC (this could be the Devnet, Testnet or Mainnet-Beta)
Get important information about the wallet
Fetch information about SPL tokens available in the wallet
Let’s Get It
components/providers/ConnectionProvider.tsx
We would use this provider to manage a Solana connection using the `@solana/web3.js`
import {Connection, type ConnectionConfig} from '@solana/web3.js';
import React, {
type FC,
type ReactNode,
useMemo,
createContext,
useContext,
} from 'react';
export const RPC_ENDPOINT = 'mainnet-beta';
export interface ConnectionProviderProps {
children: ReactNode;
endpoint: string;
config?: ConnectionConfig;
}
export const ConnectionProvider: FC<ConnectionProviderProps> = ({
children,
endpoint,
config = {commitment: 'confirmed'},
}) => {
const connection = useMemo(
() => new Connection(endpoint, config),
[endpoint, config],
);
return (
<ConnectionContext.Provider value={{connection}}>
{children}
</ConnectionContext.Provider>
);
};
export interface ConnectionContextState {
connection: Connection;
}
export const ConnectionContext = createContext<ConnectionContextState>(
{} as ConnectionContextState,
);
export function useConnection(): ConnectionContextState {
return useContext(ConnectionContext);
}
First we import the Connection class and other necessary components and types from React and solana/web3.js.
Then we defined a constant to specify the Solana RPC endpoint we want to connect to and exported it for later in other parts of our app, there are other options like the `devnet` and `testnet` but we would be connecting to the Solana Mainnet in this app so we can get info about tokens live on Mainnet. We would not be making any transaction nor would we need to sign any transaction so we are not spending any live funds.
Up next, we defined a ConnectionProviderProps Interface with props that can be passed to the ConnectionProvider component. These include children (ReactNode), endpoint (string), and config (optional) properties.
Next, we have the ConnectionProvider Functional Component that serves as a provider for the Solana connection; it takes the provided endpoint and config props and creates a Solana Connection instance using useMemo. This ensures that the connection is only created when the endpoint or config changes. It then wraps its children components with a `ConnectionContext.Provider`, providing the `connection` value to its descendants.
The ConnectionContextState interface defines the shape of the context value and has a connection property of type Connection.
The ConnectionContext creates a React context with an initial value of an empty object of type ConnectionContextState and the useConnection hook allows components within the application to access the Solana connection stored in the context. It uses the useContext hook to retrieve the connection value from the context.
In summary, we set up a provider for managing the Solana connection using context. This allows components within our application to access the Solana connection by using the `useConnection` hook and ensures that the connection is created and shared across our app's components.
components/providers/AuthorizationProvider.tsx
This component is in charge of handling authorization within our app
import {PublicKey} from '@solana/web3.js';
import {
Account as AuthorizedAccount,
AuthorizationResult,
AuthorizeAPI,
AuthToken,
Base64EncodedAddress,
DeauthorizeAPI,
ReauthorizeAPI,
} from '@solana-mobile/mobile-wallet-adapter-protocol';
import {toUint8Array} from 'js-base64';
import {useState, useCallback, useMemo, ReactNode} from 'react';
import React from 'react';
import {RPC_ENDPOINT} from './ConnectionProvider';
export type Account = Readonly<{
address: Base64EncodedAddress;
label?: string;
publicKey: PublicKey;
}>;
type Authorization = Readonly<{
accounts: Account[];
authToken: AuthToken;
selectedAccount: Account;
}>;
function getAccountFromAuthorizedAccount(account: AuthorizedAccount): Account {
return {
...account,
publicKey: getPublicKeyFromAddress(account.address),
};
}
function getAuthorizationFromAuthorizationResult(
authorizationResult: AuthorizationResult,
previouslySelectedAccount?: Account,
): Authorization {
let selectedAccount: Account;
if ( previouslySelectedAccount == null || !authorizationResult.accounts.some(
({address}) => address === previouslySelectedAccount.address,
)
) {
const firstAccount = authorizationResult.accounts[0];
selectedAccount = getAccountFromAuthorizedAccount(firstAccount);
} else {
selectedAccount = previouslySelectedAccount;
}
return {
accounts: authorizationResult.accounts.map(getAccountFromAuthorizedAccount),
authToken: authorizationResult.auth_token,
selectedAccount,
};
}
function getPublicKeyFromAddress(address: Base64EncodedAddress): PublicKey {
const publicKeyByteArray = toUint8Array(address);
return new PublicKey(publicKeyByteArray);
}
export const APP_IDENTITY = {
name: 'React Native dApp',
uri: 'https://solanamobile.com',
icon: 'favicon.ico',
};
export interface AuthorizationProviderContext {
accounts: Account[] | null;
authorizeSession: (wallet: AuthorizeAPI & ReauthorizeAPI) => Promise<Account>;
deauthorizeSession: (wallet: DeauthorizeAPI) => void;
onChangeAccount: (nextSelectedAccount: Account) => void;
selectedAccount: Account | null;
}
const AuthorizationContext = React.createContext<AuthorizationProviderContext>({
accounts: null,
authorizeSession: (_wallet: AuthorizeAPI & ReauthorizeAPI) => {
throw new Error('AuthorizationProvider not initialized');
},
deauthorizeSession: (_wallet: DeauthorizeAPI) => {
throw new Error('AuthorizationProvider not initialized');
},
onChangeAccount: (_nextSelectedAccount: Account) => {
throw new Error('AuthorizationProvider not initialized');
},
selectedAccount: null,
});
function AuthorizationProvider(props: {children: ReactNode}) {
const {children} = props;
const [authorization, setAuthorization] = useState<Authorization | null>(
null,
);
const handleAuthorizationResult = useCallback(
async (
authorizationResult: AuthorizationResult,
): Promise<Authorization> => {
const nextAuthorization = getAuthorizationFromAuthorizationResult(
authorizationResult,
authorization?.selectedAccount,
);
await setAuthorization(nextAuthorization);
return nextAuthorization;
},
[authorization, setAuthorization],
);
const authorizeSession = useCallback(
async (wallet: AuthorizeAPI & ReauthorizeAPI) => {
const authorizationResult = await (authorization
? wallet.reauthorize({
auth_token: authorization.authToken,
identity: APP_IDENTITY,
})
: wallet.authorize({
cluster: RPC_ENDPOINT,
identity: APP_IDENTITY,
}));
return (await handleAuthorizationResult(authorizationResult))
.selectedAccount;
},
[authorization, handleAuthorizationResult],
);
const deauthorizeSession = useCallback(
async (wallet: DeauthorizeAPI) => {
if (authorization?.authToken == null) {
return;
}
await wallet.deauthorize({auth_token: authorization.authToken});
setAuthorization(null);
},
[authorization, setAuthorization],
);
const onChangeAccount = useCallback(
(nextSelectedAccount: Account) => {
setAuthorization(currentAuthorization => {
if (
!currentAuthorization?.accounts.some(
({address}) => address === nextSelectedAccount.address,
)
) {
throw new Error(
`${nextSelectedAccount.address} is not one of the available addresses`,
);
}
return {
...currentAuthorization,
selectedAccount: nextSelectedAccount,
};
});
},
[setAuthorization],
);
const value = useMemo(
() => ({
accounts: authorization?.accounts ?? null,
authorizeSession,
deauthorizeSession,
onChangeAccount,
selectedAccount: authorization?.selectedAccount ?? null,
}),
[authorization, authorizeSession, deauthorizeSession, onChangeAccount],
);
return (
<AuthorizationContext.Provider value={value}>
{children}
</AuthorizationContext.Provider>
);
}
const useAuthorization = () => React.useContext(AuthorizationContext);
export {AuthorizationProvider, useAuthorization};
First, we are importing PublicKey from the Solana web3js library. PublicKey is a standard class used represent public keys in Solana, we are also importing types related to wallet authorization and authentication, and utility functions like `toUint8Array` for encoding.
Then we defined the `Account` type, representing a Solana account with an address, an optional label, and a public key. We also defined the Authorization type with an object for Account, an authorization token (authToken), and the currently selected account
We have a `getAccountFromAuthorizedAccount` function that converts an `AuthorizedAccount` object to an Account object by extracting its public key from the address, a `getAuthorizationFromAuthorizationResult` function that extracts authorization data from an AuthorizationResult object and determines the selected account and we have a `getPublicKeyFromAddress` function that converts a base64 encoded address to a PublicKey object.
Then we defined our APP_IDENTITY
which is an object that represents the dApp, it includes its name, URI, and icon.
With AuthorizationProviderContext we defined the context interface for the AuthorizationProvider with properties like accounts, authorizeSession, deauthorizeSession, onChangeAccount, and selectedAccount then we created a React context (AuthorizationContext) with default values for the AuthorizationProviderContext.
The AuthorizationProvider Component is manages user authorization and provides authorization context to its children components. It uses React hooks like `useState`, `useCallback`, and `useMemo` to manage state and functions. authorizeSession, deauthorizeSession, onChangeAccount functions handle authorization-related actions.
Lastly we defined a custom hook useAuthorization that allows components to access the authorization context and exported the `AuthorizationProvider` component and the `useAuthorization` hook for use in other parts of the application.
In summary, all we did was set up an authorization system for our mobile app so it can handle user authentication and authorization. We created a context for managing authorization throughout the app.
components/ConnectButton.tsx
This component would trigger the connection to a Solana wallet when the returned button is clicked.
import {transact} from '@solana-mobile/mobile-wallet-adapter-protocol-web3js';
import React, {ComponentProps, useState, useCallback} from 'react';
import {Button} from 'react-native';
import {useAuthorization} from './providers/AuthorizationProvider';
import {alertAndLog} from '../util/alertAndLog';
type Props = Readonly<ComponentProps<typeof Button>>;
export default function ConnectButton(props: Props) {
const {authorizeSession} = useAuthorization();
const [authorizationInProgress, setAuthorizationInProgress] = useState(false);
const handleConnectPress = useCallback(async () => {
try {
if (authorizationInProgress) {
return;
}
setAuthorizationInProgress(true);
await transact(async wallet => {
await authorizeSession(wallet);
});
} catch (err: any) {
alertAndLog(
'Error during connect',
err instanceof Error ? err.message : err,
);
} finally {
setAuthorizationInProgress(false);
}
}, [authorizationInProgress, authorizeSession]);
return (
<Button
{...props}
disabled={authorizationInProgress}
onPress={handleConnectPress}
/>
);
}
First, we imported needed components and types from React, React Native, and other modules then we defined a type named Props that inherits properties from the React Native Button component and we created a ConnectButton component that takes props as its input, representing the properties passed to the component.
We used the useAuthorization hook to get the authorizeSession function from the authorization context and authorize the Solana wallet session while the state `authorizationInProgress` keeps track whether the authorization process is ongoing.
The handleConnectPress function handles the logic when the "Connect" button is pressed, it calls the transact function which we imported from the Wallet Adapter Protocol and passes an async function that awaits the authorizeSession function.
If an error occurs during the authorization process, our alertAndLog function spoofs out the error message (Yeah, that’s its only job right?) then we set authorizationInProgress back to false whether the authorization was successful or not.
Finally, we throw the button to the screen in the return statement and set it disabled if authorization is in progress (so users don’t keep clicking yunno) and onPress we call the handleConnectPress function.
In summary, the ConnectButton component handles the authorization process for connecting to a Solana wallet and prevents multiple connection attempts while the authorization is in progress.
components/DisconnectButton.tsx
So this’ straight to point, all it does is handling the disconnection from a Solana wallet 🎯
import {transact} from '@solana-mobile/mobile-wallet-adapter-protocol-web3js';
import React, {ComponentProps} from 'react';
import {Button} from 'react-native';
import {useAuthorization} from './providers/AuthorizationProvider';
type Props = Readonly<ComponentProps<typeof Button>>;
export default function DisconnectButton(props: Props) {
const {deauthorizeSession} = useAuthorization();
return (
<Button
{...props}
color="#FF6666"
onPress={() => {
transact(async wallet => {
await deauthorizeSession(wallet);
});
}}
/>
);
}
Just like we did in ConnectButton.tsx
, we imported needed components and types from Mobile Wallet Adapter, React, React Native and the AuthorizationProvider. We defined a type named Props that inherits properties from the React Native Button component and we created a DisconnectButton component that takes props as its input.
We got the deauthorizeSession function from the authorization context with the useAuthorization hook and used it to deauthorize the Solana wallet session then we rendered a Button component passing the props received by the DisconnectButton component.
We toyed with the color and called the imported transact function onPress passing the asynchronous deauthorizeSession function so it can disconnect the Solana wallet session.
In summary, the DisconnectButton component handles the disconnection process from a connected Solana wallet. When the button is pressed, it triggers the disconnection by calling the `deauthorizeSession` function.
components/AccountInfo.tsx
Displays the information about a Solana account.
import React from 'react';
import {LAMPORTS_PER_SOL, PublicKey} from '@solana/web3.js';
import {StyleSheet, View, Text} from 'react-native';
import DisconnectButton from './DisconnectButton';
interface Account {
address: string;
label?: string | undefined;
publicKey: PublicKey;
}
type AccountInfoProps = Readonly<{
selectedAccount: Account;
balance: number | null;
}>;
function convertLamportsToSOL(lamports: number) {
return new Intl.NumberFormat(undefined, {maximumFractionDigits: 1}).format(
(lamports || 0) / LAMPORTS_PER_SOL,
);
}
export default function AccountInfo({
balance,
selectedAccount,
}: AccountInfoProps) {
return (
<View style={styles.container}>
<View style={styles.textContainer}>
<Text style={styles.walletHeader}>Account Info</Text>
<Text style={styles.walletBalance}>
{selectedAccount.label
? `${selectedAccount.label}: ◎${
balance ? convertLamportsToSOL(balance) : '0'
} SOL`
: 'Wallet name not found'}
</Text>
<Text style={styles.walletNameSubtitle}>{selectedAccount.address}</Text>
<View style={styles.buttonGroup}>
<DisconnectButton title={'Disconnect'} />
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
padding: 24,
alignItems: 'center',
justifyContent: 'flex-start',
},
textContainer: {
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
},
buttonGroup: {
flexDirection: 'row',
columnGap: 10,
},
walletHeader: {
fontWeight: 'bold',
},
walletBalance: {
fontSize: 20,
},
walletNameSubtitle: {
fontSize: 12,
marginBottom: 5,
},
});
We imported components and types from React, @solanaweb3.js and React Native then defined types for our props, interface for Account and jumped on the AccountInfo Component which takes `balance` and `selectedAccount` as props.
The convertLamportsToSOL function is used to convert lamports to SOL
. It takes a lamports value as input and uses `Intl.NumberFormat` to format the result with a maximum of one decimal place. The lamports amount is divided by LAMPORTS_PER_SOL
, which is a constant representing the number of lamports in one SOL.
Finally, we render the component showing information about the connected Solana account, including its balance in SOL, address, and an optional label in a user-friendly way. The Disconnect button is also rendered so users can disconnect their wallets at will.
components/GetTokenAccounts.tsx
Here, we fetch and display information about token accounts associated with the connected Solana wallet account.
import { useConnection } from '../components/providers/ConnectionProvider';
import { Account } from '../components/providers/AuthorizationProvider';
import React, { useState, useEffect } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { GetProgramAccountsFilter } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
type GetTokenAccountsProps = {
account: Account;
}
export default function GetTokenAccounts({ account }: GetTokenAccountsProps) {
const [tokenAccounts, setTokenAccounts] = useState([]);
const { connection } = useConnection();
const fetchData = async () => {
const filters: GetProgramAccountsFilter[] = [{
dataSize: 165,
},
{
memcmp: {
offset: 32,
bytes: account.address,
},
}];
const fetchedTokenAccounts = await connection.getParsedProgramAccounts(
TOKEN_PROGRAM_ID,
{ filters: filters }
);
setTokenAccounts(fetchedTokenAccounts);
};
useEffect(() => {
fetchData();
}, []);
return (
<>
<View>
{tokenAccounts.length === 0 ? (
<Text>No token accounts found.</Text>
) : (
tokenAccounts.map((tokenAccount, i) => {
const parsedAccountInfo: any = tokenAccount.account.data;
const mintAddress: string = parsedAccountInfo["parsed"]["info"]["mint"];
const tokenBalance: number = parsedAccountInfo["parsed"]["info"]["tokenAmount"]["uiAmount"];
return (
<View key={i}>
<Text>Token Account No. {i + 1}: {tokenAccount.pubkey.toString()}</Text>
<Text>Token Mint: {mintAddress}</Text>
<Text>Token Balance: {tokenBalance}</Text>
</View>
);
})
)}
</View>
</>
);
}
const styles = StyleSheet.create({
button: {
flex: 1,
borderRadius: 5,
padding: 10,
marginTop: 10,
alignItems: 'center',
backgroundColor: '#007AFF',
},
buttonText: {
color: '#fff',
fontSize: 18,
},
});
We imported necessary modules, including useConnection for accessing the Solana connection, Account from the `AuthorizationProvider`, and various components and types from React and React Native.
We defined the GetTokenAccounts Component that takes account as its input representing the Solana wallet account from which token accounts are to be retrieved. Then we defined a tokenAccounts state variable initialized as an empty array that will be used to store the retrieved token accounts.
fetchData function fetches token account data, in this function we have defined an array of filters to be used in querying token accounts. It also calls the getParsedProgramAccounts function using the connection object with the TOKEN_PROGRAM_ID
and filters passed to it after which it updates the tokenAccounts state with the fetched token accounts.
A useEffect hook is used to trigger the fetchData function when the component is mounted to fetch the initial data and our component is rendered (conditionally obviously -: )
In summary, the GetTokenAccounts component fetches and displays token account information associated with a Solana wallet account. It uses Solana's getParsedProgramAccounts function to retrieve token accounts and renders them.
screens/MainScreen.tsx
The main user interface
import React, {useCallback, useEffect, useState} from 'react';
import {StyleSheet, View} from 'react-native';
import ConnectButton from '../components/ConnectButton';
import AccountInfo from '../components/AccountInfo';
import {
useAuthorization,
Account,
} from '../components/providers/AuthorizationProvider';
import {useConnection} from '../components/providers/ConnectionProvider';
import GetTokenAccounts from '../components/GetTokenAccounts';
export default function MainScreen() {
const {connection} = useConnection();
const {selectedAccount} = useAuthorization();
const [balance, setBalance] = useState<number | null>(null);
const fetchAndUpdateBalance = useCallback(
async (account: Account) => {
console.log('Fetching balance for: ' + account.publicKey);
const fetchedBalance = await connection.getBalance(account.publicKey);
console.log('Balance fetched: ' + fetchedBalance);
setBalance(fetchedBalance);
},
[connection],
);
useEffect(() => {
if (!selectedAccount) {
return;
}
fetchAndUpdateBalance(selectedAccount);
}, [fetchAndUpdateBalance, selectedAccount]);
return (
<>
<View>
{selectedAccount ? (
<AccountInfo
selectedAccount={selectedAccount}
balance={balance}
/>
<GetTokenAccounts account={selectedAccount} />
) : (
<ConnectButton title="Connect wallet" />
)}
</View>
</>
);
}
const styles = StyleSheet.create({
mainContainer: {
height: '100%',
padding: 16,
flex: 1,
},
scrollContainer: {
height: '100%',
},
buttonGroup: {
flexDirection: 'column',
paddingVertical: 4,
},
});
Once again, we are importing React, React Native components, custom components and hooks from other files.
Next, we have the MainScreen Component representing the main screen of the mobile application. In this component, we have `balance` as a state variable initialized as null. It will be used to store the balance of the selected account.
The fetchAndUpdateBalance function fetches and updates the balance variable for the selected Solana account. It takes an account as input, fetches the balance using Solana's getBalance method, and updates the balance state. It is triggered by the useEffect hook when the component is mounted or when the selectedAccount changes. If there is a selectedAccount already, it fetches and updates the balance.
The component's rendering logic is enclosed in a <View>. If there is a selectedAccount, it renders the AccountInfo and the GetTokenAccounts components. If there is no selectedAccount, it renders the ConnectButton component with the title "Connect wallet," allowing users to connect their wallet.
App.tsx
import { StyleSheet } from 'react-native';
import React from 'react';
import { SafeAreaView } from 'react-native-safe-area-context';
import {clusterApiUrl} from '@solana/web3.js';
import {ConnectionProvider, RPC_ENDPOINT} from './components/providers/ConnectionProvider';
import {AuthorizationProvider} from './components/providers/AuthorizationProvider';
import MainScreen from './screens/MainScreen';
export default function App() {
return (
<ConnectionProvider
config={{commitment: 'processed'}}
endpoint={clusterApiUrl(RPC_ENDPOINT)}>
<AuthorizationProvider>
<SafeAreaView style={styles.container}>
<MainScreen />
</SafeAreaView>
</AuthorizationProvider>
</ConnectionProvider>
);
}
const styles = StyleSheet.create({
container: {
height: '100%'
},
});
One major import I would love to point out is the RPC_ENDPOINT
imported from the ConnectionProvider. We defined it as 'mainnet-beta' so when passed to the clusterApiUrl
'https://api.mainnet-beta.solana.com/' would be returned to the ConnectionProvider as the endpoint the same way 'devnet' would return 'https://api.devnet.solana.com' and 'testnet' - 'https://api.testnet.solana.com'.
Our MainScreen is also wrapped with the AuthorizationProvider and ConnectionProvider contexts so that these contexts would be available throughout our app making sure once we are authenticated in one component, we remain authenticated everywhere.
Congratulations!
You've finished the tutorial and built your first Solana mobile dApp! Play around with the app and make edits as you wish, feel free to switch to devnet and send out your token balance :)
I would encourage you to take this tutorial to further expand your Solana Mobile Stack development knowledge. Gracias ✌