Summary #
- Solana Pay is a specification for encoding Solana transaction requests within URLs, enabling standardized transaction requests across different Solana apps and wallets.
- Partial signing of transactions allows the creation of transactions that require multiple signatures before they are submitted to the network.
- Transaction gating involves implementing rules that determine whether certain transactions are allowed to be processed, based on specific conditions or the presence of particular data in the transaction.
Lesson #
The Solana community is continually improving and expanding the network's functionality. But that doesn't always mean developing brand-new technology. Sometimes it means leveraging the network's existing features in new and innovative ways.
Solana Pay is a great example of this. Rather than adding new functionality to the network, Solana Pay uses the network's existing signing features in a unique way to enable merchants and applications to request transactions and build gating mechanisms for specific transaction types.
Throughout this lesson, you'll learn how to use Solana Pay to create transfer and transaction requests, encode these requests as a QR code, partially sign transactions, and gate transactions based on conditions you choose. Rather than leaving it at that, we hope you'll see this as an example of leveraging existing features in new and innovative ways, using it as a launching pad for your own unique client-side network interactions.
Solana Pay #
The Solana Pay specification is a set of standards that allow users to request payments and initiate transactions using URLs uniformly across various Solana apps and wallets.
Request URLs are prefixed with solana:
so that platforms can direct the link
to the appropriate application. For example, on mobile, a URL that starts with
solana:
will be directed to wallet applications that support the Solana Pay
specification. From there, the wallet can use the remainder of the URL to
appropriately handle the request.
There are two types of requests defined by the Solana Pay specification:
- Transfer Request: used for simple SOL or SPL Token transfers
- Transaction Request: used to request any type of Solana transaction
Transfer requests #
The transfer request specification describes a non-interactive request for SOL
or SPL token transfer. Transfer request URLs take the following format
solana:<recipient>?<optional-query-params>
.
The value of recipient
is required and must be a base58-encoded public key of
the account from which a transfer is being requested. Additionally, the
following optional query parameters are supported:
amount
- a non-negative integer or decimal value indicating the amount of tokens to transferspl-token
- a base58-encoded public key of an SPL Token mint account if the transfer is of an SPL token and not SOLreference
- optional reference values as base58-encoded 32 byte arrays. This can be used by a client for identifying the transaction onchain since the client will not have a transaction's signature.label
- a URL-encoded UTF-8 string that describes the source of the transfer requestmessage
- a URL-encoded UTF-8 string that describes the nature of the transfer requestmemo
- a URL-encoded UTF-8 string that must be included in the SPL memo instruction in the payment transaction
By way of example, here is a URL describing a transfer request for 1 SOL:
solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN?amount=1&label=Michael&message=Thanks%20for%20all%20the%20fish&memo=OrderId12345
And here is a URL describing a transfer request for 0.1 USDC:
solana:mvines9iiHiQTysrwkJjGf2gb9Ex9jXJX8ns3qwf2kN?amount=0.01&spl-token=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
Transaction requests #
The Solana Pay transaction request is similar to a transfer request in that it is simply a URL that can be consumed by a supporting wallet. However, this request is interactive and the format is more open-ended:
solana:<link>
The value of link
should be a URL to which the consuming wallet can make an
HTTP request. Rather than containing all the information needed for a
transaction, a transaction request uses this URL to fetch the transaction that
should be presented to the user.
When a wallet receives a transaction Request URL, four things happen:
- The wallet sends a GET request to the application at the provided
link
URL to retrieve a label and icon image to display to the user. - The wallet then sends a POST request with the public key of the end user.
- Using the public key of the end user (and any additional information provided
in
link
), the application then builds the transaction and responds with a base64-encoded serialized transaction. - The wallet decodes and deserializes the transaction, then lets the user sign and send the transaction.
Given that transaction requests are more involved than transfer requests, the remainder of this lesson will focus on creating transaction requests.
Create a transaction request #
Define the API endpoint #
The main thing you, the developer, need to do to make the transaction request flow work is set up a REST API endpoint at the URL you plan to include in the transaction request. In this lesson, we'll be using Next.js API Routes for our endpoints, but you're welcome to use whatever stack and tools you're most comfortable with.
In Next.js, you do this by adding a file to the pages/api
folder and exporting
a function that handles the request and response.
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
// Handle the request
}
Handle a GET request #
The wallet consuming your transaction request URL will first issue a GET request to this endpoint. You'll want your endpoint to return a JSON object with two fields:
label
- a string that describes the source of the transaction requesticon
- a URL to an image that can be displayed to the user
Building on the empty endpoint from before, that may look like this:
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
if (request.method === "GET") {
return get(response);
}
return response.status(405).json({ error: "Method not allowed" });
}
function get(response: NextApiResponse) {
response.status(200).json({
label: "Store Name",
icon: "https://solana.com/src/img/branding/solanaLogoMark.svg",
});
}
When the wallet makes a GET request to the API endpoint, the get
function is
called, returning a response with a status code of 200 and the JSON object
containing label
and icon
.
Handle a POST request and build the transaction #
After issuing a GET request, the wallet will issue a POST request to the same
URL. Your endpoint should expect the POST request's body
to contain a JSON
object with an account
field provided by the requesting wallet. The value of
account
will be a string representing the end user's public key.
With this information and any additional parameters provided, you can build the transaction and return it to the wallet for signing by:
- Connecting to the Solana network and getting the latest
blockhash
. - Creating a new transaction using the
blockhash
. - Adding instructions to the transaction
- Serializing the transaction and returning it in a
PostResponse
object along with a message for the user.
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
if (request.method === "GET") {
return get(response);
}
if (request.method === "POST") {
return post(request, response);
}
return response.status(405).json({ error: "Method not allowed" });
}
function get(response: NextApiResponse) {
response.status(200).json({
label: "Store Name",
icon: "https://solana.com/src/img/branding/solanaLogoMark.svg",
});
}
async function post(request: NextApiRequest, response: NextApiResponse) {
const { account, reference } = request.body;
const connection = new Connection(clusterApiUrl("devnet"));
const { blockhash } = await connection.getLatestBlockhash();
const transaction = new Transaction({
recentBlockhash: blockhash,
feePayer: account,
});
const instruction = SystemProgram.transfer({
fromPubkey: new PublicKey(account),
toPubkey: Keypair.generate().publicKey,
lamports: 0.001 * LAMPORTS_PER_SOL,
});
instruction.keys.push({
pubkey: reference,
isSigner: false,
isWritable: false,
});
transaction.add(instruction);
const serializedTransaction = transaction.serialize({
requireAllSignatures: false,
});
const base64 = serializedTransaction.toString("base64");
const message = "Simple transfer of 0.001 SOL";
response.status(200).json({
transaction: base64,
message,
});
}
There is nothing too out of the ordinary here. It's the same transaction construction you would use in a standard client-side application. The only difference is that instead of signing and submitting to the network, you send the transaction as a base64-encoded string back in the HTTP response. The wallet that issued the request can then present the transaction to the user for signing.
Confirm transaction #
You may have noticed that the previous example assumed a reference
was
provided as a query parameter. While this is not a value provided by the
requesting wallet, it is useful to set up your initial transaction request URL
to contain this query parameter.
Since your application isn't the one submitting a transaction to the network, your code won't have access to a transaction signature. This would typically be how your app can locate a transaction on the network and see its status.
To get around this, you can include a reference
value as a query parameter for
each transaction request. This value should be a base58-encoded 32 byte array
that can be included as a non-signer key on the transaction. This allows your
app to then use the getSignaturesForAddress
RPC method to locate the
transaction. Your app can then tailor its UI according to a transaction's
status.
If you use the @solana/pay
library, you can use the findReference
helper
function instead of using getSignaturesForAddress
directly.
Gated transactions #
We've mentioned before how Solana Pay is an example of being able to do cool new things with the network by getting creative with existing functionality. Another small example of doing this within the Solana Pay umbrella is to only make certain transactions available once certain conditions are met.
Since you control the endpoint building the transaction, you can determine what
criteria must be met before a transaction is built. For example, you can use the
account
field provided in the POST request to check if the end user holds an
NFT from a particular collection or if that public key is on a predetermined
list of accounts who can make this particular transaction.
// retrieve array of nfts owned by the given wallet
const nfts = await metaplex.nfts().findAllByOwner({ owner: account }).run();
// iterate over the nfts array
for (let i = 0; i < nfts.length; i++) {
// check if the current nft has a collection field with the desired value
if (nfts[i].collection?.address.toString() == collection.toString()) {
// build transaction
} else {
// return an error
}
}
Partial Signing #
If you want certain transactions behind some kind of gating mechanism, that functionality will have to be enforced onchain as well. Returning an error from your Solana Pay endpoint makes it more difficult for end users to do the transaction, but they could still build it manually.
What this means is that the instruction(s) being called should require some type of "admin" signature that only your application can provide. In doing that, however, you'll have made it so that our previous examples don't work. The transaction is built and sent to the requesting wallet for the end user's signature, but the submitted transaction will fail without the admin signature.
Fortunately, Solana enables signature composability with partial signing.
Partially signing a multi-signature transaction allows signers to add their signature before the transaction is broadcast on the network. This can be useful in a number of situations, including:
- Approving transactions that require the signature of multiple parties, such as a merchant and a buyer who need to confirm the details of a payment.
- Invoking custom programs that require the signatures of both a user and an administrator. This can help to limit access to the program instructions and ensure that only authorized parties can execute them.
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash()
const transaction = new Transaction({
feePayer: account,
blockhash,
lastValidBlockHeight,
})
...
transaction.partialSign(adminKeypair)
The partialSign
function is used to add a signature to a transaction without
overriding any previous signatures on the transaction. If you are building a
transaction with multiple signers, it is important to remember that if you don't
specify a transaction's feePayer
, the first signer will be used as the fee
payer for the transaction. To avoid any confusion or unexpected behavior, make
sure to explicitly set the fee payer when necessary.
In our example of only allowing a transaction request to go through when the end
user has a specific NFT, you would simply add your admin signature to the
transaction using partialSign
before encoding the transaction as a
base64-encoded string and issuing the HTTP response.
Solana Pay QR codes #
One of the standout features of Solana Pay is its easy integration with QR codes. Since transfer and transaction requests are simply URLs, you can embed them into QR codes that you make available in your application or elsewhere.
The @solana/pay
library simplifies this with the provided createQR
helper
function. This function needs you to provide the following:
url
- the url of the transaction request.size
(optional) - the width and height of the QR code in pixels. Defaults to 512.background
(optional) - the background color. Defaults to white.color
(optional) - the foreground color. Defaults to black.
const qr = createQR(url, 400, "transparent");
Lab #
Now that you've got a conceptual grasp on Solana Pay, let's put it into practice. We'll use Solana Pay to generate a series of QR codes for a scavenger hunt. Participants must visit each scavenger hunt location in order. At each location, they'll use the provided QR code to submit the appropriate transaction to the scavenger hunt's smart contract that keeps track of user progress.
1. Starter #
To get started, download the starter code on the starter
branch of this
repository.
The starter code is a Next.js app that displays a Solana Pay QR code. Notice
that the menu bar lets you switch between different QR codes. The default option
is a simple SOL transfer for illustrative purposes. Throughout this lab, we'll
be adding functionality to the location options in the menu bar.
scavenger hunt app
To do this, we'll be creating a new endpoint for a transaction request that
builds a transaction for invoking an Anchor program on Devnet. This program has
been made specifically for this "scavenger hunt" app and has two instructions:
initialize
and check_in
. The initialize
instruction is used to set up the
user's state, while the check_in
instruction is used to record a check-in at a
location in the scavenger hunt. We won't be making any changes to the program in
this lab, but feel free to check out the
source code if
you'd like to familiarize yourself with the program.
Before moving on, make sure you get familiar with the starter code for the
Scavenger Hunt app. Looking at pages/index.tsx
,
utils/createQrCode/simpleTransfer
, and /utils/checkTransaction
will let you
see how the transaction request for sending SOL is set up. We'll be following a
similar pattern for the transaction request for checking in at a location.
2. Setup #
Before we move forward, let's make sure you can run the app locally. Start by
renaming the .env.example
file in the frontend directory to .env
. This file
contains a keypair that will be used in this lab to partially sign transactions.
Next, install dependencies with yarn
, then use yarn dev
and open your
browser localhost:3000
(or the port indicated in the console if 3000 was
already in use).
Now, if you try to scan the QR code shown on the page from your mobile device,
you'll get an error. That's because the QR code is set up to send you to your
computer's localhost:3000
, which isn't an address your phone can get to.
Further, Solana Pay needs to use an HTTPS URL to work.
To get around this, you can use ngrok. You'll need to
install it if you haven't used it before. Once it's installed, run the following
command in your terminal, replacing 3000
with whichever port you're using for
this project:
ngrok http 3000
This will provide you with a unique URL that you can use to access your local server remotely. The output will look something like this:
Session Status online
Account your_email@gmail.com (Plan: Free)
Update update available (version 3.1.0, Ctrl-U to update)
Version 3.0.6
Region United States (us)
Latency 45ms
Web Interface http://127.0.0.1:4040
Forwarding https://7761-24-28-107-82.ngrok.io -> http://localhost:3000
Now, open the HTTPS ngrok URL shown in your console in the browser (e.g. https://7761-24-28-107-82.ngrok.io). This will allow you to scan QR codes from your mobile device while testing locally.
At the time of writing, this lab works best with Solflare. Some wallets will
display an incorrect warning message when scanning a Solana Pay QR code.
Regardless of the wallet you use, make sure you switch to devnet in the wallet.
Then scan the QR code on the home page labeled “SOL Transfer”. This QR code is a
reference implementation for a transaction request that performs a simple SOL
transfer. It also calls the requestAirdrop
function to fund your mobile wallet
with Devnet SOL since most people don't have Devnet SOL available for testing.
If you were able to successfully execute the transaction using the QR code, you're good to move on!
3. Create a check-in transaction request endpoint #
Now that you're up and running, it's time to create an endpoint that supports transaction requests for location check-in using the Scavenger Hunt program.
Start by opening the file at pages/api/checkIn.ts
. Notice that it has a helper
function for initializing eventOrganizer
from a secret key environment
variable. The first thing we'll do in this file is the following:
- Export a
handler
function to handle an arbitrary HTTP request - Add
get
andpost
functions for handling those HTTP methods - Add logic to the body of the
handler
function to either callget
,post
, or return a 405 error based on the HTTP request method
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
request: NextApiRequest,
response: NextApiResponse,
) {
if (request.method === "GET") {
return get(response);
}
if (request.method === "POST") {
return await post(request, response);
}
return response.status(405).json({ error: "Method not allowed" });
}
function get(response: NextApiResponse) {}
async function post(request: NextApiRequest, response: NextApiResponse) {}
4. Update get
function #
Remember, the first request from a wallet will be a GET request expecting the
endpoint to return a label and icon. Update the get
function to send a
response with a "Scavenger Hunt!" label and a Solana logo icon.
function get(response: NextApiResponse) {
response.status(200).json({
label: "Scavenger Hunt!",
icon: "https://solana.com/src/img/branding/solanaLogoMark.svg",
});
}
5. Update post
function #
After the GET request, a wallet will issue a POST request to the endpoint. The
request's body
will contain a JSON object with an account
field representing
the end user's public key.
Additionally, the query parameters will contain whatever you encoded into the QR
code. If you take a look at utils/createQrCode/checkIn.ts
, you'll notice that
this particular app includes parameters for reference
and id
as the
following:
reference
- a randomly generated public key used to identify the transactionid
- the location id as an integer
Go ahead and update the post
function to extract account
, reference
, and
id
from the request. You should respond with an error if any of these is
missing.
Next, add a try catch
statement where the catch
block responds with an error
and the try
block calls out to a new function buildTransaction
. If
buildTransaction
is successful, respond with a 200 and a JSON object with the
transaction and a message that the user has found the given location. Don't
worry about the logic for the buildTransaction
function just yet - we'll do
that next.
Note that you'll need to import PublicKey
and Transaction
from
@solana/web3.js
here as well.
import { NextApiRequest, NextApiResponse } from "next"
import { PublicKey, Transaction } from "@solana/web3.js"
...
async function post(request: NextApiRequest, response: NextApiResponse) {
const { account } = request.body;
const { reference, id } = request.query;
if (!account || !reference || !id) {
response.status(400).json({ error: "Missing required parameter(s)" });
return;
}
try {
const transaction = await buildTransaction(
new PublicKey(account),
new PublicKey(reference),
id.toString(),
);
response.status(200).json({
transaction: transaction,
message: `You've found location ${id}!`,
});
} catch (error) {
console.log(error);
response.status(500).json({ transaction: "", message: error.message });
return;
}
}
async function buildTransaction(
account: PublicKey,
reference: PublicKey,
id: string
): Promise<string> {
return new Transaction()
}
6. Implement the buildTransaction
function #
Next, let's implement the buildTransaction
function. It should build,
partially sign, and return the check-in transaction. The sequence of items it
needs to perform is:
- Fetch the user state
- Use the
locationAtIndex
helper function and the location id to get a Location object - Verify that the user is at the correct location
- Get the current blockhash and last valid block height from the connection
- Create a new transaction object
- Add an initialize instruction to the transaction if user state does not exist
- Add a check-in instruction to the transaction
- Add the
reference
public key to the check-in instruction - Partially sign the transaction with the event organizer's keypair
- Serialize the transaction with base64 encoding and return the transaction
While each of these steps is straightforward, it's a lot of steps. To simplify
the function, we're going to create empty helper functions that we'll fill in
later for steps 1, 3, 6, and 7-8. We'll call these fetchUserState
,
verifyCorrectLocation
, createInitUserInstruction
, and
createCheckInInstruction
, respectively.
We'll also add the following imports:
import { NextApiRequest, NextApiResponse } from "next";
import {
PublicKey,
Transaction,
TransactionInstruction,
} from "@solana/web3.js";
import { locationAtIndex, Location, locations } from "../../utils/locations";
import { connection, gameId, program } from "../../utils/programSetup";
Using the empty helper functions and the new imports, we can fill in the
buildTransaction
function:
async function buildTransaction(
account: PublicKey,
reference: PublicKey,
id: string,
): Promise<string> {
const userState = await fetchUserState(account);
const currentLocation = locationAtIndex(new Number(id).valueOf());
if (!currentLocation) {
throw { message: "Invalid location id" };
}
if (!verifyCorrectLocation(userState, currentLocation)) {
throw { message: "You must visit each location in order!" };
}
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash();
const transaction = new Transaction({
feePayer: account,
blockhash,
lastValidBlockHeight,
});
if (!userState) {
transaction.add(await createInitUserInstruction(account));
}
transaction.add(
await createCheckInInstruction(account, reference, currentLocation),
);
transaction.partialSign(eventOrganizer);
const serializedTransaction = transaction.serialize({
requireAllSignatures: false,
});
const base64 = serializedTransaction.toString("base64");
return base64;
}
interface UserState {
user: PublicKey;
gameId: PublicKey;
lastLocation: PublicKey;
}
async function fetchUserState(account: PublicKey): Promise<UserState | null> {
return null;
}
function verifyCorrectLocation(
userState: UserState | null,
currentLocation: Location,
): boolean {
return false;
}
async function createInitUserInstruction(
account: PublicKey,
): Promise<TransactionInstruction> {
throw "";
}
async function createCheckInInstruction(
account: PublicKey,
reference: PublicKey,
location: Location,
): Promise<TransactionInstruction> {
throw "";
}
7. Implement fetchUserState
function #
With the buildTransaction
function finished, we can start implementing the
empty helper functions we created, starting with fetchUserState
. This function
uses the gameId
and user's account
to derive the user state PDA, then
fetches that account, returning null if it doesn't exist.
async function fetchUserState(account: PublicKey): Promise<UserState | null> {
const userStatePDA = PublicKey.findProgramAddressSync(
[gameId.toBuffer(), account.toBuffer()],
program.programId,
)[0];
try {
return await program.account.userState.fetch(userStatePDA);
} catch {
return null;
}
}
8. Implement verifyCorrectLocation
function #
Next, let's implement the verifyCorrectLocation
helper function. This function
is used to verify that a user is at the correct location in a scavenger hunt
game.
If userState
is null
, that means the user should be visiting the first
location. Otherwise, the user should be visiting the location whose index is 1
more than their last visited location.
If these conditions are satisfied, the function will return true. Otherwise, it'll return false.
function verifyCorrectLocation(
userState: UserState | null,
currentLocation: Location,
): boolean {
if (!userState) {
return currentLocation.index === 1;
}
const lastLocation = locations.find(
location => location.key.toString() === userState.lastLocation.toString(),
);
if (!lastLocation || currentLocation.index !== lastLocation.index + 1) {
return false;
}
return true;
}
9. Implement the instruction creation functions #
Lastly, let's implement createInitUserInstruction
and
createCheckInInstruction
. These can use Anchor to generate and return the
corresponding instructions. The only catch is that createCheckInInstruction
needs to add reference
to the instructions list of keys.
async function createInitUserInstruction(
account: PublicKey,
): Promise<TransactionInstruction> {
const initializeInstruction = await program.methods
.initialize(gameId)
.accounts({ user: account })
.instruction();
return initializeInstruction;
}
async function createCheckInInstruction(
account: PublicKey,
reference: PublicKey,
location: Location,
): Promise<TransactionInstruction> {
const checkInInstruction = await program.methods
.checkIn(gameId, location.key)
.accounts({
user: account,
eventOrganizer: eventOrganizer.publicKey,
})
.instruction();
checkInInstruction.keys.push({
pubkey: reference,
isSigner: false,
isWritable: false,
});
return checkInInstruction;
}
10. Test the app #
At this point your app should be working! Go ahead and test it using your mobile
wallet. Start by scanning the QR code for Location 1
. Remember to make sure
your frontend is running using the ngrok URL rather than localhost
.
After scanning the QR code, you should see a message indicating that you are at
location 1. From there, scan the QR code on the Location 2
page. You may need
to wait a few seconds for the previous transaction to finalize before
continuing.
Congratulations, you have successfully finished the scavenger hunt demo using Solana Pay! Depending on your background, this may not feel intuitive or straightforward. If that's the case, feel free to go through the lab again or make something on your own. Solana Pay opens a lot of doors for bridging the gap between real life and onchain interaction.
If you want to take a look at the final solution code you can find it on the solution branch of the same repository.
Challenge #
It's time to try this out on your own. Feel free to build out an idea of your own using Solana Pay. Or, if you need some inspiration, you can use the prompt below.
Build out an app using Solana Pay (or modify the one from the lab) to mint an NFT to users. To take it up a notch, only make the transaction possible if the user meets one or more conditions (e.g. holds an NFT from a specific collection, is already on a pre-determined list, etc.).
Get creative with this! The Solana pay spec opens up a lot of doors for unique use cases.
Push your code to GitHub and tell us what you thought of this lesson!