Technical documentation

How OpenCast works

Prediction markets on every World Cup match, settled on Solana by TxLINE's cryptographic stat proofs — no bookmaker, no admin, no trusted resolver. This page documents the architecture, the on-chain program, the TxLINE integration, and the public HTTP API. Built for the TxLINE World Cup hackathon by TxODDS.

Overview

OpenCast is a Next.js app (Vercel + Neon Postgres + Privy embedded Solana wallets) in front of a custom Anchor settlement program on Solana devnet. TxLINE is the only source of sports truth: fixtures, live scores, the reference odds line, and — the part that matters — Merkle proofs of match stats that the chain itself verifies at settlement.

plain English ──AI──▶ deterministic predicate (statKey, threshold, cmp)
      │                                │
      ▼                                ▼
 create_market ──▶ parimutuel pool ──▶ full-time
                                        │  keeper fetches TxLINE stat proof
                                        ▼
                    settle_market ──CPI──▶ txoracle.validate_stat_v2
                                        │  chain recomputes the Merkle root
                                        ▼
                              winners claim (2% fee)

Nothing in the pipeline lets a human pick an outcome. The AI only compiles language into a predicate; the keeper only relays bytes; the program only pays what the proof confirms.

How settlement works

  1. 1
    TxLINE anchors the match

    Every score update is hashed into a per-event Merkle tree, rolled into a daily sub-tree, and the day's root is written to an on-chain account (txoracle).

  2. 2
    The keeper finds a final-whistle proof

    A cron sweep (every 10 min, permissionless to trigger) walks the fixture's records — sorted by Seq, never earlier than the game_finalised marker — and fetches the stat-validation proof for the market's stat keys. (lib/txline/proof.ts)

  3. 3
    Independent check gate

    Before anything touches the chain we recompute the proof's Merkle chain ourselves — plain sha256 pair-hashing honoring isRightSibling. A proof that doesn't reconcile with its own summary root is skipped; this gate caught a genuinely inconsistent record in production (fixture 17926647, seq 87) and refused to settle against it. (lib/txline/merkle.ts)

  4. 4
    The chain verifies, the market settles

    settle_market CPIs into txoracle.validate_stat_v2 with the proof. The chain re-derives the root from the leaves and compares it to the anchored account — then the program evaluates the predicate against the proven values and stores the outcome. The proof JSON is snapshotted so receipts outlive TxLINE API access.

  5. 5
    Winners claim

    Pro-rata share of the whole pool, with a 2% fee withheld from winning claims. Parlay legs are proven individually (prove_leg) and finalized atomically.

The predicate model

Every market is one on-chain check: value(statKeyA) − value(statKeyB) ⟨cmp⟩ threshold, with statKeyB = 0 for single-stat markets. Comparison is 0 = greater, 1 = less, 2 = equal.

MarketPredicate
Will France beat Iraq?goals(FR) − goals(IQ) > 0
Match ends in a drawgoals(A) − goals(B) = 0
France to score 2+goals(FR) > 1
Win by 2+ (spread)goals(A) − goals(B) > 1
England 6+ cornerscorners(EN) > 5
2+ yellow cardsyellows(A) > 1
Leads at half-timegoals₁ₕ(A) − goals₁ₕ(B) > 0

Stat keys are TxLINE composites: period × 1000 + base — so key 1 is Participant 1 full-time goals and 1001 is their first-half goals. Goals, cards, and corners across periods are provable; anything TxLINE can't prove (shots, possession, transfers) is rejected at draft time — the market can't exist.

TxLINE integration

EndpointUsed for
POST /auth/guest/startshort-lived JWT, auto-renewed on silent 401s
on-chain subscribe + activatemints the long-lived X-Api-Token
GET /fixtures/snapshot?competitionId=7245-day epoch sweep → all 104 fixtures
GET /scores/snapshot/{fixtureId}score history, finality markers, PlayerStats, lineups
GET /scores/stream (SSE)live match card, proxied via /api/stream/[fixtureId]
GET /scores/stat-validationthe Merkle proof settlement relays on-chain
GET /odds/snapshot/{fixtureId}TxODDS 1X2 line — opening prices + reference strip
txoracle.validate_stat_v2 (CPI)on-chain proof verification at settlement

Two production lessons baked into the code: score snapshots arrive in arbitrary order(always sort by Seq), and individual seqs can serve proofs that don't reconcile — which is exactly why the independent check gate exists.

On-chain program (Anchor, devnet)

AccountAddress
opencast_settlement4pCM1Xbd4qPEPjtV1YKNPi1P6j8TmfZ2mamwGc7FB2fU
txoracle (TxLINE)6pW64gN1s2uqjHkn1unFeEjAwJkPGHoppGvS715wyP2J
test USDC mintCYPYJVu1Xs1iH826Zaed5Y232CBQ78nUuvKi2ezm3NrP
InstructionWhat it does
create_marketnew market PDA + vault, seeds the pool 50/50
create_market_splitsame, seeded at the TxODDS line (yes_bps 500–9500)
place_predictionstake USDC on YES/NO at pool odds
settle_marketCPI validate_stat_v2 → store outcome (once, immutable)
claimwinner takes pro-rata pool share minus 2% fee
place_parlaylock stake; treasury reserves the full liability
prove_legCPI-verify one leg's stat against its proof
finalize_parlayall legs proven → pay from treasury, or release reservation

Source in program/programs/opencast_settlement/src/lib.rs; settlement and validation logic is deterministic — the same proof bytes always produce the same outcome.

HTTP API

Everything the UI renders is public JSON — judges can test the backend without touching the frontend. Base URL: https://www.opencast.cc

GET/api/markets

The whole board: every created market (live on-chain pools) plus a create-template per upcoming fixture.

{ "markets": [{
  "slug": "Hz1y…seYN",        // route key = market PDA
  "question": "Will Argentina beat Spain?",
  "priceYes": 0.65,            // side pool ÷ total pool
  "totalVolumeUsdc": 157,
  "matchState": "upcoming",    // upcoming | live | ended | settled
  "statKeyA": 2, "statKeyB": 1, "threshold": 0, "comparison": 0,
  ...
}] }
GET/api/verify/[fixtureId]?m=<marketPda>

The settlement receipt: proof, named stat leaves, settlement math, player facts, independent recomputation verdict.

{
  "final": true, "seq": 1036,
  "score": { "home": 3, "away": 0, "final": true },
  "namedStats": [{ "label": "France goals (full-time)", "value": 3 }, …],
  "predicate": "France goals − Iraq goals > 0 at full-time",
  "impliedOutcome": "Yes",
  "independentCheck": "recomputed-ok",   // sha256 chain re-derived server-side
  "settleTxSig": "5S96…", "dailyRootPda": "7SCT…",
  "playerFacts": { "goals": ["M. Oyarzabal"], "yellows": […] },
  "proof": { /* full TxLINE stat-validation proof */ }
}
POST/api/draft

Plain English → provable predicate. Rejects anything TxLINE can't prove and any fixture past kickoff.

// body: { "question": "England to win 6+ corners against France" }
{
  "ok": true, "fixtureId": 18257865,
  "betType": "corners_over",
  "statKeyA": 8, "statKeyB": 0, "threshold": 5, "comparison": 0,
  "question": "Will England win 6+ corners?",
  "impliedProb": 0.55, "resolves": "at full-time from TxLINE corner counts"
}
GET/api/match/[fixtureId]

Live match state parsed from the (Seq-sorted) score snapshot: scoreboard, stat bars, event timeline. Backed by the SSE stream on market pages.

GET/api/odds/[fixtureId]

TxODDS demargined 1X2 reference line (implied percentages) — shown next to pool prices, used for opening splits.

GET/api/history/[marketPda]

Real pool-price history (PricePoint snapshots collected by the keeper sweep) for the odds chart.

{ "points": [{ "t": 1784606400000, "yes": 0.53 }, …] }
GET/api/trades?market=<pda> · ?top=traders

Recent activity per market, or the volume leaderboard. (POST records a trade after the on-chain buy.)

GET/api/leaderboard/creators · /traders

Top market creators (settled share) and top traders by volume.

GET/api/keeper

The auto-settlement sweep: syncs on-chain state, snapshots prices, settles every finished fixture with a verified proof, proves + finalizes parlay legs. Cron runs it every 10 minutes; triggering it is permissionless because settlement is trustless.

POST/api/faucet

Mints 1,000 test USDC to any wallet — body { "wallet": "<address>" }. Judge-friendly: email sign-in + faucet = trading in under a minute.

Market economics

Pools are parimutuel: price(YES) = yesPool ÷ (yesPool + noPool) — the odds literally are the money. A winning stake pays stake ÷ winningPool × totalPool × 0.98; the 2% fee is withheld from winning claims only. No spread, no juice, no market maker. New markets open 50/50 or at TxODDS' line via create_market_split. Parlays price as the product of live pool probabilities and pay from a treasury that reserves the full liability on-chain the moment a ticket is placed — a winning ticket can always pay.

Verify a proof yourself

Every receipt page has a “Download proof JSON” button. The scheme is plain sha256 pair-hashing — re-derive the sub-tree root in ~10 lines:

const { createHash } = require("crypto");
const p = require("./proof.json");
let h = Buffer.from(p.eventStatRoot);
for (const n of p.subTreeProof) {
  const s = Buffer.from(n.hash);
  h = createHash("sha256")
    .update(n.isRightSibling ? Buffer.concat([h, s]) : Buffer.concat([s, h]))
    .digest();
}
console.log(h.equals(Buffer.from(p.summary.eventStatsSubTreeRoot))
  ? "proof reconciles ✓" : "MISMATCH");

Or skip us entirely: the receipt links the settlement transaction and the daily-root account on the Solana explorer.

Devnet limitations (honest notes)

  • Kickoff locks are enforced at the app layer; a production build would store kickoff_ts on-chain and clock-check it in the program.
  • A few early markets settled against provisional records before the final-whistle selection + check gate existed — their receipts say so explicitly rather than rewriting history.
  • Markets on fixtures whose score data rotated off TxLINE's free tier stay “awaiting proof” until the data returns — no proof, no settlement, by design.
  • The 2% fee accrues in each vault; collect_fees is roadmap.