An Introduction to Solana Pay and how to Integrate it into Your Next.js App
Avneesh Agarwal
Posted on March 28, 2023
In this guide we'll walk through the following:
- What is Solana Pay?
- When to use Solana Pay?
- How to use Solana Pay in a next.js app and create an app where people can scan a QR code to buy SPL tokens in exchange for SOL
By the end of this guide, we will build something like this:
Let's get started!
What is Solana Pay?
Solana Pay is a standard protocol and set of reference implementations that enable developers to incorporate decentralized payments into their apps and services.
Using Solana Pay you can create Transfer Request and Transaction Request via a standard URL (which can also be encoded to be used in a QR). These can be used for creating a payment request and doing something once the payment is completed.
When to use Solana Pay?
Solana Pay has a lot of use cases from complex payments on the web to using it in IRL stores to collect payments instantly. Some use cases for it:
- Selling NFTs
- Selling SPL tokens
- Creating an E-commerce website
- Accepting SOL/any SPL tokens in your IRL store
- Many more, you can go crazy with it!
Using Solana Pay in our Next.js app
Creating a Next.js App
We'll first create a new next.js app, if you already have an app you can skip this section and go to the installing dependencies section. Run this command in your terminal:
npx create-next-app solana-pay-demo
I am using the following config but feel free to use whatever you like!
Once the app is created, open it in your favourite editor and let's get started!
Installing the dependencies
We are going to need these packages for integrating solana pay:
npm i @solana/pay @solana/spl-token @solana/web3.js bignumber.js bs58 # npm
yarn add @solana/pay @solana/spl-token @solana/web3.js bignumber.js bs58 # yarn
For rendering the QR code:
npm i react-qr-code # npm
yarn add react-qr-code # yarn
Finally, we are going to create our token for selling but if you already have an SPL token no need to install these:
npm i @metaplex-foundation/js @metaplex-foundation/mpl-token-metadata # npm
yarn add @metaplex-foundation/js @metaplex-foundation/mpl-token-metadata # yarn
Creating the SPL token
Create a new file called scripts/create-token.mjs
and add the following:
import {
bundlrStorage,
keypairIdentity,
Metaplex,
} from "@metaplex-foundation/js";
import { createCreateMetadataAccountV2Instruction } from "@metaplex-foundation/mpl-token-metadata";
import {
createAssociatedTokenAccountInstruction,
createInitializeMintInstruction,
createMintToInstruction,
getAssociatedTokenAddress,
getMinimumBalanceForRentExemptMint,
MINT_SIZE,
TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import {
Connection,
Keypair,
SystemProgram,
TransactionMessage,
VersionedTransaction,
} from "@solana/web3.js";
import base58 from "bs58";
import "dotenv/config";
const endpoint = "https://api.devnet.solana.com";
const solanaConnection = new Connection(endpoint);
const MINT_CONFIG = {
numDecimals: 6,
numberTokens: 10000,
};
const MY_TOKEN_METADATA = {
name: "Solana Pay Demo",
symbol: "SPD",
description: "A demo token for Solana Pay",
image: "https://cryptologos.cc/logos/solana-sol-logo.png",
};
const ON_CHAIN_METADATA = {
name: MY_TOKEN_METADATA.name,
symbol: MY_TOKEN_METADATA.symbol,
uri: "",
sellerFeeBasisPoints: 0,
creators: null,
collection: null,
uses: null,
};
const uploadMetadata = async (wallet, tokenMetadata) => {
const metaplex = Metaplex.make(solanaConnection)
.use(keypairIdentity(wallet))
.use(
bundlrStorage({
address: "https://devnet.bundlr.network",
providerUrl: endpoint,
timeout: 60000,
})
);
const { uri } = await metaplex.nfts().uploadMetadata(tokenMetadata);
console.log(`Arweave URL: `, uri);
return uri;
};
const createNewMintTransaction = async (
connection,
payer,
mintKeypair,
destinationWallet,
mintAuthority,
freezeAuthority
) => {
const metaplex = Metaplex.make(solanaConnection)
.use(keypairIdentity(payer))
.use(
bundlrStorage({
address: "https://devnet.bundlr.network",
providerUrl: endpoint,
timeout: 60000,
})
);
const requiredBalance = await getMinimumBalanceForRentExemptMint(connection);
const metadataPDA = metaplex
.nfts()
.pdas()
.metadata({ mint: mintKeypair.publicKey });
const tokenATA = await getAssociatedTokenAddress(
mintKeypair.publicKey,
destinationWallet
);
const txInstructions = [];
txInstructions.push(
SystemProgram.createAccount({
fromPubkey: payer.publicKey,
newAccountPubkey: mintKeypair.publicKey,
space: MINT_SIZE,
lamports: requiredBalance,
programId: TOKEN_PROGRAM_ID,
}),
createInitializeMintInstruction(
mintKeypair.publicKey,
MINT_CONFIG.numDecimals,
mintAuthority,
freezeAuthority,
TOKEN_PROGRAM_ID
),
createAssociatedTokenAccountInstruction(
payer.publicKey,
tokenATA,
payer.publicKey,
mintKeypair.publicKey
),
createMintToInstruction(
mintKeypair.publicKey,
tokenATA,
mintAuthority,
MINT_CONFIG.numberTokens * Math.pow(10, MINT_CONFIG.numDecimals)
),
createCreateMetadataAccountV2Instruction(
{
metadata: metadataPDA,
mint: mintKeypair.publicKey,
mintAuthority: mintAuthority,
payer: payer.publicKey,
updateAuthority: mintAuthority,
},
{
createMetadataAccountArgsV2: {
data: ON_CHAIN_METADATA,
isMutable: true,
},
}
)
);
const latestBlockhash = await connection.getLatestBlockhash();
const messageV0 = new TransactionMessage({
payerKey: payer.publicKey,
recentBlockhash: latestBlockhash.blockhash,
instructions: txInstructions,
}).compileToV0Message();
const transaction = new VersionedTransaction(messageV0);
transaction.sign([payer, mintKeypair]);
return transaction;
};
const main = async () => {
const userWallet = Keypair.fromSecretKey(
base58.decode(process.env.WALLET_PRIVATE_KEY)
);
let metadataUri = await uploadMetadata(userWallet, MY_TOKEN_METADATA);
ON_CHAIN_METADATA.uri = metadataUri;
let mintKeypair = Keypair.generate();
const newMintTransaction = await createNewMintTransaction(
solanaConnection,
userWallet,
mintKeypair,
userWallet.publicKey,
userWallet.publicKey,
userWallet.publicKey
);
const transactionId = await solanaConnection.sendTransaction(
newMintTransaction
);
console.log(
`Succesfully minted ${MINT_CONFIG.numberTokens} ${
ON_CHAIN_METADATA.symbol
} to ${userWallet.publicKey.toString()}.`
);
console.log(
`View Transaction: https://explorer.solana.com/tx/${transactionId}?cluster=devnet`
);
};
main();
This script is taking the metadata entered, and uploading it to the bundlr network, then creates a token on solana, finally, it mints the number of tokens we entered at the top.
Before running the script you need to do the following:
- Install dotenv:
npm i dotenv # npm
yarn add dotenv # yarn
- Add your private key in
.env
(Make sure to add .env in .gitignore or you can loose your funds) with the nameWALLET_PRIVATE_KEY
- Update
MY_TOKEN_METADATA
with metadata of your own token andMINT_CONFIG
with the number of tokens you wanna mint
Now, you can run the script!
node scripts/create-token.mjs
Click on the transaction URL and copy the token address, since we are going to need it later.
Creating the UI
The UI of our application will be really simple. We will have one input for selecting the number of tokens you want to buy, a button for generating the QR code and a QR code.
So, let's add the input and button:
<main className={styles.main}>
<div>
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
/>
<button onClick={createPayment}>Generate QR</button>
</div>
</main>
As you can see we need a state for storing the quantity:
const [quantity, setQuantity] = useState(0);
We also need to create a createPayment
like this:
const reference = useMemo(() => Keypair.generate().publicKey, []);
const createPayment = async () => {
if (!quantity) {
return;
}
const apiUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/makeTransaction?amount=${quantity}&reference=${reference}`;
const urlParams = {
link: new URL(apiUrl),
label: "Solana Pay Demo",
message: "Thanks for buying our tokens!",
};
const solanaUrl = encodeURL(urlParams);
setQrCode(solanaUrl.href);
};
Here, we are first creating a reference.
You might be wondering what exactly is a reference here. Click me to know more about itWhen a customer scans a QR code to make a payment, it can be tricky to update the website to show that the payment went through. Solana pay has a unique way of handling this. We generate a special public key called a "reference" and attach it to the payment. Then we use that public key to find the payment and update the website.
Then, we are checking if there is a quantity, if not we will do nothing. Later we are creating an API URL (we will create this later) and pass it to encode it and finally set it in a state.
So, create a new file called .env.local
and add the following for now:
NEXT_PUBLIC_APP_URL=http://localhost:3000
This creates a new public environment variable that we can use in our app.
We also need to create a state for storing the URL:
const [qrCode, setQrCode] = useState<string | null>(null);
Finally, let's use the react-qr-code
package to render it:
{
qrCode && <QRCode value={qrCode} />;
}
You can customise it as you want but I am going to leave it just like this for now!
If you open up localhost you will be able to see a screen like this:
If you scan this QR code it will erroQRout, but wait for a while!
Building the API
We will now build the API that we passed in for generating our QR Code. So how Solana Pay works is, it will make 2 requests to the same router. One will be a GET
request and another will be a POST
request. The GET
request is responsible for giving the icon and a label as metadata and the POST
request is where the actual transaction stuff happens.
Here is a great illustration from the Solana Pay docs that explains this:
So, let's start building the API! Create a new file makeTransaction.ts
in src/pages/api
and add the following:
import { NextApiRequest, NextApiResponse } from "next";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "GET") {
return get(res);
} else if (req.method === "POST") {
return await post(req, res);
} else {
return res.status(405).json({ error: "Method not allowed" });
}
};
export default handler;
Here, we are simply checking what the request method is and based on that calling some functions:
- If it is a
GET
method we are calling the get method and passing in res - If it is a
POST
method we are calling the post function and passing in req as well as res - Finally, if it is neither of the two we are sending the 405 status with
Method not allowed
error.
Now, let's write the get function:
const get = (res: NextApiResponse) => {
const label = "Buy some tokens";
const icon = "https://cryptologos.cc/logos/solana-sol-logo.png";
return res.status(200).json({
label,
icon,
});
};
The get function is pretty simple, we are just returning a label and an icon for the solana pay payment that the user will be able to see when the transaction pops up.
Now, let's head over to the post function which is where the actual magic happens:
const post = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const { reference, amount } = req.query as {
reference: string;
amount: string;
};
const { account } = req.body as {
account: string;
};
if (parseInt(amount) === 0) {
return res.status(400).json({ error: "Can't checkout with charge of 0" });
}
if (!reference) {
return res.status(400).json({ error: "No reference provided" });
}
if (!account) {
return res.status(400).json({ error: "No account provided" });
}
} catch (err) {
console.error("error:", err);
return res.status(500).json({ error: "error creating transaction" });
}
};
We are adding a try-catch block for checking for errs and in the try block we will first get reference
and amount
from req.query
and account
from the body. Then we are doing some normal checks to make sure that we get the correct data when the API is called.
Then we need to create a connection with the solana devnet so add this:
const connection = new Connection("https://api.devnet.solana.com", "confirmed");
You can use any RPC you want but for now, I will use the official solana RPC.
Then, for the SPL token transfer we need the wallet private key, so create a new variable in .env.local
called WALLET_PRIVATE_KEY
and add in your private key. We will get it like this:
const walletPrivateKey = process.env.WALLET_PRIVATE_KEY as string;
if (!walletPrivateKey) {
res.status(500).json({ error: "Wallet private key not available" });
}
const walletKeyPair = Keypair.fromSecretKey(base58.decode(walletPrivateKey));
Import Keypair and bs58 like this:
import { Keypair } from "@solana/web3.js";
import base58 from "bs58";
Now, we will add in the required addresses:
const buyerPublicKey = new PublicKey(account);
const walletPublicKey = new PublicKey(
"FW79xRL1yks1Y9bD8NSB888YGRmyEq4SCMYhFodHLWh9"
);
const tokenAddress = new PublicKey(
"FtQBZ2jsDLvXLdeo12LkMndjvLm6kAUWmdntiaxsWQqu"
);
The tokenAddress
is the address of the token that we deployed earlier and the walletPublicKey
is the address of the wallet where you want the funds to go and the wallet which has the tokens that need to be sent to the buyer.
Finally we need the tokenAccountAddresses of the seller and buyer:
const buyerTokenAddress = await getOrCreateAssociatedTokenAccount(
connection,
walletKeyPair,
tokenAddress,
buyerPublicKey
).then((account) => account.address);
const sellerTokenAddress = await getAssociatedTokenAddress(
tokenAddress,
walletPublicKey
);
You can import getAssociatedTokenAddress
and getOrCreateAssociatedTokenAccount
from @solana/spl-token
:
import {
getAssociatedTokenAddress,
getOrCreateAssociatedTokenAccount,
} from "@solana/spl-token";
Now, let's add the transaction and transferInstruction
of the SOL from the buyer to seller like this:
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash(
"finalized"
);
const transaction = new Transaction({
blockhash,
feePayer: buyerPublicKey,
lastValidBlockHeight,
});
const transferInstruction = SystemProgram.transfer({
fromPubkey: buyerPublicKey,
toPubkey: walletPublicKey,
lamports: parseInt(amount) * LAMPORTS_PER_SOL,
});
transferInstruction.keys.push({
pubkey: new PublicKey(reference),
isSigner: false,
isWritable: false,
});
And the token transfer instruction from the seller to the buyer:
const tokenInstruction = createTransferCheckedInstruction(
shopTokenAddress,
tokenAddress,
buyerTokenAddress,
walletPublicKey,
parseInt(amount) * 10 ** 6,
6
);
tokenInstruction.keys.push({
pubkey: walletPublicKey,
isSigner: true,
isWritable: false,
});
Finally, we need to add the instructions to the transaction, partially sign the transaction, and serialise it:
transaction.add(transferInstruction, tokenInstruction);
transaction.partialSign(walletKeyPair);
const serializedTransaction = transaction.serialize({
requireAllSignatures: false,
});
Now, we can return it like this:
const base64 = serializedTransaction.toString("base64");
return res.status(200).json({
transaction: base64,
message: `Buying ${amount} ${amount === "1" ? "token" : "tokens"}`,
});
The final API code looks something like this:
import {
createTransferCheckedInstruction,
getAssociatedTokenAddress,
getOrCreateAssociatedTokenAccount,
} from "@solana/spl-token";
import {
Connection,
Keypair,
LAMPORTS_PER_SOL,
PublicKey,
SystemProgram,
Transaction,
} from "@solana/web3.js";
import base58 from "bs58";
import { NextApiRequest, NextApiResponse } from "next";
export type MakeTransactionOutputData = {
transaction: string;
message: string;
};
const post = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const { reference, amount } = req.query as {
reference: string;
amount: string;
};
const { account } = req.body as {
account: string;
};
if (parseInt(amount) === 0) {
return res.status(400).json({ error: "Can't checkout with charge of 0" });
}
if (!reference) {
return res.status(400).json({ error: "No reference provided" });
}
if (!account) {
return res.status(400).json({ error: "No account provided" });
}
const connection = new Connection(
"https://api.devnet.solana.com",
"confirmed"
);
const walletPrivateKey = process.env.WALLET_PRIVATE_KEY as string;
if (!walletPrivateKey) {
res.status(500).json({ error: "Wallet private key not available" });
}
const walletKeyPair = Keypair.fromSecretKey(
base58.decode(walletPrivateKey)
);
const buyerPublicKey = new PublicKey(account);
const walletPublicKey = new PublicKey(
"FW79xRL1yks1Y9bD8NSB888YGRmyEq4SCMYhFodHLWh9"
);
const tokenAddress = new PublicKey(
"FtQBZ2jsDLvXLdeo12LkMndjvLm6kAUWmdntiaxsWQqu"
);
const buyerTokenAddress = await getOrCreateAssociatedTokenAccount(
connection,
walletKeyPair,
tokenAddress,
buyerPublicKey
).then((account) => account.address);
const shopTokenAddress = await getAssociatedTokenAddress(
tokenAddress,
walletPublicKey
);
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash("finalized");
const transaction = new Transaction({
blockhash,
feePayer: buyerPublicKey,
lastValidBlockHeight,
});
const transferInstruction = SystemProgram.transfer({
fromPubkey: buyerPublicKey,
toPubkey: walletPublicKey,
lamports: parseInt(amount) * LAMPORTS_PER_SOL,
});
transferInstruction.keys.push({
pubkey: new PublicKey(reference),
isSigner: false,
isWritable: false,
});
const tokenInstruction = createTransferCheckedInstruction(
shopTokenAddress,
tokenAddress,
buyerTokenAddress,
walletPublicKey,
parseInt(amount) * 10 ** 6,
6
);
tokenInstruction.keys.push({
pubkey: walletPublicKey,
isSigner: true,
isWritable: false,
});
transaction.add(transferInstruction, tokenInstruction);
transaction.partialSign(walletKeyPair);
const serializedTransaction = transaction.serialize({
requireAllSignatures: false,
});
const base64 = serializedTransaction.toString("base64");
return res.status(200).json({
transaction: base64,
message: `Buying ${amount} ${amount === "1" ? "token" : "tokens"}`,
});
} catch (err) {
console.error("error:", err);
return res.status(500).json({ error: "error creating transaction" });
}
};
const get = (res: NextApiResponse) => {
const label = "Buy some tokens";
const icon = "https://cryptologos.cc/logos/solana-sol-logo.png";
return res.status(200).json({
label,
icon,
});
};
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "GET") {
return get(res);
} else if (req.method === "POST") {
return await post(req, res);
} else {
return res.status(405).json({ error: "Method not allowed" });
}
};
export default handler;
Solana Pay doesn't allow testing on localhost, so we will use ngrok to test it.
Go to ngrok, create an account/login, and download it on your machine.
Once ngrok is downloaded run this command:
ngrok http 3000
This will start a tunnel and serve your web app
Copy the forwarding ngrok URL and replace localhost in your env variable with it.
If you now try generating a new QR code and scanning it via your phone, a transaction will pop up and you will be able to buy some SPL tokens in exchange for SOL 🥳
Conclusion
That's it for this guide. Hope you learned what is solana pay and how to use it in your Next.js app! Massive shoutout to 0xMukesh for helping me with this guide and answering my stupid questions 🫡.
Useful links
Posted on March 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.