PredictionLabs

Building a Frontend

How to build a prediction market frontend using on-chain data and the SDK.

Your frontend reads state from events, displays markets and order books, and submits transactions. All data is on-chain. No backend needed.

The @predictionlabs/sdk package provides a TypeScript client (using viem) that handles ABI encoding, event indexing, and contract interaction. The examples below use ethers.js directly, but consider the SDK as an alternative.

Connecting to the contracts

import { ethers } from "ethers";
import MarketFactoryABI from "./abi/MarketFactory.json";
import OrderBookABI from "./abi/OrderBook.json";
import CTFABI from "./abi/ConditionalTokens.json";
import ERC20ABI from "./abi/ERC20.json";

const provider = new ethers.JsonRpcProvider("https://polygon-rpc.com");
const signer = await provider.getSigner();

const factory = new ethers.Contract(FACTORY_ADDRESS, MarketFactoryABI, signer);
const orderBook = new ethers.Contract(ORDERBOOK_ADDRESS, OrderBookABI, signer);
const ctf = new ethers.Contract(CTF_ADDRESS, CTFABI, signer);
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20ABI, signer);

See Deployments for addresses. See Deployments - ABIs for how to extract ABIs from Foundry output.

Creating a market

// resolver: address of the resolver contract (UMAResolver, KalshiResolver, etc.)
const tx = await factory.createMarket(
  "Will ETH exceed $5000 by end of 2026?",
  2,              // outcomeSlotCount (binary)
  deadline,       // unix timestamp (uint256, stored as uint64  - reverts if too large)
  resolverAddress // address of the resolver for this market
);

Listing markets

There is no getMarkets() function. Markets are discovered by indexing MarketCreated events on MarketFactory.

const events = await factory.queryFilter(factory.filters.MarketCreated());

const markets = events.map(e => ({
  conditionId: e.args.conditionId,
  questionId: e.args.questionId,
  creator: e.args.creator,
  outcomeSlotCount: e.args.outcomeSlotCount,
  deadline: e.args.deadline,
  resolver: e.args.resolver,
  question: e.args.question,
}));

For current state (resolved or not), call the contract:

const trading = await factory.isTrading(conditionId);
const resolvable = await factory.isResolvable(conditionId);

For multi-outcome markets, also index MultiOutcomeMarketCreated events on NegRiskAdapter.

Building the order book

No getOrdersForMarket() exists either. Reconstruct from events.

Index the events

// All orders ever placed
const placed = await orderBook.queryFilter(orderBook.filters.OrderPlaced());

// All fills
const filled = await orderBook.queryFilter(orderBook.filters.OrderFilled());

// All cancellations
const cancelled = await orderBook.queryFilter(orderBook.filters.OrderCancelled());

Reconstruct state

// Build order map
const orders = new Map();

for (const e of placed) {
  orders.set(e.args.orderId.toString(), {
    orderId: e.args.orderId,
    maker: e.args.maker,
    conditionId: e.args.conditionId,
    outcomeIndex: e.args.outcomeIndex,
    side: e.args.side,        // 0 = BUY, 1 = SELL
    price: e.args.price,
    amount: e.args.amount,
    filled: 0n,
    cancelled: false,
  });
}

for (const e of filled) {
  const order = orders.get(e.args.orderId.toString());
  if (order) order.filled += e.args.fillAmount;

  const matched = orders.get(e.args.matchedOrderId.toString());
  if (matched) matched.filled += e.args.fillAmount;
}

for (const e of cancelled) {
  const order = orders.get(e.args.orderId.toString());
  if (order) order.cancelled = true;
}

Filter for a specific market

function getOrderBook(conditionId, outcomeIndex) {
  const active = [...orders.values()].filter(o =>
    o.conditionId === conditionId &&
    o.outcomeIndex === BigInt(outcomeIndex) &&
    !o.cancelled &&
    o.filled < o.amount
  );

  const bids = active
    .filter(o => o.side === 0n)
    .sort((a, b) => Number(b.price - a.price)); // highest bid first

  const asks = active
    .filter(o => o.side === 1n)
    .sort((a, b) => Number(a.price - b.price)); // lowest ask first

  return { bids, asks };
}

Live updates

Use WebSocket provider and listen for new events:

orderBook.on("OrderPlaced", (orderId, maker, conditionId, outcomeIndex, side, price, amount) => {
  // Add to order map
});

orderBook.on("OrderFilled", (orderId, matchedOrderId, fillAmount) => {
  // Update filled amounts
});

orderBook.on("OrderCancelled", (orderId) => {
  // Mark cancelled
});

Checking token balances

Outcome tokens are ERC-1155 on the CTF. To check a user's balance, you need the positionId.

Computing positionId

The CTF uses elliptic curve operations internally to derive positionIds. Don't try to replicate this off-chain - call the CTF's view functions directly:

// For binary markets: indexSet = 1 for YES, 2 for NO
const parentCollectionId = ethers.ZeroHash;

// Step 1: get collectionId from CTF (uses EC math on alt_bn128)
const yesCollectionId = await ctf.getCollectionId(parentCollectionId, conditionId, 1);
const noCollectionId = await ctf.getCollectionId(parentCollectionId, conditionId, 2);

// Step 2: get positionId from CTF
const yesPositionId = await ctf.getPositionId(USDC_ADDRESS, yesCollectionId);
const noPositionId = await ctf.getPositionId(USDC_ADDRESS, noCollectionId);

// Check balance
const yesBalance = await ctf.balanceOf(userAddress, yesPositionId);
const noBalance = await ctf.balanceOf(userAddress, noPositionId);

IndexSet encoding (binary markets):

OutcomeIndexIndexSetBinary
YES0101
NO1210

The indexSet is a bitmask: 1 << outcomeIndex.

Display as dollars

// Outcome tokens use 6 decimals (same as USDC)
const balanceUSD = Number(yesBalance) / 1e6;

Displaying prices

Prices are on the USDC scale where 1e6 = $1.00.

function formatPrice(price) {
  return `$${(Number(price) / 1e6).toFixed(2)}`;
}

function formatProbability(price) {
  return `${(Number(price) / 1e4).toFixed(2)}%`;
}

Placing orders

Buy order

// One-time approval
await usdc.approve(ORDERBOOK_ADDRESS, ethers.MaxUint256);

// Place order: buy YES at $0.65, 100 shares
const tx = await orderBook.placeBuyOrder(
  conditionId,
  0,            // outcomeIndex: 0 = YES
  650000,       // price: $0.65
  100_000_000   // amount: 100 shares (6 decimals)
);

const receipt = await tx.wait();
const event = receipt.logs.find(l => l.fragment?.name === "OrderPlaced");
const orderId = event.args.orderId;

Sell order

// One-time approval for CTF tokens
await ctf.setApprovalForAll(ORDERBOOK_ADDRESS, true);

// Sell YES at $0.80, 50 shares
const tx = await orderBook.placeSellOrder(conditionId, 0, 800000, 50_000_000);

Matching orders

Your frontend can also be a matcher. Find crossable orders and fill them.

// Two buy orders on opposite outcomes whose prices sum to >= $1.00
if (bid0.price + bid1.price >= 1_000_000n) {
  const fillAmount = bid0.amount - bid0.filled < bid1.amount - bid1.filled
    ? bid0.amount - bid0.filled
    : bid1.amount - bid1.filled;

  await orderBook.fillBuyVsBuy(bid0.orderId, bid1.orderId, fillAmount);
}

// Buy and sell on same outcome where buyer's price >= seller's price
if (buyOrder.price >= sellOrder.price) {
  const fillAmount = buyOrder.amount - buyOrder.filled < sellOrder.amount - sellOrder.filled
    ? buyOrder.amount - buyOrder.filled
    : sellOrder.amount - sellOrder.filled;

  await orderBook.fillBuyVsSell(buyOrder.orderId, sellOrder.orderId, fillAmount);
}

See Running a Keeper for more on automated matching.

Safety checks (required)

Your frontend is the user protection layer. The contracts are permissionless and don't prevent users from trading on bad markets. See Security - Frontend responsibilities for the full list. Summary:

  1. Whitelist resolvers - Only show markets with trusted resolver addresses by default
  2. Check linking - Kalshi/Polymarket markets must be linked before they're resolvable. Warn or hide unlinked markets
  3. Deadline warnings - Markets past deadline are in a danger zone. Warn users, prompt order cancellation
  4. Validate inputs - Check price ranges and minimum amounts before submitting transactions

On this page