Audience-side live prediction market for the Viber Night Grand Prix — 14 teams, one tap to bet, on Solana.
Forked from MathisSpark/1vs1ThenEveryoneJoin
(the 1v1→audience pm-AMM base) and rebuilt over 5 days to run a single
multi-outcome event at Network School: 100+ phones in the room, 14 teams
on stage, live odds projected on the big screen, the house keeping the book
tight via an off-chain arb daemon.
🌐 Live demo — https://spark-predict-viber.vercel.app
🔗 Anchor program (unchanged) — EwanBorgPad/pmAMM at EvWE8LGzzyZRDASKLnLBy9qZRuL8iaJYiPf2mRZh75yV
A live-event prediction market for one specific shape: a single multi-outcome bet — "Which team wins the Grand Prix?" — across 14 teams, trading in parallel with hundreds of audience phones.
Three jobs the system has to do, all under pressure during a 60-minute event:
- Onboard a non-crypto audience in <5s — single QR on the screen, phone scans, gets a pre-funded burner wallet, picks a pseudo + country flag, ready to trade.
- Keep a 14-leg multi-outcome book coherent while audience flow hits one leg at a time. Σ p_i must stay ≈ 1 or the house bleeds free arb.
- Be 100% reset-to-zero between dress rehearsals — KV wipe + on-chain wallet rebalance + market rebuild, single button.
Everything sits on the existing pm-AMM Anchor program (binary YES/NO). The multi-outcome behavior is achieved off-chain by composition, not by extending the on-chain math — see below.
This is the core technical bet of the build, and the part worth reading even if you skip the rest.
The on-chain pmAMM is binary: one pool = one YES vs NO market. The Viber event needs a 14-outcome market: "Team 1 wins" vs "Team 2 wins" vs … vs "Team 14 wins", mutually exclusive, Σ p_i = 1.
Paradigm hasn't published a multi-outcome pm-AMM variant (the original 2024 paper is binary-only). Extending the Gaussian invariant to N-dim is open research. We had 5 days.
Run 14 binary YES/NO pmAMM pools in parallel, one per team. Each leg's YES token represents "Team i wins", each NO represents "Team i doesn't win".
Seed each leg at initialYesProbability = 1/N = 1/14 ≈ 7.14% so that at open
Σ p_i = 1. Without this initial split, every leg seeded 50/50 would mean
Σ = N/2 = 7.0 — a massive free arb against the house.
See app/src/lib/event-markets-config.ts
for the preset, and SEED_TEAM_LEG_TOTAL_USD = $250.
When an audience phone clicks "Bet $20 on Team 7":
- The tx hits only Team 7's pool (
buy_outcome_tokens+swapNO→YES). - Team 7's YES price moves up; teams 1–6 and 8–14 don't move.
- After the tx, Σ p_i > 1 by the size of the move on leg 7.
That's the off-chain dispatcher's job done: route a single user bet to the single right pool, in one atomic tx pair. No need for the user to think about the other 13 legs.
Σ p_i drift is corrected by scripts/arb-house.mjs
— the house arb daemon running on the operator's laptop during the event.
Each tick (default 4s):
- Read YES price of every leg (14 RPC reads, parallelized).
- Compute Σ p_i. If
|Σ − 1| ≤ THRESHOLD(default 0.02), sleep. - Otherwise: proportional-scaling batch. Multiplier =
1/Σ. For every leg, target =current × multiplier. Iftarget < current(leg over-priced post-flow), buy NO; iftarget > current, buy YES. Trades sized via a linear approx of the pmAMM sensitivity (PRICE_TO_USD_FACTOR = 600, calibrated to the $250-seed pool curvature). - Fire all trades in parallel the same tick (Promise.all over
executeArbTrade) — same way Wintermute / JaneStreet desks keep multi-outcome books synced after a flow shock.
The daemon signs from the faucet wallet (= platform treasury, holds the USDSpark mint authority, already SOL-funded). PnL on its book = the spread it captures over the event; settled when markets resolve.
A localhost webhook (POST /wake-up on :7331) lets the admin UI fire the
daemon instantly when Mathis opens markets — no waiting for the next
4s tick.
We considered (and rejected for the event):
- NegRisk-style conditional tokens — too complex to ship in 5 days. (Memory note: also decided post-event that combinatorial markets aren't the right fit for the Spark Predict thesis — too LP-heavy for thin markets.)
- Single on-chain dispatcher program — one tx that routes through N pools atomically. The right long-term move. See "Where to go next" below.
The off-chain daemon is a deliberate hackathon trade-off: more moving parts operationally (a laptop must stay online), but zero on-chain changes and a proven pattern from TradFi market-making.
┌───────────────────┐
│ Big screen + QR │ (single shared QR for all 100+ phones)
└─────────┬─────────┘
│ phone scans
▼
┌──────────────────────────────────────┐
│ POST /api/event/claim-next-wallet │ ── atomic pop from
│ → returns burner secret in URL │ pre-funded pool
└──────────────────┬───────────────────┘ (150 wallets,
│ $1000 + 0.25 SOL each)
▼
┌──────────────────────────────────────┐
│ POST /api/pseudo │ ── pseudo + country flag
│ → "alice 🇫🇷" locked to this pubkey │ namespaced by event
└──────────────────┬───────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Grid view: 7 × 2 = 14 team tiles │
│ each tile shows live YES % │
└──────────────────┬───────────────────┘
│ tap Team 7
▼
┌──────────────────────────────────────┐
│ Inline panel below the grid: │
│ • "Bet on Team 7 — 7.4%" │
│ • Amount: $20 (chips $5/20/50/100) │
│ • "If Team 7 wins ≈ $X" (live) │
│ • [Bet] → 1-click buy_outcome+swap │
└──────────────────────────────────────┘
The bet preview is simulation-accurate: it runs
simulateBuyOutcome — the same Gaussian-invariant
math the on-chain program runs, ported to float64 and cross-validated against
82 reference vectors. What you see before clicking is what you redeem after
resolution, within rounding.
Everything Mathis touches during the event lives at
/admin:
-
Setup — one-shot
setup-event-markets.mjsscript creates all 14 vaults, commits seed YES + seed NO at 1/14 ratio, waits the 60s commit window, launches each market on-chain. ~3 minutes end-to-end. -
Lifecycle controls — per-market
open/freeze/resolvebuttons. Freeze gates the audience UI mid-event (e.g. just before a team is eliminated). Resolve cascades the YES/NO winner for that leg. -
Reset (danger zone) — type-RESET confirmation cascades three operations:
POST /api/event/reset-audience-state— wipes pseudos, user records, wallet-claimed flags via SCAN overviber:event-*:*keys; bumps thereset-cursorto kick every connected phone out.POST 127.0.0.1:7331/rebalance-wallets— daemon refunds every burner to exactly $1000 USDSpark from the faucet.POST 127.0.0.1:7331/reset-markets— daemon spawnssetup-event-markets.mjsas a child process. Wipes the existing 14-team line-up from KV + recreates fresh ones on-chain at 1/14.
Real-time progress badge during the ~3-min cascade; auto-clears the instant the operator takes any non-pending action (= leaves the "fresh baseline" state).
-
Sim observatory (
/admin/sim) — live chart of every daemon tick: Σ p_i drift, per-leg price + arb counter-trades, cumulative house book. -
Leaderboard — pseudo-aggregated PnL, snapshot-frozen at resolution time via
SETNXso it doesn't shift during redemptions. -
Print QRs + Room QR — operator-side helpers to print individual wallet QRs (for stage hand-outs) and the single shared audience QR (for the big screen).
app/
├── src/
│ ├── app/
│ │ ├── page.tsx — audience home: 7×2 team grid + inline bet panel
│ │ ├── screen/page.tsx — projector view: same grid + leaderboard
│ │ ├── import/page.tsx — roster import (team labels, country flags)
│ │ ├── admin/
│ │ │ ├── page.tsx — market lifecycle controls + Reset
│ │ │ ├── sim/page.tsx — live daemon observatory
│ │ │ ├── leaderboard/page.tsx — pseudo leaderboard with snapshot freeze
│ │ │ ├── print-qrs/page.tsx — printable wallet sheet
│ │ │ ├── room-qr/page.tsx — fullscreen shared audience QR
│ │ │ └── layout.tsx — sticky nav across /admin/*
│ │ ├── bet/[id]/page.tsx — legacy 1v1 fallback (deep-link only)
│ │ └── api/event/
│ │ ├── markets/ — KV-backed market registry
│ │ ├── markets-with-prices/ — aggregated KV + on-chain reads, edge-cached 3s
│ │ ├── claim-next-wallet/ — atomic pop from pre-funded pool
│ │ ├── claim-wallet/ — manual claim by ID (fallback)
│ │ ├── wallets/ — wallet manifest (admin)
│ │ ├── seed-available-pool/ — repopulates the pool after reset
│ │ ├── reset-audience-state/ — SCAN-based pseudo wipe + cursor bump
│ │ ├── reset-cursor/ — per-event reset epoch for client invalidation
│ │ └── leaderboard/ — frozen leaderboard at resolution
│ ├── lib/
│ │ ├── pm-math.ts — pm-AMM math (Gaussian, swap solver, payout)
│ │ ├── event-markets-config.ts — PRESET_MARKETS, 14-team config
│ │ ├── event-keys.ts — KV key shape for viber:event-2026-05-16:*
│ │ ├── admin-actions.ts — open/freeze/resolve wrappers
│ │ ├── country-flags.ts — pseudo country picker
│ │ ├── pseudo-client.ts — POST /api/pseudo wrapper
│ │ ├── burner-wallet.ts — keypair gen + localStorage persistence
│ │ ├── active-wallet.ts — Phantom (admin) ↔ burner (audience) switcher
│ │ ├── pda.ts — Anchor PDA derivation
│ │ ├── read-only-program.ts — IDL wrapper with no signer (RPC reads only)
│ │ ├── kv.ts — Upstash client w/ graceful fallback
│ │ └── constants.ts — TEAM_COUNT, EVENT_ID, fees, seed sizes
│ └── components/providers.tsx — wallet adapter + Anchor provider
├── scripts/
│ ├── setup-event-markets.mjs — one-shot creates+commits+launches 14 vaults
│ ├── generate-event-wallets.mjs — pre-funds 150 burners + persists manifest
│ ├── arb-house.mjs — live daemon: proportional-scaling batch arb
│ ├── sim-event.mjs — replay/synthetic flow harness (no real audience)
│ ├── verify-end-to-end.mjs — predicted vs realized payout cross-check
│ ├── launchd/ — macOS plist for arb daemon (auto-start)
│ └── test-pm-math.mjs — 82-vector math cross-validation
└── package.json
docs/
└── PAYOUT_MATH.md — derivation of the pro-rata redemption clamp
| Layer | Tech |
|---|---|
| On-chain | Solana + Anchor 0.32 (pm-AMM, unchanged), Token-2022 collateral, classic SPL outcome mints |
| Frontend | Next.js 16 (App Router) + React 19 + TypeScript |
| AMM | Paradigm pm-AMM (y−x)·Φ((y−x)/L) + L·ϕ((y−x)/L) − y = 0, fee 100 bps |
| Off-chain math | pm-math.ts (float64) cross-validated against on-chain math via 82 vectors |
| State | Upstash Redis, namespaced by EVENT_ID = viber:event-2026-05-16 |
| Hosting | Vercel (auto-deploy on main), Helius devnet RPC |
| Daemon host | Operator laptop (Node 22, optional launchd plist) |
| Brand | Viber DA — pure black + purple/orange + Inter |
- Node 22+ (
--experimental-strip-typesfor the scripts) - pnpm
- A Solana wallet (Phantom on devnet for admin)
- A funded faucet keypair (devnet SOL + USDSpark, holds USDSpark mint authority)
- Upstash Redis credentials
- Helius devnet API key (separate from mainnet — see DevNet caveats)
git clone https://github.com/MathisSpark/Spark-Predict-Viber.git
cd Spark-Predict-Viber/app
pnpm install
cp .env.example .env.local
# fill in:
# NEXT_PUBLIC_RPC_URL=https://devnet.helius-rpc.com/?api-key=…
# FAUCET_SECRET_KEY=<base58 keypair>
# KV_REST_API_URL=… / KV_REST_API_TOKEN=…
pnpm devcd app
# 1. Mint + fund 150 audience burners. Persists manifest to KV.
node --experimental-strip-types scripts/generate-event-wallets.mjs
# 2. Create + commit + launch 14 team markets on-chain.
node --experimental-strip-types scripts/setup-event-markets.mjs
# 3. Run the house arb daemon (keep running for the whole event).
node --experimental-strip-types scripts/arb-house.mjs \
--threshold 0.02 --tick-ms 4000 --max-size 200pnpm test:math
# 82/82 passed within tolerance.Run after any change to pm-math.ts — oracle/test_vectors.json is the
on-chain reference.
node --experimental-strip-types scripts/sim-event.mjsSynthetic audience flow that hits the same API surface as real phones — useful
for tuning PRICE_TO_USD_FACTOR and watching the daemon converge without
booking a stage.
- No oracle, no dispute window — admin marks the winning team. Buddy-vs- buddy + visible stage = fine. Adversarial = not fine.
- Burner secrets in URL + localStorage — devnet-only by design.
- Platform fee enforced off-chain — anyone bypassing the UI skips it. The on-chain LP fee is enforced by the pool.
- No rate-limit on
/api/event/claim-next-wallet— pool drainable by anyone POSTing repeatedly. Devnet, doesn't matter. - Helius devnet API key hardcoded in scripts — fine for the demo (devnet, zero financial exposure); rotate before any real deployment.
- Daemon = single point of failure during the event — if the laptop dies, Σ drifts until restart. Two operators / dual-host failover would be the obvious next step.
- No Σ-coherence guarantee inside a single slot — audience tx and daemon counter-trades land in different blocks. Theoretical 2-tick window where a fast actor could front-run the daemon. See "Jito bundles" below.
If we were keeping this alive past the event:
Today the daemon corrects Σ ~4s after an audience trade lands. A fast bot watching the mempool could:
- Spot Team 7's YES being bought (Σ now > 1).
- Predict the daemon's next counter-trade (sell YES on the over-priced legs, buy YES on the under-priced ones).
- Front-run the daemon's correction.
Fix: route both the audience trade and the daemon's counter-trades through a single Jito bundle, guaranteeing all of them land in the same slot or none of them land. Σ stays ≈ 1 at every block height an outside observer can read.
Wiring sketch — audience flow becomes:
- Phone signs
buy_outcome_tokens + swap(Team N pool only). - Tx is sent to the daemon's bundler endpoint, not the public RPC.
- Daemon adds the proportional-scaling counter-trades for the other 13 legs.
- Bundle ships to Jito block engine with a tip.
- Either the whole 14-tx bundle lands in slot S, or nothing does.
Cost: ~1k lamports tip per bundle on devnet, way below the LP fee revenue.
The natural endgame. New Anchor program:
dispatch_buy(user, team_idx, amount)— routes user's collateral through pmAMM pool for teamteam_idx, and atomically issues offsetting trades on the other 13 pools to enforce Σ = 1 post-trade.- Liquidity ledger lives on-chain, no off-chain bot, no laptop dependency.
This is the right long-term shape (and what the next Spark Predict iteration will likely converge to — see Predict tech actuelle — pm-AMM multi-outcome (coherent dispatch)). The Viber event was the off-chain prototype that proved the dispatch model works in production under flow.
Replace the "N parallel binaries" hack with a true multi-outcome pmAMM. The Paradigm 2024 paper gives the binary invariant; extending to N-dim is open research. If it works:
- No need to enforce Σ = 1 — it's the invariant.
- Capital efficiency is dramatically better (one shared L pool vs N siloed $250 pools).
- No arb leak windows by construction.
Replace the 1.5s polling on /page.tsx with subscribed pool-reserve events.
- Cuts RPC load ~30×.
- Sub-second UI refresh after every audience trade.
- Daemon ticks become event-driven rather than wall-clock.
UMA-style. Admin proposes the winning team; 24h challenge window; if unchallenged, resolution finalizes. Devnet = same UX (admin still proposes); mainnet = adversarial-safe by construction.
For mainnet: route burner signing through a custodial KMS (Squads, Fordefi,
or in-house) instead of leaving secrets in localStorage. Same audience UX
(scan → bet), real money OK.
sim-event.mjs is one synthetic. Run a Monte Carlo across 100 audience
profiles (degen / passive / coordinated whale) and measure daemon convergence
- realized PnL distribution. Tunes
PRICE_TO_USD_FACTORandTHRESHOLDwithout needing a real stage.
Spark-Predict-Viber is a fork of
MathisSpark/1vs1ThenEveryoneJoin
(itself a rewrite of as1fansar1/NSHackathon). Built for the Viber Night
Live event at Network School, Singapore, May 2026.
Inherited unchanged from the 1v1 base: the on-chain pm_amm Anchor program,
the pm-math.ts math library, the pmAMM swap mechanics, the pro-rata
redemption clamp (docs/PAYOUT_MATH.md).
Net-new for this repo:
- 14-team multi-outcome composition + off-chain coherent dispatch
- House arb daemon (
scripts/arb-house.mjs) - One-shot event setup (
scripts/setup-event-markets.mjs) - Pre-funded burner pool + atomic claim flow
- Single-page audience UX (grid + inline bet panel)
- Admin orchestration (
/admin/*with type-RESET cascade) - Big-screen display (
/screen) - Sim observatory (
/admin/sim) - Pseudo + country-flag registry, namespaced per event
The on-chain program is unchanged. Everything novel here lives client-side or in the daemon — that was the bet, and it shipped on time.