Architecture
UnitFlow Predict is composed of four upgradeable smart contracts deployed on Arc Testnet, a Next.js 15 frontend, and a set of React hooks for reading on-chain state.
Contract System
All four contracts use OpenZeppelin's TransparentUpgradeableProxy pattern. The proxy address is permanent — only the implementation can be upgraded by the owner.
PredictMarketFactory (proxy)
│
├── deploys ──▶ PredictMarket (one per question)
│ │
│ └── routes fees ──▶ FeeDistributor (proxy)
│
└── references ──▶ PredictOracle (proxy)
│
└── calls resolveMarket() on PredictMarketPredictMarketFactory
The entry point for market creation. Validates parameters, collects the creation fee and initial liquidity from the creator, deploys a new PredictMarket contract via CREATE2 (deterministic address from a bytes32 market ID), seeds the market's liquidity pools, and registers the address in getAllMarkets().
- Maintains the canonical list of all deployed markets
- Enforces minimum liquidity (10 USDC/EURC) and valid resolution dates
- Charges a 5 USDC/EURC creation fee routed to
FeeDistributor - Supports pausing all new market creation in an emergency
PredictMarket
One contract per question. Holds the YES and NO liquidity pools, tracks every user's share position, enforces staking rules, and executes payouts. It is the only contract users interact with directly when staking or claiming.
- Stores
yesPool,noPool,totalStaked - Tracks
UserPositionper address:yesShares,noShares,totalStaked,claimed - Maintains a
_participantsarray for enumeration (leaderboard) - Enforces
MIN_STAKE = 1e6(1 USDC),MAX_STAKE_BPS = 1000(10% of pool) - Only accepts calls from
resolver(the oracle) for resolution
PredictOracle
A modular resolution authority with a mandatory 24-hour dispute window. Separates the act of proposing an outcome from finalizing it, giving the community time to dispute incorrect resolutions.
- Only the owner or
authorizedResolverscan propose outcomes - Anyone can call
finalizeResolution()after the dispute window - Disputed resolutions require an owner override via
overrideResolution()
FeeDistributor
Receives all protocol fees (1% stake fee + 0.5% claim fee) and splits them ondistributeFees():
| Share | Destination |
|---|---|
| 60% | UNIT buyback-and-burn |
| 20% | LP reward pool |
| 20% | Treasury |
Before UNIT token launches, the 60% buyback share accumulates in apendingBuyback mapping per currency. The owner callsexecuteBuyback() once UNIT is live.
AMM Pricing Model
Odds are determined by a constant-product AMM. The implied probability of YES at any moment is:
yesPrice = noPool / (yesPool + noPool) // P(YES) noPrice = yesPool / (yesPool + noPool) // P(NO)
Prices are expressed in basis points (0–10,000). When a user stakes, the pool they chose grows, which lowers the odds for that side and raises them for the other.
Share Issuance
Shares represent a proportional claim on the winning pool. They are issued against theopposite pool to reward early stakers on the less-popular side:
// Stake YES netAmount = amount - protocolFee // 1% fee deducted shares = netAmount × noPool / yesPool // more shares when noPool is large // Stake NO netAmount = amount - protocolFee shares = netAmount × yesPool / noPool
Payout Calculation
After resolution, each winner's payout is proportional to their share of the winning pool:
grossPayout = winningShares × totalPool / totalWinningShares claimFee = grossPayout × 0.5% netPayout = grossPayout - claimFee
totalPool is the sum of both pools (all money staked). Winners split the entire pool proportionally — losers receive nothing.
Frontend Architecture
The frontend is built with Next.js 15 (App Router), TypeScript, Tailwind CSS, wagmi v2, viem, and TanStack Query. All on-chain reads use wagmi's useReadContractswith multicall batching — a single RPC round-trip fetches data for all markets.
Data Flow
useOnChainMarkets └── factory.getAllMarkets() → address[] └── market.getMarketInfo() → metadata (batched) └── market.yesPool / noPool / ... → pool state (batched, 15s refresh) useOnChainLeaderboard └── market.getParticipants() → address[] per market └── market.getUserPosition(addr) → position per user×market (batched) usePortfolio └── market.getUserPosition(wallet) → position per market (batched) useUserPosition └── market.getUserPosition(wallet) → single market position └── market.estimatePayout(wallet) → live payout estimate
State Management
A Zustand store (predictStore) holds UI state: active filter/sort/category, search query, stake drawer open state, and optimistic odds (applied immediately after a stake confirms, before the next RPC refresh).
Cache Invalidation
After any write transaction (stake, claim, create market), the hook callsqueryClient.invalidateQueries() with no filter. This busts wagmi's entire internal read cache, causing all useReadContract hooks to refetch on the next render. Pool state typically updates within one block (~1 second on Arc).