An Introduction to Solana Pay and how to Integrate it into Your Next.js App

An Introduction to Solana Pay and how to Integrate it into Your Next.js App

In this guide we'll walk through the following:

  1. What is Solana Pay?

  2. When to use Solana Pay?

  3. 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?

What the hell 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!

Create a new next app using create-next-app for solana pay demo

Once the app is created, open it in your favourite editor and let's get started!

Let's get started building our solana dapp

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 name WALLET_PRIVATE_KEY

  • Update MY_TOKEN_METADATA with metadata of your own token and MINT_CONFIG with the number of tokens you wanna mint

Now, you can run the script!

node scripts/create-token.mjs

Run the create token script to create an SPL token

Click on the transaction URL and copy the token address, since we are going to need it later.

Copy the token address

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 it
When 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:

Generate QR code localhost

If you scan this QR code it will erroQRout, but wait for a while!

Patience You must learn - Solana Pay

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:

How solana pay works

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

Start a new tunnel using ngrok

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 🫡.

GitHub Repo

Solana Pay

Connect with me

Did you find this article valuable?

Support Avneesh Agarwal by becoming a sponsor. Any amount is appreciated!