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
- 1TxLINE 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).
- 2The 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)
- 3Independent 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)
- 4The 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.
- 5Winners 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.
| Market | Predicate |
|---|---|
| Will France beat Iraq? | goals(FR) − goals(IQ) > 0 |
| Match ends in a draw | goals(A) − goals(B) = 0 |
| France to score 2+ | goals(FR) > 1 |
| Win by 2+ (spread) | goals(A) − goals(B) > 1 |
| England 6+ corners | corners(EN) > 5 |
| 2+ yellow cards | yellows(A) > 1 |
| Leads at half-time | goals₁ₕ(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
| Endpoint | Used for |
|---|---|
| POST /auth/guest/start | short-lived JWT, auto-renewed on silent 401s |
| on-chain subscribe + activate | mints the long-lived X-Api-Token |
| GET /fixtures/snapshot?competitionId=72 | 45-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-validation | the 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)
| Account | Address |
|---|---|
| opencast_settlement | 4pCM1Xbd4qPEPjtV1YKNPi1P6j8TmfZ2mamwGc7FB2fU |
| txoracle (TxLINE) | 6pW64gN1s2uqjHkn1unFeEjAwJkPGHoppGvS715wyP2J |
| test USDC mint | CYPYJVu1Xs1iH826Zaed5Y232CBQ78nUuvKi2ezm3NrP |
| Instruction | What it does |
|---|---|
| create_market | new market PDA + vault, seeds the pool 50/50 |
| create_market_split | same, seeded at the TxODDS line (yes_bps 500–9500) |
| place_prediction | stake USDC on YES/NO at pool odds |
| settle_market | CPI validate_stat_v2 → store outcome (once, immutable) |
| claim | winner takes pro-rata pool share minus 2% fee |
| place_parlay | lock stake; treasury reserves the full liability |
| prove_leg | CPI-verify one leg's stat against its proof |
| finalize_parlay | all 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
/api/marketsThe 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,
...
}] }/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 */ }
}/api/draftPlain 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"
}/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.
/api/odds/[fixtureId]TxODDS demargined 1X2 reference line (implied percentages) — shown next to pool prices, used for opening splits.
/api/history/[marketPda]Real pool-price history (PricePoint snapshots collected by the keeper sweep) for the odds chart.
{ "points": [{ "t": 1784606400000, "yes": 0.53 }, …] }/api/trades?market=<pda> · ?top=tradersRecent activity per market, or the volume leaderboard. (POST records a trade after the on-chain buy.)
/api/leaderboard/creators · /tradersTop market creators (settled share) and top traders by volume.
/api/keeperThe 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.
/api/faucetMints 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_tson-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_feesis roadmap.