Every match is a market for ninety minutes. A free-to-play, real-time prediction exchange for World Cup 2026 — live consensus prices from TxLINE, an AI Booth narrating every swing, and results designed to settle trustlessly on Solana by verifying TxODDS's own cryptographic proofs. Strictly play-money: you trade a live probability with credits, never money.
Live demo: ninety-nu.vercel.app
- Read this first — the forge finding
- By the numbers
- What it is · How it works
- TxLINE API — the data spine
- Built on Solana — the trust layer
- Architecture · Repository layout
- The settlement story
- What's built and verified — and what's next
- Run it in 10 minutes
- Documentation · Contributing · License & contact
We built the whole result path to settle on-chain, with no admin able to decide a result — the program settles only by verifying a TxODDS cryptographic proof of the score. Then two adversarial proof-auditor passes found something worth stopping the demo for: TxODDS's own sanctioned settle instruction, validate_stat_v2, does not bind finality on-chain.
The stat leaves carry no Action/finality field, and which record is game_finalised is chosen off-chain. So a permissionless caller could take a 100%-valid Merkle proof for statKey 1 (home goals) drawn from a mid-match batch where the home side happened to lead and settle result = HOME — even though the match ended a draw or an away win. A wrong-result forge, built entirely from a genuine proof, by choosing the batch/seq (ADR-037). An earlier layer — feeding the home − away predicate two swapped or arbitrary anchored stats — was caught and pinned first (ADR-036).
Our call: settlement is fail-closed on purpose. The first line of the handler is require!(SETTLEMENT_LIVE, …) with SETTLEMENT_LIVE = false — a real revert before any state write, not a sentinel trick. Every other on-chain defense is implemented and reviewed (market↔fixture binding, oracle-program pin, stat-identity pin, one-shot, no-admin-path); the single missing piece is a trustless gate binding the proof to the finalised record. We will not ship a settle we can prove is forgeable — even in play-money, where no funds are ever at risk. We filed it back to the sponsor as an open question (settle via validate_stat_v2 over scores roots + a finality gate, or via txoracle's own resolution-root path?). Full write-up: The settlement story · ADR-036 · ADR-037.
Verified against the repo, not estimated.
| 181 commits | in the 13-day build sprint (2026-07-07 → 2026-07-19) |
| 87 ADRs | every architectural, product, and design call recorded in docs/adr/ |
| 257 automated tests pass | 248 TypeScript (vitest, 6 packages) + 9 Python (pytest) |
| 5 / 5 Anchor tests | on-chain leaderboard claim, localnet |
| TxLINE live on devnet | subscribe → activate → snapshot ran authenticated against a real fixture |
| axe-core: 0 violations | across all 15 web routes |
| Settlement fail-closed | on purpose — see the forge finding above |
git log --oneline | wc -l → 128 · ls docs/adr/ADR-*.md | wc -l → 57 · test counts from pnpm test + pytest · a11y from node scripts/ui/axe.mjs.
| The Terminal — pro match trading | North Star — where it's going |
|---|---|
![]() |
![]() |
Ninety turns a live football match into a market. Each match opens a 1X2 market — Home / Draw / Away — priced 0–100 by an LMSR maker off a TxLINE consensus mark, and you buy the side you think the crowd has wrong. When a goal lands the market halts, re-anchors to the new reality, and reopens on a decaying spread; an AI Booth narrates the swing in plain language. At full time the result is meant to settle on Solana by verifying a TxODDS cryptographic proof on-chain — no admin can decide a result. It is play-money by construction: no deposits, no cash payouts, no real money anywhere in the system.
You arrive on a landing page whose hero is the product itself — the Momentum River as a live tape, and the goal→halt→reprice choreography playing on a real market as you scroll. The board (/board) is where you go; the Terminal is where you trade.
As a player. Sign up with an email and you get 1,000 play-money credits and a Solana wallet provisioned invisibly — no seed phrase, no extension (ADR-006, ADR-033). Open the board, tap a match that's in play, and you land on the Terminal: the live score, the Momentum River (the market's live 0–100 read of who wins), and three prices — Home / Draw / Away. Buy the side you think the crowd has wrong; an LMSR maker shows the cost before you confirm, a winning share is worth 100 credits, and you can sell any time to lock in a move (ADR-002, ADR-026). At full time winners are paid, your P&L rolls up the leaderboard, and your biggest swing can mint as a Moment.
The live loop — one match, five beats. This is the product:
- Goal. A TxLINE score-stream event (S3) arrives; the engine detects the goal.
- Market halts. Trading pauses the instant the goal is confirmed (ADR-005) — no one gets filled on a stale price.
- Reprice. The market re-anchors to the fresh consensus mark from the TxLINE odds stream, de-vigged by the pricing worker.
- Decaying spread. It reopens on a 3× spread that decays back to normal, so the first traders in pay for the uncertainty (ADR-005).
- AI Booth narrates. One two-role LLM call turns the mark move + event into commentary on
commentary.v1, filtered so nothing that reads like gambling ever ships (ADR-038, ADR-039).
At the whistle the settlement saga (ADR-035) fetches a TxLINE proof and submits an on-chain settle; winning shares pay 100 credits. That on-chain settle is deliberately fail-closed today — see The settlement story for exactly why, and what we're asking TxODDS.
TxLINE/TxODDS is not a side input — it feeds the prices, the scores, and the settlement proofs. Every endpoint below is wrapped in packages/txline/src/client.ts, returns parsed Zod types, and was verified live against txline-dev + devnet (ADR-015/016), with one real captured payload per endpoint in docs/txline-samples/. The live on-chain subscribe tx: 2RMQS9tY…rZgYDPqqtX.
Three phases — authorize once, consume live, prove at full time:
flowchart LR
subgraph A["1 · Authorize (once)"]
direction TB
G["guest/start<br/>→ JWT"] --> SUB["subscribe()<br/>on-chain · Token-2022"] --> ACT["token/activate<br/>→ apiToken"]
end
subgraph B["2 · Consume (live)"]
direction TB
ODDS["odds/stream · O3"] --> DEVIG["cortex<br/>Shin de-vig"] --> MARK["fair mark<br/>→ LMSR price you trade"]
SCORE["scores/stream · S3"] --> HALT["engine<br/>goal → halt → reprice"]
end
subgraph C["3 · Prove (full time)"]
direction TB
STAT["stat-validation · S4<br/>Merkle proof"] --> SOL["Solana verifies<br/>on-chain"]
end
A ==>|"JWT + apiToken on every call"| B ==> C
| Code | Endpoint | Wrapper | Powers which surface |
|---|---|---|---|
| A1–A3 | POST /auth/guest/start · on-chain subscribe · POST /api/token/activate |
auth flow | Gates every data call (guest JWT + apiToken headers) |
| F1 | GET /api/fixtures/snapshot |
fixtures() |
Which WC26 matches exist → the board |
| S1 | GET /api/scores/snapshot/{fixtureId} |
scoresSnapshot() |
Live score on match/Terminal; settlement reads the game_finalised record here |
| S2 | GET /api/scores/updates/{day}/{hour}/{interval} |
scoresUpdates() |
5-min buckets → replay + gap catch-up |
| S3 | GET /api/scores/stream (SSE) |
scoresStream() |
Live score ticks → goal detection → halt |
| S4 | GET /api/scores/stat-validation?fixtureId&seq&statKeys |
statValidation() |
The Merkle proof the Solana program verifies to settle |
| O1 | GET /api/odds/snapshot/{fixtureId} |
oddsSnapshot() |
Opening consensus mark |
| O2 | GET /api/odds/updates/{day}/{hour}/{interval} |
oddsUpdates() |
5-min buckets → replay + catch-up |
| O3 | GET /api/odds/stream (StablePrice SSE) |
oddsStream() |
Live consensus → de-vig → engine marks → the price you trade |
| K1 | statKeys (Score.*.Total.Goals; keys 1,2) |
statkeys.ts |
Goal truth for scoring and settlement |
Integration feedback we filed for the sponsor is in docs/SUBMISSION.md (e.g. subscribe needs the Token-2022 ATA to pre-exist; devnet only accepts SL1; /api/token/activate returns a bare-string token).
Ninety pushes onto Solana everything that must be trustless, and nothing that shouldn't be. The Anchor program omnipitch_core (Anchor 0.30.1) is deployed to devnet — 6ps8ao7CVhacnRajvFXWTmkknsRnHfEbWmtQ3nDCdBkj — and packages/chain is the only place in the monorepo allowed to construct a Solana transaction (a repo law).
How a result reaches the chain — permissionless, one-shot, and verified against TxODDS's own oracle:
sequenceDiagram
autonumber
actor Anyone as Anyone (permissionless)
participant Prog as omnipitch_core · Anchor
participant Oracle as TxODDS txoracle
Anyone->>Prog: settle_market(result, proof)
Note over Prog: require!(SETTLEMENT_LIVE) — false today → revert (fail-closed)
Prog->>Oracle: CPI validate_stat_v2(statKeys 1,2)
Oracle-->>Prog: bool · is the stat Merkle-anchored?
Note over Prog: predicate home − away · one-shot · NO admin path
Prog-->>Anyone: settled result (once the finality gate lands)
- Trustless settlement, no admin path.
settle_marketis permissionless and one-shot, and settles only by CPI-ing into TxODDS's on-chain oracle (validate_stat_v2) to verify a Merkle proof of the score — no operator can decide a result (ADR-017, ADR-036/037). It derives the outcome on-chain from goals (home − away) and is deliberately fail-closed today; see The settlement story. - On-chain leaderboard claims — live and tested. Points are earned off-chain, but each matchday's leaderboard is committed as a Merkle root on-chain (
post_leaderboard_root), and players callclaim_pointsby proving inclusion — guarded by a receipt-PDA against double-claims and paid from a PDA-owned SPL vault (ADR-003, ADR-031). This is the flow all 5/5 Anchor tests cover (valid claim once · replay rejected · wrong proof rejected · tampered leaf rejected · root post authority-gated). - On-chain data authorization. Access to the TxLINE feed is itself paid for on-chain: a Token-2022
subscribetransaction on devnet, built inpackages/chain(buildSubscribeIx) and captured live (ADR-015). - Chain → app feedback. Settlements are picked up by a Helius webhook →
chain_events→ the bus, so the rest of the system reacts to on-chain truth as just another event (ADR-034). - Invisible wallets. New players get a custodial keypair derived behind their email; power users can connect Phantom/Backpack (ADR-006, ADR-033).
- Honest scope. On-chain settlement is fail-closed pending a finality gate, and Moment cNFT minting was cut for v1 —
mint_momentis a deliberate no-op today, Moments ship as server-rendered PNGs (ADR-032/041).
The whole system is decided, not improvised — 87 ADRs in docs/adr/ record every call. Two laws hold it together: all inter-service traffic flows through packages/bus (no service calls another directly), and the engine is the single writer of market state (journal-then-ack, one logical writer per market). The full design document with layer maps and diagrams: ARCHITECTURE.md.
flowchart LR
TX["TxLINE / TxODDS<br/>odds · scores · proofs"] --> ING["worker-ingest<br/>normalize → envelopes"]
ING --> BUS[("packages/bus<br/>Redis Streams")]
BUS --> COR["worker-cortex<br/>Shin de-vig → marks + hazard"]
COR --> BUS
BUS --> ENG["engine<br/>single-writer LMSR"]
ENG --> API["api<br/>REST + WS"]
API --> WEB["web<br/>landing · board · Terminal"]
ENG -. "settlement saga (ADR-035)" .-> CHAIN["packages/chain<br/>tx builders"]
CHAIN -. "CPI validate_stat_v2" .-> SOL["Solana devnet<br/>omnipitch_core · fail-closed"]
SOL -. "Helius webhook" .-> BUS
Data-flow contract (the five lines that never bend):
- Every input becomes a canonical envelope (
packages/schema) on the bus (packages/bus) — events only, never direct calls. - Frontend reads cold data over REST, hot data over WS with seq-resume. It renders; it never computes prices.
- Postgres is durable truth (Prisma); Redis is hot state + bus + WS resume buffers. Cache derives from events, never the reverse.
- The engine (
apps/api/src/engine) is the only writer of market state; nothing inengine/imports fromhttp/orws/. - Solana holds only what needs trust — market registry, proof-verified results, leaderboard roots, point claims. The backend forwards the proof; the program verifies it.
apps/
web/ Next.js 15 frontend — landing, board, Terminal, bracket, proofs
api/ Fastify REST + uWS gateway + the single-writer engine + projections
worker-ingest/ TxLINE SSE → normalizer → bus; archived-fixture replay service
worker-cortex/ Python pricing — Shin de-vig marks + hazard liquidity
worker-jobs/ AI Booth, EarlyWhistle cards, settlement saga, Moments
packages/
schema/ zod event contracts (the wire truth) · bus/ (the only transport)
txline/ the only TxLINE caller · chain/ (the only Solana tx builder) · config/
programs/
omnipitch_core/ Anchor program — markets, proof-gated settle, leaderboard claims
infra/ docker-compose · Fly (4 processes) · free-tier map · grafana
docs/ 87 ADRs · TxLINE map + captured samples · submission · demo script
design/ tokens · screen references · provenance ledger · review verdicts
This is the most important thing in the repo, and it is addressed to TxODDS.
The design (ADR-017, a CLAUDE.md law): there is no admin result path. settle_market is permissionless and one-shot, and settles only by CPI-ing into TxODDS's own on-chain oracle to verify a Merkle proof of the score. The backend forwards the proof; the program verifies it. Home/Away are TxLINE statKeys 1 and 2 (total goals — admin- and repo-confirmed, ADR-037), and the on-chain predicate is home_goals − away_goals vs 0: >0 → HOME, ==0 → DRAW, <0 → AWAY.
The finding (two adversarial proof-auditor passes, ADR-036 → ADR-037): TxODDS's own sanctioned path, validateStatV2, does not bind finality on-chain. It proves a stat is Merkle-anchored in some batch of the fixture — any match moment — not that the proven record is the game_finalised one. The stat leaves carry no Action/finality field, game_finalised is selected off-chain, and the vendor's own v2 example hardcodes a caller-chosen seq. So a permissionless caller could take a 100%-valid proof for statKey 1 from a mid-match batch where the home side happened to lead and settle result=HOME even though the match ended a draw or an away win — a wrong-result forge by seq/batch selection, with a genuine proof. (An earlier fix that pinned a "FINAL period" was refuted too: the guessed constants were real-but-non-goal stats, so pinning them would have been more dangerous than an impossible one, not less.)
The decision — fail-closed, on purpose. The first statement of the handler is require!(SETTLEMENT_LIVE, SettlementDisabled) with SETTLEMENT_LIVE = false (txoracle_cpi.rs). Every settle reverts before any state write — a real revert, not a sentinel trick. All the other on-chain defenses are implemented and reviewed: market↔fixture binding, oracle-program pin, stat-identity pin (stat_home.key == 1, stat_away.key == 2), one-shot, and no-admin-path. The single missing piece is a trustless gate binding the proof to the finalised record. We will not ship a settle we can prove is forgeable — even in play-money, where no funds are at risk.
What we're asking the sponsor (STEP-0 #1b): should Ninety settle via validate_stat_v2 over the per-5-min scores roots (which then needs an added on-chain finality gate), or via txoracle's own resolution-root path (publish_resolution_root) that its settle_trade / claim_via_resolution already use to bind finality? Answer that and settlement flips live. Full write-up: ADR-036 and ADR-037 (the in-file UPDATE 2026-07-08 records obtaining the validate_stat_v2 IDL and confirms V2 carries no finality field).
Real numbers, counted by the test runner (pnpm test), not estimated. The full plan: ROADMAP.md.
Verified
- 257 automated tests pass: 248 TypeScript (vitest) across 6 packages —
api144,worker-jobs68,worker-ingest15,schema10,bus6,chain5 — plus 9 Python (pytest) inworker-cortex. - 5/5 Anchor tests (ts-mocha, localnet) covering the on-chain leaderboard claim: valid claim credits once, replay fails on the receipt PDA, a wrong proof fails, a tampered leaf fails,
post_leaderboard_rootis authority-gated (programs/omnipitch_core/tests/omnipitch_core.ts, ADR-031). - TxLINE integration is live on devnet — subscribe → activate → snapshot ran authenticated against a real fixture (USA v Belgium,
18193785), with in-play SSE latency measured ≈0.7–1.2 s (ADR-015/016). Real payloads captured indocs/txline-samples/. - The backend spine is real: ingest → bus → cortex → engine → api, with a durable journal (ADR-025), a pure 7×7 lifecycle reducer (ADR-024), LMSR order fills with a 1% burned fee (ADR-026), and a crash-safe, resumable settlement saga (ADR-035).
- Anchor program builds clean (
anchor build, ADR-030) and deploys to devnet — program6ps8ao7CVhacnRajvFXWTmkknsRnHfEbWmtQ3nDCdBkj(Explorer).
Not yet — stated plainly, because judges punish overclaiming, not honesty
- The web frontend renders curated fixture data, not the live API yet.
apps/web/src/lib/fixtures.ts(baked WC26 data) drives every surface; the real fetch clientlib/api.tsexists but is not yet wired. What you see is the real design on real WC26 data — the landing (with its live-store hero tape and replaying halt choreography), the board, the Terminal (with Sofascore-style depth tabs: lineups on a native SVG pitch, stats, H2H, and a live incident timeline), the 104-match bracket, the group tables, the on-chain proofs log, Moments, the leaderboard, and portfolio. Wiring them to the live/markets+/leaderboardendpoints (ADR-042) and the WS bridge is the top of the roadmap.apps/webships no unit tests yet, but every route is verified through a screenshot →design-coploop and passes a fullaxe-coreaccessibility sweep (0 violations across 15 routes). - There is no order-submission HTTP endpoint yet — the engine's order path (risk gate → LMSR fill → burned fee → ledger, ADR-026) is implemented and tested, but
POST /ordersis a stub; see API.md. - On-chain settlement is fail-closed (
SETTLEMENT_LIVE = false) pending the finality answer above. - Moments mint is a stub. The on-chain Metaplex Bubblegum cNFT was cut for v1 (ADR-032/041);
mint_momentis a deliberate no-op and Moments ship as server-rendered PNGs (ADR-040).
Needs Node 18 or 20 (the apps/api WebSocket layer uses uWebSockets.js, which ships prebuilds for those), pnpm 9, and Docker. Full instructions, env-var table, and the optional Python/Anchor setups: SETUP.md.
pnpm install
cp .env.example .env # fill TXLINE_TOKEN to hit the live feed; the mock runs without it
docker compose up -d # postgres (host :5433) + redis
pnpm dev # turbo runs web + api + workers
# optional: replay a finished WC26 fixture end-to-end through the ingest plane
./scripts/replay.shThe football context layer (flags, team names, group tables, the 104-match bracket skeleton, stadiums) is baked from worldcup26 at build time into apps/web/src/data/wc26/*.json — zero network at runtime, flags included (ADR-051, ADR-055). Re-bake with pnpm --filter web wc26:refresh. TxLINE still owns everything that moves (scores, prices, settlement); worldcup26 only fills in what sits still.
For the Anchor program: anchor test in programs/omnipitch_core (localnet). Demo script: docs/demo/script.md.
| Document | What it covers |
|---|---|
| ARCHITECTURE.md | system diagram, layer map, repo tree, the laws, the live loop |
| SETUP.md | prerequisites, env vars, database, Python worker, Anchor, production topology |
| API.md | REST endpoints, WS channels, bus topics, honest stubs |
| SECURITY.md | trust model, fail-closed settlement, reporting |
| ROADMAP.md | shipped/verified · next · deliberately cut |
| FAQ.md | is it gambling? why is settlement off? can I run it without a token? |
| CHANGELOG.md | the 8-day build, phase by phase |
docs/adr/ |
all 57 decision records — the why behind everything |
CLAUDE.md |
the repo laws (design + architecture), enforced by hooks |
Contributions are welcome — start with CONTRIBUTING.md (the ground rules are short and non-negotiable: play-money vocabulary, bus-only communication, single-writer engine, no admin result path, ADRs before decisions). CI runs pnpm lint && pnpm build && pnpm test on every push and PR.
Built for the TxODDS World Cup Hackathon, track Prediction Markets & Settlement (India Buildathon deadline Jul 13; global track to Jul 19).
Maintainer: Sushant · issues and discussions on GitHub.
License: MIT — open source, use it freely.
Credits: live scores, prices, and settlement proofs from TxLINE / TxODDS. Static World Cup context — flags, team names, group tables, the bracket skeleton, and stadiums — from worldcup26 by Reza Rahiminia (ISC), baked at build time and never called at runtime. The landing is built on the notio template (styleui.dev), pulled with shadcn add and re-skinned to Ninety's tokens, branding, and copy — its navbar/hero/features/stats/footer ship as our own skin over notio's structure (license undeclared; attributed here and in the footer; logged in design/PROVENANCE.md, ADR-068). The scroll-drawn crowd is skiper (skiper19/39/52; no declared license).
Play-money, and it is locked. Ninety uses credits with no cash value. There are no deposits, no withdrawals, and no cash payouts anywhere in the system — that is a design invariant, not a setting. Product copy never uses gambling vocabulary; the constraint is enforced in code, down to a filter on the AI Booth's output (ADR-039).
The internal package namespace @omnipitch/* predates the rename to Ninety and is intentionally left as-is — renaming it would churn every import across the monorepo for no user-facing value (ADR-044). It is a namespace, not the brand.


