Skip to main content

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)
If you want to take a quick look at the final code, check out the GitHub repository.

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:
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:
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
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
You can find your Environment ID in the Dynamic dashboard under Developer Settings → SDK & API Keys. The 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

Create src/lib/constants.ts for DFlow API URLs and trading configuration:
src/lib/constants.ts
/**
 * 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 use SolanaWalletConnectors instead of the Ethereum connectors. Create src/lib/providers.tsx:
src/lib/providers.tsx
"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>
  );
}
Unlike Polymarket (which needs Wagmi for Ethereum/Polygon), Kalshi on Solana doesn’t require a Wagmi provider — Dynamic’s Solana SDK handles wallet connectivity directly.

Create the DFlow Proxy API Route

DFlow requires server-side API key authentication. Create a proxy route at src/app/api/dflow/route.ts:
src/app/api/dflow/route.ts
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. Create src/app/api/kalshi/route.ts:
src/app/api/kalshi/route.ts
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. Create src/lib/hooks/useKalshiMarkets.ts:
src/lib/hooks/useKalshiMarkets.ts
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. Create src/lib/hooks/useKalshiTrading.ts:
src/lib/hooks/useKalshiTrading.ts
"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,
  };
}
This hook handles:
  • 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. Create src/app/api/kalshi/positions/route.ts:
src/app/api/kalshi/positions/route.ts
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

Create src/components/MarketCard.tsx to display individual markets with inline trading:
src/components/MarketCard.tsx
"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

Update src/app/page.tsx to display markets in a responsive grid:
src/app/page.tsx
"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:
npm run dev
The application will be available at 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:
  1. Wallet Connection: The user connects via Dynamic’s embedded Solana wallet
  2. Balance Check: The app checks the user’s SOL and WSOL (wrapped SOL) balances
  3. 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
  4. Order Routing: A DFlow order is requested server-side to get a signed transaction for swapping WSOL → outcome token
  5. Transaction Signing: The user signs the DFlow-generated transaction via Dynamic’s wallet
  6. Execution: The transaction is sent to the Solana network and confirmed
  7. 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
Position data is fetched by querying the user’s Solana SPL token accounts on-chain, then filtering to prediction market outcome tokens using DFlow’s filter_outcome_mints endpoint, and finally enriching with market metadata via the batch markets API.

Conclusion

If you want to take a look at the full source code, check out the GitHub repository. This integration demonstrates how Dynamic’s embedded wallets can seamlessly connect to prediction markets powered by Kalshi and DFlow on Solana. The combination of Dynamic’s Solana wallet SDK and DFlow’s order routing enables complex outcome token trading with minimal friction.

Additional Resources