Jupiter on payments
This documentation outlines the implementation of a payment system using Jupiter for token swaps and Solana for transactions. The system allows users to pay with any token while receiving a stablecoin (USDC).
Token Fetching Fetches user token accounts Retrieves mint information and prices for each token Filters tokens based on a minimum value threshold (price) Includes SOL balance if it meets the threshold Caches mint and metadata information from Firebase db Token Picker Component Displays a dropdown of available tokens Shows token symbols and images Calculates and displays the payment amount based on selected token and quantity Includes a payment button and wallet disconnect option Transaction Flow Server-side operations: Transaction creation RPC calls Jupiter integration (in case the user pays with a mint other than USDC) Transaction confirmation Validation of transaction details Client-side operation: Transaction signing by the user Transaction flow:
1. Client requests transaction creation
2. Server creates and returns the transaction
3. Client signs the transaction
4. Client sends signed transaction for confirmation
5. Server confirms and processes the transaction Post-confirmation process: Transaction validation (amount, seller, currency) Saving transaction details in Firebase database Security benefits: RPC key remains hidden on the server Only the necessary signing step is done client-side Flexibility: Supports various tokens through Jupiter integration
// note: is recommended to use api handlers to get data instead of actions
async function fetchTokens(userKey: string): Promise<TokenInfo[]> {
const tokenAccounts = await getTokenAccounts(userKey);
const mintAddresses = tokenAccounts.map((x) => x.data.mint.toBase58());
const [mints, prices] = await Promise.all([
getMints(mintAddresses),
getPrices(mintAddresses),
]);
const tokens = await Promise.all(
tokenAccounts.map(async (accountData) => {
try {
const mint = accountData.data.mint.toBase58();
const mintData = mints[mint];
const amount = accountData.data.amount.dividedBy(
new BigNumber(10).pow(mintData.decimals),
);
const price = prices.data[mint].price;
const totalValue = amount.multipliedBy(new BigNumber(price));
if (totalValue.isLessThan(threshold)) return null;
const metadata = await getMetadata(mint);
return {
mint,
address: accountData.pubkey,
amount: amount.toFixed(2),
value: threshold.dividedBy(price).toFixed(2).toString(),
decimals: mintData.decimals,
metadata,
} as TokenInfo;
} catch (e: any) {
return null;
}
}),
);
const filteredTokens = tokens.filter(
(token) => token !== null,
) as TokenInfo[];
const solMint = mintFromSymbol["SOL"];
const solDecimals = mintDecimals["SOL"];
const solBalance = await config.SOL_RPC.getBalance(new PublicKey(userKey));
const solAmount = new BigNumber(solBalance).dividedBy(
new BigNumber(10).pow(solDecimals),
);
const price = await getPrice(solMint);
if (price) {
const priceBN = new BigNumber(price);
const solValue = new BigNumber(HOUR_PRICE).dividedBy(priceBN);
const totalValue = solAmount.multipliedBy(priceBN);
if (totalValue.isGreaterThan(HOUR_PRICE)) {
filteredTokens.push({
mint: solMint,
address: userKey,
amount: solAmount.toString(),
value: solValue.toFixed(2),
decimals: solDecimals,
metadata: {
name: "Solana",
symbol: "SOL",
image: "/solanaLogo.svg",
},
});
}
}
return filteredTokens;
}
async function getMints(mints: string[]): Promise<Record<string, DecodedMint>> {
const missingMints: PublicKey[] = [];
const cachedData = await Promise.all(
mints.map(async (mint, index) => {
const cacheMint = await db.collection("mint").doc(mint).get();
if (cacheMint.exists) {
return cacheMint.data() as DecodedMint;
} else {
missingMints.push(new PublicKey(mint));
return null;
}
}),
);
const filteredCachedData = cachedData.filter(
(x) => x !== null,
) as DecodedMint[];
const fetchedData = await fetchMints(missingMints);
const combinedData: Record<string, DecodedMint> = {};
filteredCachedData.forEach((data, idx) => {
combinedData[mints[idx]] = data;
});
return { ...combinedData, ...fetchedData };
}
async function fetchMints(
missingMints: PublicKey[],
): Promise<Record<string, DecodedMint>> {
if (missingMints.length === 0) return {};
const mintsResponse =
await config.SOL_RPC.getMultipleAccountsInfo(missingMints);
const newMintData: Record<string, DecodedMint> = {};
await Promise.all(
mintsResponse.map(async (mint, index) => {
if (mint && mint.data) {
const decodedMintData = MintLayout.decode(mint.data) as Mint;
const mintAddress = missingMints[index].toBase58();
const mintData = {
mint: mintAddress,
mintAuthorityOption: decodedMintData.mintAuthorityOption,
mintAuthority: decodedMintData.mintAuthority.toBase58(),
supply: decodedMintData.supply.toString(),
decimals: decodedMintData.decimals,
isInitialized: decodedMintData.isInitialized,
freezeAuthorityOption: decodedMintData.freezeAuthorityOption,
freezeAuthority: decodedMintData.freezeAuthority.toBase58(),
};
await db.collection("mint").doc(mintAddress).set(mintData);
newMintData[mintAddress] = mintData;
}
}),
);
return newMintData;
}
async function getMetadata(mint: string): Promise<Metadata> {
const cachedMetadata = await db.collection("metadata").doc(mint).get();
if (cachedMetadata.exists) {
const metadata = cachedMetadata.data() as any;
return {
name: metadata.name,
symbol: metadata.symbol,
image: metadata.image,
};
}
const metadata = await getTokenInfo(mint);
await db.collection("metadata").doc(mint).set(metadata);
return {
name: metadata.name,
symbol: metadata.symbol,
image: metadata.image,
};
}
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const searchParams = new URLSearchParams(url.search);
const userKey = searchParams.get("userKey");
if (!userKey)
return NextResponse.json(
{ message: "Invalid or missing userKey" },
{ status: 400 },
);
const tokens = await fetchTokens(userKey);
return NextResponse.json({ tokens }, { status: 200 });
} catch (error) {
return NextResponse.json(
{ message: "Failed to fetch tokens" },
{ status: 500 },
);
}
}
export type FetchTokensResponse = { tokens: TokenInfo[] } | { message: string };
"use client";
import { TokenInfo } from "@/actions/types";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Button } from "./ui/button";
import { useWallet } from "@solana/wallet-adapter-react";
import { ClipLoader } from "react-spinners";
import { LogOutIcon } from "lucide-react";
export type TokenPickerProps = {
tokens: TokenInfo[];
selectedToken: TokenInfo | undefined;
setSelectedToken: (token: TokenInfo) => void;
handlePayment: () => void;
quantity: number;
loading: boolean;
};
export function TokenPicker({
tokens,
selectedToken,
setSelectedToken,
handlePayment,
quantity,
loading,
}: TokenPickerProps) {
const { disconnect } = useWallet();
if (loading) {
return (
<div className="flex justify-center items-center h-full w-full gap-5">
<ClipLoader size={50} color={"#123abc"} loading={loading} />
Fetching tokens
</div>
);
}
if (!selectedToken) {
return <span className="px-2">No tokens</span>;
}
return (
<div className="flex flex-col w-full">
{selectedToken && (
<>
<div className="flex items-center space-x-2 w-full">
<Select
onValueChange={(value) =>
setSelectedToken(
tokens.find((token) => token.metadata.symbol === value)!,
)
}
>
<SelectTrigger className="flex-1 flex items-center justify-between rounded-md border border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<span>{selectedToken.metadata.symbol}</span>
<img
src={selectedToken.metadata.image}
alt={selectedToken.metadata.symbol}
className="w-7 h-7"
/>
</SelectTrigger>
<SelectContent>
<div className="px-4 py-2 font-medium">
Pay per hour | 50 USD
</div>
{tokens.map((token) => (
<SelectItem
key={token.metadata.symbol}
value={token.metadata.symbol}
>
<div className="grid grid-cols-[auto_auto_1fr] gap-4 items-center">
<span>{token.metadata.symbol}</span>
<img
src={token.metadata.image}
alt={token.metadata.symbol}
width={28}
height={28}
/>
<span className="text-right">{Number(token.value)}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
onClick={handlePayment}
className="flex-1 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Pay {(Math.trunc((Number(selectedToken.value) * (quantity === 0 ? 1 : quantity)) * 100) / 100).toFixed(2)}{" "}
{selectedToken.metadata.symbol}
</Button>
<Button
type="button"
onClick={disconnect}
className="p-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
<LogOutIcon className="h-5 w-5" />
</Button>
</div>
</>
)}
</div>
);
}
async function handleSolanaPayment(data: z.infer<typeof MeetingSchema>) {
try {
if (!publicKey || !signTransaction || !selectedToken) {
toast({ title: "Wallet not connected" });
return;
}
const quantity = data.hours.length.toString();
const { mint, decimals } = selectedToken!;
const transaction = await createTransaction(
publicKey!.toBase58(),
quantity,
mint,
decimals,
);
if (!transaction) return;
const deserializedTransaction = VersionedTransaction.deserialize(
Buffer.from(transaction, "base64"),
);
const signedTransaction = await signTransaction!(deserializedTransaction);
const serializedTransaction = Buffer.from(
signedTransaction.serialize(),
).toString("base64");
const formData = JSON.stringify({
...data,
dob: data.dob.toISOString(),
});
const signature = await sendTransaction(serializedTransaction, formData);
toast({
title: "Payment done. Meeting booked.",
description: (
<a
href={`https://solana.fm/tx/${signature}`}
className="text-blue-500"
target="_blank"
rel="noopener noreferrer"
>
Check Transaction
</a>
),
});
} catch (error: any) {
toast({ title: error.message || "Payment processing failed." });
}
}
// createTransaction (is a nextjs action)
"use server";
import config from "@/lib/config";
import {
TEN,
USDC_AMOUNT,
USDC_DECIMALS,
USDC_MINT,
USDC_MINT_KEY,
} from "@/lib/constants";
import { getJupInstructions } from "@/lib/jup";
import { createPayInstruction, getTransaction } from "@/lib/solana";
import { getAccount, getAssociatedTokenAddress } from "@solana/spl-token";
import { PublicKey, TransactionInstruction } from "@solana/web3.js";
import BigNumber from "bignumber.js";
export async function createTransaction(
pubkey: string,
quantity: string,
currency: string,
decimals: number,
) {
try {
const signer = new PublicKey(pubkey);
const senderATA = await getAssociatedTokenAddress(USDC_MINT_KEY, signer);
const senderAccount = await getAccount(config.SOL_RPC, senderATA);
if (!senderAccount.isInitialized) throw new Error("sender not initialized");
if (senderAccount.isFrozen) throw new Error("sender frozen");
const quantityBN = new BigNumber(quantity);
const amount = String(
USDC_AMOUNT.multipliedBy(quantityBN)
.times(TEN.pow(USDC_DECIMALS))
.integerValue(BigNumber.ROUND_FLOOR),
);
const instructions: TransactionInstruction[] = [];
const lookupTableAddresses: string[] = [];
if (currency !== USDC_MINT) {
const { addressLookupTableAddresses, jupInstructions } =
await getJupInstructions(pubkey, currency, quantityBN);
instructions.push(...jupInstructions);
lookupTableAddresses.push(...addressLookupTableAddresses);
} else {
const tokens = BigInt(amount);
if (tokens > senderAccount.amount) throw new Error("insufficient funds");
}
const payInstruction = await createPayInstruction(
amount,
signer,
senderATA,
);
instructions.push(payInstruction);
return await getTransaction(instructions, signer, lookupTableAddresses);
} catch (error: any) {
console.error("Transaction creation failed:", error.message);
}
}
// jupiter request
export async function getJupInstructions(
signer: string,
inputMint: string,
quantity: BigNumber,
): Promise<JupInstructions> {
const quoteResponse = await getJupQuote(inputMint, quantity);
const {
setupInstructions,
swapInstruction,
cleanupInstruction,
otherInstructions,
addressLookupTableAddresses,
} = await ky
.post("https://quote-api.jup.ag/v6/swap-instructions", {
json: {
quoteResponse,
trackingAccount: PAYMENT_REFERENCE.toBase58(),
userPublicKey: signer,
wrapAndUnwrapSol: true,
useSharedAccounts: false,
dynamicComputeUnitLimit: false,
skipUserAccountsRpcCalls: true,
asLegacyTransaction: false,
useTokenLedger: false,
},
retry: {
limit: 5,
statusCodes: [408, 413, 429, 500, 502, 503, 504, 422],
methods: ["post"],
delay: (attemptCount) => 0.3 * 2 ** (attemptCount - 1) * 1000,
},
})
.json<JupSwapInstructionsResponse>();
const jupInstructions: TransactionInstruction[] = [];
if (setupInstructions && setupInstructions.length > 0)
jupInstructions.push(
...setupInstructions.map((ix: JupInstruction) =>
deserializeInstruction(ix),
),
);
jupInstructions.push(deserializeInstruction(swapInstruction));
if (cleanupInstruction)
jupInstructions.push(deserializeInstruction(cleanupInstruction));
if (otherInstructions && otherInstructions.length > 0)
jupInstructions.push(
...otherInstructions.map((ix: JupInstruction) =>
deserializeInstruction(ix),
),
);
return { jupInstructions, addressLookupTableAddresses };
}
// client transaction flow handler:
async function handleSolanaPayment(data: z.infer<typeof MeetingSchema>) {
try {
if (!publicKey || !signTransaction || !selectedToken) {
toast({ title: "Wallet not connected" });
return;
}
const quantity = data.hours.length.toString();
const { mint, decimals } = selectedToken!;
const transaction = await createTransaction(
publicKey!.toBase58(),
quantity,
mint,
decimals,
);
if (!transaction) return;
const deserializedTransaction = VersionedTransaction.deserialize(
Buffer.from(transaction, "base64"),
);
const signedTransaction = await signTransaction!(deserializedTransaction);
const serializedTransaction = Buffer.from(
signedTransaction.serialize(),
).toString("base64");
const formData = JSON.stringify({
...data,
dob: data.dob.toISOString(),
});
const signature = await sendTransaction(serializedTransaction, formData);
toast({
title: "Payment done. Meeting booked.",
description: (
<a
href={`https://solana.fm/tx/${signature}`}
className="text-blue-500"
target="_blank"
rel="noopener noreferrer"
>
Check Transaction
</a>
),
});
} catch (error: any) {
toast({ title: error.message || "Payment processing failed." });
}
}
// confirmTransaction
"use server";
import { db } from "@/lib/firebase";
import config from "@/lib/config";
import {
HOUR_PRICE,
mintDecimals,
PAYMENT_REFERENCE,
RIKI_PUBKEY,
TEN,
USDC_MINT,
} from "@/lib/constants";
import {
AccountLayout,
transferCheckedInstructionData,
} from "@solana/spl-token";
import { VersionedTransaction } from "@solana/web3.js";
import BigNumber from "bignumber.js";
import { generateMeet } from "@/lib/googleMeet";
export async function sendTransaction(transaction: string, data: string) {
try {
const deserializedTransaction = VersionedTransaction.deserialize(
Buffer.from(transaction, "base64"),
);
const signature = await config.SOL_RPC.sendRawTransaction(
deserializedTransaction.serialize(),
{
skipPreflight: true,
maxRetries: 0,
},
);
let confirmedTx: any = null;
const latestBlockHash = await config.SOL_RPC.getLatestBlockhash();
const confirmTransactionPromise = config.SOL_RPC.confirmTransaction(
{
signature,
blockhash: deserializedTransaction.message.recentBlockhash,
lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
},
"confirmed",
);
while (!confirmedTx) {
confirmedTx = await Promise.race([
confirmTransactionPromise,
new Promise((resolve) =>
setTimeout(() => {
resolve(null);
}, 2000),
),
]);
if (!confirmedTx) {
await config.SOL_RPC.sendRawTransaction(
deserializedTransaction.serialize(),
{
skipPreflight: true,
maxRetries: 0,
},
);
}
}
if (!confirmedTx) throw new Error("Transaction confirmation failed");
const response = await fetchTransaction(signature);
const { message } = response.transaction;
const versionedTransaction = new VersionedTransaction(message);
const instructions = versionedTransaction.message.compiledInstructions;
const payInstruction = instructions.pop();
if (!payInstruction) throw new Error("missing transfer instruction");
const { amount: rawAmount } = transferCheckedInstructionData.decode(
payInstruction.data,
);
const [source, mint, destination, owner, paymentReference] =
payInstruction.accountKeyIndexes.map(
(index: number) =>
versionedTransaction.message.staticAccountKeys[index],
);
const sellerATA = await config.SOL_RPC.getAccountInfo(
destination,
"confirmed",
);
if (!sellerATA) throw new Error("error fetching ata info");
const decodedSellerATA = AccountLayout.decode(sellerATA.data);
const price = BigNumber(HOUR_PRICE)
.times(TEN.pow(mintDecimals["USDC"]))
.integerValue(BigNumber.ROUND_FLOOR);
const signer = owner.toBase58();
const seller = decodedSellerATA.owner.toBase58();
const currency = decodedSellerATA.mint.toBase58();
const amount = rawAmount.toString(16);
const quotient = new BigNumber(amount, 16).dividedBy(price);
if (!quotient.isInteger()) throw new Error("amount not transferred");
if (PAYMENT_REFERENCE.toString() !== paymentReference.toString())
throw new Error("wrong app reference");
if (seller !== RIKI_PUBKEY.toBase58()) throw new Error("wrong seller");
if (currency !== USDC_MINT) throw new Error("wrong seller");
await db.collection(`cryptoPayment`).doc(signature).set({
signature,
signer,
currency,
amount,
timestamp: new Date().toISOString(),
});
await generateMeet(data);
return signature;
} catch (error: any) {
console.error("An error occurred:", error);
return error.message;
}
}
async function fetchTransaction(signature: string) {
const retryDelay = 400;
const response = await config.SOL_RPC.getTransaction(signature, {
commitment: "confirmed",
maxSupportedTransactionVersion: 0,
});
if (response) {
return response;
} else {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
return fetchTransaction(signature);
}
}