Introduction

In this guide, we’ll show you how to create Sui transactions where a fee payer wallet pays the gas fees. We achieve this by using Sui’s sponsored transaction feature, which allows one account to pay for another account’s transaction fees.
For the complete source code and working example, check out our GitHub repository.

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-sui --framework nextjs --chains sui
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 Sui wallet and add some funds to it (for paying gas fees)
  2. Add its private key to your .env.local file:
.env.local
FEE_PAYER_PRIVATE_KEY="suiprivkey..."
NEXT_PUBLIC_DYNAMIC_ENV_ID="..."
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 sponsored transactions. Create a file at app/api/gas/route.ts:
app/api/gas/route.ts
import { SuiClient, getFullnodeUrl } from "@mysten/sui/client";
import { Ed25519Keypair } from "@mysten/sui/keypairs/ed25519";
import { Transaction } from "@mysten/sui/transactions";
import { NextRequest, NextResponse } from "next/server";
import { decodeSuiPrivateKey } from "@mysten/sui/cryptography";
import { Secp256r1Keypair } from "@mysten/sui/keypairs/secp256r1";
import { Secp256k1Keypair } from "@mysten/sui/keypairs/secp256k1";

// Connect to Sui testnet
const client = new SuiClient({ url: getFullnodeUrl("testnet") });

export async function POST(request: NextRequest) {
  try {
    // Extract transaction data and sender address from request
    const body = await request.json();
    const { tx, senderAddress } = body;

    // Validate required parameters
    if (!tx || !senderAddress) {
      return NextResponse.json(
        { error: "Missing required parameters" },
        { status: 400 }
      );
    }

    // Get the fee payer's private key from environment variables
    const feePayerPrivateKey = process.env.FEE_PAYER_PRIVATE_KEY;
    if (!feePayerPrivateKey) {
      console.error("FEE_PAYER_PRIVATE_KEY not set in environment variables");
      return NextResponse.json(
        { error: "Sponsor wallet not configured" },
        { status: 500 }
      );
    }

    // Create the fee payer keypair from the private key
    // This supports different key formats (ED25519, Secp256r1, Secp256k1)
    let feePayerKeypair;
    try {
      const { scheme, secretKey } = decodeSuiPrivateKey(feePayerPrivateKey);

      switch (scheme) {
        case "ED25519":
          feePayerKeypair = Ed25519Keypair.fromSecretKey(secretKey);
          break;
        case "Secp256r1":
          feePayerKeypair = Secp256r1Keypair.fromSecretKey(secretKey);
          break;
        case "Secp256k1":
          feePayerKeypair = Secp256k1Keypair.fromSecretKey(secretKey);
          break;
        default:
          throw new Error(`Unsupported key pair scheme: ${scheme}`);
      }
    } catch (keyError) {
      console.error("Error processing private key:", keyError);
      return NextResponse.json(
        { error: "Invalid sponsor private key format" },
        { status: 500 }
      );
    }

    // Get the fee payer's address from the keypair
    const feePayerAddress = feePayerKeypair.getPublicKey().toSuiAddress();

    // Check if the fee payer has SUI coins to pay for gas
    const sponsorCoins = await client.getCoins({
      owner: feePayerAddress,
      coinType: "0x2::sui::SUI",
    });

    if (sponsorCoins.data.length === 0) {
      return NextResponse.json(
        { error: "Sponsor has no SUI coins for gas payment" },
        { status: 500 }
      );
    }

    // Prepare the gas payment using the first available SUI coin
    const payment = sponsorCoins.data.slice(0, 1).map((coin) => ({
      objectId: coin.coinObjectId,
      version: coin.version,
      digest: coin.digest,
    }));

    // Convert the transaction bytes and create a sponsored transaction
    const txBytes =
      tx instanceof Uint8Array ? tx : new Uint8Array(Object.values(tx));
    const sponsoredTx = Transaction.fromKind(txBytes);

    // Set up the sponsored transaction:
    // - sender: the user who initiated the transaction
    // - gasOwner: the fee payer who will pay for gas
    // - gasPayment: the SUI coins that will be used for gas
    sponsoredTx.setSender(senderAddress);
    sponsoredTx.setGasOwner(feePayerAddress);
    sponsoredTx.setGasPayment(payment);

    // Build the complete transaction with gas estimation
    const builtTx = await sponsoredTx.build({ client: client as any });

    // Sign the transaction with the fee payer's key
    const signedTx = await feePayerKeypair.signTransaction(builtTx);

    // Return the sponsored transaction data to the frontend
    return NextResponse.json({
      sponsoredBytes: builtTx,
      sponsorSignature: signedTx.signature,
      feePayerAddress,
    });
  } catch (error) {
    console.error("Error creating sponsored transaction:", error);
    return NextResponse.json(
      { error: error instanceof Error ? error.message : String(error) },
      { status: 500 }
    );
  }
}

How It Works

Our API route:
  1. Receives a transaction from the frontend (already built by the user’s wallet)
  2. Validates the fee payer private key and creates the keypair
  3. Fetches SUI coins from the fee payer wallet for gas payment
  4. Creates a sponsored transaction by setting the gas owner and payment
  5. Signs the transaction with the fee payer’s key
  6. Returns the sponsored transaction bytes and sponsor signature

Client Implementation

Creating the Frontend Component

Now let’s create a UI for users to send USDC tokens without paying gas. Create app/components/Send.tsx:
app/components/Send.tsx
import { useDynamicContext, useIsLoggedIn } from "@dynamic-labs/sdk-react-core";
import { isSuiWallet } from "@dynamic-labs/sui";
import { useState, useEffect, useCallback } from "react";
import { Transaction } from "@mysten/sui/transactions";

// Constants for Sui and USDC
const MIST_PER_SUI = 1_000_000_000; // 1 SUI = 1 billion MIST (smallest unit)
const USDC_COIN_TYPE =
  "0xa1ec7fc00a6f40db9693ad1415d0c193ad3906494428cf252621037bd7117e29::usdc::USDC";
const USDC_DECIMALS = 6; // USDC has 6 decimal places
const USDC_PER_UNIT = Math.pow(10, USDC_DECIMALS); // Used to convert between units

export default function Send() {
  // Dynamic wallet integration
  const isLoggedIn = useIsLoggedIn();
  const { primaryWallet } = useDynamicContext();

  // UI state management
  const [isLoading, setIsLoading] = useState(false);
  const [result, setResult] = useState("");
  const [recipientAddress, setRecipientAddress] = useState("");
  const [amount, setAmount] = useState("");
  const [balances, setBalances] = useState({ sui: "0", usdc: "0" });
  const [isBalanceLoading, setIsBalanceLoading] = useState(false);
  const [txSignature, setTxSignature] = useState<string | null>(null);
  const [currentStage, setCurrentStage] = useState<string>("initial");

  // Function to fetch and display user's SUI and USDC balances
  const fetchBalances = useCallback(async () => {
    // Check if wallet is connected and is a Sui wallet
    if (!primaryWallet || !isSuiWallet(primaryWallet)) {
      setBalances({ sui: "0", usdc: "0" });
      return;
    }

    setIsBalanceLoading(true);
    try {
      // Get the Sui client from the wallet
      const walletClient = await primaryWallet.getSuiClient();
      if (!walletClient) return;

      // Fetch both SUI and USDC coins in parallel for better performance
      const [suiCoins, usdcCoins] = await Promise.all([
        walletClient.getCoins({
          owner: primaryWallet.address,
          coinType: "0x2::sui::SUI",
        }),
        walletClient.getCoins({
          owner: primaryWallet.address,
          coinType: USDC_COIN_TYPE,
        }),
      ]);

      // Calculate total balances by summing all coins and converting to human-readable format
      const suiBalance = (
        suiCoins.data.reduce((sum, coin) => sum + Number(coin.balance), 0) /
        MIST_PER_SUI
      ).toFixed(9);
      const usdcBalance = (
        usdcCoins.data.reduce((sum, coin) => sum + Number(coin.balance), 0) /
        USDC_PER_UNIT
      ).toFixed(USDC_DECIMALS);

      setBalances({ sui: suiBalance, usdc: usdcBalance });
    } catch (error) {
      console.error("Error fetching balances:", error);
      setBalances({ sui: "0", usdc: "0" });
    } finally {
      setIsBalanceLoading(false);
    }
  }, [primaryWallet]);

  useEffect(() => {
    if (isLoggedIn && primaryWallet && isSuiWallet(primaryWallet)) {
      fetchBalances();
    } else {
      setBalances({ sui: "0", usdc: "0" });
    }
  }, [isLoggedIn, primaryWallet, fetchBalances]);

  // Main function to send USDC using gasless transactions
  const sendUSDC = async () => {
    // Validate wallet connection
    if (!primaryWallet || !isSuiWallet(primaryWallet)) {
      setResult("Wallet not connected or not a SUI wallet");
      return;
    }

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

    try {
      setIsLoading(true);
      setResult("Creating transaction...");
      setCurrentStage("creating");

      // Convert human-readable amount to smallest unit (e.g., 10.5 USDC -> 10,500,000)
      const amountInSmallestUnit = Math.floor(
        parseFloat(amount) * USDC_PER_UNIT
      );
      const walletClient = await primaryWallet.getSuiClient();

      if (!walletClient) {
        throw new Error("Failed to get SUI client from wallet");
      }

      // Get all USDC coins owned by the user
      const ownedCoins = await walletClient.getCoins({
        owner: primaryWallet.address,
        coinType: USDC_COIN_TYPE,
      });

      if (ownedCoins.data.length === 0) {
        throw new Error("No USDC coins found in wallet");
      }

      // Select enough coins to cover the transaction amount
      // This handles cases where the user has multiple USDC coins
      let totalAvailable = 0;
      const coinObjectsToUse = [];

      for (const coin of ownedCoins.data) {
        coinObjectsToUse.push(coin.coinObjectId);
        totalAvailable += Number(coin.balance);

        if (totalAvailable >= amountInSmallestUnit) {
          break;
        }
      }

      // Check if user has enough USDC
      if (totalAvailable < amountInSmallestUnit) {
        throw new Error(
          `Insufficient USDC balance. Available: ${
            totalAvailable / USDC_PER_UNIT
          } USDC`
        );
      }

      // Create a new Sui transaction
      const tx = new Transaction();
      tx.setSender(primaryWallet.address);

      // Handle multiple USDC coins by merging them into one
      // This is necessary because Sui requires specific coin objects for transactions
      let mergedCoin;
      if (coinObjectsToUse.length > 1) {
        const primaryCoin = coinObjectsToUse[0];
        const otherCoins = coinObjectsToUse.slice(1);

        // Merge all coins into the primary coin
        tx.mergeCoins(
          tx.object(primaryCoin),
          otherCoins.map((id) => tx.object(id))
        );
        mergedCoin = tx.object(primaryCoin);
      } else {
        mergedCoin = tx.object(coinObjectsToUse[0]);
      }

      // Split the exact amount needed for the transfer
      const [coinToSend] = tx.splitCoins(mergedCoin, [
        tx.pure.u64(amountInSmallestUnit),
      ]);

      // Transfer the split coin to the recipient
      tx.transferObjects([coinToSend], tx.pure.address(recipientAddress));

      // Build the transaction kind (without gas estimation) for sponsorship
      setCurrentStage("building");
      const kindBytes = await tx.build({
        client: walletClient as any,
        onlyTransactionKind: true,
      });

      // Send the transaction to our API for gas sponsorship
      setResult("Requesting gas sponsorship...");
      setCurrentStage("sponsoring");

      const response = await fetch("/api/gas", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          tx: Array.from(kindBytes),
          senderAddress: primaryWallet.address,
        }),
      });

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.error || "Failed to sponsor transaction");
      }

      // Get the sponsored transaction data from the API
      const sponsorData = await response.json();

      // Ask the user to sign the sponsored transaction
      setResult("Requesting your signature...");
      setCurrentStage("signing");

      // Convert the sponsored bytes back to a Uint8Array
      const sponsoredBytesArray = new Uint8Array(
        Object.values(sponsorData.sponsoredBytes)
      );

      // Sign the transaction with the user's wallet
      // Try different signing formats in case of compatibility issues
      let signedTransaction;
      try {
        const sponsoredTransaction = Transaction.from(sponsoredBytesArray);
        signedTransaction = await primaryWallet.signTransaction(
          sponsoredTransaction as any
        );
      } catch (signError) {
        signedTransaction = await primaryWallet.signTransaction(
          sponsoredBytesArray as any
        );
      }

      // Execute the transaction on the Sui network
      setResult("Executing transaction...");
      setCurrentStage("executing");

      const executionResult = await walletClient.executeTransactionBlock({
        transactionBlock: sponsoredBytesArray,
        signature: [signedTransaction.signature, sponsorData.sponsorSignature], // Both user and sponsor signatures
        options: {
          showEffects: true,
          showEvents: true,
          showObjectChanges: true,
        },
      });

      // Transaction completed successfully
      setTxSignature(executionResult?.digest || null);
      setResult(`USDC transfer successful! ${executionResult.digest}`);
      setCurrentStage("complete");

      // Wait for transaction confirmation and refresh balances
      await walletClient.waitForTransaction({
        digest: executionResult.digest,
      });

      fetchBalances();
    } catch (error) {
      console.error("Error:", error);
      setTxSignature(null);
      setCurrentStage("error");
      setResult(
        `Error: ${error instanceof Error ? error.message : String(error)}`
      );
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      <h2>Send USDC on Sui</h2>
      {isLoggedIn && primaryWallet && (
        <div>
          <p>
            Your SUI Balance: {isBalanceLoading ? "Loading..." : `${balances.sui} SUI`}
          </p>
          <p>
            Your USDC Balance: {isBalanceLoading ? "Loading..." : `${balances.usdc} USDC`}
          </p>
          <button onClick={fetchBalances} disabled={isBalanceLoading}>
            Refresh Balance
          </button>
        </div>
      )}
      <div>
        <input
          type="text"
          placeholder="Recipient Address"
          value={recipientAddress}
          onChange={(e) => setRecipientAddress(e.target.value)}
          disabled={isLoading}
        />
        <input
          type="text"
          placeholder="Amount in USDC"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
          disabled={isLoading}
        />
        <button
          onClick={sendUSDC}
          disabled={
            !isLoggedIn ||
            !primaryWallet ||
            isLoading ||
            !recipientAddress ||
            !amount
          }
        >
          {isLoading ? "Processing..." : "Send USDC"}
        </button>
      </div>

      {result && (
        <div>
          <p>{result}</p>
          {txSignature && (
            <div>
              <p>Signature: {txSignature}</p>
              <a
                href={`https://testnet.suivision.xyz/txblock/${txSignature}`}
                target="_blank"
                rel="noopener noreferrer"
              >
                View on Sui Vision
              </a>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

How the Gasless Transaction Works

The gasless transaction flow works as follows:
  1. Transaction Creation: The user’s wallet creates a transaction for transferring USDC
  2. Transaction Building: The transaction is built with only the transaction kind (no gas payment)
  3. Sponsorship Request: The frontend sends the transaction to our API for sponsorship
  4. Gas Payment Setup: The server adds gas payment from the fee payer wallet
  5. Dual Signing: Both the user and the fee payer sign the transaction
  6. Execution: The transaction is executed on the Sui network

Simple Explanation

Think of gasless transactions like having someone else pay for your Uber ride. Here’s what happens:
  • You (the user) want to send USDC to someone
  • Your app (the fee payer) pays the gas fees for you
  • Sui network processes the transaction using your app’s gas payment
  • You only pay for the USDC you’re sending, not the gas fees
This creates a better user experience because users don’t need to worry about having SUI for gas fees - they can just send their tokens directly.

Advanced Usage

Supporting Different Token Types

You can easily modify the component to support different token types by changing the USDC_COIN_TYPE constant:
// For SUI tokens
const SUI_COIN_TYPE = "0x2::sui::SUI";

// For custom tokens
const CUSTOM_TOKEN_TYPE = "0xpackage_id::module::token_name";

Gas Estimation

You can add gas estimation before executing transactions:
// In your API route
const gasEstimate = await client.estimateGasCost({
  transactionBlock: tx,
  sender: senderAddress,
});

console.log("Estimated gas cost:", gasEstimate);

Security Considerations

  1. Rate Limiting: Implement rate limiting to prevent abuse of your fee payer wallet
  2. Transaction Validation: Validate all transaction parameters on the server side
  3. Gas Limits: Set appropriate gas limits to prevent excessive spending
  4. Monitoring: Monitor your fee payer wallet balance and transaction patterns
  5. Private Key Security: Never expose your fee payer private key in client-side code

Testing

To test the implementation:
  1. Make sure you have USDC tokens in your wallet on Sui testnet
  2. Set up your fee payer wallet with sufficient SUI for gas fees
  3. Connect your wallet using Dynamic
  4. Enter a recipient address and amount
  5. Execute the transaction
The transaction should complete without requiring you to pay gas fees.

Conclusion

Congratulations! You’ve successfully implemented gasless transactions on Sui 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 to protect your fee payer wallet
  • Transaction validation and approval workflows
  • Gas cost monitoring and alerts
  • Support for different Sui networks (mainnet, testnet, devnet)
  • Support for other token types and complex transactions

Complete Source Code

For the complete working example and source code, visit our GitHub repository.