The prediction arcade on Somnia — every move is a real trade on a DreamDEX Event Contract, and every result is settled by the chain pushing the oracle's answer into our contract, in the same block, with no keeper.
Most prediction games pick a price feed you can trust and then ask you to trust their resolution. DreamArena answers the harder question — can settlement happen without anyone running anything? It wraps Somnia's rolling DreamDEX markets (fresh BTC/ETH binary markets mint every 60s and resolve themselves), turns them into arcade games, and uses Somnia's reactivity precompile so the oracle's own answer lands in ArcadeEngine.onEvent in the same block. No cron, no keeper, no backend, no house bankroll. The Reactive Settlement Monitor proves it against the chain instead of claiming it.
Somnia × DreamDEX Event Contracts Hackathon · Shannon testnet (chain 50312)
- The problem I set out to solve
- What I built
- Architecture
- How a bet actually works, step by step
- The reactive settlement claim (and how it's verified, not asserted)
- Live verification with real funds
- What's real vs pending — the honesty table
- Checks
- API / scripts
- Tech stack
- Project layout
- Run it locally
- How I'd deploy it
- Tests
Prediction games on chain have always had a trust bottleneck: someone has to resolve the market. Fixed payout pools need a keeper or a cron to sweep expired markets. House-bankroll games need an oracle you take on faith, plus a treasury you hope is funded. Every link in that chain is a place where the product can be claimed to work and quietly not.
Somnia changed the shape of that problem. DreamDEX already runs real binary markets that resolve themselves — the OracleHub pushes each market's answer on-chain with no human in the loop. And Somnia's reactivity precompile (0x0100) can dispatch a chain event straight into a contract in the same block it lands.
The design is the same move twice over: don't build the thing you'd have to trust — use the one the protocol already settles. DreamArena holds no funds, takes no spread, runs no bankroll. ArcadeEngine is a scoreboard, not a counterparty. The interesting part is that the scoreboard is reactive: the chain calls it.
A prediction arcade where every screen is backed by a live read and every write is a real trade:
- Arcade — three game modes over the rolling BTC/ETH series. Higher or Lower, Streak Survivor (chain wins, one miss resets), and Boss Battle (three straight calls slay the boss; boss health is on-chain, so a run can't be faked).
- Community arenas — deploy a branded arena (
CommunityDAOFactory) and every order routed through it carries the arena as its builder, paying its treasury a builder fee up to the pool's own ceiling. Every live Shannon pool currently freezes that ceiling at 0, so orders are attributed but not yet charged — the app reads the cap and says so rather than sending a fee the pool would reject. - AI Oracle — an MCP server with eight tools (
mcp-server/) that lets an agent list markets, read odds, score risk, check a streak or balances, inspect arenas, and place a bet. The in-app Oracle panel runs the exact same reads throughPOST /api/oracle, composes them to line up a bet ("find the lowest-risk BTC market and bet 5 tUSDC on Up"), and the player then signs that order with their own wallet. - Leaderboard — four ranking boards (Streaks / Wins / Win rate / Bosses), all sorting the same exact per-player reads from
ArcadeEngine. - Profile — an on-chain player card with real stats and eight achievements, each threshold read from an on-chain counter, not a local variable.
- Reactive Settlement Monitor — the part that makes the whole thing worth believing in: it takes a real settlement, finds the oracle's log and our handler's transaction in the same block, and shows both, explorer-linked.
contracts/ ArcadeEngine (reactive scoreboard) + CommunityDAOFactory (builder-fee arenas)
frontend/ Next.js 15 app — the arcade, arenas, AI oracle panel, leaderboard, profile
mcp-server/ MCP server exposing the arcade to an AI agent
| Contract | Role |
|---|---|
ArcadeEngine |
Records entries, resolves them reactively from the oracle's own answer, and moves streaks / multipliers / boss HP. Extends SomniaEventHandler; holds no funds. |
CommunityDAOFactory |
Deploys an arena: a name, a treasury contract, and a builder-fee rate. |
ArcadeEngine verifies entries against the player's real ERC-6909 outcome-share balance, so a streak cannot be farmed without actually trading. Resolution is capped per reactive callback (the subscription buys a fixed gas budget) with a permissionless settleMarket sweep for anything past the cap.
| Contract | Address | Source |
|---|---|---|
| ArcadeEngine | 0x14A8fee19259877789440218AEb1Dc7aF18A0701 |
verified |
| CommunityDAOFactory | 0xca0fCf47D2F59cb5a50b88521D56BC244421a36d |
— |
| Reactivity subscription | 15321499 — creation tx |
— |
The engine pays for its own reactive callbacks out of its own balance. It is dispatched roughly 35 times an hour at ~346k gas each, about 1.7 STT a day. Below 32 STT it can no longer open a new subscription (the precompile's SUBSCRIPTION_OWNER_MINIMUM_BALANCE), so top it up before a redeploy — send STT straight to the engine address.
These come from the SDK's SOMNIA_TESTNET_ADDRESSES rather than being hardcoded.
| Contract | Address |
|---|---|
| BinaryMarketsModule | 0x3ecC694Cef705358864a646142ac17A90E29e388 |
| OracleHub | 0xe40db387cC98601Dd11bd634fF2f3AD5686dE32b |
| OutcomeToken6909 | 0xB52c5934113Af5c0Bb20eb3C72290C8215f755b9 |
| tUSDC | 0x70a86D8842FB63C4Ad2b7cdddF530eBf1BB25d8E |
| Reactivity precompile | 0x0000000000000000000000000000000000000100 |
A bet is buying outcome shares on a market's own order book. Each winning share redeems for exactly 1 tUSDC, so a share bought at 0.40 pays 2.5x.
You call UP on "BTC ≥ $78,426, 60s"
→ a real taker order fills on the DreamDEX binary pool
→ the market expires; OracleHub resolves it and emits AnswerDelivered
→ Somnia's reactivity precompile (0x0100) dispatches that log
→ ArcadeEngine.onEvent resolves your entry and moves your streak ← same block
→ you redeem winning shares for tUSDC
Step four is the interesting one. ArcadeEngine extends SomniaEventHandler and holds a reactivity subscription on OracleHub's AnswerDelivered. Nothing polls. There is no cron, no keeper, and no backend — the chain calls us.
And a detail that matters: the engine reads each market's own payoutNumerators() rather than the numerators carried in the event. Those disagree — the oracle's answer space is not the market's YES/NO space, and a testnet market settled [0, 1e7] on-chain while its event said [1e7, 0]. Trusting the event tells a losing player they won.
"Same-block settlement, no keeper" is easy to write and hard to believe, so the app verifies it instead of asserting it. The Reactive Settlement Monitor takes a real settlement, finds the AnswerDelivered log for its market in the exact block our engine emitted GameResolved, and shows both transactions explorer-linked — with a green "Verified: the oracle's answer and our handler both landed in block #…" line, or an amber note if it settled through the tail sweep instead.
The live monitor also surfaces every reactive callback the engine receives, whether or not anyone bet: each OracleAnswered event arrives with nobody running anything, and each one links to the transaction the chain injected. That's the always-on evidence that the reactivity claim is true, visible continuously rather than only when your own bet settles.
But the hub does not emit AnswerDelivered for every market it finalizes. Markets have been observed reaching Finalized with a real winner and no event at all. So the reactive callback is the fast path, not the only path: settleMarket(marketId, limit) is permissionless, reads the market's own outcome, and finishes anything the callback missed. "Usually instant" must not be allowed to mean "sometimes never".
The full loop, end to end — bet 3 tUSDC on BTC ≥ $78,359.88 → filled 4.815 shares at 0.623 → market resolved YES → the engine settled it and the streak moved → redeemed 4.815 tUSDC. That run was against the previous engine, before the Boss Battle redeploy.
On the currently deployed engine, npm run prove placed a real 5 tUSDC order filling 7.012 ETH-UP shares, and recorded the entry in block 479511324 — entrantCount 1, pendingResolves 1. Only daily series were open, so it settles at that market's expiry rather than inside a demo. The reactive leg itself is independently observable in the meantime: the precompile dispatches onEvent into the engine about 35 times an hour.
Reproduce with npm run prove in frontend/ (it reads contracts/.env; set MAX_WAIT above the series interval when only long-dated markets are open).
| Capability | How it's backed |
|---|---|
| Live BTC/ETH markets | Real. Read from DreamDEX via @somnia-chain/markets-sdk + the Somnia indexer. Markets roll every 60s. |
| Betting | Real. Every "Place bet" sends an approve + a taker order that fills on a real order book, then records the entry on-chain. |
| Reactive settlement | Real and partially live. Subscription 15321499 is active on the deployed engine; the engine holds ~26 STT and receives ~35 callbacks/hour. Verified on-chain by the monitor, not asserted. |
| Streaks / multipliers / Boss Battle | Real. Moved and stored in ArcadeEngine state; player stats read per-player from the contract, exact. |
| Community arenas | Real, with one venue limit. createDAO deploys a real treasury and routed orders name it as builder. Every live Shannon pool reports getMaxBuilderFeeBpsTimes1k() == 0, and a fee above that reverts BuilderFeeExceedsCap on both the approval and the order, so the app clamps the arena's fee to the pool's cap (npm run check:arena-fee reads every live pool) and shows the player the number it actually charges. Frontend reads the deployed factory live. getDAOCount is currently 1 — one live arena ("Saber DAO GC") on testnet, and the UI shows it. |
| Leaderboard / Profile | Real reads from ArcadeEngine; when nobody has settled in the log window, the tables say so. |
| AI Oracle (panel + MCP) | Real. Seven read dreamarena_* tools plus the dreamarena_place_bet write over live testnet; the live board shows every read's real tool name and latency. The in-app Oracle panel and the MCP server run the same runReadTool dispatcher in mcp-server/src/arena.ts for reads — no key needed. The MCP write needs PRIVATE_KEY and is capped at 25 tUSDC. The in-app panel composes the same reads to line up a bet, then the player signs the actual order with their OWN wallet (approve → taker order → ArcadeEngine recordEntry, the exact Arcade-tab path) — the panel holds no server key, so the route stays read-only. |
| 23 contract tests | Real, npx hardhat test against a local fork-safe harness — reactive callback settles winners/losers, breaks streaks, emits milestones, honours voids, refuses non-precompile callers, sweeps the tail. |
| Deployment record staleness | Mostly clean. contracts/deployments/50312.json, .mcp.json, and this README all point at the live engine (0x14A8fee1…, subscription 15321499). One stale spot remains, and it's gitignored: frontend/.env.local still carries the pre-redeploy engine and factory (0xf23147Df… / 0xe66dd4f6…) from the earlier deployment, so a freshly-cloned env is correct but a long-lived local env is not. Fix local copies on a redeploy. |
| Command | What it proves |
|---|---|
contracts: npx hardhat test |
23 tests. The reactive callback settles winners and losers, breaks streaks, emits milestones, honours voids, refuses non-precompile callers, and sweeps its tail. |
frontend: npm run check:chain |
The board loads, quotes fill, escrow stays within stake, orders are tick/lot aligned, price feeds respond. |
frontend: npm run prove |
Places a real bet against live testnet and watches it settle — the end-to-end reactive loop, asserted against the chain. |
frontend: npm run faucet |
Mints 10,000 tUSDC to the configured wallet (the TestUSDC contract's public faucet). |
mcp-server: npm run check |
All eight tools register and answer over real stdio MCP; a read-only server refuses to bet. |
check:chain exists because this project's original defect was invisible: a fabricated ABI renders as an empty state, which looks exactly like "no markets yet" until something asserts against the real chain.
| Route / tool | Returns |
|---|---|
POST /api/oracle |
The panel's route — dispatches any dreamarena_* read tool by name to the same runReadTool the MCP server uses. |
MCP dreamarena_list_markets |
Live open markets, soonest to expire first. |
MCP dreamarena_get_odds |
Both sides of a market's book (probabilities 0–1). |
MCP dreamarena_assess_risk |
1–10 risk score on one side, from time-to-expiry, spread, and distance from a coin flip. |
MCP dreamarena_get_streak |
A player's current streak, multiplier, best, and milestones. |
MCP dreamarena_get_balances |
A wallet's spendable tUSDC and STT for gas. |
MCP dreamarena_list_arenas |
Every community arena deployed via the factory, with its builder fee. |
MCP dreamarena_status |
Network, wired contracts, wallet/caps. |
MCP dreamarena_place_bet |
Real taker order + entry recording (capped, wallet-gated). |
Call the MCP server directly: .mcp.json registers it with the MCP client; reads are free, writes need PRIVATE_KEY in that env block. The panel reaches the identical reads through POST /api/oracle, which binds to the same mcp-server/src/arena.ts so the two cannot drift.
Next.js 15 · Privy · wagmi/viem · Tailwind · Framer Motion · Solidity 0.8.30 · Hardhat
@somnia-chain/markets-sdk · @somnia-chain/reactivity-contracts · @modelcontextprotocol/sdk
contracts/
src/
ArcadeEngine.sol # reactive scoreboard: entries, streaks, boss HP, settleMarket
CommunityDAOFactory.sol # arenas: builder-fee treasuries
test/ArcadeEngine.t.js # 23 tests
scripts/ # deploy.js · subscribe.js · retire.js
deployments/50312.json # live deployment record (engine + subscription)
frontend/
app/app/page.tsx # the arcade: 5 tabs, HUD, control deck, bet modal
components/ # TopHUD · ControlDeck · AgentTab · DAOTab · ProfileTab ·
# ReactivityProof · SettlementFeed · SettlementCelebration ·
# MilestoneCelebration · toast system · RollingNumber
hooks/ # useDreamDEX · useArena · useSettlements · useContracts · useDAO
lib/dreamdex.ts # real SDK + on-chain reads/writes
lib/abis.ts # `as const` ABIs (generated by npm run abis)
scripts/ # gen-abis, check:chain, faucet-tusdc, prove-reactivity
mcp-server/
src/index.ts # eight MCP tools over the real arcade
src/arena.ts # trading + risk layer + shared runReadTool dispatcher (chain-enforced bet cap)
Node 18+, and a wallet with STT from the faucet.
# Contracts
cd contracts && npm install
npx hardhat test # 23 tests
npx hardhat run scripts/deploy.js --network somniaTestnet
# The engine pays for its own reactivity callbacks, so it must hold 32 STT
# BEFORE it can subscribe — the subscription owner is the contract, not you.
npx hardhat run scripts/subscribe.js --network somniaTestnet
# Source-verify what you just deployed
npx hardhat verify --network somniaTestnet <daoFactory>
npx hardhat verify --network somniaTestnet <arcadeEngine> \
0x3ecC694Cef705358864a646142ac17A90E29e388 \
0xB52c5934113Af5c0Bb20eb3C72290C8215f755b9 \
0xe40db387cC98601Dd11bd634fF2f3AD5686dE32b
# Frontend
cd ../frontend && npm install
cp .env.local.example .env.local # required: NEXT_PUBLIC_PRIVY_APP_ID from dashboard.privy.io
npm run abis # regenerate ABIs from the compiled artifacts
npm run check:chain # smoke-test the read path against live testnet
npm run dev
# Test tUSDC for betting (mints 10,000 tUSDC to the .env wallet, capped by the contract)
npm run faucet
# MCP server
cd ../mcp-server && npm install && npm run build
npm run check # drives the server over stdio against live testnetThree consumers, three files — a single root .env is read by nothing. .env.example at the repo root is a map of all three, not a file to copy.
| File | Loaded by | Keys |
|---|---|---|
contracts/.env |
hardhat.config.js via dotenv, and the frontend's npm run faucet / npm run prove via --env-file |
PRIVATE_KEY, ARCADE_ENGINE |
frontend/.env.local |
Next.js | NEXT_PUBLIC_ARCADE_ENGINE, NEXT_PUBLIC_DAO_FACTORY, NEXT_PUBLIC_PRIVY_APP_ID |
.mcp.json env block |
the MCP client | ARCADE_ENGINE, MAX_BET_TUSDC, optional PRIVATE_KEY |
NEXT_PUBLIC_PRIVY_APP_ID comes from dashboard.privy.io and is required — Privy throws at render if it is missing or malformed, so next build fails rather than shipping a page whose connect button silently does nothing. Only NEXT_PUBLIC_* keys belong in frontend/.env.local; everything there is inlined into the browser bundle, so the deploy key must never go in it.
Same pattern as the running stack: the contracts on Shannon testnet (deploy + fund the engine with 32+ STT + subscribe), the Next.js app on Vercel or any Node host, the MCP server wherever an agent can reach it. The one trap is the deployment record — update contracts/deployments/50312.json and .mcp.json with the live engine address after every redeploy, or tooling will read a dead address.
cd contracts && npx hardhat test— 23 passing. The reactive callback settles winners and losers, breaks streaks, emits milestones, honours voids, refuses non-precompile callers, and sweeps its tail.cd frontend && npm run check:chain— live smoke test of the read path against testnet.cd frontend && npm run prove— end-to-end reactive loop against the live chain with real funds.cd mcp-server && npm run check— all eight tools over real stdio MCP.
The strongest verification is the one that cannot be faked: npm run prove places a real order and watches ArcadeEngine settle it from the chain's own callback. That is the whole product, asserted against the live chain.
MIT