Skip to main content
This is a React-only guide.

Introduction

In this guide, we’ll show you how to create Solana transactions where users pay transaction fees in SPL tokens (like USDC) instead of SOL. We achieve this using Kora, a fee abstraction service that allows users to pay fees in any supported SPL token. This approach is different from traditional gasless transactions because:
  • Users still pay fees, but in SPL tokens instead of SOL
  • No server-side fee payer wallet is required
  • Kora handles fee estimation and payment instruction creation
  • Kora co-signs transactions as the fee payer

Getting Started

Setting up the Project

We’ll use Next.js for this example. To get started, create a new project with:
npx create-dynamic-app@latest gasless-solana-kora
If you already have a Next.js app, simply follow our quickstart guide to add the Dynamic SDK.

Installing Dependencies

Install the required packages:
bun add @solana/kora @solana/kit @solana-program/compute-budget @solana-program/memo @solana/transaction-confirmation

Setting Up Kora

You’ll need a running Kora instance before you can use sponsored transactions. You can:
  1. Run Kora locally - Follow the Kora setup guide to run a local instance
  2. Use a hosted Kora instance - Point your configuration to your hosted Kora server URL
For local development, Kora typically runs on http://localhost:8080/. If you’re using devnet instead of localnet, pass the RPC URL to the Kora command:
kora rpc start --signers-config signers.toml --rpc-url https://api.devnet.solana.com

Configuration

The example uses a configuration object with the following values:
const CONFIG = {
  computeUnitLimit: 200_000,
  computeUnitPrice: BigInt(1_000_000) as MicroLamports,
  transactionVersion: 0 as TransactionVersion,
  solanaRpcUrl: "https://api.devnet.solana.com",
  solanaWsUrl: "wss://api.devnet.solana.com",
  koraRpcUrl: "http://localhost:8080/",
  tokenMintAddress: "5whA1qmcFkywQPoxsZ43185kzpeChAVbiRj2j5HanBZy",
};
Update koraRpcUrl to point to your Kora instance, and set tokenMintAddress to the SPL token you want to use for fee payments. If you’re using devnet, make sure solanaRpcUrl and solanaWsUrl point to devnet endpoints. For production, consider using environment variables instead of hardcoded values.

Client Implementation

Creating the Transaction Component

Now let’s create a component that handles sponsored transactions using Kora. Create components/gasless-transaction-demo.tsx:
components/gasless-transaction-demo.tsx
"use client";

import { useDynamicContext, useIsLoggedIn } from "@dynamic-labs/sdk-react-core";
import { isSolanaWallet } from "@dynamic-labs/solana";
import {
  updateOrAppendSetComputeUnitLimitInstruction,
  updateOrAppendSetComputeUnitPriceInstruction,
} from "@solana-program/compute-budget";
import { getAddMemoInstruction } from "@solana-program/memo";
import {
  findAssociatedTokenPda,
  TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";
import {
  Base64EncodedWireTransaction,
  Blockhash,
  Instruction,
  MicroLamports,
  TransactionVersion,
  address,
  appendTransactionMessageInstructions,
  createNoopSigner,
  createSolanaRpc,
  createSolanaRpcSubscriptions,
  createTransactionMessage,
  getBase64EncodedWireTransaction,
  partiallySignTransactionMessageWithSigners,
  pipe,
  setTransactionMessageFeePayerSigner,
  setTransactionMessageLifetimeUsingBlockhash,
} from "@solana/kit";
import { KoraClient } from "@solana/kora";
import { createRecentSignatureConfirmationPromiseFactory } from "@solana/transaction-confirmation";
import { VersionedTransaction } from "@solana/web3.js";
import { useState, useEffect } from "react";

const CONFIG = {
  computeUnitLimit: 200_000,
  computeUnitPrice: BigInt(1_000_000) as MicroLamports,
  transactionVersion: 0 as TransactionVersion,
  solanaRpcUrl: "https://api.devnet.solana.com",
  solanaWsUrl: "wss://api.devnet.solana.com",
  koraRpcUrl: "http://localhost:8080/",
  tokenMintAddress: "5whA1qmcFkywQPoxsZ43185kzpeChAVbiRj2j5HanBZy",
};

export default function GaslessTransactionDemo() {
  const isLoggedIn = useIsLoggedIn();
  const { primaryWallet } = useDynamicContext();
  const [status, setStatus] = useState<string>("");
  const [loading, setLoading] = useState(false);
  const [transactionSignature, setTransactionSignature] = useState<
    string | null
  >(null);
  const [tokenBalance, setTokenBalance] = useState<string | null>(null);
  const [tokenBalanceLoading, setTokenBalanceLoading] = useState(false);

  // Fetch the user's token balance for the configured payment token
  useEffect(() => {
    const fetchTokenBalance = async () => {
      if (!primaryWallet || !isSolanaWallet(primaryWallet)) {
        setTokenBalance(null);
        return;
      }

      setTokenBalanceLoading(true);
      try {
        const rpc = createSolanaRpc(CONFIG.solanaRpcUrl);
        const mintAddress = address(CONFIG.tokenMintAddress);
        const ownerAddress = address(primaryWallet.address);

        const [ata] = await findAssociatedTokenPda({
          mint: mintAddress,
          owner: ownerAddress,
          tokenProgram: TOKEN_PROGRAM_ADDRESS,
        });

        const tokenAccountData = await rpc
          .getAccountInfo(ata, {
            encoding: "jsonParsed",
          })
          .send();

        if (
          tokenAccountData.value?.data &&
          "parsed" in tokenAccountData.value.data
        ) {
          const parsed = tokenAccountData.value.data.parsed as {
            info?: {
              tokenAmount?: {
                amount: string;
                decimals: number;
              };
            };
          };
          if (parsed.info?.tokenAmount) {
            const amount = parsed.info.tokenAmount.amount;
            const decimals = parsed.info.tokenAmount.decimals;
            const balance = Number(amount) / Math.pow(10, decimals);
            setTokenBalance(balance.toFixed(decimals > 6 ? 6 : decimals));
          } else {
            setTokenBalance("0");
          }
        } else {
          setTokenBalance("0");
        }
      } catch (error) {
        setTokenBalance("Error");
      } finally {
        setTokenBalanceLoading(false);
      }
    };

    fetchTokenBalance();
  }, [primaryWallet]);

  const handleGaslessTransaction = async () => {
    if (!primaryWallet || !isSolanaWallet(primaryWallet)) {
      setStatus("Error: Solana wallet not available or not properly connected");
      return;
    }

    setLoading(true);
    setStatus("Initializing...");
    setTransactionSignature(null);

    try {
      setStatus("Connecting to Kora...");
      const koraClient = new KoraClient({
        rpcUrl: CONFIG.koraRpcUrl,
      });

      const rpc = createSolanaRpc(CONFIG.solanaRpcUrl);
      const rpcSubscriptions = createSolanaRpcSubscriptions(CONFIG.solanaWsUrl);
      const confirmTransaction =
        createRecentSignatureConfirmationPromiseFactory({
          rpc,
          rpcSubscriptions,
        });

      setStatus("Getting Kora signer...");
      const { signer_address } = await koraClient.getPayerSigner();
      const noopSigner = createNoopSigner(address(signer_address));

      setStatus("Getting payment token...");
      const config = await koraClient.getConfig();
      const paymentToken = config.validation_config.allowed_spl_paid_tokens[0];

      setStatus("Creating transaction...");
      const memoInstruction = getAddMemoInstruction({
        memo: "Hello from Dynamic + Kora gasless transaction!",
      });
      const instructions: Instruction[] = [memoInstruction];

      setStatus("Estimating fees...");
      const latestBlockhash = await koraClient.getBlockhash();

      const initialEstimateTransaction = pipe(
        createTransactionMessage({ version: CONFIG.transactionVersion }),
        (tx) => setTransactionMessageFeePayerSigner(noopSigner, tx),
        (tx) =>
          setTransactionMessageLifetimeUsingBlockhash(
            {
              blockhash: latestBlockhash.blockhash as Blockhash,
              lastValidBlockHeight: BigInt(0),
            },
            tx
          ),
        (tx) =>
          updateOrAppendSetComputeUnitPriceInstruction(
            CONFIG.computeUnitPrice,
            tx
          ),
        (tx) =>
          updateOrAppendSetComputeUnitLimitInstruction(
            CONFIG.computeUnitLimit,
            tx
          ),
        (tx) => appendTransactionMessageInstructions(instructions, tx)
      );

      const signedInitialEstimate =
        await partiallySignTransactionMessageWithSigners(
          initialEstimateTransaction
        );
      const initialEstimateBase64 = getBase64EncodedWireTransaction(
        signedInitialEstimate
      );

      setStatus("Getting payment instruction...");
      const initialPaymentResponse = await koraClient.getPaymentInstruction({
        transaction: initialEstimateBase64,
        fee_token: paymentToken,
        source_wallet: primaryWallet.address,
      });
      let paymentInstruction: Instruction =
        initialPaymentResponse.payment_instruction;

      setStatus("Building final transaction...");
      const newBlockhash = await koraClient.getBlockhash();

      const fullTransaction = pipe(
        createTransactionMessage({ version: CONFIG.transactionVersion }),
        (tx) => setTransactionMessageFeePayerSigner(noopSigner, tx),
        (tx) =>
          setTransactionMessageLifetimeUsingBlockhash(
            {
              blockhash: newBlockhash.blockhash as Blockhash,
              lastValidBlockHeight: BigInt(0),
            },
            tx
          ),
        (tx) =>
          updateOrAppendSetComputeUnitPriceInstruction(
            CONFIG.computeUnitPrice,
            tx
          ),
        (tx) =>
          updateOrAppendSetComputeUnitLimitInstruction(
            CONFIG.computeUnitLimit,
            tx
          ),
        (tx) =>
          appendTransactionMessageInstructions(
            [...instructions, paymentInstruction],
            tx
          )
      );

      setStatus("Re-estimating fees...");
      const finalEstimateTransaction =
        await partiallySignTransactionMessageWithSigners(fullTransaction);
      const finalEstimateBase64 = getBase64EncodedWireTransaction(
        finalEstimateTransaction
      );

      const finalPaymentResponse = await koraClient.getPaymentInstruction({
        transaction: finalEstimateBase64,
        fee_token: paymentToken,
        source_wallet: primaryWallet.address,
      });
      paymentInstruction = finalPaymentResponse.payment_instruction;

      const correctedFullTransaction = pipe(
        createTransactionMessage({ version: CONFIG.transactionVersion }),
        (tx) => setTransactionMessageFeePayerSigner(noopSigner, tx),
        (tx) =>
          setTransactionMessageLifetimeUsingBlockhash(
            {
              blockhash: newBlockhash.blockhash as Blockhash,
              lastValidBlockHeight: BigInt(0),
            },
            tx
          ),
        (tx) =>
          updateOrAppendSetComputeUnitPriceInstruction(
            CONFIG.computeUnitPrice,
            tx
          ),
        (tx) =>
          updateOrAppendSetComputeUnitLimitInstruction(
            CONFIG.computeUnitLimit,
            tx
          ),
        (tx) =>
          appendTransactionMessageInstructions(
            [...instructions, paymentInstruction],
            tx
          )
      );

      setStatus("Signing transaction...");
      const signedFullTransaction =
        await partiallySignTransactionMessageWithSigners(
          correctedFullTransaction
        );

      const wireTransactionBase64 = getBase64EncodedWireTransaction(
        signedFullTransaction
      );
      const originalTransactionBytes = Buffer.from(
        wireTransactionBase64,
        "base64"
      );
      const originalTransaction = VersionedTransaction.deserialize(
        originalTransactionBytes
      );

      const message = originalTransaction.message;
      const numRequiredSignatures = message.header.numRequiredSignatures;
      const accountKeys = message.staticAccountKeys;
      const userAddress = primaryWallet.address;

      let userSignatureIndex = -1;
      for (
        let i = 0;
        i < numRequiredSignatures && i < accountKeys.length;
        i++
      ) {
        if (accountKeys[i].toBase58() === userAddress) {
          userSignatureIndex = i;
          break;
        }
      }

      if (userSignatureIndex === -1) {
        throw new Error(
          `User address ${userAddress} not found in transaction signers`
        );
      }

      const signer = await primaryWallet.getSigner();
      const signedTransaction = await signer.signTransaction(
        originalTransaction as any
      );

      const userSignature = signedTransaction.signatures[userSignatureIndex];
      if (!userSignature || userSignature.every((b: number) => b === 0)) {
        throw new Error("Failed to get signature from Dynamic wallet");
      }

      if (userSignature.length !== 64) {
        throw new Error(
          `Invalid signature length: expected 64 bytes, got ${userSignature.length}`
        );
      }

      const preservedTransaction = VersionedTransaction.deserialize(
        originalTransactionBytes
      );
      preservedTransaction.signatures[userSignatureIndex] = userSignature;

      const base64EncodedWireFullTransaction = Buffer.from(
        preservedTransaction.serialize()
      ).toString("base64");

      if (
        preservedTransaction.message.compiledInstructions.length <
        instructions.length + 1
      ) {
        throw new Error(
          `Transaction missing instructions. Expected at least ${
            instructions.length + 1
          }, got ${preservedTransaction.message.compiledInstructions.length}`
        );
      }

      setStatus("Getting Kora signature...");
      const { signed_transaction } = await koraClient.signTransaction({
        transaction: base64EncodedWireFullTransaction,
        signer_key: signer_address,
      });

      setStatus("Submitting to Solana network...");
      const signature = await rpc
        .sendTransaction(signed_transaction as Base64EncodedWireTransaction, {
          encoding: "base64",
        })
        .send();

      setTransactionSignature(signature);
      setStatus("Transaction submitted! Waiting for confirmation...");

      await confirmTransaction({
        commitment: "confirmed",
        signature,
        abortSignal: new AbortController().signal,
      });

      setStatus("✅ Transaction confirmed!");
    } catch (error) {
      setStatus(
        `❌ Error: ${error instanceof Error ? error.message : "Unknown error"}`
      );
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="w-full max-w-2xl mx-auto p-6 border rounded-lg space-y-4">
      <h2 className="text-2xl font-semibold">Gasless Transaction Demo</h2>
      <p className="text-sm text-muted-foreground">
        This demo sends a gasless transaction on Solana using Kora. The
        transaction fees are paid in SPL tokens instead of SOL.
      </p>

      <div className="space-y-2">
        <div className="text-sm">
          <strong>Wallet:</strong>{" "}
          {primaryWallet && isSolanaWallet(primaryWallet)
            ? primaryWallet.address
            : "Not connected"}
        </div>
        <div className="text-sm">
          <strong>Kora RPC:</strong> {CONFIG.koraRpcUrl}
        </div>
        <div className="text-sm">
          <strong>Solana RPC:</strong> {CONFIG.solanaRpcUrl}
        </div>
        {primaryWallet && isSolanaWallet(primaryWallet) && (
          <div className="text-sm">
            <strong>
              Token Balance ({CONFIG.tokenMintAddress.slice(0, 8)}...):
            </strong>{" "}
            {tokenBalanceLoading ? (
              <span className="text-gray-500">Loading...</span>
            ) : tokenBalance !== null ? (
              `${tokenBalance} tokens`
            ) : (
              <span className="text-gray-500">N/A</span>
            )}
          </div>
        )}
      </div>

      <button
        onClick={handleGaslessTransaction}
        disabled={
          !isLoggedIn ||
          !primaryWallet ||
          !isSolanaWallet(primaryWallet) ||
          loading
        }
        className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        {loading ? "Processing..." : "Send Gasless Transaction"}
      </button>

      {status && (
        <div className="p-4 bg-gray-100 dark:bg-gray-800 rounded text-sm">
          <strong>Status:</strong> {status}
        </div>
      )}

      {transactionSignature && (
        <div className="p-4 bg-green-50 dark:bg-green-900/20 rounded text-sm">
          <strong>Transaction Signature:</strong>
          <div className="mt-2 break-all font-mono text-xs">
            {transactionSignature}
          </div>
          <a
            href={`https://explorer.solana.com/tx/${transactionSignature}?cluster=devnet`}
            target="_blank"
            rel="noopener noreferrer"
            className="mt-2 inline-block text-blue-600 hover:underline"
          >
            View on Solana Explorer →
          </a>
        </div>
      )}
    </div>
  );
}

Technical Deep Dive

The handleGaslessTransaction function orchestrates a multi-step process to create a transaction where Kora pays fees in SOL while the user pays in SPL tokens. Here’s a technical breakdown: Initial Setup (lines 206-217): Creates a KoraClient instance to communicate with the Kora service, sets up Solana RPC clients for both HTTP and WebSocket connections, and initializes a transaction confirmation promise factory for monitoring transaction status. Kora Configuration (lines 219-225): Retrieves Kora’s fee payer signer address (which will pay fees in SOL) and creates a noop signer placeholder. Fetches Kora’s configuration to determine which SPL token the user will pay fees with. Transaction Building with Fee Estimation (lines 227-275):
  • Creates the user’s instructions (in this case, a memo instruction)
  • Builds an initial transaction estimate using the pipe function from @solana/kit to compose transaction modifications
  • Sets the fee payer to Kora’s signer, adds compute budget instructions (limit and price), and appends user instructions
  • Partially signs the transaction (without user signature) and converts it to base64
  • Sends this estimate to Kora’s getPaymentInstruction endpoint, which calculates the SPL token fee and returns a payment instruction
Final Transaction Construction (lines 277-348):
  • Gets a fresh blockhash (required for transaction validity)
  • Builds the full transaction including both user instructions and the payment instruction
  • Re-estimates fees by sending the complete transaction to Kora again (since adding the payment instruction changes the transaction size and fee calculation)
  • Rebuilds the transaction with the corrected payment instruction
User Signing (lines 350-424):
  • Partially signs the transaction to get a base64-encoded wire format
  • Deserializes it to a VersionedTransaction to access the message structure
  • Identifies the user’s signature index by finding their address in the transaction’s required signers
  • Uses Dynamic’s wallet signer to sign the transaction, extracting only the user’s signature from the signed result
  • Preserves the original transaction structure and injects the user’s signature at the correct index (this is necessary because Dynamic’s signer may modify the transaction structure)
Kora Co-signing and Submission (lines 426-448):
  • Sends the user-signed transaction to Kora’s signTransaction endpoint, which adds Kora’s signature as the fee payer
  • Submits the fully signed transaction to the Solana network via RPC
  • Waits for transaction confirmation using the confirmation promise factory, which monitors both RPC polling and WebSocket subscriptions for efficient confirmation
The two-phase fee estimation (initial estimate → final estimate) is necessary because the payment instruction itself consumes compute units, so the final fee calculation must account for the complete transaction including the payment instruction.

How It Works

The sponsored transaction flow follows these steps:
  1. Get Kora signer - Retrieve the address that will pay fees (in SOL)
  2. Get payment token - Determine which SPL token the user will pay fees with
  3. Build estimate transaction - Create a transaction with your instructions to estimate fees
  4. Get payment instruction - Request a payment instruction from Kora that charges the user in SPL tokens
  5. Build final transaction - Combine your instructions with the payment instruction
  6. Re-estimate fees - Get an updated payment instruction based on the full transaction
  7. Sign with user wallet - User signs the transaction (including the payment instruction)
  8. Get Kora signature - Kora co-signs as the fee payer (paying in SOL)
  9. Submit to network - Send the fully signed transaction to Solana

Using the Component

Add the component to your main page in app/page.tsx:
app/page.tsx
"use client";

import { useIsLoggedIn } from "@dynamic-labs/sdk-react-core";
import { DynamicWidget } from "@dynamic-labs/sdk-react-core";
import GaslessTransactionDemo from "@/components/gasless-transaction-demo";

export default function Home() {
  const isLoggedIn = useIsLoggedIn();

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-8">
      <div className="w-full max-w-2xl space-y-8">
        <div className="text-center space-y-2">
          <h1 className="text-4xl font-bold">Gasless Solana Transactions</h1>
          <p className="text-muted-foreground">
            Powered by Dynamic SDK and Kora
          </p>
        </div>

        <div className="flex justify-center">
          <DynamicWidget />
        </div>

        {isLoggedIn && (
          <div className="mt-8">
            <GaslessTransactionDemo />
          </div>
        )}

        {!isLoggedIn && (
          <div className="text-center text-muted-foreground mt-8">
            Connect your Solana wallet to get started
          </div>
        )}
      </div>
    </main>
  );
}

Extending the Demo

The current demo sends a simple memo instruction. You can extend it to:
  • Token transfers - Use @solana-program/token to transfer SPL tokens
  • SOL transfers - Transfer native SOL
  • Program interactions - Call any Solana program
  • Multiple instructions - Combine multiple operations in one transaction
Example: Add a token transfer instruction:
import { getTransferInstruction } from "@solana-program/token";

const transferInstruction = getTransferInstruction({
  source: sourceTokenAccount,
  destination: destinationTokenAccount,
  amount: 1000000n, // 1 token (6 decimals)
  owner: publicKey,
});

const instructions = [transferInstruction, memoInstruction];

Configuration

Compute Budget

The demo uses default compute budget settings:
const CONFIG = {
  computeUnitLimit: 200_000,
  computeUnitPrice: BigInt(1_000_000), // 0.001 SOL per compute unit
};
Adjust these based on your transaction complexity.

Transaction Version

The demo uses version 0 (legacy) transactions. For versioned transactions:
const transactionVersion = 0 as TransactionVersion; // or 1 for versioned

Troubleshooting

”Wallet not connected”

  • Ensure you’ve connected a Solana wallet through Dynamic
  • Check that useSolanaWallet() returns a valid publicKey and signTransaction

”Kora RPC error”

  • Verify Kora is running and accessible at the configured URL
  • Check network connectivity and CORS settings
  • For local development, ensure Kora is running on http://localhost:8080/

”Transaction failed”

  • Check that your wallet has sufficient SPL tokens for fees
  • Verify the Solana RPC endpoint is accessible
  • Check transaction logs for specific error messages

”Payment instruction failed”

  • Ensure the payment token is configured in Kora
  • Verify the source wallet has sufficient balance of the payment token
  • Check Kora’s validation configuration

Conclusion

Congratulations! You’ve successfully implemented sponsored transactions on Solana using Dynamic’s SDK and Kora. This approach allows users to pay transaction fees in SPL tokens instead of SOL, creating a more flexible payment experience.

Next Steps

  • Configure Kora to support your preferred payment tokens
  • Implement fee estimation UI to show users the cost before signing
  • Add support for multiple payment tokens with user selection
To get the complete source code, check out our GitHub repository.