System Architecture Document

BetWhisper — Technical Deep Dive

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).

What is BetWhisper?

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.

2
Chains (Monad + Polygon)
ZK
Privacy via Unlink
~14s
End-to-end trade latency
Why We Need Unlink

Without Unlink (Direct Path)

User (0xABC) sends 238 MON to BetWhisper (0x530)
BetWhisper executes: Lakers YES $5
Anyone on the explorer sees: "0xABC bet on Lakers for $5"
Identity: PUBLIC
Position: PUBLIC
Amount: PUBLIC

With Unlink (Private Path)

User (0xABC) deposits MON into Unlink Pool
ZK Transfer inside pool → BetWhisper wallet
BetWhisper (0xDEF) withdraws from pool
Explorer shows 2 unrelated TXs. Zero link.
Identity: HIDDEN
Position: HIDDEN
Deposit ↔ Bet: UNLINKABLE
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
Full System Diagram
┌─────────────────────────────────────────────────────────────────────────────┐
  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     
└────────────────────────────┘   └───────────────────────────────────────┘
The Intent Relay Model

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              
                                                                    
How We Use Unlink (Server-Side)

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().

S
Server Wallet — lib/unlink-server.ts
// 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 }
}
C
Client Flow — @unlink-xyz/react
// 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'
  })
})
PostgreSQL Schema (Neon Serverless)

orders — Every trade, pending or completed

18 columns
PK id SERIAL
UQ monad_tx_hash TEXT Replay protection — same tx can't execute twice
unlink_tx_hash TEXT Private transfer hash (Unlink path only)
wallet_address TEXT User's EOA (for portfolio lookup)
market_slug TEXT "will-lakers-win-tonight"
condition_id TEXT Polymarket market identifier
side TEXT 'Yes' | 'No'
amount_usd NUMERIC Requested amount in USD
verified_amount_usd NUMERIC Server-verified amount (oracle re-check)
mon_paid TEXT Actual MON received
mon_price_usd NUMERIC MON price at time of trade (server oracle)
execution_mode TEXT 'direct' or 'unlink'
status TEXT 'pending' → 'success' | 'clob_failed'
polygon_tx_hash TEXT CLOB execution hash on Polygon
order_id TEXT Polymarket order ID
shares NUMERIC Number of shares purchased
fill_price NUMERIC Actual execution price (e.g. 0.57)
refund_status TEXT null → 'processing' → 'refunded' | 'failed'

positions — User portfolio (aggregated shares per market)

14 columns
PK id SERIAL
IDX wallet_address TEXT User's EOA
market_slug TEXT
condition_id TEXT
IDX token_id TEXT Polymarket YES/NO token
side TEXT 'Yes' | 'No'
shares NUMERIC Current share count (decremented on sell)
avg_price NUMERIC Rolling average cost basis
total_usd NUMERIC Total USD invested in this position

pulse_trades — Geo-tagged trades for the heatmap (privacy-preserving)

9 columns
PK id SERIAL
IDX condition_id TEXT
side TEXT
amount_bucket TEXT '1-10' | '10-50' | '50-100' | '100+' (not exact)
lat NUMERIC Fuzzed ±80m (never exact location)
lng NUMERIC Fuzzed ±80m
IDX timestamp_bucket BIGINT Rounded to 60s windows (no exact time)
wallet_hash TEXT Truncated hash (first 10 chars only)
execution_mode TEXT 'unlink' = ZK circle glow on map

users — PIN authentication (web only)

6 columns
PK id SERIAL
UQ wallet_address TEXT
pin_hash TEXT bcrypt hash of 4-digit PIN
failed_attempts INTEGER Lock after 3 failures
locked_until TIMESTAMP
Full Trade Timeline ($5 Lakers YES via Unlink)
t = 0s

User Intent

User says "5 dollars Lakers yes" (voice via Ray-Ban) or types in chat. UI shows confirmation card with amount and market details.

t = 3s

Confirm (auto or click)

iOS: auto-confirms after 3-second countdown. Web: user clicks "Confirm Trade" button.

t = 3.1s

Deposit MON → Unlink Pool

On-chain transaction on Monad Testnet. PUBLIC deposit amount, HIDDEN recipient. Uses useDeposit() from @unlink-xyz/react.

t = 6s

ZK Private Transfer

Inside the pool: user → BetWhisper server wallet. Amount HIDDEN, sender HIDDEN, recipient HIDDEN. This is the core privacy guarantee.

t = 9s

POST /api/bet/execute

Client sends {unlinkTxHash, amountUSD: 5, conditionId, side: 'Yes', executionMode: 'unlink'} to the server.

t = 9.2s

Server Validates

Checks daily spend limit ($500), rate limit (5/min), replay protection (unique tx hash). All pass.

t = 12s

Unlink Verification

Server calls wallet.sync() (2-3s latency) then wallet.getNotes(). Matches incoming note by txHash + amount + token. Transfer confirmed.

t = 12.1s

Record + Broadcast

INSERT into orders (status='pending') and pulse_trades (fuzzed location). SSE broadcast to all map viewers — trade appears on heatmap instantly.

t = 12.2s

Execute on Polymarket CLOB

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.

t = 14s

Trade Complete

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.

All Server Endpoints
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)
Safety Mechanisms
!
Trade Safety
REPLAY UNIQUE index on monad_tx_hash — same tx can't execute twice
LIMIT Max $100/trade, $500/day per wallet
RATE Max 5 trades per 60 seconds per wallet
PRICE Server re-fetches MON price (never trusts client)
SLIP 5% slippage cap on FOK orders
REFUND Auto-refund cron with FOR UPDATE SKIP LOCKED
S
Privacy Layers
UNLINK ZK proofs break deposit → bet link
GPS Location fuzzed ±80m server-side
AMOUNT Bucketed (never exact amount on map)
TIME Timestamps rounded to 60-second windows
WALLET Hash truncated to 10 chars
K-ANON Side breakdown only shown if ≥5 unique wallets
MON Price — 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
Social Pulse Map — Real-Time Heatmap

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).

H
Thermal Heatmap Layer (Public Bets)

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).

Z
ZK Circle Layer (Private Bets)

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.

Data Pipeline
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
Technology Stack
Frontend
Next.js 15 (React 19)
Mapbox GL JS (heatmap)
Reown AppKit (WalletConnect)
@unlink-xyz/react
Tailwind CSS
Vercel (hosting)
Backend
Next.js API Routes (serverless)
Neon PostgreSQL (serverless)
@unlink-xyz/node
@polymarket/clob-client
ethers.js v6
SSE (Server-Sent Events)
Chains
Monad Mainnet (143) — payments
Monad Testnet (10143) — Unlink
Polygon (137) — Polymarket CLOB

Unlink Pool: 0x0813...a254
Deposit: 0x530a...c3
What's Real vs What's Next
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