What We’re Building
A React (Next.js) app that connects Dynamic’s embedded wallets to Kalshi-style prediction markets on Solana, allowing users to:- Browse active prediction markets with real-time prices
- Buy Yes/No outcome shares on markets using SOL
- View and manage portfolio positions
- Sell positions and redeem winning outcomes
- Deposit funds via multiple methods (QR, cross-chain swap)
Key Components
- Dynamic Embedded Wallets - Non-custodial Solana wallets with seamless auth
- DFlow - Order execution protocol for Kalshi markets on Solana
- Solana Network - All trades execute on Solana mainnet
Building the Application
Project Setup
Start by creating a new Dynamic project with Solana support:Copy
Ask AI
npx create-dynamic-app@latest kalshi-app --framework nextjs --library viem --wagmi false --chains solana --pm npm
cd kalshi-app
Install Dependencies
Add the Solana web3 libraries and other required dependencies:Copy
Ask AI
npm install @solana/web3.js @solana/spl-token @tanstack/react-query motion lucide-react
@solana/web3.js provides Solana RPC interactions, @solana/spl-token handles SPL token accounts (for USDC and outcome token balances), and DFlow handles the order routing.
Configure Environment
Create a.env.local file with your Dynamic environment ID and Solana RPC URL:
.env.local
Copy
Ask AI
NEXT_PUBLIC_DYNAMIC_ENV_ID=your-environment-id-here
NEXT_PUBLIC_SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
NEXT_PUBLIC_USDC_MINT_ADDRESS=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v
DFLOW_API_KEY=your-dflow-api-key-here
DFLOW_API_KEY is optional but recommended for higher rate limits. NEXT_PUBLIC_USDC_MINT_ADDRESS is Circle’s USDC token mint address on Solana mainnet.
Define Constants
Createsrc/lib/constants.ts for DFlow API URLs and trading configuration:
src/lib/constants.ts
Copy
Ask AI
/**
* DFlow API Configuration
*/
export const DFLOW_TRADE_API_URL = "https://c.quote-api.dflow.net";
export const DFLOW_METADATA_API_URL =
"https://c.prediction-markets-api.dflow.net";
/**
* Solana Token Mint Addresses
* WSOL: native SOL wrapped as an SPL token
* USDC: Circle's USDC on Solana mainnet
*/
export const WSOL_MINT = "So11111111111111111111111111111111111111112";
// Use the well-known USDC SPL token mint for Solana mainnet
export const USDC_MINT = process.env.NEXT_PUBLIC_USDC_MINT_ADDRESS!;
/**
* Trading Configuration
*/
export const DEFAULT_SLIPPAGE_BPS = 50; // 0.5%
export const MIN_BET_USD = 5;
export const SOL_PRICE_ESTIMATE = 200;
export const TX_FEE_RESERVE = 0.01; // SOL
/**
* Solana RPC
*/
export const SOLANA_RPC_URL =
process.env.NEXT_PUBLIC_SOLANA_RPC_URL ||
"https://api.mainnet-beta.solana.com";
Configure Providers
Set up the providers with Dynamic and React Query. Kalshi markets on Solana useSolanaWalletConnectors instead of the Ethereum connectors. Create src/lib/providers.tsx:
src/lib/providers.tsx
Copy
Ask AI
"use client";
import { useState } from "react";
import {
DynamicContextProvider,
DynamicUserProfile,
} from "@dynamic-labs/sdk-react-core";
import { SolanaWalletConnectors } from "@dynamic-labs/solana";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<DynamicContextProvider
theme="dark"
settings={{
environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENV_ID!,
walletConnectors: [SolanaWalletConnectors],
}}
>
<QueryClientProvider client={queryClient}>
{children}
<DynamicUserProfile />
</QueryClientProvider>
</DynamicContextProvider>
);
}
Create the DFlow Proxy API Route
DFlow requires server-side API key authentication. Create a proxy route atsrc/app/api/dflow/route.ts:
src/app/api/dflow/route.ts
Copy
Ask AI
import { NextRequest, NextResponse } from "next/server";
import { DFLOW_TRADE_API_URL } from "@/lib/constants";
/**
* Proxy endpoint for DFlow Trade API
* Keeps the API key server-side
*/
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const endpoint = searchParams.get("endpoint");
if (!endpoint) {
return NextResponse.json(
{ error: "Missing endpoint parameter" },
{ status: 400 }
);
}
searchParams.delete("endpoint");
const headers: HeadersInit = { "Content-Type": "application/json" };
if (process.env.DFLOW_API_KEY) {
headers["x-api-key"] = process.env.DFLOW_API_KEY;
}
const fullUrl = `${DFLOW_TRADE_API_URL}/${endpoint}?${searchParams.toString()}`;
try {
const response = await fetch(fullUrl, { headers });
const data = await response.json();
if (!response.ok) {
return NextResponse.json(
{ error: data?.error || data?.message || "DFlow API error", details: data },
{ status: response.status }
);
}
return NextResponse.json(data);
} catch (error) {
return NextResponse.json(
{ error: "Failed to proxy request to DFlow" },
{ status: 500 }
);
}
}
Create Markets API Route
Create an API route to fetch markets from DFlow’s prediction markets API. Createsrc/app/api/kalshi/route.ts:
src/app/api/kalshi/route.ts
Copy
Ask AI
import { NextResponse } from "next/server";
import { DFLOW_METADATA_API_URL, USDC_MINT } from "@/lib/constants";
const CATEGORY_MAP: Record<string, string> = {
politics: "Politics",
economics: "Economics",
science: "Science",
sports: "Sports",
entertainment: "Entertainment",
crypto: "Crypto",
weather: "Weather",
culture: "Culture",
technology: "Technology",
finance: "Finance",
news: "News",
pop_culture: "Pop Culture",
};
interface DFlowMarketAccount {
yesMint: string;
noMint: string;
marketLedger: string;
redemptionStatus: string;
scalarOutcomePct?: number;
}
interface DFlowMarket {
id: string;
title: string;
subtitle?: string;
ticker: string;
category: string;
status: string;
result: string;
accounts: Record<string, DFlowMarketAccount>;
yesBid?: string | null;
yesAsk?: string | null;
noBid?: string | null;
noAsk?: string | null;
volume?: number;
openInterest?: number;
expirationTime?: number;
imageUrl?: string;
}
interface TransformedMarket {
id: string;
question: string;
endDate: string;
yesPrice: string;
noPrice: string;
category: string;
imageUrl: string;
yesTraders: number;
noTraders: number;
ticker: string;
yesTokenMint?: string;
noTokenMint?: string;
tags: string[];
volume: number;
status: "open" | "closed" | "settled";
}
function parsePrice(price: string | null | undefined): number | null {
if (!price) return null;
const parsed = parseFloat(price);
return isNaN(parsed) ? null : parsed * 100;
}
function transformDFlowMarket(market: DFlowMarket, now: number): TransformedMarket | null {
try {
const usdcAccount = market.accounts?.[USDC_MINT];
const firstAccount = usdcAccount || Object.values(market.accounts || {})[0];
if (!firstAccount) return null;
const yesPrice = parsePrice(market.yesAsk) ?? parsePrice(market.yesBid) ?? 50;
const noPrice = parsePrice(market.noAsk) ?? parsePrice(market.noBid) ?? 100 - yesPrice;
const category = CATEGORY_MAP[market.category?.toLowerCase()] || market.category || "All";
const openInterest = market.openInterest || 0;
const yesTraders = Math.floor(openInterest * 0.45);
const noTraders = Math.floor(openInterest * 0.35);
const tags: string[] = [];
const endTime = market.expirationTime
? market.expirationTime * 1000
: now + 30 * 24 * 60 * 60 * 1000;
const hoursUntilEnd = (endTime - now) / (1000 * 60 * 60);
if (hoursUntilEnd > 0 && hoursUntilEnd < 24) tags.push("ending soon");
const volume = market.volume || 0;
if (volume > 10000) tags.push("hot");
if (volume > 50000) tags.push("trending");
if (openInterest > 100000) tags.push("high stakes");
const priceDiff = Math.abs(yesPrice - noPrice);
if (priceDiff < 10) tags.push("close call");
let status: "open" | "closed" | "settled" = "open";
if (market.result && market.result !== "") {
status = "settled";
} else if (
market.status === "closed" ||
market.status === "determined" ||
market.status === "finalized"
) {
status = "closed";
}
return {
id: market.id || market.ticker,
question: market.title + (market.subtitle ? ` - ${market.subtitle}` : ""),
endDate: market.expirationTime
? new Date(market.expirationTime * 1000).toISOString()
: new Date(now + 30 * 24 * 60 * 60 * 1000).toISOString(),
yesPrice: yesPrice.toFixed(1),
noPrice: noPrice.toFixed(1),
category,
imageUrl: market.imageUrl || "",
yesTraders,
noTraders,
ticker: market.ticker || market.id,
yesTokenMint: firstAccount.yesMint,
noTokenMint: firstAccount.noMint,
tags,
volume,
status,
};
} catch {
return null;
}
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get("limit") || "100");
const active = searchParams.get("active") !== "false";
const category = searchParams.get("category");
const url = new URL(`${DFLOW_METADATA_API_URL}/api/v1/markets`);
if (active) url.searchParams.append("status", "active");
url.searchParams.append("limit", "100");
const headers: HeadersInit = { "Content-Type": "application/json" };
if (process.env.DFLOW_API_KEY) {
headers["x-api-key"] = process.env.DFLOW_API_KEY;
}
const response = await fetch(url.toString(), {
method: "GET",
headers,
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error(`DFlow API error: ${response.status}`);
}
const data = await response.json();
const now = Date.now();
let markets: DFlowMarket[] = data.markets || [];
if (category && category !== "All") {
markets = markets.filter(
(m) => m.category?.toLowerCase() === category.toLowerCase()
);
}
if (active) {
const activeMarkets = markets.filter(
(m) =>
m.status === "active" ||
m.status === "initialized" ||
(m.status !== "determined" &&
m.status !== "closed" &&
m.result !== "yes" &&
m.result !== "no")
);
if (activeMarkets.length > 0) {
markets = activeMarkets;
}
}
const transformedMarkets = markets
.map((market) => transformDFlowMarket(market, now))
.filter((market): market is TransformedMarket => market !== null)
.sort((a, b) => b.volume - a.volume)
.slice(0, limit);
return NextResponse.json(transformedMarkets, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json(
{ error: "Failed to fetch markets", details: errorMessage },
{ status: 500 }
);
}
}
Create Markets Hook
Create a hook to fetch and cache market data. Createsrc/lib/hooks/useKalshiMarkets.ts:
src/lib/hooks/useKalshiMarkets.ts
Copy
Ask AI
import { useQuery } from "@tanstack/react-query";
export interface Market {
id: string;
question: string;
endDate: string;
yesPrice: string;
noPrice: string;
category: string;
imageUrl: string;
yesTraders: number;
noTraders: number;
ticker: string;
yesTokenMint?: string;
noTokenMint?: string;
tags: string[];
volume: number;
status: "open" | "closed" | "settled";
}
export function calculateTimeRemaining(endDate: string): string {
try {
const end = new Date(endDate).getTime();
const now = Date.now();
const diff = end - now;
if (diff <= 0) return "Closed";
const days = Math.floor(diff / 86400000);
const hours = Math.floor((diff % 86400000) / 3600000);
const minutes = Math.floor((diff % 3600000) / 60000);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
} catch {
return "Unknown";
}
}
async function fetchMarkets(category?: string): Promise<Market[]> {
const params = new URLSearchParams({ limit: "100", active: "true" });
if (category && category !== "All") {
params.set("category", category);
}
const response = await fetch(`/api/kalshi?${params.toString()}`);
if (!response.ok) {
throw new Error("Failed to fetch markets");
}
return response.json();
}
export function useKalshiMarkets(category?: string) {
return useQuery({
queryKey: ["kalshi-markets", category],
queryFn: () => fetchMarkets(category),
staleTime: 60000,
refetchInterval: 60000,
});
}
Create Trading Hook
Now let’s build the trading hook that handles placing orders via DFlow. Createsrc/lib/hooks/useKalshiTrading.ts:
src/lib/hooks/useKalshiTrading.ts
Copy
Ask AI
"use client";
import { useState, useCallback } from "react";
import { useDynamicContext } from "@dynamic-labs/sdk-react-core";
import { isSolanaWallet } from "@dynamic-labs/solana";
import {
Connection,
PublicKey,
VersionedTransaction,
Transaction,
SystemProgram,
LAMPORTS_PER_SOL,
TransactionMessage,
} from "@solana/web3.js";
import {
NATIVE_MINT,
getAssociatedTokenAddress,
createSyncNativeInstruction,
createAssociatedTokenAccountInstruction,
getAccount,
TokenAccountNotFoundError,
} from "@solana/spl-token";
import {
WSOL_MINT,
USDC_MINT,
DEFAULT_SLIPPAGE_BPS,
MIN_BET_USD,
SOL_PRICE_ESTIMATE,
TX_FEE_RESERVE,
} from "@/lib/constants";
export interface TradeParams {
marketId: string;
ticker: string;
tokenMint?: string;
side: "yes" | "no";
amount: number;
isMarketOrder?: boolean;
}
export function useKalshiTrading() {
const { primaryWallet } = useDynamicContext();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const getConnection = useCallback(async (): Promise<Connection> => {
if (!primaryWallet || !isSolanaWallet(primaryWallet)) {
throw new Error("Solana wallet not connected");
}
return primaryWallet.getConnection();
}, [primaryWallet]);
const getSolBalance = useCallback(async (): Promise<number> => {
if (!primaryWallet?.address) return 0;
try {
const connection = await getConnection();
const balance = await connection.getBalance(new PublicKey(primaryWallet.address));
return balance / LAMPORTS_PER_SOL;
} catch {
return 0;
}
}, [primaryWallet, getConnection]);
const getWsolBalance = useCallback(async (): Promise<number> => {
if (!primaryWallet?.address) return 0;
try {
const connection = await getConnection();
const publicKey = new PublicKey(primaryWallet.address);
const accounts = await connection.getParsedTokenAccountsByOwner(publicKey, {
mint: new PublicKey(WSOL_MINT),
});
return accounts.value[0]?.account.data.parsed.info.tokenAmount.uiAmount || 0;
} catch {
return 0;
}
}, [primaryWallet, getConnection]);
/**
* Wraps SOL into WSOL (wrapped SOL) needed for DFlow swaps
*/
const wrapSol = useCallback(
async (solAmount: number): Promise<{ success: boolean; txHash?: string; error?: string }> => {
if (!primaryWallet?.address || !isSolanaWallet(primaryWallet)) {
return { success: false, error: "Please connect a Solana wallet" };
}
try {
const connection = await getConnection();
const publicKey = new PublicKey(primaryWallet.address);
const wsolAta = await getAssociatedTokenAddress(NATIVE_MINT, publicKey);
const lamports = Math.floor(solAmount * LAMPORTS_PER_SOL);
const transaction = new Transaction();
let ataExists = false;
try {
await getAccount(connection, wsolAta);
ataExists = true;
} catch (e) {
if (!(e instanceof TokenAccountNotFoundError)) throw e;
}
if (!ataExists) {
transaction.add(
createAssociatedTokenAccountInstruction(
publicKey,
wsolAta,
publicKey,
NATIVE_MINT
)
);
}
transaction.add(
SystemProgram.transfer({ fromPubkey: publicKey, toPubkey: wsolAta, lamports })
);
transaction.add(createSyncNativeInstruction(wsolAta));
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
// Convert legacy Transaction to VersionedTransaction for Dynamic wallet compatibility
const messageV0 = new TransactionMessage({
payerKey: publicKey,
recentBlockhash: blockhash,
instructions: transaction.instructions,
}).compileToV0Message();
const versionedTx = new VersionedTransaction(messageV0);
const signer = await primaryWallet.getSigner();
const signedTx = await signer.signTransaction(
versionedTx as unknown as Parameters<typeof signer.signTransaction>[0]
);
const signature = await connection.sendRawTransaction(signedTx.serialize());
await connection.confirmTransaction(
{ signature, blockhash, lastValidBlockHeight },
"confirmed"
);
return { success: true, txHash: signature };
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to wrap SOL",
};
}
},
[primaryWallet, getConnection]
);
/**
* Executes a DFlow swap from inputMint to outputMint.
* This is used both for buying outcome tokens (WSOL → outcome token)
* and selling positions (outcome token → USDC).
*/
const executeDFlowSwap = useCallback(
async (
inputMint: string,
outputMint: string,
amount: number
): Promise<{ success: boolean; txHash?: string; error?: string }> => {
if (!primaryWallet?.address || !isSolanaWallet(primaryWallet)) {
return { success: false, error: "Please connect a Solana wallet" };
}
try {
const connection = await getConnection();
const queryParams = new URLSearchParams({
endpoint: "order",
inputMint,
outputMint,
amount: amount.toString(),
slippageBps: DEFAULT_SLIPPAGE_BPS.toString(),
userPublicKey: primaryWallet.address,
});
const orderResponse = await fetch(`/api/dflow?${queryParams.toString()}`);
if (!orderResponse.ok) {
const errorData = await orderResponse.json();
throw new Error(errorData.error || "DFlow API error");
}
const orderData = await orderResponse.json();
const transactionBuffer = Buffer.from(orderData.transaction, "base64");
const transaction = VersionedTransaction.deserialize(transactionBuffer);
const signer = await primaryWallet.getSigner();
const signedTx = await signer.signTransaction(
transaction as unknown as Parameters<typeof signer.signTransaction>[0]
);
const signature = await connection.sendRawTransaction(signedTx.serialize(), {
skipPreflight: false,
preflightCommitment: "confirmed",
});
// DFlow may use sync or async execution modes
if (orderData.executionMode === "sync") {
const confirmation = await connection.confirmTransaction(
{
signature,
blockhash: transaction.message.recentBlockhash,
lastValidBlockHeight: (await connection.getLatestBlockhash()).lastValidBlockHeight,
},
"confirmed"
);
if (confirmation.value.err) {
throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`);
}
return { success: true, txHash: signature };
} else {
// Poll for async order status
for (let i = 0; i < 30; i++) {
const statusParams = new URLSearchParams({
endpoint: "order-status",
signature,
});
const statusResponse = await fetch(`/api/dflow?${statusParams.toString()}`);
if (statusResponse.status === 404) {
const txStatus = await connection.getSignatureStatus(signature);
if (
txStatus.value?.confirmationStatus === "confirmed" ||
txStatus.value?.confirmationStatus === "finalized"
) {
if (!txStatus.value.err) return { success: true, txHash: signature };
throw new Error("Transaction failed on-chain");
}
await new Promise((r) => setTimeout(r, 2000));
continue;
}
const statusData = await statusResponse.json();
if (statusData.status === "closed") return { success: true, txHash: signature };
if (statusData.status === "failed") throw new Error("Order failed to execute");
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error("Order timed out");
}
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : "Swap failed",
};
}
},
[primaryWallet, getConnection]
);
const placeOrder = useCallback(
async (
params: TradeParams
): Promise<{ success: boolean; txHash?: string; error?: string }> => {
if (!primaryWallet?.address || !isSolanaWallet(primaryWallet)) {
return { success: false, error: "Please connect a Solana wallet" };
}
if (!params.tokenMint) {
return { success: false, error: "This market does not have a valid outcome token." };
}
if (params.amount < MIN_BET_USD) {
return { success: false, error: `Minimum bet is $${MIN_BET_USD}.` };
}
setIsLoading(true);
setError(null);
try {
const solBalance = await getSolBalance();
const wsolBalance = await getWsolBalance();
const totalBalance = solBalance + wsolBalance;
const baseSolNeeded = params.amount / SOL_PRICE_ESTIMATE;
const estimatedSolNeeded = baseSolNeeded * 1.2 + TX_FEE_RESERVE;
if (totalBalance < estimatedSolNeeded) {
const maxBetUsd = Math.floor(
((totalBalance - TX_FEE_RESERVE) * SOL_PRICE_ESTIMATE) / 1.2
);
setIsLoading(false);
return {
success: false,
error: `Insufficient balance. Max bet: ~$${Math.max(0, maxBetUsd)}.`,
};
}
const lamportsToSwap = Math.floor(baseSolNeeded * 1.2 * LAMPORTS_PER_SOL);
// Ensure enough WSOL is available, wrapping SOL if needed
if (wsolBalance < baseSolNeeded * 1.2) {
const wsolNeeded = baseSolNeeded * 1.2 - wsolBalance;
const wrapResult = await wrapSol(wsolNeeded + 0.002);
if (!wrapResult.success) {
setIsLoading(false);
return { success: false, error: `Failed to wrap SOL: ${wrapResult.error}` };
}
}
// Swap WSOL → outcome token via DFlow
const result = await executeDFlowSwap(WSOL_MINT, params.tokenMint, lamportsToSwap);
setIsLoading(false);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to place order";
setError(errorMessage);
setIsLoading(false);
return { success: false, error: errorMessage };
}
},
[primaryWallet, getSolBalance, getWsolBalance, wrapSol, executeDFlowSwap]
);
const sellPosition = useCallback(
async (params: {
marketId: string;
tokenMint?: string;
settlementMint?: string;
side: "yes" | "no";
size: number;
}): Promise<{ success: boolean; txHash?: string; error?: string }> => {
if (!primaryWallet?.address || !isSolanaWallet(primaryWallet)) {
return { success: false, error: "Please connect a Solana wallet" };
}
if (!params.tokenMint) {
return { success: false, error: "Position does not have a valid outcome token." };
}
// Sell outcome tokens → USDC (or other settlement currency)
const outputMint = params.settlementMint || USDC_MINT;
const amountToSwap = Math.floor(params.size * 1000000);
return executeDFlowSwap(params.tokenMint, outputMint, amountToSwap);
},
[primaryWallet, executeDFlowSwap]
);
return {
placeOrder,
sellPosition,
getSolBalance,
getWsolBalance,
wrapSol,
isLoading,
error,
};
}
- Getting SOL and WSOL (wrapped SOL) balances from the Solana network
- Wrapping SOL into WSOL — required before DFlow can route swaps
- Placing orders by swapping WSOL → outcome tokens via DFlow’s API
- Selling positions by swapping outcome tokens → USDC via DFlow
Create Positions API Route
Create a route to fetch on-chain positions by checking SPL token balances. Createsrc/app/api/kalshi/positions/route.ts:
src/app/api/kalshi/positions/route.ts
Copy
Ask AI
import { NextResponse } from "next/server";
import { Connection, PublicKey } from "@solana/web3.js";
import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { DFLOW_METADATA_API_URL, SOLANA_RPC_URL } from "@/lib/constants";
function getDFlowHeaders(): HeadersInit {
const headers: HeadersInit = { "Content-Type": "application/json" };
if (process.env.DFLOW_API_KEY) {
headers["x-api-key"] = process.env.DFLOW_API_KEY;
}
return headers;
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const walletAddress = searchParams.get("wallet");
if (!walletAddress) {
return NextResponse.json({ error: "Wallet address is required" }, { status: 400 });
}
try {
new PublicKey(walletAddress);
} catch {
return NextResponse.json({ error: "Invalid wallet address" }, { status: 400 });
}
try {
const connection = new Connection(SOLANA_RPC_URL, "confirmed");
const userWallet = new PublicKey(walletAddress);
// Fetch all SPL and Token-2022 token accounts
const [splAccounts, token2022Accounts] = await Promise.all([
connection.getParsedTokenAccountsByOwner(userWallet, { programId: TOKEN_PROGRAM_ID }),
connection.getParsedTokenAccountsByOwner(userWallet, { programId: TOKEN_2022_PROGRAM_ID }),
]);
const allTokens = [...splAccounts.value, ...token2022Accounts.value]
.map(({ account }) => {
const info = account.data.parsed.info;
return {
mint: info.mint as string,
balance: info.tokenAmount.uiAmount as number,
decimals: info.tokenAmount.decimals as number,
};
})
.filter((t) => t.balance > 0);
if (allTokens.length === 0) {
return NextResponse.json({ positions: [], orders: [] });
}
// Filter to only prediction market outcome mints via DFlow
const filterResponse = await fetch(
`${DFLOW_METADATA_API_URL}/api/v1/filter_outcome_mints`,
{
method: "POST",
headers: getDFlowHeaders(),
body: JSON.stringify({ addresses: allTokens.map((t) => t.mint) }),
}
);
if (!filterResponse.ok) {
return NextResponse.json({ positions: [], orders: [] });
}
const filterData = await filterResponse.json();
const outcomeMints: string[] = filterData.outcomeMints || [];
if (outcomeMints.length === 0) {
return NextResponse.json({ positions: [], orders: [] });
}
const outcomeTokens = allTokens.filter((t) => outcomeMints.includes(t.mint));
// Batch fetch market metadata for the outcome mints
const marketsResponse = await fetch(
`${DFLOW_METADATA_API_URL}/api/v1/markets/batch`,
{
method: "POST",
headers: getDFlowHeaders(),
body: JSON.stringify({ mints: outcomeMints }),
}
);
if (!marketsResponse.ok) {
return NextResponse.json({ positions: [], orders: [] });
}
const marketsData = await marketsResponse.json();
const markets = marketsData.markets || [];
// Map outcome tokens to positions using market metadata
const marketsByMint = new Map<string, typeof markets[0]>();
markets.forEach((market: typeof markets[0]) => {
if (market.accounts) {
Object.values(market.accounts).forEach((account: unknown) => {
const acc = account as { yesMint: string; noMint: string };
if (acc.yesMint) marketsByMint.set(acc.yesMint, market);
if (acc.noMint) marketsByMint.set(acc.noMint, market);
});
}
});
const positions = outcomeTokens
.map((token) => {
const market = marketsByMint.get(token.mint);
if (!market) return null;
let side: "yes" | "no" | null = null;
let settlementMint: string | undefined;
let redemptionStatus: string | undefined;
for (const [mint, account] of Object.entries(market.accounts || {})) {
const acc = account as { yesMint: string; noMint: string; redemptionStatus: string };
if (acc.yesMint === token.mint) {
side = "yes";
settlementMint = mint;
redemptionStatus = acc.redemptionStatus;
break;
} else if (acc.noMint === token.mint) {
side = "no";
settlementMint = mint;
redemptionStatus = acc.redemptionStatus;
break;
}
}
if (!side) return null;
const currentPrice = side === "yes" ? (market.yesPrice ?? 50) : (market.noPrice ?? 50);
const avgPrice = 50;
const pnl = ((currentPrice - avgPrice) * token.balance) / 100;
const isRedeemable =
redemptionStatus === "open" &&
(market.status === "determined" || market.status === "finalized") &&
((market.result === "yes" && side === "yes") ||
(market.result === "no" && side === "no"));
return {
marketId: market.id || token.mint,
ticker: market.ticker || "UNKNOWN",
question: market.title || "Unknown Market",
side,
size: token.balance,
avgPrice,
currentPrice,
pnl,
pnlPercent: ((currentPrice - avgPrice) / avgPrice) * 100,
outcomeMint: token.mint,
settlementMint,
marketStatus: market.status,
isRedeemable,
redemptionStatus,
result: market.result,
category: market.category,
imageUrl: market.imageUrl,
};
})
.filter(Boolean);
return NextResponse.json({ positions, orders: [] });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json(
{ error: "Failed to fetch positions", details: errorMessage },
{ status: 500 }
);
}
}
Create the Market Card Component
Createsrc/components/MarketCard.tsx to display individual markets with inline trading:
src/components/MarketCard.tsx
Copy
Ask AI
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useRef, useState } from "react";
import { useKalshiTrading } from "@/lib/hooks/useKalshiTrading";
import { useDynamicContext } from "@dynamic-labs/sdk-react-core";
interface MarketCardProps {
question: string;
timeRemaining: string;
yesPrice: string;
noPrice: string;
yesTraders?: number;
noTraders?: number;
ticker?: string;
yesTokenMint?: string;
noTokenMint?: string;
marketId?: string;
tags?: string[];
}
export function MarketCard({
question,
timeRemaining,
yesPrice,
noPrice,
yesTraders,
noTraders,
ticker,
yesTokenMint,
noTokenMint,
marketId,
tags = [],
}: MarketCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [selectedOption, setSelectedOption] = useState<"yes" | "no" | null>(null);
const [betAmount, setBetAmount] = useState<number>(0);
const [betAmountInput, setBetAmountInput] = useState<string>("");
const [tradingError, setTradingError] = useState<string | null>(null);
const [tradingSuccess, setTradingSuccess] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const cardPosition = useRef<{ top: number; left: number; width: number } | null>(null);
const isSubmittingRef = useRef(false);
const { primaryWallet, setShowAuthFlow } = useDynamicContext();
const { placeOrder } = useKalshiTrading();
const handleOptionClick = (option: "yes" | "no") => {
if (!primaryWallet) {
setShowAuthFlow(true);
return;
}
if (cardRef.current) {
const rect = cardRef.current.getBoundingClientRect();
cardPosition.current = { top: rect.top, left: rect.left, width: rect.width };
}
setSelectedOption(option);
setIsExpanded(true);
setBetAmount(0);
setBetAmountInput("");
setTradingError(null);
setTradingSuccess(false);
};
const handleClose = useCallback(() => {
setIsExpanded(false);
setTimeout(() => {
setSelectedOption(null);
cardPosition.current = null;
}, 300);
}, []);
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value.replace(/\$/g, "").replace(/[^0-9.]/g, "");
setBetAmountInput(value);
const numValue = parseFloat(value);
setBetAmount(!isNaN(numValue) ? numValue : 0);
};
const handleBuy = useCallback(async () => {
if (isSubmittingRef.current || betAmount === 0 || !selectedOption || !primaryWallet) return;
isSubmittingRef.current = true;
setIsProcessing(true);
setTradingError(null);
setTradingSuccess(false);
try {
const tokenMint = selectedOption === "yes" ? yesTokenMint : noTokenMint;
const result = await placeOrder({
marketId: marketId || "",
ticker: ticker || "",
tokenMint: tokenMint || "",
side: selectedOption,
amount: betAmount,
isMarketOrder: true,
});
if (result.success) {
setTradingSuccess(true);
setIsProcessing(false);
setTimeout(() => handleClose(), 2000);
} else {
setIsProcessing(false);
setTradingError(result.error || "Failed to place order");
}
} catch (error) {
setIsProcessing(false);
setTradingError(error instanceof Error ? error.message : "An unexpected error occurred");
} finally {
isSubmittingRef.current = false;
}
}, [betAmount, selectedOption, primaryWallet, ticker, yesTokenMint, noTokenMint, marketId, placeOrder, handleClose]);
const potentialWin =
betAmount > 0
? selectedOption === "yes"
? (betAmount / parseFloat(yesPrice)) * 100
: (betAmount / parseFloat(noPrice)) * 100
: 0;
const cardContent = (
<motion.div
layout
className={`bg-[#12131a] rounded-[22px] w-full relative border border-[#262a34] ${
isExpanded ? "z-50" : "z-0"
}`}
style={
isExpanded && cardPosition.current
? {
position: "fixed",
top: cardPosition.current.top,
left: cardPosition.current.left,
width: cardPosition.current.width,
}
: undefined
}
>
<div className="flex flex-col items-start overflow-clip rounded-[inherit] w-full">
{/* Card Header */}
<div className="bg-[#1a1b23] rounded-[22px] shrink-0 w-full p-4">
<div className="flex gap-3 items-start w-full">
<div className="flex-1 flex flex-col gap-2">
<p className="font-semibold text-white text-lg leading-snug line-clamp-2">
{question}
</p>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-0.5 rounded-full text-xs font-medium bg-[#8b5cf6]/20 text-[#8b5cf6] border border-[#8b5cf6]/30"
>
{tag}
</span>
))}
</div>
)}
</div>
<span className="text-xs text-[rgba(221,226,246,0.5)] shrink-0 pt-1">
{timeRemaining}
</span>
</div>
{/* Volume display */}
{yesTraders !== undefined && noTraders !== undefined && (
<div className="mt-3 flex items-center gap-3">
<span className="text-xs font-semibold text-[#14b8a6] bg-[#14b8a6]/10 px-2 py-1 rounded-full">
Yes {yesPrice}%
</span>
<span className="text-xs font-semibold text-[#ef4444] bg-[#ef4444]/10 px-2 py-1 rounded-full">
No {noPrice}%
</span>
<span className="text-xs text-[rgba(221,226,246,0.4)] ml-auto">
${((yesTraders + noTraders) * 234).toLocaleString()} Vol.
</span>
</div>
)}
</div>
{/* Yes/No Buttons + Expanded Section */}
<motion.div layout className="relative shrink-0 w-full p-3">
<div className="flex gap-2 h-[41px]">
<motion.button
onClick={() => handleOptionClick("yes")}
whileTap={{ scale: 0.9 }}
className={`flex-1 rounded-[9px] font-semibold text-base transition-colors ${
selectedOption === "yes"
? "bg-[#14b8a6] text-[#0a0b0f]"
: "bg-[#14b8a6]/10 text-[#14b8a6] hover:bg-[#14b8a6]/20"
}`}
>
Yes {yesPrice}¢
</motion.button>
<motion.button
onClick={() => handleOptionClick("no")}
whileTap={{ scale: 0.9 }}
className={`flex-1 rounded-[9px] font-semibold text-base transition-colors ${
selectedOption === "no"
? "bg-[#ef4444] text-white"
: "bg-[#ef4444]/10 text-[#ef4444] hover:bg-[#ef4444]/20"
}`}
>
No {noPrice}¢
</motion.button>
</div>
{/* Expanded Trading Section */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
className="overflow-hidden w-full mt-3"
>
<div className="flex flex-col gap-3">
{/* Amount Input */}
<div className="bg-[#1a1b23] h-12 rounded-[10px] border border-[#262a34] flex items-center px-3 justify-between">
<input
type="text"
inputMode="decimal"
value={betAmountInput === "" ? "" : `$${betAmountInput}`}
onChange={handleAmountChange}
placeholder="$0"
className="text-[#8b5cf6] text-xl bg-transparent border-none outline-none flex-1 placeholder:text-[#8b5cf6] placeholder:opacity-30"
/>
<div className="flex gap-1">
{[5, 10].map((amt) => (
<button
key={amt}
onClick={() => {
const newAmt = betAmount + amt;
setBetAmount(newAmt);
setBetAmountInput(newAmt.toString());
}}
className="bg-[#8b5cf6]/10 hover:bg-[#8b5cf6]/20 text-[#7b7f8d] text-sm rounded-full w-9 h-6 flex items-center justify-center"
>
+{amt}
</button>
))}
</div>
</div>
{tradingError && (
<div className="bg-[#ef4444]/10 border border-[#ef4444]/30 rounded-[10px] p-3">
<p className="text-[#ef4444] text-sm">{tradingError}</p>
</div>
)}
{tradingSuccess && (
<div className="bg-[#14b8a6]/10 border border-[#14b8a6]/30 rounded-[10px] p-3">
<p className="text-[#14b8a6] text-sm">Order placed successfully!</p>
</div>
)}
{/* Buy Button */}
<motion.button
onClick={handleBuy}
whileTap={{ scale: 0.9 }}
disabled={betAmount === 0 || isProcessing}
className={`h-12 rounded-[12px] w-full font-bold text-white transition-all ${
betAmount > 0 && !isProcessing
? "bg-gradient-to-r from-[#8b5cf6] to-[#06b6d4] cursor-pointer"
: "bg-[#1a2239] cursor-not-allowed opacity-40"
}`}
>
<p>
{isProcessing
? "Processing..."
: `Buy ${selectedOption === "yes" ? "Yes" : "No"}`}
</p>
{betAmount > 0 && !isProcessing && (
<p className="text-sm opacity-70">To win ${potentialWin.toFixed(2)}</p>
)}
</motion.button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</motion.div>
);
return (
<>
{/* Blur Overlay */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40"
onClick={handleClose}
/>
)}
</AnimatePresence>
{/* Placeholder to maintain grid layout */}
<div ref={cardRef} className="w-full">
{isExpanded ? (
<div className="bg-transparent rounded-[22px] w-full opacity-0 pointer-events-none">
<div className="bg-[#1a1b23] rounded-[22px] w-full p-4">
<p className="text-white text-lg">{question}</p>
</div>
</div>
) : (
cardContent
)}
</div>
{isExpanded && cardContent}
</>
);
}
Build the Main Page
Updatesrc/app/page.tsx to display markets in a responsive grid:
src/app/page.tsx
Copy
Ask AI
"use client";
import { MarketCard } from "@/components/MarketCard";
import {
useKalshiMarkets,
type Market,
calculateTimeRemaining,
} from "@/lib/hooks/useKalshiMarkets";
export default function Home() {
const { data: markets = [], isLoading, error } = useKalshiMarkets();
return (
<div className="min-h-screen bg-[#0e1219] p-6">
{/* Header */}
<div className="max-w-6xl mx-auto mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Prediction Markets</h1>
<p className="text-[rgba(221,226,246,0.5)]">Powered by Kalshi & DFlow on Solana</p>
</div>
{/* Markets Grid */}
<div className="max-w-6xl mx-auto">
{isLoading ? (
<div className="text-center py-20">
<p className="text-[rgba(221,226,246,0.3)] text-lg">Loading markets...</p>
</div>
) : error ? (
<div className="text-center py-20">
<p className="text-[rgba(221,226,246,0.3)] text-lg">
Error loading markets. Please try again.
</p>
</div>
) : markets.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{markets.map((market: Market) => (
<MarketCard
key={market.id}
question={market.question}
timeRemaining={calculateTimeRemaining(market.endDate)}
yesPrice={market.yesPrice}
noPrice={market.noPrice}
yesTraders={market.yesTraders}
noTraders={market.noTraders}
ticker={market.ticker}
yesTokenMint={market.yesTokenMint}
noTokenMint={market.noTokenMint}
marketId={market.id}
tags={market.tags}
/>
))}
</div>
) : (
<div className="text-center py-20">
<p className="text-[rgba(221,226,246,0.3)] text-lg">No markets found</p>
</div>
)}
</div>
</div>
);
}
Run the Application
Start the development server:Copy
Ask AI
npm run dev
http://localhost:3000.
Configure CORS
Add your local development URL to the CORS origins in your Dynamic dashboard under Developer Settings > CORS Origins.How Trading Works
When a user places a trade through this app:- Wallet Connection: The user connects via Dynamic’s embedded Solana wallet
- Balance Check: The app checks the user’s SOL and WSOL (wrapped SOL) balances
- SOL Wrapping: If the user doesn’t have enough WSOL, the app wraps SOL by sending it to a token account and syncing the native balance — this is a Solana-specific step required before DFlow can route the swap
- Order Routing: A DFlow order is requested server-side to get a signed transaction for swapping WSOL → outcome token
- Transaction Signing: The user signs the DFlow-generated transaction via Dynamic’s wallet
- Execution: The transaction is sent to the Solana network and confirmed
- Confirmation: The user sees success/error feedback
Portfolio Management
Users can view and manage their positions from the portfolio modal:- Active positions: Shows current Yes/No shares held, with estimated P&L based on current market prices
- Sell positions: Users can sell outcome tokens back to USDC by executing a reverse DFlow swap (outcome token → USDC)
- Redeem winnings: For settled markets where the user won, they can redeem their winning shares for USDC via DFlow
filter_outcome_mints endpoint, and finally enriching with market metadata via the batch markets API.