# Chubi — Conviction Market Protocol > Prediction markets powered by conviction. Back your beliefs with SOL, earn more for being early and right. ## Base URL **https://api.chubi.fun/api/v1** — all REST endpoints live under this prefix. Example: `GET https://api.chubi.fun/api/v1/markets`. A root-level alias (`GET /markets`) also works for convenience, but `/api/v1/...` is canonical. ## What this service is Chubi is a **prediction market** protocol (not a DeFi yield/lending/TVL service). Users back one side of a binary or multi-option market with SOL. Markets auto-resolve on a timer. Winners split the pool weighted by conviction and entry time (TWCD). All responses are `application/json`. ## Quick Start (for agents) 1. No registration needed for reading. All GET endpoints are public. 2. Discover open markets: GET https://api.chubi.fun/api/v1/markets?status=open&sort=volume&limit=20 3. Analyze a market before trading: GET https://api.chubi.fun/api/v1/markets/{marketId}/analytics Returns: pool state, implied probability per side, expected value for a hypothetical 1 SOL position, entry weight at current time, lockout status, TWD snapshots, payout shares. 4. Place a position (back a side): Deposits are executed on-chain directly. Construct and sign a Solana tx calling the `deposit(side, amount, min_weight)` instruction on the `chubi-escrow` program (IDL: https://api.chubi.fun/idl/chubi_escrow.json). amount is in lamports (1 SOL = 1e9 lamports). min_weight is a slippage guard (set to 0 to disable; set to displayed_weight * 0.98 otherwise). The chain-sync worker persists the resulting deposit into the read API. 5. Check your positions and P&L: GET https://api.chubi.fun/api/v1/user/{address}/stats GET https://api.chubi.fun/api/v1/user/{address}/positions 6. Claim payout after market resolves: On-chain: call `claim_payout()` on the position PDA. The chain-sync worker records the PayoutClaimed event and updates /payouts. That's it. No API key needed for basic usage. For higher rate limits (120/min vs 30/min), request an API key and send it as X-API-Key header. ## How It Works Chubi is NOT an order book. It is a conviction pool: - Users deposit SOL to a side (A or B). No matching, no counterparty needed. - Pool share = implied probability. If pool A has 70% of SOL, side A is priced at 70%. - When the market resolves, winners split the pool weighted by entry time (early = more). - Markets auto-resolve when the timer expires. No oracle needed. ## Key Mechanics ### Entry Weight (Early-Bird Bonus) - Formula: weight = 0.3 + 0.7 * (timeRemaining / totalDuration)^exp - Exponent is 1 (linear) for markets under 30 minutes, 2 (quadratic) for all others. - Position at market open: 1.0x weight (maximum) - Position at 50% elapsed (market >=30min): ~0.475x weight - Position at 90% elapsed: ~0.307x weight (minimum floor: 0.3x) - Weight is locked at entry and never changes. ### Deposit Slippage Guard - The on-chain `deposit` instruction takes `(side, amount, min_weight)`. - The program reverts with `WeightSlippage` (error 6025) if the actual entry weight (after any lockout or fraction-remaining drift between UI-read and tx-landing) is below `min_weight`. - Pass `0` to disable the guard. Frontend default is `displayed_weight * 0.98`. ### Market Health Signals - `/markets/{id}/analytics` returns a `health` block with: - `lastDepositAt` (ms timestamp), `hoursSinceLastDeposit` - `isDormant` (true when no activity for >=4h — late entry still works but the pool may not reflect current beliefs) - `uniqueMakers`, `top3Share`, `isConcentrated` (top-3 hold >=60% of pool — outcomes can pivot on one late deposit) - Use these to detect zombie or duel markets before committing SOL. ### Dynamic Lockout - The last 5-25% of market time blocks new positions. - Lockout % adapts: high pool imbalance or low participation = longer lockout. - Check `GET /markets/{id}/analytics` → `timing.inLockout` before placing positions. ### TWCD Payout (Binary Markets) - TWD = time-weighted average of conviction over the market's lifetime. - If the winner's conviction was consistent, they get up to 100% of the pool. - If there was a late swing, winners get as low as 50% (losers keep some). - Protocol fee: 2% on winner profits only (not on principal). ### Multi-Option Markets - 2-6 named sides. Winner takes all, split by entry weight. - No TWCD swing penalty for 3+ sides. ### Withdrawal (optional per market) - Some markets allow withdrawal with a penalty: 5% (early) up to 30% (late). - Penalty stays in the pool, rewarding remaining participants. - Blocked entirely during lockout. ## Base URL REST API endpoints live under `https://api.chubi.fun/api/v1/...`. A few endpoints are served from the **root** (`https://api.chubi.fun/...`), NOT under `/api/v1`: `/health`, `/llms.txt`, `/openapi.json`, `/mcp`, `/.well-known/mcp.json`, `/robots.txt`, `/idl/chubi_escrow.json`, `/idl/chubi_perp.json`. WebSocket is `wss://api.chubi.fun` (also root). Hitting these under `/api/v1` returns 404. ## Endpoints ### Markets (read) GET /markets List markets. Query: ?status=open|resolved&category=crypto|sports|politics|...&sort=volume|newest&limit=50&offset=0 GET /markets/{id}/meta Market metadata: sides, timer, lockout, resolution status, winner. GET /markets/{id}/book Pool snapshot: positions per side, pool sizes, share. GET /markets/{id}/analytics Rich bot/agent data: probability, EV, TWD, payout shares, entry weight, lockout, depth. GET /markets/{id}/conviction Current conviction state: poolA, poolB, shareA. GET /markets/{id}/conviction/history Time-series of conviction snapshots. Query: ?limit=200 GET /markets/{id}/positions Active positions. Query: ?side=A|B GET /markets/{id}/positions/history All positions including withdrawn. Query: ?limit=100&offset=0 GET /markets/{id}/payouts Payout breakdown (only available after resolution). GET /markets/{id}/payout-preview Estimated payout shares if resolved now. Useful for strategy. GET /markets/{id}/claims?maker={address} Claim status per position. ### Markets (write) POST /markets Create a market. Body (JSON or multipart): marketId: string (required, max 128 chars) sideAName / sideBName: string (binary mode) sides: ["Option1", "Option2", ...] (multi-option, 2-6 items) resolutionDurationHours: 24-168 (default 72) category: general|sports|crypto|politics|entertainment|tech|gaming|science allowWithdrawal: boolean description: string imageA / imageB: file (max 2MB, JPEG/PNG/GIF/WebP) POST /markets/{id}/positions (REMOVED — 410 Gone) POST /markets/{id}/withdraw (REMOVED — 410 Gone) POST /markets/{id}/claim (REMOVED — 410 Gone) Deposits, withdrawals, and claims are executed on-chain (Solana devnet) by the user's wallet directly via the `chubi-escrow` Anchor program. The chain-sync worker picks up the resulting events and persists them. POST /markets/{id}/deposit-preview EASIEST PATH for LLM-only agents (no Anchor SDK / no PDA derivation code). Body: {"maker":"","side":"A"|"B","amount":,"minWeight":<0..1000000>?} Returns a base64-encoded legacy Solana Transaction with the deposit instruction already wired (market/vault/position PDAs derived, fee payer set, recent blockhash attached). The agent decodes the base64, ed25519- signs it with the maker's keypair, and broadcasts via sendRawTransaction. Response includes: unsignedTxBase64, blockhash, lastValidBlockHeight, expiresAtMs (~70s), `chainStateCommitment: "processed"`, and a `context` object with the derived PDAs + the nonce used. Race caveat: the position PDA depends on positionCount AT PREVIEW TIME. Server reads it with `processed` commitment (~0.5s stale) to keep the race window tight — but a competing deposit between preview and broadcast still collides (AccountAlreadyInUse on chain). Retry the preview if so. Broadcasting within ~5s of the response makes collisions negligible. POST /markets/{id}/claim-preview Same idea but for `claim_payout` on a resolved market — closes the LLM- only agent loop (no SDK needed to exit a winning position). Body: {"maker":"","positionPda":""} Find the positionPda via GET /markets/{id}/positions or GET /user/{address}/positions. Server validates the position belongs to the maker, market is resolved, and the position isn't already claimed — returns clear 409 codes (MARKET_NOT_RESOLVED, ALREADY_CLAIMED, POSITION_WITHDRAWN) before reaching chain so an agent can recover. No PDA race: the position account already exists on-chain; nothing competes for it. Same response shape as /deposit-preview (with action="claim_payout"). If you'd rather build txs yourself (any language with a Solana SDK): - Program IDL: https://api.chubi.fun/idl/chubi_escrow.json - Key instructions: deposit(side: u8, amount: u64, min_weight: u64) withdraw() — only if market was created with allow_withdrawal=true claim_payout() — after resolution POST /markets/{id}/resolve Resolve a market. Body: {"winner": "A"|"B"} or omit winner for auto-resolution (must be expired). Note: on-chain resolution is permissionless via `resolve_market` after expiry; this REST endpoint is a legacy/convenience path. ### Perpetuals (read) Perpetual markets are open-ended (no resolution timer). They use a separate program (`chubi-perp`, IDL at https://api.chubi.fun/idl/chubi_perp.json) and implement an unusual funding model: funding flows loser → winner (opposite of conventional perps). Agents should treat perps as a distinct surface — they share the WebSocket and discovery surfaces with timed markets but the on-chain semantics differ. GET /perpetuals List all perp markets. No query filters yet — returns up to 100. GET /perpetuals/{id}/analytics Pool state, imbalance bps, funding rate per epoch + per day, time until next crank, entry weight for a new position, and honest majority/minority strategy notes. The primary decision endpoint for perps. Like the timed /analytics, but with no terminal-payoff EV — perps don't resolve, so the only "EV" comes from believing the popularity direction will hold long enough to compound funding inflows. GET /perpetuals/{id}/positions Active perp positions on a market. Filter with ?side=A|B. ### Perpetuals (write) POST /perpetuals/{id}/deposit-preview Same as the timed-market deposit-preview but for the chubi-perp program. Body: {"maker":"","side":"A"|"B","amount":} No minWeight (perp has no slippage guard). amount must be ≥ 20_000_000 lamports (0.02 SOL) — enforced on-chain. Returns the same shape as the timed version (unsignedTxBase64, blockhash, context, etc.). Same PDA-race caveat applies — retry the preview on competing deposits. POST /perpetuals/{id}/exit-preview Closes the LLM-only agent loop on the perpetuals side. Perps don't resolve, so there is no claim — instead you call `exit_perpetual` when you choose to close, which returns principal + accrued funding from the pool plus the position-account rent. Body: {"maker":"","positionPda":""} Find positionPda via GET /perpetuals/{id}/positions or GET /user/{address}/positions. Server validates the perp is still open, the position belongs to the maker, and is still active — returns 409 codes (MARKET_NOT_OPEN, POSITION_NOT_ACTIVE) before reaching chain. No PDA race. Response shape identical to /deposit-preview with action="exit_perpetual". POST /perpetuals/{id}/crank Permissionless funding crank. Anyone can call after an epoch (3600s) elapses to settle funding for that epoch and earn a 5% rebate from the paid funding (CRANKER_REBATE_BPS=500). Returns {txSignature} on success; 409 with code EPOCH_NOT_ELAPSED or NO_FUNDING_NEEDED otherwise. User-signed perp instructions (built off the same IDL): deposit(side: u8, amount: u64) exit_perpetual() — close your position, refund principal + rent claim_creator_fees() — perp market creators only ### User GET /user/{address}/stats P&L, win rate, volume, position count. GET /user/{address}/positions All positions across all markets. Query: ?limit=100&offset=0 ### Root-level (no /api/v1 prefix) These live directly under https://api.chubi.fun — NOT under /api/v1. GET /health Returns: {status, db, markets, wsClients, ts} GET /robots.txt Standard robots.txt — agents allowed. GET /llms.txt This file. GET /openapi.json OpenAPI 3.0 spec with full endpoint + schema definitions. GET /mcp Model Context Protocol manifest (tool list with input schemas). GET /.well-known/mcp.json MCP discovery alias. GET /idl/chubi_escrow.json Anchor IDL for the timed-market program. Required to construct deposit/ claim/withdraw txs yourself (or use POST /markets/{id}/deposit-preview to skip the IDL entirely). GET /idl/chubi_perp.json Anchor IDL for the perpetuals program. ## WebSocket Connect to: wss://api.chubi.fun ### Subscribe to a market Send: {"type": "subscribe", "marketId": "my-market"} ### Events (per-market, after subscribe) - position:added — new position placed - conviction:snapshot — pool state updated (poolA, poolB, shareA) - position:withdrawn — position withdrawn with penalty - market:resolved — market resolved, winner determined ### Global events (sent to all clients, no subscribe needed) - global:position — any new position on any market - global:market — new market created - global:resolved — any market resolved - global:withdrawal — any withdrawal on any market ### WebSocket connection example (Node.js / browser) ```js // Node 20+: built-in WebSocket. Browser: same API. const ws = new WebSocket('wss://api.chubi.fun'); ws.addEventListener('open', () => { // Subscribe to a specific market for per-market events: ws.send(JSON.stringify({ type: 'subscribe', marketId: 'kratos-vs-arthur-morgan-u14zumpc' })); // Global events arrive without subscribing. }); ws.addEventListener('message', (evt) => { const msg = JSON.parse(evt.data); switch (msg.type) { case 'subscribed': console.log('subscribed to', msg.marketId); break; case 'position:added': console.log('new position on tracked market:', msg.data); break; case 'conviction:snapshot': // msg.data = { marketId, poolA, poolB, shareA } — strings for amounts console.log('pool moved:', msg.data.shareA); break; case 'market:resolved': console.log('resolved:', msg.data.winner, 'wps:', msg.data.winnerPayoutShare); break; case 'global:position': // arrives for ANY market, even ones you didn't subscribe to break; } }); ws.addEventListener('close', () => { // No auto-reconnect server-side; clients should reconnect with backoff. setTimeout(() => { /* reopen */ }, 1000); }); ``` ### Python (websockets library) ```python import asyncio, json import websockets async def watch(market_id): async with websockets.connect('wss://api.chubi.fun') as ws: await ws.send(json.dumps({'type': 'subscribe', 'marketId': market_id})) async for raw in ws: msg = json.loads(raw) if msg['type'] == 'conviction:snapshot': print('shareA:', msg['data']['shareA']) asyncio.run(watch('kratos-vs-arthur-morgan-u14zumpc')) ``` ## Agent Strategy Guide 1. **Find markets**: GET /markets?status=open&sort=volume 2. **Analyze**: GET /markets/{id}/analytics — check probability, EV, entry weight 3. **Decision rule**: If `strategy.sideA.expectedValue > 0` and `entry.canEnter == true`, consider entering side A 4. **Timing**: Higher `entry.earlyBirdWeight` = more weight = better payout if you win 5. **Risk**: Check `timing.inLockout` — if true, no entry allowed. Check `timing.fractionRemaining` — lower = less weight 6. **Monitor**: Subscribe via WebSocket for real-time updates 7. **Collect**: After resolution, GET /markets/{id}/payouts to find your payout. Claim is on-chain: call `claim_payout()` on the position PDA via the `chubi-escrow` program. The chain-sync worker indexes the `PayoutClaimed` event and updates `/payouts` so you can verify. ### Minimal end-to-end flow (curl) ```bash # 1. List open markets, sorted by volume curl -sS 'https://api.chubi.fun/api/v1/markets?status=open&sort=volume&limit=10' # 2. Pick a candidate and pull analytics — this is the single endpoint that drives # a trading decision (probability, EV per side, entry weight, lockout, depth). MARKET_ID=kratos-vs-arthur-morgan-u14zumpc curl -sS "https://api.chubi.fun/api/v1/markets/${MARKET_ID}/analytics" # 3. Decide: enter only if entry.canEnter == true AND # strategy..expectedValue > 0. # Read entry.earlyBirdWeight — that is your locked weight at this moment. # 4. Deposit is on-chain. Construct a Solana tx calling the chubi-escrow # program's `deposit(side, amount, min_weight)` instruction. The IDL is # served at: curl -sS https://api.chubi.fun/idl/chubi_escrow.json # 5. Verify the deposit landed in the read API. The chain-sync worker polls # Solana with a 15s baseline interval, but end-to-end latency from # `sendAndConfirmTransaction` returning to your tx being indexed has been # observed at ~30-60s on devnet (RPC + Anchor confirmation tail + sync # poll + DB write + WS fanout). Poll for up to ~60s before assuming a # failure: curl -sS "https://api.chubi.fun/api/v1/markets/${MARKET_ID}/positions?side=A" # 6. After resolution, see who won + the payout breakdown: curl -sS "https://api.chubi.fun/api/v1/markets/${MARKET_ID}/payouts" ``` ### Python — analyse + decide (read-only) ```python import requests BASE = 'https://api.chubi.fun/api/v1' def candidates(limit=20): r = requests.get(f'{BASE}/markets', params={'status': 'open', 'sort': 'volume', 'limit': limit}) r.raise_for_status() return r.json()['markets'] def analyse(market_id): r = requests.get(f'{BASE}/markets/{market_id}/analytics') r.raise_for_status() a = r.json() # Decision: pick the side with the best positive expected value, # but only if we can still enter (lockout/early-bird floor). if not a['entry']['canEnter']: return None best = max(['sideA', 'sideB'], key=lambda s: a['strategy'][s]['expectedValue'] if a['strategy'][s] else -1) if a['strategy'][best] and a['strategy'][best]['expectedValue'] > 0: return { 'market_id': market_id, 'side': 'A' if best == 'sideA' else 'B', 'weight': a['entry']['earlyBirdWeight'], 'ev': a['strategy'][best]['expectedValue'], 'fraction_remaining': a['timing']['fractionRemaining'], } return None picks = [p for m in candidates() if (p := analyse(m['id']))] for p in sorted(picks, key=lambda x: -x['ev']): print(p) ``` ## Simplest agent path (recommended default) For an agent that just wants to participate without engaging the full feature set, the **simplest viable market shape** is: - **Binary** (2 sides A vs B) — no multi-option complexity, TWCD swing penalty applies cleanly. - **72-hour duration** — long enough to gather conviction, short enough to iterate. - **`allowWithdrawal: false`** — no withdrawal penalty math to reason about. - **Default dynamic lockout** (enable_lockout: true) — the protocol picks 5–25% automatically. Creating one (no on-chain auth needed for the metadata side; the on-chain market PDA is created by the authority when devnet bots boot): ```bash curl -sS -X POST 'https://api.chubi.fun/api/v1/markets' \ -H 'Content-Type: application/json' \ -d '{ "marketId": "my-test-market-001", "sideAName": "Yes", "sideBName": "No", "resolutionDurationHours": 72, "category": "general", "description": "Will X happen by deadline Y?", "allowWithdrawal": false }' ``` For agents only **reading** markets and placing positions, you can skip creation entirely — there are always live markets you can analyse via `GET /markets`. ### Simplest deposit path (LLM-only agents, no Anchor SDK) If your agent can call HTTP, ed25519-sign 64 bytes, and broadcast a base64 blob to a Solana RPC, that's enough to deposit on chubi. No PDA derivation, no IDL parsing, no Anchor SDK required. ``` # 1. Ask the backend to build the tx for you. curl -sS -X POST 'https://api.chubi.fun/api/v1/markets/{marketId}/deposit-preview' \ -H 'Content-Type: application/json' \ -d '{"maker":"","side":"A","amount":20000000}' # Returns: { unsignedTxBase64, blockhash, lastValidBlockHeight, feePayer, context, ... } # 2. Decode the base64, sign with your maker keypair, re-serialize: # const tx = Transaction.from(Buffer.from(unsignedTxBase64, 'base64')); # tx.partialSign(maker); # const raw = tx.serialize(); # 3. Broadcast to any devnet RPC: # await connection.sendRawTransaction(raw); # await connection.confirmTransaction({ signature, blockhash, lastValidBlockHeight }); ``` For perpetuals, swap the path: `POST /perpetuals/{marketId}/deposit-preview`. To claim a winning position on a resolved timed market, repeat steps 2-3 with `POST /markets/{marketId}/claim-preview`: ``` curl -sS -X POST 'https://api.chubi.fun/api/v1/markets/{marketId}/claim-preview' \ -H 'Content-Type: application/json' \ -d '{"maker":"","positionPda":""}' ``` For perpetuals, swap the path: `POST /perpetuals/{marketId}/exit-preview` (same body shape as claim-preview). exit_perpetual returns your principal plus any funding the pool has accrued to your side. Caveats: - The blockhash expires ~70s after issuance — sign and broadcast promptly. - For /deposit-preview: the position PDA depends on `positionCount` at preview time. Server reads it with `processed` commitment (~0.5s stale) to keep the race tight, but a competing deposit between preview and broadcast still collides (AccountAlreadyInUse on chain). Retry the preview if so. Broadcasting within ~5s of the response makes collisions negligible. - /claim-preview has no race — the position account already exists. - For verification after broadcast, poll `GET /markets/{id}/positions` (devnet chain-sync indexes within ~30-60s) or subscribe via WS. ## Units Chubi has **two amount units** in flight; both matter: - **On-chain (the `deposit` u64 argument)** — **lamports** (1 SOL = 1e9 = 1_000_000_000). This is what you pass to the Anchor instruction. Solana's native unit. - **API responses (`pool_a`, `amount`, `payout`, `volume`, `pnl`, etc.)** — **wei** (1 SOL = 1e18 = 1_000_000_000_000_000_000). The chain-sync worker multiplies on-chain lamports by 1e9 before writing to the DB, so every amount you read back via REST or WebSocket is wei. Stringified ints because the values exceed Number.MAX_SAFE_INTEGER — parse with BigInt / Decimal. Conversion: `wei = lamports × 1e9`, `SOL = wei / 1e18 = lamports / 1e9`. So a 0.02 SOL deposit: - you sign on-chain with `amount = 20_000_000` (lamports) - the chain-sync indexer records it as `"20000000000000000"` (wei) in the API. Other units: - Shares / weights: scaled by 1_000_000 (1.0 = 1000000) - Probabilities: 0.0 to 1.0 - Time: milliseconds (timeRemainingMs, durationMs) - Fees: basis points (200 bps = 2%)