Introduction

In this guide, we’ll show you how to create Solana transactions where a fee payer wallet pays the gas fees instead of your users. We achieve this by partially signing the transaction on the server and sending it to the client for final signing.

Getting Started

Setting up the Project

We’ll use Next.js for this example since we can then keep the frontend and the API route together. To get started, create a new project with:

npx create-dynamic-app@latest gasless-solana

If you already have a Next.js app, simply follow our quickstart guide to add the Dynamic SDK.

Setting Up the Fee Payer Wallet

You’ll need a wallet that will pay for gas fees on behalf of your users:

  1. Create a Solana wallet and add some funds to it (for paying gas fees)
  2. Add its private key (string format) to your .env.local file:
.env.local
FEE_PAYER_PRIVATE_KEY=your_private_key_here
NEXT_PUBLIC_RPC=https://api.devnet.solana.com

Never share your private key or commit it to your code repository. Always use environment variables and add them to your .gitignore.

Server Implementation

Creating the API Route

Now we’ll create an API route that will prepare partially-signed transactions. Create a file at app/api/gas/route.ts:

app/api/gas/route.ts
import {
  createTransferCheckedInstruction,
  getAssociatedTokenAddress,
  createAssociatedTokenAccountInstruction,
} from "@solana/spl-token";
import { Connection, Keypair, PublicKey, Transaction } from "@solana/web3.js";
import { NextRequest, NextResponse } from "next/server";
import bs58 from "bs58";

export const dynamic = "force-dynamic";

// This is a USDC token address on devnet - replace with your token address if needed
const USDC_MINT = new PublicKey("Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr");
const RPC_URL = process.env.NEXT_PUBLIC_RPC || "https://api.devnet.solana.com";
const PRIVATE_KEY = process.env.FEE_PAYER_PRIVATE_KEY || "";

export async function POST(request: NextRequest) {
  try {
    // Get data from the request
    const body = await request.json();
    const { senderAddress, recipientAddress, amount } = body;

    // Basic validation
    if (!senderAddress || !recipientAddress || !amount) {
      return NextResponse.json(
        { success: false, message: "Missing required parameters" },
        { status: 400 }
      );
    }

    if (!PRIVATE_KEY) {
      return NextResponse.json(
        {
          success: false,
          message: "Missing fee payer private key in environment variables",
        },
        { status: 500 }
      );
    }

    const connection = new Connection(RPC_URL, "confirmed");

    // Set up the fee payer wallet
    const privateKeyBuffer = bs58.decode(PRIVATE_KEY);
    const feePayer = Keypair.fromSecretKey(new Uint8Array(privateKeyBuffer));

    // Convert addresses to PublicKey objects
    let sender: PublicKey;
    let recipient: PublicKey;
    try {
      sender = new PublicKey(senderAddress);
      recipient = new PublicKey(recipientAddress);
    } catch (error) {
      return NextResponse.json(
        { success: false, message: "Invalid Solana address" },
        { status: 400 }
      );
    }

    // Get token accounts for sender and recipient
    const senderTokenAccount = await getAssociatedTokenAddress(
      USDC_MINT,
      sender
    );
    const recipientTokenAccount = await getAssociatedTokenAddress(
      USDC_MINT,
      recipient
    );

    const recipientTokenInfo = await connection.getAccountInfo(
      recipientTokenAccount
    );

    const instructions = [];

    // Create recipient token account if it doesn't exist
    if (!recipientTokenInfo) {
      instructions.push(
        createAssociatedTokenAccountInstruction(
          feePayer.publicKey,
          recipientTokenAccount,
          recipient,
          USDC_MINT
        )
      );
    }

    // Add transfer instruction
    instructions.push(
      createTransferCheckedInstruction(
        senderTokenAccount,
        USDC_MINT,
        recipientTokenAccount,
        sender,
        BigInt(amount),
        6
      )
    );

    // Create and partially sign transaction
    const { blockhash, lastValidBlockHeight } =
      await connection.getLatestBlockhash("confirmed");

    const transaction = new Transaction();
    transaction.recentBlockhash = blockhash;
    transaction.lastValidBlockHeight = lastValidBlockHeight;
    transaction.feePayer = feePayer.publicKey;
    instructions.forEach((instruction) => transaction.add(instruction));
    transaction.partialSign(feePayer);

    // Serialize the transaction to send back to client
    const serializedTransaction = bs58.encode(
      transaction.serialize({
        requireAllSignatures: false,
        verifySignatures: false,
      })
    );

    return NextResponse.json({
      success: true,
      serializedTransaction,
      message: transaction.serializeMessage().toString("base64"),
    });
  } catch (error) {
    return NextResponse.json(
      {
        success: false,
        message:
          error instanceof Error ? error.message : "Unknown error occurred",
      },
      { status: 500 }
    );
  }
}

How It Works

Our API route:

  1. Receives details about the transfer (who’s sending, receiving, and how much)
  2. Creates a transaction with the instructions
  3. Sets our server wallet as the fee payer
  4. Partially signs the transaction with the fee payer wallet
  5. Returns the transaction to the frontend

Client Implementation

Creating the Frontend Component

Now let’s create a simple UI for users to send tokens without paying gas. Create app/components/Send.tsx:

app/components/Send.tsx
import { useDynamicContext, useIsLoggedIn } from "@dynamic-labs/sdk-react-core";
import { isSolanaWallet } from "@dynamic-labs/solana";
import { useState } from "react";
import { PublicKey, Transaction } from "@solana/web3.js";
import bs58 from "bs58";
import "./Send.css";

export default function Send() {
  const isLoggedIn = useIsLoggedIn();
  const { primaryWallet } = useDynamicContext();
  const [isLoading, setIsLoading] = useState(false);
  const [result, setResult] = useState("");
  const [recipientAddress, setRecipientAddress] = useState("");
  const [amount, setAmount] = useState("");
  const [txSignature, setTxSignature] = useState<string | null>(null);

  const sendUSDC = async () => {
    if (!primaryWallet || !isSolanaWallet(primaryWallet)) {
      setResult("Wallet not connected or not a Solana wallet");
      return;
    }

    if (!recipientAddress || !amount) {
      setResult("Please enter recipient address and amount");
      return;
    }

    try {
      setIsLoading(true);
      setResult("Preparing transaction...");

      // Validate recipient address
      let toAddress: PublicKey;
      try {
        toAddress = new PublicKey(recipientAddress);
      } catch (error) {
        setResult("Invalid recipient address");
        setIsLoading(false);
        return;
      }

      // Convert amount to USDC units (6 decimals)
      const amountInUsdcUnits = parseFloat(amount) * 1_000_000;

      // Call our API to prepare the transaction
      const response = await fetch("/api/gas", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          senderAddress: primaryWallet.address,
          recipientAddress: toAddress.toString(),
          amount: amountInUsdcUnits,
        }),
      });

      const responseData = await response.json();

      if (!response.ok) {
        throw new Error(
          responseData.message || "Failed to prepare transaction"
        );
      }

      // Get signer from wallet and the prepared transaction
      const { serializedTransaction } = responseData;
      const signer = await primaryWallet.getSigner();

      setResult("Please sign the transaction...");

      try {
        // Have the user sign and send the transaction
        setResult("Signing transaction...");
        const transaction = Transaction.from(
          bs58.decode(serializedTransaction)
        );

        const { signature } = await signer.signAndSendTransaction(transaction);

        setTxSignature(signature);
        setResult(`USDC transfer successful!`);
      } catch (err) {
        setTxSignature(null);
        setResult(
          `Error signing transaction: ${
            err instanceof Error ? err.message : String(err)
          }`
        );
      }
    } catch (error) {
      setTxSignature(null);
      setResult(
        `Error: ${error instanceof Error ? error.message : String(error)}`
      );
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className="send">
      <div className="usdc-transfer">
        <h2>Send USDC (Gasless)</h2>
        <div className="input-group">
          <input
            type="text"
            placeholder="Recipient Address"
            value={recipientAddress}
            onChange={(e) => setRecipientAddress(e.target.value)}
          />
          <input
            type="text"
            placeholder="Amount in USDC"
            value={amount}
            onChange={(e) => setAmount(e.target.value)}
          />
          <button
            onClick={sendUSDC}
            disabled={
              !isLoggedIn ||
              !primaryWallet ||
              isLoading ||
              !recipientAddress ||
              !amount
            }
          >
            {isLoading ? "Processing..." : "Send USDC"}
          </button>
        </div>
      </div>

      {result && (
        <div className="result">
          <p className="result-message">{result}</p>
          {txSignature && (
            <div className="tx-details">
              <p className="tx-signature">
                <span>Signature:</span> {txSignature}
              </p>
              <a
                href={`https://explorer.solana.com/tx/${txSignature}?cluster=devnet`}
                target="_blank"
                rel="noopener noreferrer"
                className="explorer-link"
              >
                View on Solana Explorer
              </a>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

For styling, create the Send.css file and you can find the styles we used here.

Using the Component

Finally, add the component to your main page in app/page.tsx:

app/page.tsx
<div className="modal">
  <DynamicWidget />
  <Send />
</div>

Conclusion

Congratulations! You’ve successfully implemented gasless transactions on Solana using Dynamic’s SDK. This approach creates a superior user experience by allowing your users to perform transactions without worrying about gas fees.

Next Steps

Consider implementing rate limiting or additional verification to protect your fee payer wallet from abuse.

To get the complete source code, check out our GitHub repository.