Confidential prediction markets powered by Fully Homomorphic Encryption
Bet without revealing your hand.
- The idea
- Features
- Demo
- Architecture
- Contract API
- Deployed contracts
- Tech stack
- Getting started
- API routes
- Security & known limitations
- Roadmap
- Contributing
- License
Public prediction markets leak the exact thing they need to work well — every stake and every position is visible, so whales get copy-traded and order flow gets front-run the moment it lands.
PredIQ encrypts the two fields that leak an edge — stake amount and chosen side — while keeping the market outcome public. Every bet is FHE ciphertext; the contract tallies pools and pays out winners without ever seeing an individual bet. Only the aggregate pools are revealed, and only at resolution.
| Public markets | PredIQ | |
|---|---|---|
| Bet amount | 👁️ public | 🔒 encrypted |
| Chosen side | 👁️ public | 🔒 encrypted |
| Per-user position | 👁️ public | 🔒 encrypted |
| Aggregate pools | 👁️ public | 🔒 until resolution |
| Question / outcome | public | public |
- Fully encrypted bets — amount and side encrypted client-side via the Zama relayer SDK; the chain only sees ciphertext
- Indistinguishable writes — every
bet()updates both pools and both stakes (FHE.selectadds an encrypted0to the non-chosen side), so storage writes never leak which side you took - ACL-gated decryption — decrypt rights are granted only to a winner at claim time
- On-chain reveal integrity — pool totals are decrypted via the Zama KMS relayer and verified on-chain with
FHE.checkSignaturesbefore any payout math runs - ERC-7984 confidential token (CST) — bets are funded with an encrypted token end-to-end
- Testnet faucet — one click for 1,000 CST, via a serverless function so the deployer key never reaches the client
- Dynamic OG share cards, Polymarket trending feed, leaderboard, and category filtering
Live at prediq-umber.vercel.app — no real funds, everything runs on Sepolia.
- Connect wallet — RainbowKit, auto-switches to Sepolia.
- Get test CST — one-click faucet sends 1,000 CST.
- Approve once — ERC-7984 uses time-bounded operators, not ERC-20 allowances.
- Pick or create a market — creating one makes you its resolver.
- Bet privately — choose YES/NO, enter a stake, hit Encrypt & bet. Encrypted in-browser before broadcast.
- Resolve — after the deadline, the resolver declares the winning side.
- Finalize — pools are decrypted off-chain and submitted back with a proof (
finalizePools); only now are totals public. - Claim — winners claim; payout is computed on ciphertext and revealed to you alone via
userDecrypt.
┌─────────────── BROWSER ───────────────┐ ┌──────────── SEPOLIA ──────────────────┐
│ React 19 + Vite + RainbowKit │ │ │
│ │ bet │ PredictionMarket.sol │
│ relayer-sdk: encrypt │ ─────► │ fromExternal(amount, side) │
│ add64(amount).addBool(side) │ (ct + │ allowTransient(amt, CST) ────────────┼──► ConfidentialStakeToken.sol
│ → handles + inputProof │ proof) │ CST.confidentialTransferFrom() │ (ERC7984, euint64 balances)
│ │ │ FHE.select → both pools + stakes │
│ │ │ FHE.allowThis(handles) │
│ Get test CST ──────────────────────► │ │ │
│ POST /api/faucet │ │ resolve(): makePubliclyDecryptable │
│ │ │ │ ▲ │
└──────────┼────────────────────────────┘ └──────────────┼───────────────────────── ┘
│ │
▼ Vercel serverless (deployer wallet) │ publicDecrypt([yesPool, noPool])
relayer-sdk node: encrypt 1000 CST ┌───────┴──────────┐
→ CST.confidentialTransfer(user) │ Zama KMS / │
│ relayer │
finalizePools(clears, proof) ◄──────────────────────┤ + checkSig │
claim(): mul/div on ct → CST.transfer └──────────────────┘
Indistinguishable write pattern: bet() writes to both pools and both stake slots on every call — the non-chosen
side just adds an encrypted 0. No write pattern leaks which side you took.
Cross-contract ACL: the market calls FHE.allowTransient(amt, CST) so the token contract can operate on the handle
for exactly one transaction, then calls confidentialTransferFrom. The funded amount returned (clamped by ERC-7984 to
your real balance) becomes the stake, not the requested amount.
Reveal flow: resolve → makePubliclyDecryptable → off-chain publicDecrypt → finalizePools verifies the
relayer's proof with FHE.checkSignatures before decoding pool totals.
createMarket(question, deadline) → caller becomes resolver
bet(id, encAmount, encSide, proof) → both pools + both stakes update (side stays hidden)
resolve(id, winningSide) → resolver-only, after deadline; flags pools decryptable
finalizePools(id, clears, proof) → publicDecrypt output → checkSignatures → decode pools
claim(id) → payout = stake × totalPool / winningPool (paid in CST)
Views: getMarket · getPools · getStakes · getPayout · hasClaimed · stakeToken
| Contract | Address |
|---|---|
PredictionMarket |
0x5B6Cb01B6AcEBa5148e16fBEa3d1f77e434004d9 |
ConfidentialStakeToken (CST) |
0xF9B73bF34D8EAb58D3d3498714BF22C2a927463B |
Both verified on Etherscan. Full lifecycle exercised end-to-end on real Sepolia (test/PredictionMarketSepolia.ts) —
the publicDecrypt → checkSignatures round-trip and a CST-funded payout both pass on-chain.
| Layer | Technology |
|---|---|
| Smart contracts | Solidity 0.8.27, @fhevm/solidity 0.11, @openzeppelin/confidential-contracts (ERC-7984) |
| FHE types | euint64, ebool, FHE.select, euint128 (payout arithmetic) |
| FHE runtime | Zama FHEVM (Sepolia co-processor), @zama-fhe/relayer-sdk, Zama KMS relayer |
| Contract tooling | Hardhat 2, @fhevm/hardhat-plugin, TypeChain, hardhat-deploy, hardhat-verify, solidity-coverage |
| Frontend | React 19, TypeScript 6, Vite 7, Tailwind CSS v4, framer-motion |
| Web3 | ethers v6, wagmi v2, viem, RainbowKit |
| Off-chain | Supabase (categories, faucet claims), Vercel (serverless + edge), @vercel/og |
Prerequisites: Node.js ≥ 20, npm ≥ 10
git clone https://github.com/Illtrapx/prediq.git
cd prediq
npm install # contract tooling
cd frontend && npm install # frontendFrontend env — frontend/.env.local:
| Variable | Required | Description |
|---|---|---|
VITE_SUPABASE_URL |
yes | Supabase project URL |
VITE_SUPABASE_ANON_KEY |
yes | Supabase publishable anon key |
VITE_DEMO_TRADES |
no | Set off to disable synthetic showcase activity |
Serverless API env — Vercel project settings:
| Variable | Required | Description |
|---|---|---|
FAUCET_PRIVATE_KEY |
yes | Deployer wallet key. Testnet-only. Never commit. |
SUPABASE_URL |
no | Falls back to VITE_SUPABASE_URL |
SUPABASE_KEY |
no | Falls back to VITE_SUPABASE_ANON_KEY |
SEPOLIA_RPC_URL |
no | Defaults to a public Sepolia RPC |
APP_ORIGIN |
no | Defaults to https://prediq-umber.vercel.app |
Hardhat uses npx hardhat vars set MNEMONIC|INFURA_API_KEY|ETHERSCAN_API_KEY instead of .env; defaults to the
throwaway test … junk mnemonic so local runs need no setup.
# Contracts
npm run compile # compile + generate TypeChain types
npm test # full suite on mock FHEVM
npm run test:sepolia # real-network E2E
npm run deploy:sepolia # deploy CST then PredictionMarket
npm run verify:sepolia # verify both on Etherscan
# Frontend
cd frontend
npm run dev # http://localhost:5173, uses the deployed Sepolia contracts
npm run build # type-check + production build
npx vercel --prod # deploy (or connect the repo, root dir frontend/)vercel.json sets security headers (COOP, X-Frame-Options, CSP scoped to Sepolia RPC + Supabase + Zama relayer) and
rewrites /m/:id to /api/share?id=:id for short bet links.
All routes live in frontend/api/ and run as Vercel functions.
| Route | Method | Purpose |
|---|---|---|
/api/faucet |
POST | Sends 1,000 CST to { "address": "0x…" }, IP + address rate-limited |
/api/og?q=&deadline= |
GET | Edge-rendered OG image for a market |
/api/share?id= |
GET | Share card HTML for /m/:id short links |
Hidden: each bet's amount and side, every per-user stake. Public by design: question, deadline, resolver,
winning side, and aggregate pools after finalizePools.
- ACL discipline — every persisted ciphertext gets
FHE.allowThis; cross-contract calls useFHE.allowTransient - Reveal integrity —
finalizePoolsverifies the relayer's proof on-chain (FHE.checkSignatures) and is replay-guarded - Input guards — overflow, empty-market, and zero-pool checks reject unsafe states before they can execute
Known limitations:
- Trusted resolver — outcome is set unilaterally; the 24 h review window is informational, not yet enforced on-chain
- Division dust — integer payout division can leave a tiny CST remainder in the contract
- Faucet abuse — rate-limited by wallet + IP, but a determined attacker rotating IPs can still farm testnet CST
- Oracle-backed resolution (Chainlink / UMA) so resolvers can't manipulate outcomes
- On-chain dispute window enforcing the resolution-review period
- Multi-choice markets beyond binary YES/NO
- Batch finalization bot
- Mainnet CST supply with confidential, ETH-gated minting
See CONTRIBUTING.md. Short version:
git checkout -b feat/your-feature
npm run lint && npm test # contracts
cd frontend && npm run lint && npm run build # frontendOpen an issue first for non-trivial changes.