Confidential Dark Pool Prediction Market
Every deposit, every bet, every payout β a ciphertext from browser to blockchain.
The chain knows you bet. It does not know on what, in what size, or for how much.
- What It Is
- Architecture
- Privacy Model
- Encrypted Stack
- Oracle Agent Quorum
- User Journey
- Deployed Contracts
- Repository Layout
- Running Locally
- Tests
- Operational Notes
GhostMarket is a binary prediction market where no financial value ever exists as plaintext on-chain. Not deposits. Not pool totals. Not bet sizes. Not payouts. Every value is an euint64 ciphertext processed by the Zama FHEVM coprocessor on Ethereum Sepolia.
The ERC-7984 cUSDC token holding all collateral literally cannot answer the question "how much does this user own?" The oracle signs over an encrypted handle, not a uint256. Two users can place bets in the same window and neither can infer the other's size from a price delta, because the price is frozen until the window closes.
Combined with a sealed-bid window mechanism, a 4-agent AI oracle quorum (3-of-4 majority; seven agent personas defined in code, configurable via ACTIVE_ORACLE_AGENTS) grounded in live exchange feeds, and Privy walletless login, this is what Polymarket would look like if the chain refused to publish your size.
graph TB
subgraph Browser["π Browser"]
direction LR
FE["<b>Next.js 15</b><br/>React 19 Β· Tailwind v4 Β· viem"]
SDK["<b>@zama-fhe/relayer-sdk</b><br/>encrypt(amount) β (handle, ZKPoK)"]
PV["<b>Privy v3</b><br/>Embedded EOA Β· Google/Email/Passkey"]
end
subgraph Contracts["βοΈ Ethereum Sepolia (chainId 11155111)"]
direction LR
GM["<b>GhostMarket</b><br/>Metadata registry<br/>Lifecycle entry point"]
EAMM["<b>GhostEAMM</b><br/>Encrypted AMM<br/>YES/NO euint64 pools<br/>Sealed-bid windows"]
VLT["<b>GhostVaultV2</b><br/>ERC-7984 cUSDC custody<br/>EIP-712 settlement"]
CUSDC["<b>cUSDC Mock</b><br/>ERC-7984 confidential token<br/>Zama canonical Sepolia"]
end
subgraph Oracle["π€ Oracle Service (Node.js Β· port 8092)"]
direction LR
AGT["<b>4 Active AI Agents</b> (7 defined)<br/>Cipher Β· Specter Β· Wraith Β· Phantom<br/>+ Shade Β· Echo Β· Vex if ACTIVE_ORACLE_AGENTS=7"]
WATCHER["<b>Sealed-Window Watcher</b><br/>Settle β Zama KMS decrypt<br/>β publishWindowPrice"]
SIGNER["<b>Oracle Signer</b><br/>EIP-712 Claim(amountHandle)"]
end
FE --> SDK
FE --> PV
SDK --> EAMM
SDK --> VLT
GM --> EAMM
VLT --> CUSDC
AGT --> GM
AGT --> SIGNER
WATCHER --> EAMM
SIGNER --> VLT
style Browser fill:transparent,stroke:#7c3aed,color:#333
style Contracts fill:transparent,stroke:#3b82f6,color:#333
style Oracle fill:transparent,stroke:#f59e0b,color:#333
style FE fill:#7c3aed,stroke:#a78bfa,color:#fff
style SDK fill:#412891,stroke:#7c3aed,color:#fff
style PV fill:#8b5cf6,stroke:#a78bfa,color:#fff
style GM fill:#64748b,stroke:#94a3b8,color:#fff
style EAMM fill:#3b82f6,stroke:#60a5fa,color:#fff
style VLT fill:#22c55e,stroke:#4ade80,color:#000
style CUSDC fill:#06b6d4,stroke:#22d3ee,color:#000
style AGT fill:#f59e0b,stroke:#fbbf24,color:#000
style WATCHER fill:#ef4444,stroke:#f87171,color:#fff
style SIGNER fill:#f59e0b,stroke:#fbbf24,color:#000
graph LR
A["π€ User encrypts<br/>amount in browser"] --> B["π cUSDC.setOperator<br/>+ GhostVaultV2.deposit"]
B --> C["π¦ euint64 balance<br/>in vault"]
C --> D["π― lockForBet +<br/>placeBet (FHE.add)"]
D --> E["πͺ Sealed window<br/>pool frozen"]
E --> F["β° Window expires<br/>β PriceRevealed"]
F --> G["π€ Oracle quorum<br/>resolveMarket on-chain"]
G --> H["βοΈ Oracle signs<br/>Claim(amountHandle)"]
H --> I["πΈ confidentialTransfer<br/>(user, amountHandle)"]
style A fill:#f8fafc,stroke:#334155,color:#000
style B fill:#412891,stroke:#7c3aed,color:#fff
style C fill:#22c55e,stroke:#4ade80,color:#000
style D fill:#3b82f6,stroke:#60a5fa,color:#fff
style E fill:#06b6d4,stroke:#22d3ee,color:#000
style F fill:#06b6d4,stroke:#22d3ee,color:#000
style G fill:#f59e0b,stroke:#fbbf24,color:#000
style H fill:#f59e0b,stroke:#fbbf24,color:#000
style I fill:#22c55e,stroke:#4ade80,color:#000
| On-chain β anyone can read | FHE-encrypted (euint64 ciphertext handle only) |
|---|---|
| Market ID, title, category, expiry | Vault deposit amount |
| User EOA address | Vault balance per user |
| Bet side (YES / NO) | Per-market collateral lock |
| Market status, resolved outcome | Total locked per user |
| Sealed-window timestamps | YES pool total Β· NO pool total |
| Tx hashes, block numbers | Per-user YES position Β· NO position |
| Pre-window pool snapshot | |
| Payout amount handle |
Every on-chain event has zero amount fields β by design:
event BetPlaced (uint256 indexed marketId, address indexed user, bool indexed side);
event Deposited (address indexed user);
event BetLocked (address indexed user, bytes32 indexed marketId, bool side);
event PayoutClaimed(address indexed user, bytes32 indexed marketId);ACL β who can decrypt what:
Privacy is trader-private, not authority-private. A regulator can be issued an ACL grant to specific handles via contract upgrade β the cryptography supports selective disclosure. This is the same shape as institutional dark pools in TradFi.
| Layer | Sepolia Address | Role |
|---|---|---|
0x9b5Cβ¦dFfF |
Faucet token β mint, then wrap | |
0x7c5Bβ¦3639 |
What users actually deposit |
deposit β the key insight: the vault never holds a plaintext amount at any step.
user: setOperator(vault, forever) β encrypted-token "approve"
user: encrypt(amount) β (handle, ZKPoK) β plaintext gone in browser
GhostVaultV2.deposit(handle, proof):
FHE.fromExternal(handle, proof) β verify ZKPoK
FHE.allow(amount, address(cUSDC)) β let cUSDC use this handle
cUSDC.confidentialTransferFrom(user, vault) β homomorphic debit, no plaintext
_balances[user] = FHE.add(_balances[user], transferred)
Overdraw can't be a revert β the EVM never sees a plaintext comparison. Instead:
ebool ok = FHE.ge(freeBalance, amount);
euint64 effectiveAmount = FHE.select(ok, amount, FHE.asEuint64(0)); // clamp in coprocessorGhostEAMM pool totals accumulate via FHE.add. No party has ACL access until market resolution or sealed-window settlement.
The minimum-bet guard runs entirely inside the coprocessor β the EVM never sees whether the submitted amount passed:
ebool aboveMin = FHE.gt(amount, FHE.asEuint64(MIN_BET_UNITS));
euint64 effectiveAmount = FHE.select(aboveMin, amount, FHE.asEuint64(0));Per-user positions are ACL-scoped to the owner on every write:
_yesPositions[marketId][msg.sender] = FHE.add(..., effectiveAmount);
FHE.allowThis(_yesPositions[marketId][msg.sender]);
FHE.allow (_yesPositions[marketId][msg.sender], msg.sender); // user decrypts own positionAfter resolution the oracle gets one-shot ACL on only the winning side of one specific user via grantPositionAccess.
Even with encrypted bets, a continuously visible price is a side-channel: a tiny probe bet + the resulting price delta reveals the previous bet's size. Sealed windows close this.
stateDiagram-v2
[*] --> Open: openSealedWindow β snapshot pool handles
Open --> Accepting: placeBet (price display frozen at snapshot)
Accepting --> Pending: window timer expires β placeBet blocked
Pending --> Settled: oracle calls settleSealedWindow
Settled --> Revealed: publishWindowPrice β PriceRevealed event
Revealed --> [*]: frontend animates price jump
Commit-reveal without a second user transaction. The clock is the reveal; the bets were real the whole time; nobody owes a second tx. The combined delta publishes as one
PriceRevealedevent β no participant can chart anyone else's order flow inside the window.
After oracle quorum the oracle signs over the encrypted ciphertext handle, not a plaintext amount:
// GhostVaultV2 β CLAIM_TYPEHASH
"Claim(address user, bytes32 marketId, bytes32 amountHandle, uint256 nonce, uint256 expiry)"
// ^^^^^^^^^^^^^^^^^^^
// euint64 handle β not a uint256 amountThe vault verifies the signature and calls cUSDC.confidentialTransfer(user, amountHandle). The handle flows through homomorphically.
The plaintext payout exists only in the user's browser when they decrypt their own balance handle. It never appears in: oracle memory, vault storage, token storage, calldata, events, or any block explorer.
Replay protection: usedNonces[user][marketId][nonce]. Settlement TTL: 24 hours. Signer rotation: setSettlementSigner(newOracle).
Each active agent: fetching (live API call, 6s timeout) β attesting (personality-specific gpt-4o-mini prompt with the real fetched value, JSON-only {vote, reasoning}) β submitted. With the default four agents, quorum is 3-of-4 (floor(N/2)+1). Set ACTIVE_ORACLE_AGENTS up to 7 to use every definition (then quorum is 4-of-7). On quorum: resolveMarket on Sepolia, settlement endpoint opens.
The Oracle Room (/oracle, ~1250 LOC) streams every agent's state, fetched price, live LLM reasoning, and on-chain tx hash via WebSocket. Every reasoning line is a real LLM response grounded in a real API call β nothing is pre-canned.
| Contract | Address | Role |
|---|---|---|
0x9b5Cβ¦dFfF |
Underlying ERC-20, public mint | |
0x7c5Bβ¦3639 |
ERC-7984 confidential wrapper | |
0xab75β¦0F5E |
Encrypted AMM + sealed-bid windows | |
0xddd9β¦B48f |
cUSDC custody + EIP-712 settlement | |
0xbD4fβ¦Bfd0 |
Metadata registry + lifecycle entry |
The cUSDC addresses are Zama's canonical Sepolia tokens β anyone with Sepolia USDC can interact with the same vault.
Live shielded bet tx (encrypted calldata, no amount in events): 0x5994β¦9350
GhostMarket/
βββ π contracts/
β βββ contracts/
β β βββ GhostEAMM.sol β FHE encrypted AMM + sealed-bid windows
β β βββ GhostVaultV2.sol β ERC-7984 cUSDC custody + EIP-712 settlement
β β βββ GhostMarket.sol β Metadata registry + lifecycle forwarder
β βββ scripts/
β β βββ deploy-sepolia.ts β Full stack deploy (use this)
β β βββ redeploy-eamm.ts β EAMM-only upgrade + auto-rewire
β β βββ demo-sealed-window.ts β Seed a 5-min sealed-window market
β β βββ seed-markets.ts / seed-3-markets.ts
β βββ test/
β βββ GhostEAMM.test.ts β FHE bets, sealed windows, ACL grants, min-bet guard
β
βββ π€ oracle/src/
β βββ index.ts β Express + WebSocket server (port 8092)
β βββ agents.ts β 7 agent definitions; first 4 active by default (`ACTIVE_ORACLE_AGENTS`)
β βββ fetcher.ts β Live CEX / DeFiLlama / FRED fetchers (no API keys needed)
β βββ sealed-window-watcher.ts β Settle expired windows β Zama KMS decrypt β publishWindowPrice
β βββ oracle-signer.ts β EIP-712 sign Claim(user, marketId, amountHandle)
β βββ eamm-resolver.ts β On-chain resolveMarket + grantPositionAccess
β
βββ π web/src/
βββ app/ β / Β· /markets/[id] Β· /vault Β· /portfolio Β· /oracle Β· /admin
βββ components/
β βββ bet-slip.tsx β 5-step shielded bet chain (one button click)
β βββ sealed-countdown.tsx β Window countdown + price reveal animation
β βββ oracle-room.tsx β Live agent WebSocket dashboard (~1250 LOC)
βββ lib/
βββ eamm.ts β relayer-sdk init, encryption, sealed-window subscriptions
βββ vault.ts β wrap β setOperator β deposit β lock β claim β withdraw
cd contracts && npm install
cp .env.example .env # fill DEPLOYER_PRIVATE_KEY, ORACLE_PRIVATE_KEY, SEPOLIA_RPC_URL
npx hardhat compile
npx hardhat run scripts/deploy-sepolia.ts --network sepolia
# Prints addresses β paste into contracts/.env, oracle/.env, and web/.env.local (all three)npx hardhat run scripts/seed-3-markets.ts --network sepolia
# Optional: sealed-window demo market
npx hardhat run scripts/demo-sealed-window.ts --network sepolia
# Note the market ID; set WATCHED_MARKETS=<id> in oracle/.envcd oracle && npm install && cp .env.example .env
# Required: SEPOLIA_RPC_URL, SEPOLIA_PRIVATE_KEY, contract addresses, GHOST_VAULT_EIP712_VERSION=2
# Optional: OPENAI_API_KEY, ACTIVE_ORACLE_AGENTS, WATCHED_MARKETS
npm run dev
# http://localhost:8092/oracle/health
# ws://localhost:8092/oracle/ws/:marketIdcd web && npm install && cp .env.local.example .env.local
# Fill NEXT_PUBLIC_* contract addresses, NEXT_PUBLIC_PRIVY_APP_ID, NEXT_PUBLIC_ORACLE_URL
npm run dev # http://localhost:3000Optional: FastAPI orchestration layer
python -m venv .venv && source .venv/bin/activate
pip install -r api/requirements.txt
uvicorn api.main:app --reload # http://localhost:8000/docsThe frontend works without this. It's an orchestration surface for relay / server-side flows.
cd contracts
npx hardhat test --network hardhat # mock FHE β fast, CI-friendly
npx hardhat test --network sepolia # real FHE coprocessor β slower, costs gasFull test coverage β GhostEAMM.test.ts
placeBet β encrypted YES + NO bets accepted
β BetPlaced event has 3 args only β no amount field
β self-decrypt of own position correct
β cross-decrypt by another user rejected
β multi-bet accumulation in encrypted pool
min-bet guard β above-min: stored handle equals original amount
β below-min: silently zeroed β no dust position
resolveMarket β resolver / owner can resolve; others revert
β double-resolve reverts; post-resolve placeBet reverts
grantPositionAccess β winner-only ACL after YES resolution
β both sides granted after cancellation
β rejects users with no position; rejects active markets
sealed windows β open β snapshot handles non-zero
β bets accepted during open window
β bets blocked between expiry and settlement
β settle-before-expiry reverts
β resolver decrypts pool ciphertexts after settle
β double-settle reverts; bets reopen after settle
β publishWindowPrice emits PriceRevealed
β publish on unsettled window reverts
β getActiveWindowIdx returns max-uint when no active window
admin β pause / unpause gate all state changes
- Always full-deploy.
deploy-sepolia.tswiresGhostMarket β GhostEAMMatomically. Redeploying only the EAMM without updatingGhostMarket.setEammleaves metadata and encrypted state on different contracts (MarketNotFoundreverts on bets). Usescripts/redeploy-eamm.tsfor EAMM-only upgrades β it handles the rewire. - EIP-712 domain version is
"2".GhostVaultV2usesEIP712("GhostVault", "2"). SetGHOST_VAULT_EIP712_VERSION=2in oracle.env. - Sealed windows need a watcher. Set
WATCHED_MARKETS=<id>inoracle/.env, or rely on dynamic discovery via theSealedWindowOpenedevent listener. - Mainnet swap. Replace underlying USDC + cUSDC addresses with Circle USDC + a production ERC-7984 wrapper. Zero contract or frontend code changes needed.
