Voice-first AI agent for prediction markets with ZK privacy via Unlink Protocol. Built for the Monad Privacy Hackathon (Foundry NYC, Feb 28 – Mar 2, 2026).
BetWhisper lets users trade on prediction markets using voice (Meta Ray-Ban glasses) or chat (web/iOS). Users pay in MON on Monad — our server relays the intent and executes on Polymarket CLOB (Polygon) using pre-funded USDC. With Unlink, the link between a user's deposit and their bet is cryptographically broken by ZK proofs.
| Unlink Operation | Amount | Sender | Recipient | Token |
|---|---|---|---|---|
| Deposit (EOA → Pool) | Public | Public | Private | Public |
| Transfer (Pool → Pool) | Private | Private | Private | Private |
| Withdraw (Pool → EOA) | Public | Private | Public | Public |
┌─────────────────────────────────────────────────────────────────────────────┐ │ CLIENT LAYER │ │ │ │ Web (/predict) iOS (Ray-Ban Glasses) │ │ • React Chat UI • Gemini 2.5 Flash (native audio) │ │ • WalletConnect (Reown AppKit) • Face ID authentication │ │ • PIN → JWT auth • Auto-confirm after 3s │ │ • @unlink-xyz/react hooks • Gemini tool-calling │ └──────────────────┬──────────────────────────────────┬───────────────────────┘ │ │ │ User says: "$5 Lakers YES" │ ▼ ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ PAYMENT LAYER (Monad Chain) │ │ │ │ PATH A: Direct PATH B: Private (Unlink) │ │ ───────────────── ───────────────────────── │ │ User EOA ──MON──▶ BetWhisper User EOA ──MON──▶ Unlink Pool │ │ (fully public on-chain) (deposit public, recipient HIDDEN) │ │ │ │ │ Chain: Monad Mainnet (143) ZK Transfer (amt, from, to: ALL HIDDEN) │ │ Deposit: 0x530a...c3 │ │ │ BetWhisper wallet receives │ │ Proof: monadTxHash Proof: unlinkTxHash │ │ Pool: 0x0813...a254 │ │ Chain: Monad Testnet (10143) │ └──────────────────┬──────────────────────────────────┬───────────────────────┘ │ │ └──────────────────┬───────────────┘ │ POST /api/bet/execute ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ SERVER EXECUTION LAYER (Vercel Serverless — Next.js API Routes) │ │ │ │ 1. VALIDATE │ │ • amount ≤ $100 per trade │ │ • daily spend ≤ $500 per wallet │ │ • rate limit: 5 trades / 60s per wallet │ │ • replay protection: UNIQUE(monad_tx_hash) │ │ │ │ 2. VERIFY PAYMENT │ │ Direct: eth_getTransactionReceipt (5 retries) → oracle price check │ │ Unlink: wallet.sync() → wallet.getNotes() → match txHash+amount │ │ │ │ 3. RECORD │ │ INSERT INTO orders (..., status='pending', execution_mode) │ │ INSERT INTO pulse_trades (fuzzed GPS, bucketed amount) │ │ pulseBroadcaster.broadcast() → SSE to all map viewers │ │ │ │ 4. EXECUTE ON POLYMARKET CLOB │ │ Gamma API → resolve token IDs │ │ Order book → best price + 5% slippage │ │ FOK market order → shares + fill price │ │ Funded by: server's pre-funded USDC on Polygon │ │ │ │ 5. SUCCESS → UPDATE orders, INSERT positions │ │ FAILURE → status='clob_failed' → auto-refund cron │ └─────────────────────────────────────────────────────────────────────────────┘ │ ┌───────────────┴───────────────┐ ▼ ▼ ┌────────────────────────────┐ ┌───────────────────────────────────────┐ │ Polymarket CLOB │ │ Social Pulse Map (/pulse) │ │ Polygon Mainnet (137) │ │ Real-time heatmap via SSE │ │ • Token resolution (Gamma) │ │ • Thermal layer: public bets │ │ • Order book + slippage │ │ • Emerald glow: ZK private bets │ │ • FOK execution │ │ • Privacy: ±80m GPS, K-anonymity │ └────────────────────────────┘ └───────────────────────────────────────┘
Polymarket runs on Polygon and requires USDC. Our users have MON on Monad. BetWhisper bridges this gap with an intent relay pattern — the user expresses an intent (bet), pays in MON, and our server executes with its own USDC.
User Intent BetWhisper Server Polymarket ─────────── ───────────────── ────────── "Lakers YES $5" Receives 238 MON payment CLOB Order Book │ │ │ │ 238 MON ($5) │ │ ├────────────────────────▶ Verifies MON payment │ │ Monad chain │ (oracle: $0.021/MON) │ │ │ │ │ │ $5 USDC │ │ ├──────────────────────────────────▶ FOK Buy │ │ Polygon chain │ │ │ (server's own USDC) │ │ │ 8.77 shares │ │ ◀──────────────────────────────────┤ │ │ @ $0.57 per share │ │ "✓ 8.77 shares" │ │ ◀────────────────────────┤ Records position in DB │ │ │ │
Our server runs @unlink-xyz/node to maintain a persistent wallet inside the Unlink pool. When a user sends a private transfer, the server detects it via wallet.sync() + wallet.getNotes().
// Initialize server Unlink wallet (singleton, cached across requests) import { initWallet, encodeAddress } from '@unlink-xyz/node' async function getServerUnlinkWallet() { const wallet = await initWallet({ chain: 'monad-testnet', // Monad Testnet (10143) setup: false, // Don't create new wallet sync: false, // Manual sync for control }) await wallet.seed.importMnemonic(process.env.UNLINK_SERVER_MNEMONIC) await wallet.accounts.create() await wallet.sync() // 2-3s latency (syncs from Gateway) return wallet } // Get server's Unlink address (for receiving private transfers) async function getServerUnlinkAddress(): string { const wallet = await getServerUnlinkWallet() return encodeAddress(wallet.accounts[0].masterPublicKey) // → "unlink1q7k3j8x..." (bech32m format) } // Verify a private transfer arrived (match by txHash + amount) async function verifyUnlinkTransfer(txHash, expectedAmount, token) { const wallet = await getServerUnlinkWallet() await wallet.sync() // Re-sync to see new notes const notes = await wallet.getNotes() const match = notes.find(n => n.txHash === txHash && n.amount === expectedAmount && n.token === token ) if (!match) { await sleep(3000) // Wait for relay propagation await wallet.sync() // Try again // ... retry logic } return { verified: !!match, note: match } }
// Client-side: deposit + private transfer import { useDeposit, useTransfer } from '@unlink-xyz/react' // Step 1: Deposit MON into Unlink pool (public → private) const { execute: deposit } = useDeposit() await deposit({ depositor: userEOA, // User's connected wallet (0xABC...) deposits: [{ token: MON_TOKEN, // 0xEeee...EEeE (native MON) amount: parseEther('238') // ~$5 worth of MON }] }) // TX visible on explorer: "0xABC deposited to pool" // Recipient: HIDDEN by ZK proof // Step 2: Private transfer to BetWhisper server wallet const { execute: transfer } = useTransfer() const result = await transfer({ transfers: [{ token: MON_TOKEN, recipient: "unlink1q7k3j8x...", // Server's Unlink address amount: parseEther('238') }] }) // Amount: HIDDEN | Sender: HIDDEN | Recipient: HIDDEN // This is the core privacy guarantee // Step 3: Tell server to verify + execute bet await fetch('/api/bet/execute', { method: 'POST', body: JSON.stringify({ unlinkTxHash: result.txHash, // Proof of private transfer amountUSD: 5, conditionId: '0x...', side: 'Yes', executionMode: 'unlink' }) })
User says "5 dollars Lakers yes" (voice via Ray-Ban) or types in chat. UI shows confirmation card with amount and market details.
iOS: auto-confirms after 3-second countdown. Web: user clicks "Confirm Trade" button.
On-chain transaction on Monad Testnet. PUBLIC deposit amount, HIDDEN recipient. Uses useDeposit() from @unlink-xyz/react.
Inside the pool: user → BetWhisper server wallet. Amount HIDDEN, sender HIDDEN, recipient HIDDEN. This is the core privacy guarantee.
Client sends {unlinkTxHash, amountUSD: 5, conditionId, side: 'Yes', executionMode: 'unlink'} to the server.
Checks daily spend limit ($500), rate limit (5/min), replay protection (unique tx hash). All pass.
Server calls wallet.sync() (2-3s latency) then wallet.getNotes(). Matches incoming note by txHash + amount + token. Transfer confirmed.
INSERT into orders (status='pending') and pulse_trades (fuzzed location). SSE broadcast to all map viewers — trade appears on heatmap instantly.
Gamma API → resolve token IDs. Order book → best price $0.57. Apply 5% slippage → $0.5985. FOK market order with server's pre-funded USDC on Polygon.
Polymarket confirms: 8.77 shares @ $0.57. Order status → 'success'. Position recorded. User sees "✓ 8.77 shares Lakers YES" in chat. Green ZK glow appears on map.
| Method | Endpoint | Purpose | Auth |
|---|---|---|---|
| TRADE EXECUTION | |||
| POST | /api/bet/execute | Verify payment + execute CLOB order | Wallet signature |
| POST | /api/bet/sell | Sell position + MON cashout | JWT or Face ID |
| POST | /api/bet | Record position (after CLOB success) | — |
| MARKET DATA | |||
| GET | /api/markets | Search + trending markets (Polymarket) | — |
| GET | /api/markets/history | Historical price data | — |
| USER PORTFOLIO | |||
| GET | /api/user/balance | Positions + PnL + current prices | JWT |
| GET | /api/user/history | Last 30 orders with status | JWT |
| UNLINK PRIVACY | |||
| GET | /api/unlink/address | Server's Unlink address (unlink1...) | — |
| GET | /api/unlink/status | Wallet health, balances, sync timing | — |
| SOCIAL PULSE MAP | |||
| GET | /api/pulse/heatmap | Aggregated heatmap data + stats | — |
| GET | /api/pulse/stream | SSE real-time trade feed | — |
| POST | /api/pulse/seed | Generate demo narrative data | — |
| AUTH & SYSTEM | |||
| POST | /api/user/pin/setup | Register 4-digit PIN (bcrypt) | Wallet |
| POST | /api/user/pin/verify | Verify PIN → return JWT (1hr) | Wallet + PIN |
| GET | /api/cron/refund | Auto-refund failed CLOB orders | CRON_SECRET |
| GET | /api/mon-price | MON price oracle (multi-source consensus) | — |
We never trust a single price source. The server fetches from 3 independent oracles in parallel, computes a median, and refuses all trades if sources disagree by more than 15%.
Price Sources (parallel, 4s timeout each) ───────────────────────────────────────── 1. DeFi Llama Pro coins.llama.fi/prices/current/coingecko:monad 2. CoinGecko api.coingecko.com/api/v3/simple/price?ids=monad 3. GeckoTerminal api.geckoterminal.com/api/v2/networks/monad/pools Consensus Logic ───────────────────────────────────────── 3 sources agree → Use median price ✓ Trade executes 2 sources agree → Use median, warn ✓ Trade executes 1 source only → Use single, warn ✓ Trade executes Sources disagree >15% → Circuit breaker open ✗ REFUSE ALL TRADES 0 sources respond → Circuit breaker open ✗ REFUSE ALL TRADES
Every trade that passes through BetWhisper appears on a live Mapbox heatmap. Public bets render as thermal heat; ZK private bets (via Unlink) render as emerald green circles with a glow effect. Data flows in real-time via Server-Sent Events (SSE).
Mapbox GL heatmap layer with thermal color ramp: transparent → deep blue → purple → red → orange → yellow. Intensity driven by bet amount bucket. Represents trades made via the direct MON payment path (fully visible on-chain).
Two Mapbox circle layers: outer glow (large radius, low opacity emerald, blur: 1) + inner core (bright emerald, intensity-scaled color). Represents trades routed through Unlink's privacy pool — cryptographically unlinkable.
User places bet │ ▼ POST /api/bet/execute │ ├──▶ INSERT pulse_trades │ • Fuzz GPS: lat ± 0.0014°, lng ± 0.0018° (~80m) │ • Bucket amount: $5 → "1-10" │ • Bucket time: floor(now / 60s) │ • execution_mode: 'direct' | 'unlink' │ └──▶ pulseBroadcaster.broadcast() │ ▼ SSE endpoint: GET /api/pulse/stream │ data: {"type":"trade","lat":40.75,"lng":-73.99,...} ▼ Client: usePulseStream() hook │ ├──▶ if direct → add to thermal heatmap source └──▶ if unlink → add to ZK circle glow source
| Component | Status | Details |
|---|---|---|
| Wallet Connect | LIVE | WalletConnect v2 via Reown AppKit |
| MON Payment (Direct) | LIVE | On-chain on Monad mainnet, server-verified |
| Unlink Deposit → Pool | LIVE | On-chain on Monad Testnet |
| ZK Private Transfer | LIVE | Fully private: amount, sender, recipient ALL hidden |
| Privacy (unlinkability) | LIVE | Deposit and withdrawal are cryptographically unlinked |
| Polymarket CLOB Execution | LIVE | Real orders on Polygon mainnet (pre-funded $15 USDC) |
| Social Pulse Map | LIVE | Real-time SSE + Mapbox heatmap + ZK circle layer |
| Voice AI (iOS) | LIVE | Gemini 2.5 Flash native audio, EN/ES |
| Auto-Refund System | LIVE | Cron job, atomic with SKIP LOCKED |
| MON → USDC Swap | NEXT | No DEX liquidity on Monad testnet yet |
| Cross-chain Bridge | NEXT | Testnet can't bridge to Polygon (planned: CCTP) |
| DeFi Adapter (private swap) | NEXT | Private MON→USDC swap inside the pool |