A sponsored transaction is one where your server pays the network fee instead of the user. This removes the SOL requirement for new users and enables gasless UX patterns like free mints, claim flows, and onboarding actions.
How it works
This recipe covers the embedded wallet flow (Google/Apple sign-in via Phantom Connect). For injected/extension wallets, direct pre-signing works — you can build and sign the transaction server-side before passing it to the extension.
For embedded wallets:
- Your client builds a transaction with the sponsor’s public key as
payerKey
- The client calls
signAndSendTransaction with a presignTransaction callback
- Phantom validates the transaction, then invokes the callback with the transaction bytes (base64url)
- The callback sends those bytes to your server, which signs as fee payer and returns the partially-signed transaction (base64url)
- Phantom adds the user’s signature and submits — no SOL required from the user
Phantom embedded wallets do not accept pre-signed transactions passed directly. The presignTransaction callback is the only supported way to add a second signer (such as a fee payer) for embedded wallets. This restriction does not apply to injected providers (the Phantom browser extension), which support direct pre-signing.
Server: sign as fee payer
Your API endpoint receives a base64url-encoded transaction from the presignTransaction callback, signs it with the sponsor keypair, and returns the partially-signed bytes.
// app/api/sponsor/route.ts (Next.js App Router)
import { NextRequest, NextResponse } from "next/server";
import { Keypair, VersionedTransaction } from "@solana/web3.js";
import bs58 from "bs58";
export async function POST(req: NextRequest) {
try {
const { transaction } = await req.json();
if (!transaction) {
return NextResponse.json({ error: "transaction is required" }, { status: 400 });
}
const sponsor = Keypair.fromSecretKey(
bs58.decode(process.env.SPONSOR_PRIVATE_KEY!)
);
// Decode the base64url transaction sent by Phantom's presignTransaction callback
const txBytes = Buffer.from(transaction, "base64url");
const tx = VersionedTransaction.deserialize(txBytes);
// Sign as fee payer — Phantom will add the user's signature next
tx.sign([sponsor]);
// signatures[0] is the fee payer's signature = the Solana transaction ID.
// Return it here because the client only gets Phantom's signature from
// signAndSendTransaction, which is the second signer — not the tx ID.
const txId = bs58.encode(tx.signatures[0]);
return NextResponse.json({
transaction: Buffer.from(tx.serialize()).toString("base64url"),
txId,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
Keep SPONSOR_PRIVATE_KEY server-side only. Never expose it to the client or include it in NEXT_PUBLIC_* variables.
React
Browser SDK
React Native
import { useSolana } from "@phantom/react-sdk";
import {
Connection,
PublicKey,
TransactionInstruction,
TransactionMessage,
VersionedTransaction,
} from "@solana/web3.js";
const MEMO_PROGRAM_ID = new PublicKey(
"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"
);
// Expose only the public key to the client — never the private key
const SPONSOR_PUBLIC_KEY = new PublicKey(
process.env.NEXT_PUBLIC_SPONSOR_PUBLIC_KEY!
);
function SponsoredAction() {
const { solana } = useSolana();
const handleSponsoredTransaction = async () => {
const publicKey = solana.publicKey;
if (!publicKey) return;
const connection = new Connection("https://api.mainnet-beta.solana.com");
const { blockhash } = await connection.getLatestBlockhash();
const userPubkey = new PublicKey(publicKey);
// Any instruction that requires the user's signature
// Here: a Memo instruction — user signs, sponsor pays fees
const instruction = new TransactionInstruction({
keys: [{ pubkey: userPubkey, isSigner: true, isWritable: false }],
programId: MEMO_PROGRAM_ID,
data: new TextEncoder().encode("Sponsored by dApp"),
});
// Build the transaction with the sponsor as fee payer
const transaction = new VersionedTransaction(
new TransactionMessage({
payerKey: SPONSOR_PUBLIC_KEY,
recentBlockhash: blockhash,
instructions: [instruction],
}).compileToV0Message()
);
// presignTransaction fires after Phantom validates the transaction.
// The server signs as fee payer and returns the partially-signed tx.
// Phantom then adds the user's signature and submits.
//
// We capture txId from the API because the Solana transaction ID is always
// the fee payer's (sponsor's) signature — not Phantom's signature returned
// by signAndSendTransaction.
let txId: string | undefined;
await solana.signAndSendTransaction(transaction, {
presignTransaction: async (tx) => {
const res = await fetch("/api/sponsor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ transaction: tx }),
});
if (!res.ok) throw new Error("Failed to presign transaction");
const { transaction: signed, txId: sponsorTxId } = await res.json();
txId = sponsorTxId;
return signed;
},
});
console.log("Transaction confirmed:", txId);
};
return (
<button onClick={handleSponsoredTransaction}>
Claim (free for you)
</button>
);
}
import { BrowserSDK, AddressType } from "@phantom/browser-sdk";
import {
Connection,
PublicKey,
TransactionInstruction,
TransactionMessage,
VersionedTransaction,
} from "@solana/web3.js";
const MEMO_PROGRAM_ID = new PublicKey(
"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"
);
const SPONSOR_PUBLIC_KEY = new PublicKey("YOUR_SPONSOR_PUBLIC_KEY");
const sdk = new BrowserSDK({
providers: ["google", "apple", "injected"],
appId: "your-app-id",
addressTypes: [AddressType.solana],
});
// Connect with your preferred auth provider before accessing sdk.solana
await sdk.connect({ provider: "google" }); // or "apple" / "injected"
async function handleSponsoredTransaction() {
const publicKey = sdk.solana.publicKey;
if (!publicKey) return;
const connection = new Connection("https://api.mainnet-beta.solana.com");
const { blockhash } = await connection.getLatestBlockhash();
const userPubkey = new PublicKey(publicKey);
const instruction = new TransactionInstruction({
keys: [{ pubkey: userPubkey, isSigner: true, isWritable: false }],
programId: MEMO_PROGRAM_ID,
data: new TextEncoder().encode("Sponsored by dApp"),
});
const transaction = new VersionedTransaction(
new TransactionMessage({
payerKey: SPONSOR_PUBLIC_KEY,
recentBlockhash: blockhash,
instructions: [instruction],
}).compileToV0Message()
);
let txId: string | undefined;
await sdk.solana.signAndSendTransaction(transaction, {
presignTransaction: async (tx) => {
const res = await fetch("/api/sponsor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ transaction: tx }),
});
if (!res.ok) throw new Error("Failed to presign transaction");
const { transaction: signed, txId: sponsorTxId } = await res.json();
txId = sponsorTxId;
return signed;
},
});
console.log("Transaction confirmed:", txId);
}
import { useSolana } from "@phantom/react-native-sdk";
import {
Connection,
PublicKey,
TransactionInstruction,
TransactionMessage,
VersionedTransaction,
} from "@solana/web3.js";
import { Button, Alert } from "react-native";
const MEMO_PROGRAM_ID = new PublicKey(
"MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"
);
const SPONSOR_PUBLIC_KEY = new PublicKey("YOUR_SPONSOR_PUBLIC_KEY");
function SponsoredAction() {
const { solana } = useSolana();
const handleSponsoredTransaction = async () => {
try {
const publicKey = solana.publicKey;
if (!publicKey) return;
const connection = new Connection("https://api.mainnet-beta.solana.com");
const { blockhash } = await connection.getLatestBlockhash();
const userPubkey = new PublicKey(publicKey);
const instruction = new TransactionInstruction({
keys: [{ pubkey: userPubkey, isSigner: true, isWritable: false }],
programId: MEMO_PROGRAM_ID,
data: new TextEncoder().encode("Sponsored by dApp"),
});
const transaction = new VersionedTransaction(
new TransactionMessage({
payerKey: SPONSOR_PUBLIC_KEY,
recentBlockhash: blockhash,
instructions: [instruction],
}).compileToV0Message()
);
let txId: string | undefined;
await solana.signAndSendTransaction(transaction, {
presignTransaction: async (tx) => {
const res = await fetch("https://your-api.com/api/sponsor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ transaction: tx }),
});
if (!res.ok) throw new Error("Failed to presign transaction");
const { transaction: signed, txId: sponsorTxId } = await res.json();
txId = sponsorTxId;
return signed;
},
});
Alert.alert("Success", `Transaction confirmed: ${txId}`);
} catch (error) {
Alert.alert("Error", error instanceof Error ? error.message : "Failed");
}
};
return <Button title="Claim (free for you)" onPress={handleSponsoredTransaction} />;
}
Environment setup
1. Phantom Portal setup
Before you can use the Phantom Connect SDK, register your app at Phantom Portal:
- Create a new app and copy your App ID
- Allowlist your domain (e.g.
localhost:3000 for development, your production domain for prod)
- For mobile integrations, configure your redirect URL
# .env.local
NEXT_PUBLIC_PHANTOM_APP_ID=your_app_id_from_phantom_portal
# .env.local — SPONSOR_PRIVATE_KEY is server-side only, never prefix with NEXT_PUBLIC_
SPONSOR_PRIVATE_KEY=your_base58_encoded_private_key
NEXT_PUBLIC_SPONSOR_PUBLIC_KEY=your_sponsor_public_key
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
Generate a sponsor keypair with the Solana CLI:
solana-keygen new --outfile sponsor-keypair.json
# Fund it on devnet for testing
solana airdrop 1 $(solana-keygen pubkey sponsor-keypair.json) --url devnet
Or with web3.js:
import { Keypair } from "@solana/web3.js";
import bs58 from "bs58";
const keypair = Keypair.generate();
console.log("Public key:", keypair.publicKey.toString());
console.log("Private key:", bs58.encode(keypair.secretKey));
When to use this pattern
- Free mints — users claim NFTs or tokens without needing SOL
- Onboarding actions — first transaction is free to reduce friction
- Protocol interactions — dApp covers fees for protocol-specific instructions
- Gasless vouchers — sponsor a fixed number of transactions per user
Security considerations
- Rate-limit sponsorship per wallet address to prevent abuse
- Keep the scope narrow — only sign transactions your dApp explicitly builds, not arbitrary user-provided transactions
- Monitor your sponsor wallet balance and set up alerts when it runs low
- For injected/extension wallets, the
presignTransaction callback is not invoked — direct pre-signing works, but the embedded wallet restriction above still applies to embedded provider users