--- title: SBET V2 Architecture description: High-level architecture of the SBET V2 protocol — contracts, state machine, grader oracle, dispute flow, and custody boundaries. canonical: https://sbettoken.org/docs/protocol-v2/architecture version: 2.0.0 updated: 2026-04-05 --- # Architecture SBET V2 is a decentralized sports-betting and prediction-market protocol on EVM L2 (targeting Base / Arbitrum). It resolves outcomes via a staked, VRF-selected panel of AI graders, protects users with a three-tier pause architecture, and settles disputes through a bond-escalation state machine with a 5-of-9 arbiter multisig as final court. This document is the map. Pair it with [state-machine.md](./state-machine.md) for transition-by-transition detail, and [security-model.md](./security-model.md) for the economic security story. --- ## Design principles - **Non-custodial where possible.** User stakes live in [`SBETTreasuryV2`](#sbettreasuryv2); Core never holds custody. - **Fail-closed.** Every write is guarded by explicit match-state, pool-type, and pause checks. There is no generic `_transition()` hook — each transition writes inline and emits. - **Immutable per-match parameters.** `PoolType`, `panelRoot`, `panelSize`, and `quorumM` are set once at `registerMatch` and never mutated. - **Defense in depth.** OpenZeppelin `ReentrancyGuard`, `SafeERC20`, `AccessControl`, `Pausable`, plus a CEI (Checks-Effects-Interactions) discipline in every external function. - **Separation of authority.** Graders attest; arbiter rules; guardian pauses; governance rotates keys. No single role can finalize an outcome unilaterally outside of the 5-of-9 arbiter emergency override. --- ## Contract map ``` ┌─────────────────────────────┐ │ SBETCoreV2 │ ← match state machine │ (match registry + │ │ bet accounting + │ │ finalize / dispute) │ └──┬───────────┬──────────┬───┘ │ │ │ receiveStake │ │ │ openDispute releasePayout │ │ │ refundVoid │ │ │ distributeSlash │ │ ▼ │ ▼ ┌────────────────┐ │ ┌────────────────┐ │ SBETTreasuryV2 │ │ │ DisputeManager │ │ (custody + │ │ │ (bond ladder │ │ auto-claim + │ │ │ + arbiter) │ │ slash dist.) │ │ └───────┬────────┘ └──────┬─────────┘ │ │ │ │ │ notifyReward │ │ slashWithChallenger ▼ ▼ ▼ ┌──────────────┐ ┌────────────────────┐ │ StakerRewards│ │ GraderRegistryV2 │ │ (Masterchef) │ │ (stake + submit + │ └──────────────┘ │ slash sink) │ └────────────────────┘ ┌──────────────────┐ │ GuardianCouncil │ ← category + global pause │ (3-of-5 cat, │ (Core reads isPaused) │ 5-of-9 global) │ └──────────────────┘ ┌─────────────────────────────────────────────────┐ │ PREDICTION MARKET LAYER │ │ │ │ ┌───────────────────┐ ┌──────────────────┐ │ │ │ PredictionMarketV2│──▶│ PredictionAMMV2 │ │ │ │ (lifecycle + │ │ (LMSR pricing + │ │ │ │ metadata + │ │ ERC-1155 shares +│ │ │ │ resolution sync) │ │ autonomous payout│ │ │ └────────┬──────────┘ └──────────┬───────┘ │ │ │ │ │ │ │ registerMatch │ fees │ │ ▼ ▼ │ │ SBETCoreV2 SBETTreasuryV2 │ └─────────────────────────────────────────────────┘ ``` Every arrow is an external call crossing a trust boundary. Core calls Treasury; Treasury calls StakerRewards; Grader registry calls Treasury's slash distributor; DisputeManager calls Core's `resolveDispute`. No cycles. The prediction market layer (PredictionMarketV2 + PredictionAMMV2) sits on top of Core and Treasury. Market registers matches in Core via `MATCH_REGISTRAR_ROLE`. AMM routes protocol fees to Treasury. AMM holds trading collateral directly (LMSR requires instant balance reads for pricing). --- ## Contract responsibilities ### SBETCoreV2 The state machine. Holds the `MatchInfo` for every match, routes stakes to Treasury, and coordinates the finalize / dispute / void transitions. Core **does not hold custody** — every token movement is delegated to Treasury. Key responsibilities: - `registerMatch` — commits `PoolType`, `panelRoot`, `panelSize`, `quorumM` immutably - `placeBet` (parimutuel) / `openSignedPosition` (long-short) — enforce per-match cap - `proposeOutcome` — transition `Open → Proposed`, compute challenge-window tier - `finalize` / `openDispute` — transition out of `Proposed` - `resolveDispute` — called by DisputeManager to transition `Disputed → Finalized|Voided` - `claimParimutuel` / `claimSignedPosition` / `claimVoidRefund` — trigger Treasury payouts - `overrideOutcome` — arbiter emergency path (5-of-9) - `lockMatch` / `releaseMatchLock` — bond-backed 24h per-match pause Source: [`SBETCoreV2.sol`](https://github.com/sbettoken/contracts/blob/main/v2/SBETCoreV2.sol) · Interface: [`ISBETCoreV2.sol`](https://github.com/sbettoken/contracts/blob/main/v2/interfaces/ISBETCoreV2.sol) ### SBETTreasuryV2 Sole custodian of user stakes. Exposes four entry points to Core (`receiveStake`, `releasePayout`, `refundVoid`, `autoClaim`), a slash distributor to GraderRegistry, and the ERC-2771 meta-transaction surface for gasless auto-claim. Key responsibilities: - **Custody**: pulls stakes via `safeTransferFrom`, releases via `safeTransfer` - **Auto-claim**: meta-tx via Biconomy forwarder (2% SBET / 3.5% other tokens) - **Slash distributor**: 50% challenger / 20% treasury / 20% stakers / 10% bounty - **Token allowlist**: governance-managed set of supported settlement tokens The trusted forwarder is **IMMUTABLE** (set in the constructor via `ERC2771Context`). Rotating the forwarder requires a redeploy, by design — this eliminates signer-rotation replay vectors. Source: [`SBETTreasuryV2.sol`](https://github.com/sbettoken/contracts/blob/main/v2/SBETTreasuryV2.sol) ### GraderRegistryV2 The grader staking and attestation contract. Stores grader stakes (minimum $25k SBET), per-match grade submissions with confidence scores, and is the sole source-of-truth for grader status. Key responsibilities: - Grader lifecycle: `register` → `Active` → `Cooldown` (7d) → `Inactive` - `submitGrade(matchId, outcome, confidenceBps)` — one submission per grader per match - `aggregateConfidence(matchId)` — on-chain plurality calculator returning `(avgConf, dissents, signers)` - `slashWithChallenger(grader, challenger, matchId, amount, reasonCode)` — slashes stake, forwards to Treasury distributor Slashing flow: 1. `DisputeManager` (holding `SLASHER_ROLE`) calls `slashWithChallenger` 2. Registry deducts stake, transfers `amount` to Treasury 3. Registry calls `Treasury.distributeSlash` 4. Treasury splits 50/20/20/10 to challenger/treasury/stakers/bounty Source: [`GraderRegistryV2.sol`](https://github.com/sbettoken/contracts/blob/main/v2/GraderRegistryV2.sol) ### DisputeManager The bond-escalation state machine. Opened by a challenger who disagrees with a proposed outcome; escalates through up to 3 rounds with 24h response windows and 2× bond doubling per round before final resolution by the 5-of-9 arbiter. State: `Open → Escalated → AwaitingArbiter → Resolved` (or `Expired` on timeout). Initial bond = **1% of match TVL**, clamped to **[$5k, $50k]**. Maximum cumulative bond = 8× initial (post round 3). Source: [`DisputeManager.sol`](https://github.com/sbettoken/contracts/blob/main/v2/DisputeManager.sol) ### GuardianCouncil Three-tier pause controller. Handles **category** and **global** pauses; the per-match lock lives inside Core itself (bond-backed, permissionless, 24h max). | Tier | Quorum | Max Duration | Special Rule | |------|-------:|-------------:|--------------| | **Match** | 1 (bond) | 24h | Anyone can lock with $1k SBET bond | | **Category** | 3-of-5 | 72h | Per-category (sport/league) | | **Global** | 5-of-9 | 14d | 72h unpause timelock | Source: [`GuardianCouncil.sol`](https://github.com/sbettoken/contracts/blob/main/v2/GuardianCouncil.sol) ### SBETStakerRewards Pull-based MasterChef-style accumulator. Stakers deposit SBET → receive shares 1:1 → earn a pro-rata share of slashed bonds (20% of every slash) and auto-claim fees. Rewards are allowlisted by governance. Auto-claim fees and slash funds flow in via `notifyReward` from Treasury. Source: [`SBETStakerRewards.sol`](https://github.com/sbettoken/contracts/blob/main/v2/SBETStakerRewards.sol) ### PredictionMarketV2 The prediction market lifecycle adapter. Creates markets that register as `PARIMUTUEL` matches in V2 Core, stores prediction-market-specific metadata (question, outcome labels, category, resolution source), and bridges Core's finalization state into the market layer via `syncResolution` / `syncVoid`. Key responsibilities: - `createMarket` — validates parameters, registers a Core match with a domain-separated `coreMatchId`, optionally seeds AMM liquidity - `syncResolution` — permissionless, idempotent one-way sync of Core's finalized outcome into the market - `syncVoid` — same pattern for voided markets - `syncAndDistribute` — keeper convenience: sync resolution + push batch payouts in a single tx - `syncAndRefund` — same pattern for voided markets with batch refunds - `isMarketTradeable` — checks Core match state, pause tier, and resolution time - `getDerivedStatus` — real-time status derived from Core's `MatchState` + local metadata **Domain separation** [Decision #14]: prediction market IDs are namespaced to avoid collision with sportsbook match IDs. The formula is: ```solidity coreMatchId = uint256(keccak256(abi.encode(PREDICTION_DOMAIN, marketId))) // where PREDICTION_DOMAIN = keccak256("SBET.PredictionMarket.v2") ``` This is deterministic and requires no coordination between sportsbook and market registrars. The domain prefix is a compile-time constant. PredictionMarketV2 **does not call** `Core.placeBet()` or `Core.claimParimutuel()`. Trading flows through the AMM, which handles its own collateral accounting. Source: [`PredictionMarketV2.sol`](https://github.com/sbettoken/contracts/blob/main/v2/PredictionMarketV2.sol) · Interface: [`IPredictionMarketV2.sol`](https://github.com/sbettoken/contracts/blob/main/v2/interfaces/IPredictionMarketV2.sol) ### PredictionAMMV2 LMSR (Logarithmic Market Scoring Rule) automated market maker with ERC-1155 outcome shares. Holds trading collateral directly — unlike Core matches, where Treasury custodies stakes, the AMM must hold collateral for LMSR pricing (instant `balanceOf` reads required during cost computation). Key responsibilities: - `seedLiquidity` — initializes the LMSR pool with a `b` parameter and collateral - `buy` / `sell` — LMSR-priced share trading with symmetric fees (Polymarket pattern) - `splitCollateral` / `mergeCollateral` — create/destroy complete sets of outcome shares - `redeem` / `redeemFor` / `batchRedeemFor` — pro-rata payout from pool collateral to winning-share holders - `refund` / `refundFor` / `batchRefundFor` — pro-rata refund on voided markets - `removeLiquidity` — LP withdrawal (30-day grace period, cannot drain collateral owed to unclaimed winners) **Autonomous payouts** [Decision #15]: keepers push payouts to holders without requiring user action. `redeemFor(marketId, holder)` pays out any holder; `batchRedeemFor(marketId, holders)` does this for up to 100 holders per call with per-holder failure isolation (try/catch). The Market contract's `syncAndDistribute` combines resolution sync + batch payout in one tx. **Fee routing**: protocol fees go to `SBETTreasuryV2` (participate in staker rewards and bounty pool). Creator fees go directly to the market creator. Symmetric fee formula: `fee = totalFeeBps * min(price, 1-price) * amount`. **PREDICTION_MARKET reference** [HIGH-1]: the AMM's reference to PredictionMarketV2 is governance-settable with a **48-hour timelock** (propose → wait 48h → execute, 14-day grace window). This replaces the original immutable reference that would have forced full AMM redeploy on any Market contract fix. Source: [`PredictionAMMV2.sol`](https://github.com/sbettoken/contracts/blob/main/v2/PredictionAMMV2.sol) · Interface: [`IPredictionAMMV2.sol`](https://github.com/sbettoken/contracts/blob/main/v2/interfaces/IPredictionAMMV2.sol) --- ## Autonomous payout architecture V2 prediction markets use a **push-based payout model**. Users do not need to claim winnings — the protocol pushes payouts to holders via keeper bots. ``` ┌────────────────────────────────────┐ │ Keeper / Bot │ └─────────┬──────────────────────────┘ │ │ syncAndDistribute(marketId, holders[]) ▼ ┌─────────────────────┐ │ PredictionMarketV2 │ │ 1. _syncResolution │ ← idempotent, reads Core │ 2. amm.batchRedeem │ └─────────┬───────────┘ │ │ batchRedeemFor(marketId, holders[]) ▼ ┌─────────────────────┐ │ PredictionAMMV2 │ │ per holder: │ │ try { │ │ burn ERC-1155 │ │ transfer payout │ │ } catch { │ ← failure isolated │ emit failed │ │ skip holder │ │ } │ └─────────────────────┘ ``` Key properties: - **Idempotent sync**: `syncResolution` is safe to call multiple times — subsequent calls are no-ops - **Failure isolation**: a failed transfer to one holder does not revert the entire batch - **Max batch size**: 100 holders per call (gas safety bound) - **Snapshot invariant**: total winning shares are snapshotted on first redemption to prevent burn-reduces-totalSupply accounting drift - **Voided markets**: same pattern via `syncAndRefund` + `batchRefundFor`, with a separate grand-total snapshot covering all outcomes --- ## ChallengeWindowLib Pure library (no storage, no external calls) that computes the **dynamic tiered challenge window** after every `proposeOutcome` call. ```solidity function computeTier( uint256 n, // panel size uint256 m, // quorum (M in M-of-N) uint256 signers, // graders who signed the proposal uint256 dissents, // graders who disagreed uint16 confidenceBps, // AI confidence (0..10_000) uint8 sourceAgreement // independent sources agreeing (0..3) ) internal pure returns (Tier tier, uint32 windowSeconds); ``` Tier rules, applied in this priority order: | Tier | Duration | Condition | |------|---------:|-----------| | **Contentious** | 4h | `dissents >= 2` OR `confidenceBps < 6_000` | | **OneDissent** | 2h | `dissents == 1` | | **Fast** | 90s | unanimous (`signers == n`, `dissents == 0`) AND `confidenceBps >= 9_500` AND `sourceAgreement >= 2` | | **QuorumClean** | 15min | quorum met, zero dissent, but not fast-path eligible | Constants in source: ```solidity uint32 TIER_FAST_SECONDS = 90; uint32 TIER_QUORUM_CLEAN_SECONDS = 15 minutes; uint32 TIER_ONE_DISSENT_SECONDS = 2 hours; uint32 TIER_CONTENTIOUS_SECONDS = 4 hours; uint16 CONFIDENCE_FAST_BPS = 9_500; uint16 CONFIDENCE_LOW_BPS = 6_000; uint8 SOURCE_AGREEMENT_FAST = 2; ``` See [security-model.md#challenge-windows](./security-model.md#challenge-windows) for the economic reasoning behind these numbers. --- ## PoolTypes V2 supports **two immutable pool accounting regimes**, locked per match at `registerMatch`: ### SIGNED_POSITION (binary only) V1-compatible long/short semantics. Binary only — `numOutcomes` **must** equal 2, enforced by `SignedPositionMustBeBinary` error in `registerMatch`. Dual-position is allowed: a single bettor may hold both a long and a short position on the same match (useful for hedging). Exposure cap is `max(longPool, shortPool)`, **not** the sum — they offset at settlement. Payoff math in `SignedPositionLib`: ``` payoff_long = |position| × finalPrice / MAX_PRICE payoff_short = |position| × (MAX_PRICE - finalPrice) / MAX_PRICE ``` Where `MAX_PRICE = 1e9` (V1 parity). The V2 Core maps the resolved outcome index onto the V1 price domain linearly: ``` finalPrice = outcome × MAX_PRICE / (numOutcomes - 1) ``` For a binary match: `outcome 0 → price 0`, `outcome 1 → MAX_PRICE`. ### PARIMUTUEL (N-outcome) Single outcome per bettor per match. Payout is proportional to the winning pool: ``` payout = userStake × totalAccepted / winningOutcomeStake ``` Constraint: once a user has placed a stake on outcome `X` for a match, subsequent bets on the same match **must** be on outcome `X`. Attempting to bet a different outcome reverts with `ExistingPositionDifferentOutcome` (Bug Fix #1). For parimutuel pools, the per-match cap bounds the **full pot** (`totalAccepted`); for signed-position pools it bounds `max(longPool, shortPool)`. --- ## VRF panel Every match is graded by a **VRF-selected sub-panel**. Panel size defaults to **13**; quorum to **9** (tolerates 4 dropouts). These can be overridden per-match at `registerMatch`. ### Commitment At `registerMatch`, the caller (`MATCH_REGISTRAR_ROLE`) supplies: - `panelRoot` — `keccak256` Merkle root of the sorted panel addresses - `panelSize` — `N` in M-of-N - `panelQuorumM` — `M` in M-of-N These are stored immutably in `MatchInfo`. The root commits to a specific set of panel members drawn from the active grader pool using a VRF-supplied seed. ### VRF seeding ``` registerMatch() → emit VRFSeedRequested(matchId, 0) ↓ off-chain: Chainlink VRF v2.5 request ↓ fulfillVRFSeed(matchId, seed) → emit VRFSeedFulfilled(matchId, seed, false) ↓ off-chain: drawer computes panel, publishes Merkle root ``` If VRF does not fulfill within `VRF_FALLBACK_DELAY = 24h`, **anyone** may call `seedFromBlockhashFallback(matchId)`, which seeds with `blockhash(matchCreationBlock)`. This is a liveness guarantee; the challenge-window tier math limits the blast radius of a potentially miner-influenced blockhash. Current state: the VRFCoordinatorV2Plus wiring is stubbed. A privileged `VRF_FULFILLER_ROLE` off-chain relay pushes the seed in the pre-deployment build. The production deploy will replace the role gate with `require(msg.sender == VRF_COORDINATOR, ...)` and implement `fulfillRandomWords`. ### Membership verification Graders supply a Merkle proof when signing a grade; `verifyPanelMembership` validates against `panelRoot`: ```solidity function verifyPanelMembership( uint256 matchId, address grader, bytes32[] calldata proof ) external view returns (bool); ``` Leaves are `keccak256(abi.encode(grader))`. --- ## State machine (overview) ```mermaid stateDiagram-v2 [*] --> None None --> Open : registerMatch Open --> Proposed : proposeOutcome Open --> Voided : voidMatch(NATURAL/PROTOCOL) Open --> Finalized : overrideOutcome (arbiter) Proposed --> Finalized : finalize (window elapsed) Proposed --> Disputed : openDispute (during window) Proposed --> Voided : voidMatch(NATURAL/PROTOCOL) Proposed --> Finalized : overrideOutcome (arbiter) Disputed --> Finalized : resolveDispute (arbiter, !void) Disputed --> Voided : resolveDispute (arbiter, void) Disputed --> Finalized : overrideOutcome (arbiter) Finalized --> [*] : claimParimutuel / claimSignedPosition Voided --> [*] : claimVoidRefund ``` See [state-machine.md](./state-machine.md) for transition-by-transition detail, including the actor, role, event, and revert conditions for each edge. --- ## Dispute flow (overview) ```mermaid sequenceDiagram participant User participant Core participant DM as DisputeManager participant A as Arbiter (5-of-9) participant GR as GraderRegistry participant T as Treasury User->>Core: openDispute(matchId, counterOutcome) Core->>DM: openDispute(...) DM->>User: pull initial bond (1% TVL, [5k,50k]) Note over DM: state = Open, awaiting Defender User->>DM: respond(disputeId) [defender] DM->>User: pull 2× bond Note over DM: round=1, awaiting Challenger User->>DM: respond(disputeId) [challenger] DM->>User: pull 4× bond Note over DM: round=2, awaiting Defender User->>DM: respond(disputeId) DM->>User: pull 8× bond Note over DM: round=3 → state = AwaitingArbiter A->>DM: arbiterResolve(disputeId, winner, finalOutcome, void, evidenceHash) DM->>User: refund winner's contributions DM->>User: transfer loser's pot to winner DM->>Core: resolveDispute(matchId, outcome, void, DISPUTE) Core->>Core: state = Finalized | Voided Note over GR,T: future: DM→GR.slashWithChallenger →
GR→T.distributeSlash ``` See [security-model.md#dispute-bond-escalation](./security-model.md#dispute-bond-escalation) for the full bond ladder and expected-value analysis. --- ## Pause architecture The three pause tiers compose. If `Global` is active, every match is paused. If `Global` is inactive but the match's `Category` is paused, the match is paused. If both are inactive but a `Match` lock is active, the match is paused. Core resolves the active tier via `pauseTierOf(matchId)`: ```solidity function pauseTierOf(uint256 matchId) public view returns (PauseTier) { if (GUARDIANS.isGloballyPaused()) return PauseTier.Global; bytes32 cat = _matches[matchId].categoryId; if (cat != bytes32(0) && GUARDIANS.isCategoryPaused(cat)) return PauseTier.Category; MatchLock memory ml = _matchLocks[matchId]; if (ml.active && block.timestamp < ml.expiresAt) return PauseTier.Match; return PauseTier.None; } ``` Pause blocks: `placeBet`, `openSignedPosition`, `proposeOutcome`, `finalize`. It does **not** block: `claim*` (winners can always exit on finalize), `claimVoidRefund` (users can always recover principal on void), `lockMatch`, `voidMatch`. See [security-model.md#pause-tiers](./security-model.md#pause-tiers). --- ## Custody and trust boundaries | Asset | Held by | Released by | Trust assumption | |-------|---------|-------------|------------------| | User stakes | `SBETTreasuryV2` | `CORE_ROLE` only | Core cannot be spoofed (role-gated) | | Grader stakes | `GraderRegistryV2` | `SLASHER_ROLE` or grader (after cooldown) | Registry is sole stake custodian | | Dispute bonds | `DisputeManager` | DisputeManager winner-payout | Per-dispute bond book | | Match-lock bonds | `SBETCoreV2` | Permissionless on expiry, governance on slash | 24h max, slashable on malicious lock | | Staker deposits | `SBETStakerRewards` | Staker `unstake` (no lockup) | Pull-based rewards | | AMM trading collateral | `PredictionAMMV2` | AMM `redeem` / `redeemFor` / `removeLiquidity` | LMSR requires direct custody for pricing | | AMM protocol fees | `SBETTreasuryV2` | Routed on each trade | Participates in staker reward distribution | Core holds **no** user funds. This is a hard boundary: if Core is compromised, user stakes are protected by the role gate on Treasury. AMM collateral is held separately from Core-managed stakes — AMM pools are independent custody domains. --- ## Compiler and toolchain ```toml # foundry.toml [profile.default] solc_version = "0.8.24" evm_version = "paris" # L2-portable; Arbitrum/Base support up to Cancun via_ir = true optimizer = true optimizer_runs = 200 ``` OpenZeppelin Contracts v5.x pinned. No custom assembly outside of `unchecked { ++i; }` loop counters. --- ## Next - [State machine](./state-machine.md) — every transition, actor, and event - [Security model](./security-model.md) — economic security, slashing math, attack scenarios - [Contracts reference](./contracts-reference.md) — API-level function reference