Introduction
Solana’s fast and cheap transactions make it the perfect testing ground for novel mobile applications. Until recently, however, there was no way for native mobile apps to interact with existing Solana wallets. In order to connect to users, Solana dapps had to be web-based. In a mobile context, these web dapps were most often viewed within a wallet’s in-app browser.
All of this changed in Phantom v22.04.01
with the release of Phantom deeplinks. With deeplinks, iOS and Android apps can now interact directly with the Phantom mobile app to connect, sign, and send transactions. In this tutorial, we’ll walk through how you can integrate Phantom deeplinks into your Solana mobile application.
What are deeplinks?
If you've ever created a link using an HTML <a>
tag, you're probably familiar with the mailto:
scheme. Unlike a normal url that directs users straight to a website (i.e. href="<https://google.com>"
), the mailto:
scheme tells the operating system that it should instead open the email application. Once open, your email app can then parse the url to present a rich user experience. For example, the url href="mailto:[email protected]?subject=Deeplinking Tutorial Feedback&body=Hi Brian,"
will compose an email to me with both a subject and body already pre-configured. These url schemes are often referred to as deeplinks.
Just like using the mailto:
scheme, other applications can also link to each other by using their own custom url schemes. When developing a mobile application, we can specify our own protocol handler (such as: discord://
or phantom://
) and create deeplinks that tell the operating system to open our native app.
We can even take it a step further: using a concept known as universal links, we can create deeplinks based on regular https://
urls for a domain that we control. This comes with the added benefits of being more secure, as well as a better user experience in cases where users have yet to install our app.
Phantom supports both universal links ( https://phantom.app/ul/
) as well as a custom protocol handler (phantom://
). For the remainder of this tutorial, we'll focus on Phantom's universal links and refer to these collectively as deeplinks.
What we're building
To showcase the power of Phantom deeplinks, we'll be creating a mobile app that can interact with a program (aka smart contract) on Solana. Specifically, we’re going to be building a mobile-version of the movie reviews dapp from module 1 of the Solana Development Course.
For the purposes of this tutorial, we won’t be writing any Solana programs ourselves. The Solana Development Course already does a great job of teaching that! Instead, we’ll be writing a mobile app that interacts with the course’s program which already exists on devnet. Once complete, our app will let users sign in and send reviews using Phantom. All in all, it'll look a little something like this:
We'll be building this app with React Native and Expo CLI. If you're new to mobile app development, Expo makes it easy to get started within minutes. The majority of code we’ll be writing will be indistinguishable from web-based React and TypeScript. An added benefit of using Expo with React Native is that our app will be ready to use across both iOS and Android devices.
Overview
This tutorial covers 3 main sections:
- Creating our mobile app using React Native and Expo CLI
- Connecting our app to Phantom
- Signing and sending transactions via Phantom
Creating our mobile app using React Native and Expo CLI
Prerequisites
Before we can build anything, we first need to install the necessary command-line tools. Chief amongst these are Node.js and Expo CLI. If you don’t have Node yet, I recommend installing it via Node Version Manager (NVM).
Before proceeding, you should be able to run the following commands in your terminal. I developed mine with the following versions:
node -v
# output: v16.15.0
expo-cli --version
# output: 5.5.1
In addition to our laptop’s development environment, we’ll also want a phone to test our React Native app. One of the many benefits of Expo is that it makes it easy to test your app on a physical device throughout the development process. Before proceeding, make sure to install both the Expo and Phantom mobile apps from your phone’s official app store.
Scaffolding our React Native app
With the necessary tools installed, we can get started with our app. To recap: we’re going to be building a mobile app for the movie reviews program in the Solana Development Course. Instead of rehashing the lessons from that course, I’ve added the necessary components to a starter repo located on our GitHub. To get started, simply fork the repo and clone it to your local developer environment:
git clone -b starter https://github.com/phantom-labs/deep-links-movie-tutorial.git
Then, change into the project directory and install the necessary dependencies:
cd deep-links-movie-tutorial
yarn
Once you’ve installed the dependencies, you can fire up the app with:
yarn start
From here, your terminal should present you with a large QR code. Go ahead and grab your phone, open the camera app, and scan the QR code that is present on your laptop’s terminal. If you’re using an Android phone, you may need to scan it from within the Expo mobile app. If all goes well, your code should begin bundling and your phone will open the mobile app from the video above.
Go ahead and try scrolling and searching within the app. If you’ve already completed the Solana Development Course’s module on Deserializing Custom Account Data, then this should look pretty familiar. Here, our app is fetching reviews from the course’s program and then paginating and filtering the results based on how far you scroll or what you search. If you take a look at our starter repo, you’ll see that the MovieList.tsx
and MovieCoordinator.ts
components from the course have already been integrated into our React Native app under the components
directory. For the purposes of this tutorial, we won’t be going into detail over how these are working under the hood. If you’re interested in learning more here, be sure to check out the Solana Development Course!
After playing around with our app, we can see that we’re already reading and displaying movies reviews that are stored on Solana. How would we go about adding new reviews? If you were to tap on the “Connect Phantom” button, you’ll see that nothing happens. Let’s see what’s going on by opening up the App.tsx
file at the root of directory:
import "react-native-get-random-values";
import "react-native-url-polyfill/auto";
import { Buffer } from "buffer";
global.Buffer = global.Buffer || Buffer;
import { StatusBar } from "expo-status-bar";
import React, { useRef, useState } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import {
clusterApiUrl,
Connection,
PublicKey,
Transaction,
} from "@solana/web3.js";
import MovieList from "./components/MovieList";
import Button from "./components/Button";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import ActionSheet from "react-native-actions-sheet";
import AddReviewSheet from "./components/AddReviewSheet";
import Toast from "react-native-toast-message";
import { toastConfig } from "./components/ToastConfig";
import { COLORS } from "./constants";
import * as Linking from "expo-linking";
const connection = new Connection(clusterApiUrl("devnet"));
export default function App() {
const [phantomWalletPublicKey, setPhantomWalletPublicKey] =
useState<PublicKey | null>(null);
const [submitting, setSubmitting] = useState(false);
const actionSheetRef = useRef<ActionSheet>(null);
// Initiate a new connection to Phantom
const connect = async () => {};
// Initiate a disconnect from Phantom
const disconnect = async () => {};
// Initiate a new transaction via Phantom. We call this in `components/AddReviewSheet.tsx` to send our movie review to the Solana network
const signAndSendTransaction = async (transaction: Transaction) => {};
// Open the 'Add a Review' sheet defined in `components/AddReviewSheet.tsx`
const openAddReviewSheet = () => {
actionSheetRef.current?.show();
};
return (
<SafeAreaProvider>
<SafeAreaView style={styles.container}>
<View style={styles.header}>
{phantomWalletPublicKey ? (
<>
<View style={[styles.row, styles.wallet]}>
<View style={styles.greenDot} />
<Text
style={styles.text}
numberOfLines={1}
ellipsizeMode="middle"
>
{`Connected to: ${phantomWalletPublicKey.toString()}`}
</Text>
</View>
<View style={styles.row}>
<Button title="Add Review" onPress={openAddReviewSheet} />
<Button title="Disconnect" onPress={disconnect} />
</View>
</>
) : (
<View style={{ marginTop: 15 }}>
<Button title="Connect Phantom" onPress={connect} />
</View>
)}
</View>
{submitting && (
<ActivityIndicator
color={COLORS.WHITE}
size="large"
style={styles.spinner}
/>
)}
<MovieList connection={connection} />
<AddReviewSheet
actionSheetRef={actionSheetRef}
phantomWalletPublicKey={phantomWalletPublicKey}
signAndSendTransaction={signAndSendTransaction}
/>
<Toast config={toastConfig} />
<StatusBar style="auto" />
</SafeAreaView>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.DARK_GREY,
flexGrow: 1,
position: "relative",
},
greenDot: {
height: 8,
width: 8,
borderRadius: 10,
marginRight: 5,
backgroundColor: COLORS.GREEN,
},
header: {
width: "95%",
marginLeft: "auto",
marginRight: "auto",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 5,
},
spinner: {
position: "absolute",
alignSelf: "center",
top: "50%",
zIndex: 1000,
},
text: {
color: COLORS.LIGHT_GREY,
width: "100%",
},
wallet: {
alignItems: "center",
margin: 10,
marginBottom: 15,
},
});
We’ll be focusing mostly on this file for the majority of our walkthrough. Looking at our App.tsx
component, you can see right off the bat that our app is looking to store a phantomWalletPublicKey
. If we look down at what is returned from this component, you’ll see that if this phantomWalletPublicKey
exists, we’ll render a button that lets us add a new movie review. Otherwise, we’ll return a “Connect Phantom” button:
{phantomWalletPublicKey ? (
<>
<View style={[styles.row, styles.wallet]}>
<View style={styles.greenDot} />
<Text
style={styles.text}
numberOfLines={1}
ellipsizeMode="middle"
>
{`Connected to: ${phantomWalletPublicKey.toString()}`}
</Text>
</View>
<View style={styles.row}>
<Button title="Add Review" onPress={openAddReviewSheet} />
<Button title="Disconnect" onPress={disconnect} />
</View>
</>
) : (
<View style={{ marginTop: 15 }}>
<Button title="Connect Phantom" onPress={connect} />
</View>
)
}
We can also see that our connect
function is currently lacking functionality:
// Initiate a new connection to Phantom
const connect = async () => {};
Let’s go ahead and change that!
Connecting our app to Phantom
When we first introduced the concept of deeplinking, we compared it to clicking on a mailto:
link. In React Native, however, there are no <a>
tags for us to work with. Instead, we can use the expo-linking package to create and open URLs from within our app.
Let’s use this package to try and connect to Phantom from within our connect
function. Taking a look at Phantom’s documentation, we can see that the URL needed to deeplink into Phantom’s connect method is: https://phantom.app/ul/v1/connect
. Let’s try a naive approach by replacing our connect method with the following:
// Initiate a new connection to Phantom
const connect = async () => {
const url = "https://phantom.app/ul/v1/connect";
Linking.openURL(url);
};
Turning back to our phone, let’s try tapping on our “Connect Phantom” button again. This time, we should instantly teleport over to our Phantom app. Pretty cool!
While our connect
deeplink jumped us over to the Phantom app, it didn’t quite work as expected. When we opened Phantom, we were never prompted with a connection request. If we switch back over to our dapp, it appears as if nothing had happened.
Taking another look at the Phantom documentation, it’s clear that our connection request requires us to specify four additional parameters:
app_url
: A URL used to fetch app metadata (i.e. title, icon) using the same properties found in Displaying Your App.dapp_encryption_public_key
: A public key used for end-to-end encryption.redirect_link
: The URI where Phantom should redirect the user upon connection.cluster
: The network that should be used for subsequent interactions.
The app_url
and cluster
are both straightforward. For the app_url
, we just need to pass a URL that represents our app. I created a dummy site at https://deeplink-movie-tutorial-dummy-site.vercel.app/
, but feel free to add your own website here. When it comes to selecting a cluster, we’ll want to specify devnet. This is because the program we are interacting with has already been deployed on devnet.
// Initiate a new connection to Phantom
const connect = async () => {
// TODO
const params = {
cluster: "devnet",
app_url: "https://deeplink-movie-tutorial-dummy-site.vercel.app/",
};
const url = "https://phantom.app/ul/v1/connect";
Linking.openURL(url);
};
Before we dive deeper into how we can configure a proper connection request, let’s take a minute to switch our wallet’s network over to devnet
. We’ll need this to connect and sign devnet requests coming from our app. You can change this within Phantom’s settings by following the steps in this video:
Turning back to our developer environment, the last two parameters we have to specify are dapp_encryption_public_key
and redirect_link
. We will need both of these not only for our connect
request, but also for all subsequent requests to Phantom. Let’s look at what each of these entails.
Handling Encryption
When using deeplinks, all communication between our app and Phantom will be encrypted by default. We can achieve this encryption via a simple key exchange known as Diffie-Hellman.
In the same way that a Phantom user has a keypair made of up a public key (i.e. their Solana address) and a private key (managed by Phantom), our app will create its own keypair specific to its connection with Phantom. During a Diffie-Hellman exchange, our app will then share its public key in the initial connect
request to Phantom. Once it receives our public key, Phantom will then generate its own keypair that’s unique to our app. When sending a connect
response back to our app, Phantom will also share its corresponding public key with our app.
Once both parties have each other’s public keys, they can then combine them with their own private keys that they did not share and end up with the same result. The beauty of this process is that while neither party shared any sensitive information (both only shared public keys), they can now reliably create the same secret that can be used for encrypting messages. We’ll refer to this secret as the shared secret.
To begin implementing Diffie-Hellman, we’ll need a way of generating a x25519 keypair. We recommend using TweetNaCl which is already installed with our starter repo. In addition to importing TweetNaCl, we’ll need to keep track of the public key we generate as well as the shared secret we’ll get when Phantom sends us a response. When we send our public key as a dapp_encryption_public_key
parameter, we’ll also need to encode with with bs58
. Let’s update our App.tsx
file with the following:
import "react-native-get-random-values";
import "react-native-url-polyfill/auto";
import { Buffer } from "buffer";
global.Buffer = global.Buffer || Buffer;
import { StatusBar } from "expo-status-bar";
import React, { useRef, useState } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import {
clusterApiUrl,
Connection,
PublicKey,
Transaction,
} from "@solana/web3.js";
import MovieList from "./components/MovieList";
import Button from "./components/Button";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import ActionSheet from "react-native-actions-sheet";
import AddReviewSheet from "./components/AddReviewSheet";
import Toast from "react-native-toast-message";
import { toastConfig } from "./components/ToastConfig";
import { COLORS } from "./constants";
import * as Linking from "expo-linking";
import nacl from "tweetnacl";
import bs58 from "bs58";
const connection = new Connection(clusterApiUrl("devnet"));
export default function App() {
const [phantomWalletPublicKey, setPhantomWalletPublicKey] =
useState<PublicKey | null>(null);
const [submitting, setSubmitting] = useState(false);
const actionSheetRef = useRef<ActionSheet>(null);
const [dappKeyPair] = useState(nacl.box.keyPair());
const [sharedSecret, setSharedSecret] = useState<Uint8Array>();
// Initiate a new connection to Phantom
const connect = async () => {
// TODO
const params = {
cluster: "devnet",
app_url: "https://deeplink-movie-tutorial-dummy-site.vercel.app/",
dapp_encryption_public_key: bs58.encode(dappKeyPair.publicKey),
};
const url = "https://phantom.app/ul/v1/connect";
Linking.openURL(url);
};
// Initiate a disconnect from Phantom
const disconnect = async () => {
console.log("disconnect");
};
// Initiate a new transaction via Phantom. We call this in `components/AddReviewSheet.tsx` to send our movie review to the Solana network
const signAndSendTransaction = async (transaction: Transaction) => {
console.log("signAndSendTransaction");
};
// Open the 'Add a Review' sheet defined in `components/AddReviewSheet.tsx`
const openAddReviewSheet = () => {
actionSheetRef.current?.show();
};
return (
<SafeAreaProvider>
<SafeAreaView style={styles.container}>
<View style={styles.header}>
{phantomWalletPublicKey ? (
<>
<View style={[styles.row, styles.wallet]}>
<View style={styles.greenDot} />
<Text
style={styles.text}
numberOfLines={1}
ellipsizeMode="middle"
>
{`Connected to: ${phantomWalletPublicKey.toString()}`}
</Text>
</View>
<View style={styles.row}>
<Button title="Add Review" onPress={openAddReviewSheet} />
<Button title="Disconnect" onPress={disconnect} />
</View>
</>
) : (
<View style={{ marginTop: 15 }}>
<Button title="Connect Phantom" onPress={connect} />
</View>
)}
</View>
{submitting && (
<ActivityIndicator
color={COLORS.WHITE}
size="large"
style={styles.spinner}
/>
)}
<MovieList connection={connection} />
<AddReviewSheet
actionSheetRef={actionSheetRef}
phantomWalletPublicKey={phantomWalletPublicKey}
signAndSendTransaction={signAndSendTransaction}
/>
<Toast config={toastConfig} />
<StatusBar style="auto" />
</SafeAreaView>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.DARK_GREY,
flexGrow: 1,
position: "relative",
},
greenDot: {
height: 8,
width: 8,
borderRadius: 10,
marginRight: 5,
backgroundColor: COLORS.GREEN,
},
header: {
width: "95%",
marginLeft: "auto",
marginRight: "auto",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 5,
},
spinner: {
position: "absolute",
alignSelf: "center",
top: "50%",
zIndex: 1000,
},
text: {
color: COLORS.LIGHT_GREY,
width: "100%",
},
wallet: {
alignItems: "center",
margin: 10,
marginBottom: 15,
},
});
Before we can get to using our dapp_encryption_public_key
, we’ll need to set up some way of handling a response from Phantom. Let’s go ahead and set that up now.
Specifying Redirects
Whenever we make a deeplinking request to Phantom, we’ll also need to tell Phantom how to get back to our app. We can do this by creating our own set of deeplinks for our React Native app. Specifically, we can create a deeplink for receiving a Phantom connect
event and include that as the redirect_link
in our initial request.
Once again, the expo-linking package can help us achieve this. First, we’ll need to specify our own app’s URL scheme, just like how Phantom specifies phantom://
. We can do this from within our app.json
file. Here, I’ve already set us up with deep-links-movie-tutorial://
. Next, we’ll need to create the URL endpoint we want to pass as our redirect_link
. Let’s call this onConnect
. We can create this URL from within App.tsx
like so:
const onConnectRedirectLink = Linking.createURL("onConnect");
Now that we have a valid URL that Phantom can redirect back to, we’ll need a way for our app to actually listen for in-bound links. There are two scenarios in which our app could be opened with this link.
- Our app is already already opened and a Linking URL event is fired
- Our app is not already opened and a URL is passed which opens our app and sets it as our app’s
initialURL
In the first case, because our app is already open and running, we can simply listen for a url
event and track the incoming URL:
const [deepLink, setDeepLink] = useState<string>("");
// On app start up, listen for a "url" event
useEffect(() => {
Linking.addEventListener("url", handleDeepLink);
return () => {
Linking.removeEventListener("url", handleDeepLink);
};
}, [])
// When a "url" event occurs, track the url
const handleDeepLink = ({ url }: Linking.EventType) => {
setDeepLink(url);
};
In the second scenario, we can’t rely on just listening for the event because our app may not already be open when the event is fired. To account for this, we should make sure we always call Linking.getInitialURL on our app startup:
const [deepLink, setDeepLink] = useState<string>("");
// On app start up, check if we were opened by an inbound deeplink. If so, track the intial URL
// Then, listen for a "url" event
useEffect(() => {
const initializeDeeplinks = async () => {
const initialUrl = await Linking.getInitialURL();
if (initialUrl) {
setDeepLink(initialUrl);
}
};
initializeDeeplinks();
const listener = Linking.addEventListener("url", handleDeepLink);
return () => {
listener.remove();
};
}, []);
// When a "url" event occurs, track the url
const handleDeepLink = ({ url }: Linking.EventType) => {
setDeepLink(url);
};
Finally, we’ll need some way of actually parsing the inbound link from Phantom and responding to it accordingly. Whenever we receive a deeplink
that matches the onConnect
URL we specified earlier, we know we’ll be dealing with a connect
response. We can handle this like so:
// Handle in-bound links
useEffect(() => {
if (!deepLink) return;
const url = new URL(deepLink);
const params = url.searchParams;
// Handle an error response from Phantom
if (params.get("errorCode")) {
const error = Object.fromEntries([...params]);
const message =
error?.errorMessage ??
JSON.stringify(Object.fromEntries([...params]), null, 2);
console.log("error: ", message);
return;
}
// Handle a `connect` response from Phantom
if (/onConnect/.test(url.pathname)) {
console.log("we received a connect response from Phantom: ", url);
}
}, [deepLink]);
Making a proper request to Phantom
Now that we have all of the necessary components, it’s time for us to make a proper connect
request to Phantom. To recap, our connect request requires four parameters:
app_url
dapp_encryption_public_key
redirect_link
cluster
When interacting with deeplinks, we can specify additional parameters by simply appending them to the base URL as query string parameters. Let’s put all these pieces together and try making a new connect request. Go ahead and update your App.tsx
file with the following:
import "react-native-get-random-values";
import "react-native-url-polyfill/auto";
import { Buffer } from "buffer";
global.Buffer = global.Buffer || Buffer;
import { StatusBar } from "expo-status-bar";
import React, { useEffect, useRef, useState } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import {
clusterApiUrl,
Connection,
PublicKey,
Transaction,
} from "@solana/web3.js";
import MovieList from "./components/MovieList";
import Button from "./components/Button";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import ActionSheet from "react-native-actions-sheet";
import AddReviewSheet from "./components/AddReviewSheet";
import Toast from "react-native-toast-message";
import { toastConfig } from "./components/ToastConfig";
import { COLORS } from "./constants";
import nacl from "tweetnacl";
import bs58 from "bs58";
import * as Linking from "expo-linking";
const onConnectRedirectLink = Linking.createURL("onConnect");
const connection = new Connection(clusterApiUrl("devnet"));
export default function App() {
const [phantomWalletPublicKey, setPhantomWalletPublicKey] =
useState<PublicKey | null>(null);
const [submitting, setSubmitting] = useState(false);
const actionSheetRef = useRef<ActionSheet>(null);
const [dappKeyPair] = useState(nacl.box.keyPair());
const [sharedSecret, setSharedSecret] = useState<Uint8Array>();
const [deepLink, setDeepLink] = useState<string>("");
// On app start up, check if we were opened by an inbound deeplink. If so, track the intial URL
// Then, listen for a "url" event
useEffect(() => {
const initializeDeeplinks = async () => {
const initialUrl = await Linking.getInitialURL();
if (initialUrl) {
setDeepLink(initialUrl);
}
};
initializeDeeplinks();
const listener = Linking.addEventListener("url", handleDeepLink);
return () => {
listener.remove();
};
}, []);
// When a "url" event occurs, track the url
const handleDeepLink = ({ url }: Linking.EventType) => {
setDeepLink(url);
};
// Handle in-bound links
useEffect(() => {
if (!deepLink) return;
const url = new URL(deepLink);
const params = url.searchParams;
// Handle an error response from Phantom
if (params.get("errorCode")) {
const error = Object.fromEntries([...params]);
const message =
error?.errorMessage ??
JSON.stringify(Object.fromEntries([...params]), null, 2);
console.log("error: ", message);
return;
}
// Handle a `connect` response from Phantom
if (/onConnect/.test(url.pathname)) {
console.log("we received a connect response from Phantom: ", url);
}
}, [deepLink]);
// Initiate a new connection to Phantom
const connect = async () => {
const params = new URLSearchParams({
dapp_encryption_public_key: bs58.encode(dappKeyPair.publicKey),
cluster: "devnet",
app_url: "https://phantom.app",
redirect_link: onConnectRedirectLink,
});
const url = `https://phantom.app/ul/v1/connect?${params.toString()}`;
Linking.openURL(url);
};
// Initiate a disconnect from Phantom
const disconnect = async () => {
console.log("disconnect");
};
// Initiate a new transaction via Phantom. We call this in `components/AddReviewSheet.tsx` to send our movie review to the Solana network
const signAndSendTransaction = async (transaction: Transaction) => {
console.log("signAndSendTransaction");
};
// Open the 'Add a Review' sheet defined in `components/AddReviewSheet.tsx`
const openAddReviewSheet = () => {
actionSheetRef.current?.show();
};
return (
<SafeAreaProvider>
<SafeAreaView style={styles.container}>
<View style={styles.header}>
{phantomWalletPublicKey ? (
<>
<View style={[styles.row, styles.wallet]}>
<View style={styles.greenDot} />
<Text
style={styles.text}
numberOfLines={1}
ellipsizeMode="middle"
>
{`Connected to: ${phantomWalletPublicKey.toString()}`}
</Text>
</View>
<View style={styles.row}>
<Button title="Add Review" onPress={openAddReviewSheet} />
<Button title="Disconnect" onPress={disconnect} />
</View>
</>
) : (
<View style={{ marginTop: 15 }}>
<Button title="Connect Phantom" onPress={connect} />
</View>
)}
</View>
{submitting && (
<ActivityIndicator
color={COLORS.WHITE}
size="large"
style={styles.spinner}
/>
)}
<MovieList connection={connection} />
<AddReviewSheet
actionSheetRef={actionSheetRef}
phantomWalletPublicKey={phantomWalletPublicKey}
signAndSendTransaction={signAndSendTransaction}
/>
<Toast config={toastConfig} />
<StatusBar style="auto" />
</SafeAreaView>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.DARK_GREY,
flexGrow: 1,
position: "relative",
},
greenDot: {
height: 8,
width: 8,
borderRadius: 10,
marginRight: 5,
backgroundColor: COLORS.GREEN,
},
header: {
width: "95%",
marginLeft: "auto",
marginRight: "auto",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 5,
},
spinner: {
position: "absolute",
alignSelf: "center",
top: "50%",
zIndex: 1000,
},
text: {
color: COLORS.LIGHT_GREY,
width: "100%",
},
wallet: {
alignItems: "center",
margin: 10,
marginBottom: 15,
},
});
Now, when we tap our “Connect Phantom” button, we should also see a connect modal pop up from within Phantom. When we approve this request by tapping “Connect”, Phantom should then redirect us back to our app. Progress!
Putting it all together
Let’s take a look at our console to see what Phantom is sending back to our app. If you take a look your console, you should see that we’re logging a URL that looks something like this:
"exp://172.20.10.2:19000/--/onConnect?phantom_encryption_public_key=AHWZNYZ7Q3wsnugvYSmSLfupcRHvAJqDRWkHwwBBm5jh&nonce=2n76Ujnsi6u5i3NM1xC4HE6m6WuMyX3L2&data=FspPVemQN61nddg5DhyftJYcycsNQrvUpnSQi6DxtgmpvMh82bDUrE3itP5LSCJmqKgbQdYU3M5N5duM6HMrvesC2pyMdDmAJtGawo9wjWqxG6F1VUAg5dndAqVHN1XWsDhuBjsJYRL1DMFMSgb6PveeV6tJ3joxjGMi4ZrjFGxXFakGUWRKsQzgd44w28B4yJH1n9Eiwp8rPLwi4BaWwtuMzjUX42UwmVyEbeCEZrJ3rJQZfuTYEULWo5RPnDZDKssnR4nLY4nfYjk27iSG3ooDX5C4SoCvvZLbMd7jXtR6hhwWfzdYUM66fsjbN9WPaVDvxVBDmVSjwJBsM1HQv1GfKf981eD9aeZJptVA6yJjxTHYoVyQJd8WMqPJrhjZUJHbhPwfc9qLiKhXvpECSwGyKfGxLuqW5kK"
Recall that when we made our connect
request to Phantom, we passed additional data such as our app_url
and dapp_encryption_public_key
as query string parameters. Now that we have a response, we can see that the URL we’re logging from Phantom also has additional data at the following query string parameters:
phantom_encryption_public_key
nonce
data
Taking a closer look at these query string parameters, you’ll see now that all data from Phantom has been encrypted. In particular, the data
field sent back from Phantom is now an encrypted JSON string. Let’s go ahead and decrypt it!
When we made our connect
request to Phantom, we passed along our dapp_encryption_public_key
that Phantom could use to generate a shared secret on its side. Now that Phantom has passed us back its phantom_encryption_public_key
, we can also generate this shared secret and complete the Diffie-Hellman key exchange needed to decrypt the data
. We can do this by decoding the phantom_encryption_public_key
received from Phantom and combining it with our app’s dappKeyPair.secretKey
like so:
// Handle a `connect` response from Phantom
if (/onConnect/.test(url.pathname)) {
console.log("we received a connect response from Phantom: ", url);
const sharedSecretDapp = nacl.box.before(
bs58.decode(params.get("phantom_encryption_public_key")!),
dappKeyPair.secretKey
);
}
Moving over to our utils
folder, let’s create a new file named decryptPayload.ts
and add the following function:
import nacl from "tweetnacl";
import bs58 from "bs58";
export const decryptPayload = (
data: string,
nonce: string,
sharedSecret?: Uint8Array
) => {
if (!sharedSecret) throw new Error("missing shared secret");
const decryptedData = nacl.box.open.after(
bs58.decode(data),
bs58.decode(nonce),
sharedSecret
);
if (!decryptedData) {
throw new Error("Unable to decrypt data");
}
return JSON.parse(Buffer.from(decryptedData).toString("utf8"));
};
Here, we’re creating a helper function that will decrypt a given data
string using the sharedSecret
we just generated in our Diffie-Hellman key exchange and the nonce
passed back from Phantom. With this method now in place, we can now decrypt the data
passed back from Phantom:
// Handle a `connect` response from Phantom
if (/onConnect/.test(url.pathname)) {
console.log("we received a connect response from Phantom: ", url);
const sharedSecretDapp = nacl.box.before(
bs58.decode(params.get("phantom_encryption_public_key")!),
dappKeyPair.secretKey
);
const connectData = decryptPayload(
params.get("data")!,
params.get("nonce")!,
sharedSecretDapp
);
console.log("decrypted data received from Phantom: ", connectData);
}
Taking a look at our console, we should see that our app has decoded the data
from Phantom to an object that has public_key
and session
fields:
{
"public_key": "GYLkraPfvT3UtUbdxcHiVWV2EShBoZtqW1Bcq4VazUCt",
"session": "22XD6EDJ4X3ALDJkHkQrdmrFdTHVHjD2noCbPrPum7xr9x5cbLN9XS5MtiVtNF5tCUFAY6XyBmyvyLatcCrzpGCGJ6kFFwYf4jKkG3YnYJYZDbTaEFPraADF2U56gtXq14ZMu7YTSA3Uge8sV51Qhs5HSv2UvP4wsdsDDhNXZJB7jUcbY4LgE6sfdP2UEEwvYxf91dmMQgEc3BwFD8ab59Lssi"
}
Here, the public_key
that Phantom passes us is the public key of the end user. The session
we receive is specific to this user and represents our user’s connection to our app. Our app should continue to pass this session
param back to Phantom to authenticate all subsequent requests. As long as this session
is valid, we should consider this user “connected” to our app.
With this data now decrypted, we can go ahead and store both the public_key
and session
received from Phantom. Let’s update our App.tsx
file with the following:
import "react-native-get-random-values";
import "react-native-url-polyfill/auto";
import { Buffer } from "buffer";
global.Buffer = global.Buffer || Buffer;
import { StatusBar } from "expo-status-bar";
import React, { useEffect, useRef, useState } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import {
clusterApiUrl,
Connection,
PublicKey,
Transaction,
} from "@solana/web3.js";
import MovieList from "./components/MovieList";
import Button from "./components/Button";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import ActionSheet from "react-native-actions-sheet";
import AddReviewSheet from "./components/AddReviewSheet";
import Toast from "react-native-toast-message";
import { toastConfig } from "./components/ToastConfig";
import { COLORS } from "./constants";
import * as Linking from "expo-linking";
import nacl from "tweetnacl";
import bs58 from "bs58";
import { decryptPayload } from "./utils/decryptPayload";
const onConnectRedirectLink = Linking.createURL("onConnect");
const connection = new Connection(clusterApiUrl("devnet"));
export default function App() {
const [phantomWalletPublicKey, setPhantomWalletPublicKey] =
useState<PublicKey | null>(null);
const [submitting, setSubmitting] = useState(false);
const actionSheetRef = useRef<ActionSheet>(null);
const [dappKeyPair] = useState(nacl.box.keyPair());
const [sharedSecret, setSharedSecret] = useState<Uint8Array>();
const [session, setSession] = useState<string>();
const [deepLink, setDeepLink] = useState<string>("");
// On app start up, check if we were opened by an inbound deeplink. If so, track the intial URL
// Then, listen for a "url" event
useEffect(() => {
const initializeDeeplinks = async () => {
const initialUrl = await Linking.getInitialURL();
if (initialUrl) {
setDeepLink(initialUrl);
}
};
initializeDeeplinks();
const listener = Linking.addEventListener("url", handleDeepLink);
return () => {
listener.remove();
};
}, []);
// When a "url" event occurs, track the url
const handleDeepLink = ({ url }: Linking.EventType) => {
setDeepLink(url);
};
// Handle in-bound links
useEffect(() => {
if (!deepLink) return;
const url = new URL(deepLink);
const params = url.searchParams;
// Handle an error response from Phantom
if (params.get("errorCode")) {
const error = Object.fromEntries([...params]);
const message =
error?.errorMessage ??
JSON.stringify(Object.fromEntries([...params]), null, 2);
console.log("error: ", message);
return;
}
// Handle a `connect` response from Phantom
if (/onConnect/.test(url.pathname)) {
const sharedSecretDapp = nacl.box.before(
bs58.decode(params.get("phantom_encryption_public_key")!),
dappKeyPair.secretKey
);
const connectData = decryptPayload(
params.get("data")!,
params.get("nonce")!,
sharedSecretDapp
);
setSharedSecret(sharedSecretDapp);
setSession(connectData.session);
setPhantomWalletPublicKey(new PublicKey(connectData.public_key));
console.log(`connected to ${connectData.public_key.toString()}`);
}
}, [deepLink]);
// Initiate a new connection to Phantom
const connect = async () => {
const params = new URLSearchParams({
dapp_encryption_public_key: bs58.encode(dappKeyPair.publicKey),
cluster: "devnet",
app_url: "https://deeplink-movie-tutorial-dummy-site.vercel.app/",
redirect_link: onConnectRedirectLink,
});
const url = `https://phantom.app/ul/v1/connect?${params.toString()}`;
Linking.openURL(url);
};
// Initiate a disconnect from Phantom
const disconnect = async () => {
console.log("disconnect");
};
// Initiate a new transaction via Phantom. We call this in `components/AddReviewSheet.tsx` to send our movie review to the Solana network
const signAndSendTransaction = async (transaction: Transaction) => {
console.log("signAndSendTransaction");
};
// Open the 'Add a Review' sheet defined in `components/AddReviewSheet.tsx`
const openAddReviewSheet = () => {
actionSheetRef.current?.show();
};
return (
<SafeAreaProvider>
<SafeAreaView style={styles.container}>
<View style={styles.header}>
{phantomWalletPublicKey ? (
<>
<View style={[styles.row, styles.wallet]}>
<View style={styles.greenDot} />
<Text
style={styles.text}
numberOfLines={1}
ellipsizeMode="middle"
>
{`Connected to: ${phantomWalletPublicKey.toString()}`}
</Text>
</View>
<View style={styles.row}>
<Button title="Add Review" onPress={openAddReviewSheet} />
<Button title="Disconnect" onPress={disconnect} />
</View>
</>
) : (
<View style={{ marginTop: 15 }}>
<Button title="Connect Phantom" onPress={connect} />
</View>
)}
</View>
{submitting && (
<ActivityIndicator
color={COLORS.WHITE}
size="large"
style={styles.spinner}
/>
)}
<MovieList connection={connection} />
<AddReviewSheet
actionSheetRef={actionSheetRef}
phantomWalletPublicKey={phantomWalletPublicKey}
signAndSendTransaction={signAndSendTransaction}
/>
<Toast config={toastConfig} />
<StatusBar style="auto" />
</SafeAreaView>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.DARK_GREY,
flexGrow: 1,
position: "relative",
},
greenDot: {
height: 8,
width: 8,
borderRadius: 10,
marginRight: 5,
backgroundColor: COLORS.GREEN,
},
header: {
width: "95%",
marginLeft: "auto",
marginRight: "auto",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 5,
},
spinner: {
position: "absolute",
alignSelf: "center",
top: "50%",
zIndex: 1000,
},
text: {
color: COLORS.LIGHT_GREY,
width: "100%",
},
wallet: {
alignItems: "center",
margin: 10,
marginBottom: 15,
},
});
Now, when we approve our connect
request, our app should recognize that the request was successful and render “Add Review” and “Disconnect” buttons. We’re now officially connected!
Notes on Sessions
From here on out, all communication with Phantom will require the session
param that our app has stored. This session
does not expire, and will represent our connection to Phantom until it is deemed invalid. There are two scenarios in which a session
can be deemed invalid:
- It was not signed by the current wallet keypair. This could mean that the session is entirely fake, or that it was signed by another keypair in the user’s wallet.
- It was signed by the current wallet keypair, but the session's JSON
data
does not pass muster. There are a few reasons why this might occur: a. The user switched chains (or possibly networks). b. Theapp_url
could be blocked if malicious.
In cases where our app loses this session
(such as if a user force closes our app) we should simply consider the user disconnected and require a new connection before continuing.
Disconnecting From Phantom
Before we continue on to adding movie reviews to our app, we should also give our users a way to disconnect. Disconnecting follows much of the same process as connecting. Namely, we will make a deeplink to Phantom that:
- Links to a specific base URL endpoint (in this case,
disconnect
) - Passes additional query string parameters (including
dapp_encryption_public_key
andredirect_link
) - When successful, is set up with logic to decrypt and parse a redirect link received from Phantom.
We’ve already covered most of the heavy lifting here in our initial connect
request. To set ourselves up for future requests, let’s refactor some of our existing connect logic.
First, let’s add the following line to our constants/index.ts
file:
export const BASE_URL = "https://phantom.app/ul/v1/";
Next, create a new file within our utils
folder named buildUrl.ts
and add the following function. This will make it easier for us to construct different Phantom deeplinks in the future:
import { BASE_URL } from "../constants";
export const buildUrl = (path: string, params: URLSearchParams) =>
`${BASE_URL}${path}?${params.toString()}`;
If we take a look at the query string parameters required for disconnect, we can see that most of it looks similar to our initial connect
request. This time, however, we will also need to encrypt a payload
in our request to Phantom. Remember, our encrypted channel with Phantom works both ways!
To set ourselves up for encrypting future requests, let’s create a new file under our utils
folder named encryptPayload.ts
and add the following:
import nacl from "tweetnacl";
export const encryptPayload = (payload: any, sharedSecret?: Uint8Array) => {
if (!sharedSecret) throw new Error("missing shared secret");
const nonce = nacl.randomBytes(24);
const encryptedPayload = nacl.box.after(
Buffer.from(JSON.stringify(payload)),
nonce,
sharedSecret
);
return [nonce, encryptedPayload];
};
Here, we’re using the sharedSecret
we generated from our Diffie-Hellman key exchange to encrypt some arbitrary payload that we will send to Phantom. As part of this encryption process, we’ll also generate a nonce
that we’ll send to Phantom so that Phantom can decrypt our payload.
Switching over to our App.tsx
file, we can now add the necessary components to make a disconnect
deeplink and handle its corresponding redirect link back from Phantom. First, we’ll need to create a redirect link:
const onDisconnectRedirectLink = Linking.createURL("onDisconnect");
Then, we’ll need a way to handle this incoming redirect link. In the case of disconnect
, Phantom doesn’t return us anything—we should simply forget our users phantomWalletPublicKey
as our session
would no longer be valid:
// Handle a `disconnect` response from Phantom
if (/onDisconnect/.test(url.pathname)) {
setPhantomWalletPublicKey(null);
console.log("disconnected");
}
With our redirect route in place, we can finally build and open our disconnect
deeplink. Note that we are encrypting a payload to Phantom that includes our session
param from earlier. We will need to pass this to Phantom to authenticate all subsequent requests!
// Initiate a disconnect from Phantom
const disconnect = async () => {
const payload = {
session,
};
const [nonce, encryptedPayload] = encryptPayload(payload, sharedSecret);
const params = new URLSearchParams({
dapp_encryption_public_key: bs58.encode(dappKeyPair.publicKey),
nonce: bs58.encode(nonce),
redirect_link: onDisconnectRedirectLink,
payload: bs58.encode(encryptedPayload),
});
const url = buildUrl("disconnect", params);
Linking.openURL(url);
};
Once all this in place, our app should be able to quickly connect and disconnect like so:
Before moving on, take a minute to refactor connect
to make use of our new buildUrl
helper method. Our App.tsx
file should now look like this:
import "react-native-get-random-values";
import "react-native-url-polyfill/auto";
import { Buffer } from "buffer";
global.Buffer = global.Buffer || Buffer;
import { StatusBar } from "expo-status-bar";
import React, { useEffect, useRef, useState } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import {
clusterApiUrl,
Connection,
PublicKey,
Transaction,
} from "@solana/web3.js";
import MovieList from "./components/MovieList";
import Button from "./components/Button";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import ActionSheet from "react-native-actions-sheet";
import AddReviewSheet from "./components/AddReviewSheet";
import Toast from "react-native-toast-message";
import { toastConfig } from "./components/ToastConfig";
import { COLORS } from "./constants";
import * as Linking from "expo-linking";
import nacl from "tweetnacl";
import bs58 from "bs58";
import { decryptPayload } from "./utils/decryptPayload";
import { encryptPayload } from "./utils/encryptPayload";
import { buildUrl } from "./utils/buildUrl";
const onConnectRedirectLink = Linking.createURL("onConnect");
const onDisconnectRedirectLink = Linking.createURL("onDisconnect");
const connection = new Connection(clusterApiUrl("devnet"));
export default function App() {
const [phantomWalletPublicKey, setPhantomWalletPublicKey] =
useState<PublicKey | null>(null);
const [submitting, setSubmitting] = useState(false);
const actionSheetRef = useRef<ActionSheet>(null);
const [dappKeyPair] = useState(nacl.box.keyPair());
const [sharedSecret, setSharedSecret] = useState<Uint8Array>();
const [session, setSession] = useState<string>();
const [deepLink, setDeepLink] = useState<string>("");
// On app start up, check if we were opened by an inbound deeplink. If so, track the intial URL
// Then, listen for a "url" event
useEffect(() => {
const initializeDeeplinks = async () => {
const initialUrl = await Linking.getInitialURL();
if (initialUrl) {
setDeepLink(initialUrl);
}
};
initializeDeeplinks();
const listener = Linking.addEventListener("url", handleDeepLink);
return () => {
listener.remove();
};
}, []);
// When a "url" event occurs, track the url
const handleDeepLink = ({ url }: Linking.EventType) => {
setDeepLink(url);
};
// Handle in-bound links
useEffect(() => {
if (!deepLink) return;
const url = new URL(deepLink);
const params = url.searchParams;
// Handle an error response from Phantom
if (params.get("errorCode")) {
const error = Object.fromEntries([...params]);
const message =
error?.errorMessage ??
JSON.stringify(Object.fromEntries([...params]), null, 2);
console.log("error: ", message);
return;
}
// Handle a `connect` response from Phantom
if (/onConnect/.test(url.pathname)) {
const sharedSecretDapp = nacl.box.before(
bs58.decode(params.get("phantom_encryption_public_key")!),
dappKeyPair.secretKey
);
const connectData = decryptPayload(
params.get("data")!,
params.get("nonce")!,
sharedSecretDapp
);
setSharedSecret(sharedSecretDapp);
setSession(connectData.session);
setPhantomWalletPublicKey(new PublicKey(connectData.public_key));
console.log(`connected to ${connectData.public_key.toString()}`);
}
// Handle a `disconnect` response from Phantom
if (/onDisconnect/.test(url.pathname)) {
setPhantomWalletPublicKey(null);
console.log("disconnected");
}
}, [deepLink]);
// Initiate a new connection to Phantom
const connect = async () => {
const params = new URLSearchParams({
dapp_encryption_public_key: bs58.encode(dappKeyPair.publicKey),
cluster: "devnet",
app_url: "https://deeplink-movie-tutorial-dummy-site.vercel.app/",
redirect_link: onConnectRedirectLink,
});
const url = buildUrl("connect", params);
Linking.openURL(url);
};
// Initiate a disconnect from Phantom
const disconnect = async () => {
const payload = {
session,
};
const [nonce, encryptedPayload] = encryptPayload(payload, sharedSecret);
const params = new URLSearchParams({
dapp_encryption_public_key: bs58.encode(dappKeyPair.publicKey),
nonce: bs58.encode(nonce),
redirect_link: onDisconnectRedirectLink,
payload: bs58.encode(encryptedPayload),
});
const url = buildUrl("disconnect", params);
Linking.openURL(url);
};
// Initiate a new transaction via Phantom. We call this in `components/AddReviewSheet.tsx` to send our movie review to the Solana network
const signAndSendTransaction = async (transaction: Transaction) => {
console.log("signAndSendTransaction");
};
// Open the 'Add a Review' sheet defined in `components/AddReviewSheet.tsx`
const openAddReviewSheet = () => {
actionSheetRef.current?.show();
};
return (
<SafeAreaProvider>
<SafeAreaView style={styles.container}>
<View style={styles.header}>
{phantomWalletPublicKey ? (
<>
<View style={[styles.row, styles.wallet]}>
<View style={styles.greenDot} />
<Text
style={styles.text}
numberOfLines={1}
ellipsizeMode="middle"
>
{`Connected to: ${phantomWalletPublicKey.toString()}`}
</Text>
</View>
<View style={styles.row}>
<Button title="Add Review" onPress={openAddReviewSheet} />
<Button title="Disconnect" onPress={disconnect} />
</View>
</>
) : (
<View style={{ marginTop: 15 }}>
<Button title="Connect Phantom" onPress={connect} />
</View>
)}
</View>
{submitting && (
<ActivityIndicator
color={COLORS.WHITE}
size="large"
style={styles.spinner}
/>
)}
<MovieList connection={connection} />
<AddReviewSheet
actionSheetRef={actionSheetRef}
phantomWalletPublicKey={phantomWalletPublicKey}
signAndSendTransaction={signAndSendTransaction}
/>
<Toast config={toastConfig} />
<StatusBar style="auto" />
</SafeAreaView>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.DARK_GREY,
flexGrow: 1,
position: "relative",
},
greenDot: {
height: 8,
width: 8,
borderRadius: 10,
marginRight: 5,
backgroundColor: COLORS.GREEN,
},
header: {
width: "95%",
marginLeft: "auto",
marginRight: "auto",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 5,
},
spinner: {
position: "absolute",
alignSelf: "center",
top: "50%",
zIndex: 1000,
},
text: {
color: COLORS.LIGHT_GREY,
width: "100%",
},
wallet: {
alignItems: "center",
margin: 10,
marginBottom: 15,
},
});
Signing and sending transactions via Phantom
Now that our users can connect and disconnect from our app, we’re finally ready to give them the ability to write their own movie reviews. So far, we’ve just been reading reviews created by other people who have interacted with the program that’s already on devnet. Now, our users can write their own reviews and use Phantom to broadcast them to the network.
If you tap on our existing “Add Review” button, you’ll see a form pop up that asks for a movie’s title, review, and rating. All of this is already set up for us within components/AddReviewSheet.tsx
. I won’t rehash everything in here, since the Solana Development Course already does a great job of explaining how to interact with their existing program. If we look at our handleSubmit
function, however, we can see that we’re already constructing our review as a TransactionInstruction and then packaging it into a Transaction. This transaction now needs to be signed and submitted to the Solana network.
Let’s zoom back out to our App.tsx
file. Here, we’ll need to add much of the same deeplinking flows that we’ve already covered. Specifically, we’ll need:
- A redirect link for
onSignAndSendTransaction
. - A way to react to an incoming
onSignAndSendTransaction
redirect. - A method that properly serializes our transaction and sends it to Phantom.
Go ahead and try your hand at implementing the first two items from our list above. Afterwards, scroll down to find our signAndSendTransaction
method. Here, we can add the necessary logic to pass our transaction to Phantom so that it can be signed and submitted to the network:
// Initiate a new transaction via Phantom. We call this in `components/AddReviewSheet.tsx` to send our movie review to the Solana network
const signAndSendTransaction = async (transaction: Transaction) => {
if (!phantomWalletPublicKey) return;
setSubmitting(true);
transaction.feePayer = phantomWalletPublicKey;
transaction.recentBlockhash = (
await connection.getLatestBlockhash()
).blockhash;
const serializedTransaction = transaction.serialize({
requireAllSignatures: false,
});
const payload = {
session,
transaction: bs58.encode(serializedTransaction),
};
const [nonce, encryptedPayload] = encryptPayload(payload, sharedSecret);
const params = new URLSearchParams({
dapp_encryption_public_key: bs58.encode(dappKeyPair.publicKey),
nonce: bs58.encode(nonce),
redirect_link: onSignAndSendTransactionRedirectLink,
payload: bs58.encode(encryptedPayload),
});
const url = buildUrl("signAndSendTransaction", params);
Linking.openURL(url);
};
In this method, the transaction we’re dealing with is already equipped with a movie review instruction. Before we serialize it, however, we’ll first need to specify its feePayer and date it with a recentBlockhash. The feePayer portion is straightforward—we can simply set this to our user’s phantomWalletPublicKey. Whenever we’re sending a transaction to Solana, we’ll want to wait until the last possible moment to fetch and set a recentBlockhash using connection.getLatestBlockhash. If you’re interested in learning more about why this is considered best practice, I highly recommend reading these posts from Justin Starry and the Solana Cookbook.
When serializing our transaction, it’s very important to pass an additional options object that sets requireAllSignatures: false. This is because our transaction has not been signed yet! That's what Phantom will handle for us. Once our transaction is serialized, we can then base58-encode it and add it to our payload. From here, everything else should be review from our disconnect section: we’ll encrypt our payload, specify the necessary query string params, and build and open our deeplink.
With this method in place, we’re now finally ready to submit our movie review! If you haven’t done so already, feel free to customize your onSignAndSendTransaction redirect handler in whatever way you see fit. In my case, I simply triggered a “Success” popup that links the user to the transaction id on a block explorer. All in all, your App.tsx file should now look something like this:
import "react-native-get-random-values";
import "react-native-url-polyfill/auto";
import { Buffer } from "buffer";
global.Buffer = global.Buffer || Buffer;
import { StatusBar } from "expo-status-bar";
import React, { useEffect, useRef, useState } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import {
clusterApiUrl,
Connection,
PublicKey,
Transaction,
} from "@solana/web3.js";
import MovieList from "./components/MovieList";
import Button from "./components/Button";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import ActionSheet from "react-native-actions-sheet";
import AddReviewSheet from "./components/AddReviewSheet";
import Toast from "react-native-toast-message";
import { toastConfig } from "./components/ToastConfig";
import { COLORS } from "./constants";
import * as Linking from "expo-linking";
import nacl from "tweetnacl";
import bs58 from "bs58";
import { decryptPayload } from "./utils/decryptPayload";
import { encryptPayload } from "./utils/encryptPayload";
import { buildUrl } from "./utils/buildUrl";
const onConnectRedirectLink = Linking.createURL("onConnect");
const onDisconnectRedirectLink = Linking.createURL("onDisconnect");
const onSignAndSendTransactionRedirectLink = Linking.createURL(
"onSignAndSendTransaction"
);
const connection = new Connection(clusterApiUrl("devnet"));
export default function App() {
const [phantomWalletPublicKey, setPhantomWalletPublicKey] =
useState<PublicKey | null>(null);
const [submitting, setSubmitting] = useState(false);
const actionSheetRef = useRef<ActionSheet>(null);
const [dappKeyPair] = useState(nacl.box.keyPair());
const [sharedSecret, setSharedSecret] = useState<Uint8Array>();
const [session, setSession] = useState<string>();
const [deepLink, setDeepLink] = useState<string>("");
// On app start up, check if we were opened by an inbound deeplink. If so, track the intial URL
// Then, listen for a "url" event
useEffect(() => {
const initializeDeeplinks = async () => {
const initialUrl = await Linking.getInitialURL();
if (initialUrl) {
setDeepLink(initialUrl);
}
};
initializeDeeplinks();
const listener = Linking.addEventListener("url", handleDeepLink);
return () => {
listener.remove();
};
}, []);
// When a "url" event occurs, track the url
const handleDeepLink = ({ url }: Linking.EventType) => {
setDeepLink(url);
};
// Handle in-bound links
useEffect(() => {
setSubmitting(false);
if (!deepLink) return;
const url = new URL(deepLink);
const params = url.searchParams;
// Handle an error response from Phantom
if (params.get("errorCode")) {
const error = Object.fromEntries([...params]);
const message =
error?.errorMessage ??
JSON.stringify(Object.fromEntries([...params]), null, 2);
console.log("error: ", message);
return;
}
// Handle a `connect` response from Phantom
if (/onConnect/.test(url.pathname)) {
const sharedSecretDapp = nacl.box.before(
bs58.decode(params.get("phantom_encryption_public_key")!),
dappKeyPair.secretKey
);
const connectData = decryptPayload(
params.get("data")!,
params.get("nonce")!,
sharedSecretDapp
);
setSharedSecret(sharedSecretDapp);
setSession(connectData.session);
setPhantomWalletPublicKey(new PublicKey(connectData.public_key));
console.log(`connected to ${connectData.public_key.toString()}`);
}
// Handle a `disconnect` response from Phantom
if (/onDisconnect/.test(url.pathname)) {
setPhantomWalletPublicKey(null);
console.log("disconnected");
}
// Handle a `signAndSendTransaction` response from Phantom
if (/onSignAndSendTransaction/.test(url.pathname)) {
actionSheetRef.current?.hide();
const signAndSendTransactionData = decryptPayload(
params.get("data")!,
params.get("nonce")!,
sharedSecret
);
console.log("transaction submitted: ", signAndSendTransactionData);
Toast.show({
type: "success",
text1: "Review submitted 🎥",
text2: signAndSendTransactionData.signature,
});
}
}, [deepLink]);
// Initiate a new connection to Phantom
const connect = async () => {
const params = new URLSearchParams({
dapp_encryption_public_key: bs58.encode(dappKeyPair.publicKey),
cluster: "devnet",
app_url: "https://deeplink-movie-tutorial-dummy-site.vercel.app/",
redirect_link: onConnectRedirectLink,
});
const url = buildUrl("connect", params);
Linking.openURL(url);
};
// Initiate a disconnect from Phantom
const disconnect = async () => {
const payload = {
session,
};
const [nonce, encryptedPayload] = encryptPayload(payload, sharedSecret);
const params = new URLSearchParams({
dapp_encryption_public_key: bs58.encode(dappKeyPair.publicKey),
nonce: bs58.encode(nonce),
redirect_link: onDisconnectRedirectLink,
payload: bs58.encode(encryptedPayload),
});
const url = buildUrl("disconnect", params);
Linking.openURL(url);
};
// Initiate a new transaction via Phantom. We call this in `components/AddReviewSheet.tsx` to send our movie review to the Solana network
const signAndSendTransaction = async (transaction: Transaction) => {
if (!phantomWalletPublicKey) return;
setSubmitting(true);
transaction.feePayer = phantomWalletPublicKey;
transaction.recentBlockhash = (
await connection.getLatestBlockhash()
).blockhash;
const serializedTransaction = transaction.serialize({
requireAllSignatures: false,
});
const payload = {
session,
transaction: bs58.encode(serializedTransaction),
};
const [nonce, encryptedPayload] = encryptPayload(payload, sharedSecret);
const params = new URLSearchParams({
dapp_encryption_public_key: bs58.encode(dappKeyPair.publicKey),
nonce: bs58.encode(nonce),
redirect_link: onSignAndSendTransactionRedirectLink,
payload: bs58.encode(encryptedPayload),
});
const url = buildUrl("signAndSendTransaction", params);
Linking.openURL(url);
};
// Open the 'Add a Review' sheet defined in `components/AddReviewSheet.tsx`
const openAddReviewSheet = () => {
actionSheetRef.current?.show();
};
return (
<SafeAreaProvider>
<SafeAreaView style={styles.container}>
<View style={styles.header}>
{phantomWalletPublicKey ? (
<>
<View style={[styles.row, styles.wallet]}>
<View style={styles.greenDot} />
<Text
style={styles.text}
numberOfLines={1}
ellipsizeMode="middle"
>
{`Connected to: ${phantomWalletPublicKey.toString()}`}
</Text>
</View>
<View style={styles.row}>
<Button title="Add Review" onPress={openAddReviewSheet} />
<Button title="Disconnect" onPress={disconnect} />
</View>
</>
) : (
<View style={{ marginTop: 15 }}>
<Button title="Connect Phantom" onPress={connect} />
</View>
)}
</View>
{submitting && (
<ActivityIndicator
color={COLORS.WHITE}
size="large"
style={styles.spinner}
/>
)}
<MovieList connection={connection} />
<AddReviewSheet
actionSheetRef={actionSheetRef}
phantomWalletPublicKey={phantomWalletPublicKey}
signAndSendTransaction={signAndSendTransaction}
/>
<Toast config={toastConfig} />
<StatusBar style="auto" />
</SafeAreaView>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.DARK_GREY,
flexGrow: 1,
position: "relative",
},
greenDot: {
height: 8,
width: 8,
borderRadius: 10,
marginRight: 5,
backgroundColor: COLORS.GREEN,
},
header: {
width: "95%",
marginLeft: "auto",
marginRight: "auto",
},
row: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 5,
},
spinner: {
position: "absolute",
alignSelf: "center",
top: "50%",
zIndex: 1000,
},
text: {
color: COLORS.LIGHT_GREY,
width: "100%",
},
wallet: {
alignItems: "center",
margin: 10,
marginBottom: 15,
},
});
Wrapping Up
Our movie reviews app is now complete! All together, our new flow should look a little something like this:
Once we’ve added our review, it will live forever on Solana. Try finding for your review by either scrolling or searching in our app!
If you enjoyed this tutorial or you have any feedback, please let me know via Twitter! Make sure to also check out our Developer Discord where you can stay up to date on the latest from Phantom.