--- title: SBET V2 Match State Machine description: Every state transition in the SBET V2 match lifecycle — trigger, actor, role, event, and revert conditions. canonical: https://sbettoken.org/docs/protocol-v2/state-machine version: 2.0.0 updated: 2026-04-05 --- # Match State Machine Every match in SBET V2 moves through a deterministic lifecycle of six states. This document is the exhaustive transition table: for every edge in the state graph it lists the trigger, the actor, the required role, the event emitted, and the guards that can revert. Pair this with [architecture.md](./architecture.md) for the high-level diagram and [contracts-reference.md](./contracts-reference.md) for the full NatSpec. --- ## States Defined in [`ISBETCoreV2.MatchState`](./contracts-reference.md#matchstate): | # | State | Meaning | |---|-------|---------| | 0 | `None` | Uninitialized — matchId unused | | 1 | `Open` | Accepting bets | | 2 | `Proposed` | Outcome proposed, challenge window running | | 3 | `Disputed` | Bond-backed challenge raised, DisputeManager owns resolution | | 4 | `Finalized` | Outcome locked, claims enabled | | 5 | `Voided` | Refunds enabled (principal + fees) | State is stored in `MatchInfo.state` (single byte). The `PoolType` of a match is **not** a state — it is an immutable attribute set at registration. --- ## Full diagram ```mermaid stateDiagram-v2 direction LR [*] --> None None --> Open : registerMatch Open --> Proposed : proposeOutcome
(GRADER_AGGREGATOR_ROLE) Open --> Voided : voidMatch
(GOVERNANCE_ROLE, NATURAL/PROTOCOL) Open --> Finalized : overrideOutcome
(ARBITER_ROLE) Proposed --> Finalized : finalize
(permissionless, window elapsed) Proposed --> Disputed : openDispute
(permissionless + bond) Proposed --> Voided : voidMatch
(GOVERNANCE_ROLE) Proposed --> Finalized : overrideOutcome
(ARBITER_ROLE) Disputed --> Finalized : resolveDispute
(DisputeManager, !void) Disputed --> Voided : resolveDispute
(DisputeManager, void=true) Disputed --> Finalized : overrideOutcome
(ARBITER_ROLE) Finalized --> [*] : claimParimutuel / claimSignedPosition Voided --> [*] : claimVoidRefund ``` --- ## Transition table Every row corresponds to exactly one external function in `SBETCoreV2`. The leftmost column is the source state; the caller is the actor; events listed are those emitted by Core (DisputeManager and Treasury emit their own events on the nested calls). ### 1. `None → Open` | Field | Value | |-------|-------| | **Function** | `registerMatch(matchId, categoryId, numOutcomes, poolType, panelRoot, panelSize, panelQuorumM)` | | **Actor** | match registrar (off-chain relay / ops script) | | **Role** | `MATCH_REGISTRAR_ROLE` | | **Events** | `MatchRegistered`, `MatchStateTransitioned(None→Open)`, `VRFSeedRequested(matchId, 0)` | | **Guards** | `matchId != 0`; `numOutcomes ∈ [2, 255]`; `PoolType.SIGNED_POSITION ⇒ numOutcomes == 2`; `panelQuorumM ∈ (0, panelSize]`; `panelRoot != 0`; match does not already exist | | **Reverts** | `SignedPositionMustBeBinary`, `InvalidPanelCommitment`, `"Core: match exists"` | [Decision #1, #2, #12] `poolType` is committed **once** here and never mutated. The panel Merkle root seals the VRF-drawn grader set for this specific match. ### 2. `Open → Proposed` | Field | Value | |-------|-------| | **Function** | `proposeOutcome(matchId, outcome, confidenceBps, sourceAgreement, signers, dissents)` | | **Actor** | grader aggregator (off-chain) | | **Role** | `GRADER_AGGREGATOR_ROLE` | | **Events** | `OutcomeProposed(matchId, outcome, tier, unlockAt, confidenceBps, proposer)`, `MatchStateTransitioned(Open→Proposed)` | | **Guards** | state == `Open`; `outcome < numOutcomes`; not paused; `signers + dissents ≤ panelSize`; `signers >= quorumM` | | **Reverts** | `MatchNotOpen`, `InvalidOutcome`, `MatchPaused`, `CategoryPaused`, `GlobalPaused`, `"Core: tally overflows panel"`, `"Core: below quorum"` | [Decision #1, Bug Fix #3] `panelSize` (not `signers`) is the N in M-of-N; using `signers` would collapse N == M and defeat the dynamic challenge-window tier. The challenge-window tier is computed inline via `ChallengeWindowLib.computeTier` and stored in `MatchInfo.unlockAt`. See [architecture.md#challengewindowlib](./architecture.md#challengewindowlib). ### 3. `Proposed → Finalized` (natural) | Field | Value | |-------|-------| | **Function** | `finalize(matchId)` | | **Actor** | anyone | | **Role** | permissionless | | **Events** | `MatchFinalized(matchId, outcome, finalizedAt)`, `MatchStateTransitioned(Proposed→Finalized)` | | **Guards** | state == `Proposed`; `block.timestamp >= unlockAt`; not paused | | **Reverts** | `MatchNotProposed`, `ChallengeWindowActive(matchId, unlockAt)`, pause errors | This is the happy path: the challenge window elapses without a dispute, anyone calls `finalize`, and claims become enabled. ### 4. `Proposed → Disputed` | Field | Value | |-------|-------| | **Function** | `openDispute(matchId, counterOutcome) returns (disputeId)` | | **Actor** | any user with bond | | **Role** | permissionless + bond approval on `DisputeManager` | | **Events** | `MatchStateTransitioned(Proposed→Disputed)`, `DisputeEscalated(matchId, DISPUTES, bond)`, plus `DisputeOpened` from DisputeManager | | **Guards** | state == `Proposed`; `block.timestamp < unlockAt` (challenge window still active); `counterOutcome < numOutcomes`; `counterOutcome != m.outcome` | | **Reverts** | `MatchNotProposed`, `ChallengeWindowActive` (inverse — must be before unlock), `InvalidOutcome` | **Critical**: the caller must have approved **DisputeManager** (not Core) for the initial bond amount before calling `openDispute`. Core forwards the call to `DISPUTES.openDispute(...)`, which pulls the bond via `safeTransferFrom`. Initial bond = `1% of totalAccepted`, clamped to `[5_000e18, 50_000e18]`. See [security-model.md#dispute-bond-escalation](./security-model.md#dispute-bond-escalation). ### 5. `Disputed → Finalized | Voided` (via DisputeManager) | Field | Value | |-------|-------| | **Function** | `resolveDispute(matchId, newOutcome, void_, voidReason)` | | **Actor** | `DisputeManager` (contract-to-contract call only) | | **Role** | `msg.sender == address(DISPUTES)` | | **Events** | If `void_`: `MatchVoided(matchId, reason)`, `MatchStateTransitioned(Disputed→Voided)`. Else: `MatchFinalized(matchId, newOutcome, finalizedAt)`, `MatchStateTransitioned(Disputed→Finalized)` | | **Guards** | caller == DisputeManager; state == `Disputed`; if non-void: `newOutcome < numOutcomes` | | **Reverts** | `NotDisputeManager`, `InvalidMatchState`, `"Core: bad outcome"` | DisputeManager calls this after either `arbiterResolve` (5-of-9 ruling) or `claimByDefault` (counterparty missed deadline). ### 6. `Open | Proposed | Disputed → Finalized` (arbiter override) | Field | Value | |-------|-------| | **Function** | `overrideOutcome(matchId, newOutcome, evidenceHash)` | | **Actor** | 5-of-9 Safe multisig | | **Role** | `ARBITER_ROLE` | | **Events** | `MatchFinalized`, `MatchStateTransitioned(prev→Finalized)` | | **Guards** | state ∈ `{Open, Proposed, Disputed}`; `newOutcome < numOutcomes` | | **Reverts** | `"Core: bad state for override"`, `"Core: bad outcome"` | [Decision #5] Emergency path when the normal propose/dispute cycle is not applicable (e.g., oracle failure long after match start). The arbiter's 5-of-9 quorum is enforced **off-chain inside Safe** — this contract trusts `ARBITER_ROLE` and performs no on-chain signature verification of its own. `evidenceHash` is intentionally not emitted in a dedicated event to save calldata; off-chain indexers recover it via topic-index search. ### 7. `Open | Proposed → Voided` (governance) | Field | Value | |-------|-------| | **Function** | `voidMatch(matchId, reason)` | | **Actor** | governance | | **Role** | `GOVERNANCE_ROLE` | | **Events** | `MatchVoided(matchId, reason)`, `MatchStateTransitioned(prev→Voided)` | | **Guards** | state ∈ `{Open, Proposed}`; `reason != VoidReason.DISPUTE` (dispute voids flow through `resolveDispute`) | | **Reverts** | `"Core: void wrong state"`, `"Core: use resolveDispute"` | [Decision #9, #11] `VoidReason` enum: `NATURAL` (game cancelled), `PROTOCOL` (protocol-level abort), `DISPUTE` (arbiter-ruled unresolvable — only set via the dispute path). ### 8. `Finalized → [*]` (claim) Three claim paths, one per pool type plus void refund. None of them transition state — they zero out the caller's stake accounting and request payout from Treasury. #### 8a. PARIMUTUEL claim | Field | Value | |-------|-------| | **Function** | `claimParimutuel(matchId, token)` | | **Actor** | bettor | | **Guards** | state == `Finalized`; `poolType == PARIMUTUEL`; `_stakes[matchId][msg.sender][token] > 0` | | **Reverts** | `MatchNotFinalized`, `WrongPoolType`, `"Core: no stake"` | | **Payout formula** | `userStake × totalAccepted / winningOutcomeStake` (if bettor's outcome == resolved); `0` otherwise | #### 8b. SIGNED_POSITION claim | Field | Value | |-------|-------| | **Function** | `claimSignedPosition(matchId, token)` | | **Actor** | bettor | | **Guards** | state == `Finalized`; `poolType == SIGNED_POSITION`; `longPos > 0 \|\| shortPos > 0` | | **Reverts** | `MatchNotFinalized`, `WrongPoolType`, `"Core: no position"` | | **Payout formula** | `longPayoff + shortPayoff` — see [architecture.md#signed_position-binary-only](./architecture.md#signed_position-binary-only) | Both long and short are settled in a single call — a hedged user receives the net sum from both sides. ### 9. `Voided → [*]` (void refund) | Field | Value | |-------|-------| | **Function** | `claimVoidRefund(matchId, token)` | | **Actor** | bettor | | **Guards** | state == `Voided`; stake or position > 0 for caller | | **Reverts** | `InvalidMatchState`, `"Core: no stake"`, `"Core: no position"` | | **Refund** | principal (+ fees, pending fee-schedule lock-in) | [Decision #9] Void refund always returns principal + fees, regardless of the `VoidReason`. For parimutuel: refunds `_stakes[matchId][msg.sender][token]`. For signed-position: refunds `longPos + shortPos`. See the `claimVoidRefund` NatSpec — the fee-portion is flagged as a TODO until the per-`PoolType` fee schedule is finalized. --- ## Match-lock (sub-state) In addition to the match state, a **match-lock** overlay can block bet placement and proposal for up to 24h. ```mermaid stateDiagram-v2 direction LR Unlocked --> Locked : lockMatch
(anyone, 1k SBET bond) Locked --> Unlocked : releaseMatchLock
(anyone after expiry) Locked --> Slashed : releaseMatchLock
(GOVERNANCE_ROLE, maliciousSlash=true) ``` | Function | Actor | Effect | |----------|-------|--------| | `lockMatch(matchId)` | anyone with 1k SBET | Locks match for 24h; state must be `Open` or `Proposed` | | `releaseMatchLock(matchId, false)` | anyone after expiry | Refunds bond to locker | | `releaseMatchLock(matchId, true)` | `GOVERNANCE_ROLE` | Slashes bond to Treasury bounty pool | Bond: `MATCH_LOCK_BOND = 1_000e18` SBET. Duration: `MATCH_LOCK_DURATION = 24 hours`. The match-lock overlay is reflected by `pauseTierOf(matchId) == PauseTier.Match` while active. --- ## VRF seed (sub-state) Orthogonal to the match state: every match has a VRF seed either fulfilled or pending. This does not gate state transitions today — it's used by the off-chain panel drawer to compute the committed `panelRoot`. Future versions may require seed fulfillment before `proposeOutcome`. ```mermaid stateDiagram-v2 direction LR Pending --> Fulfilled : fulfillVRFSeed
(VRF_FULFILLER_ROLE) Pending --> Fallback : seedFromBlockhashFallback
(anyone, 24h elapsed) ``` | Function | When | Seed source | |----------|------|-------------| | `fulfillVRFSeed(matchId, seed)` | normal path | Chainlink VRF v2.5 | | `seedFromBlockhashFallback(matchId)` | after 24h | `blockhash(matchCreationBlock)` | The fallback blockhash is miner-influenceable within 256 blocks; this risk is explicitly documented in source and accepted as a liveness guarantee. --- ## Invariants State machine invariants enforced across all transitions: 1. **Monotonic state** — a match never moves back to an earlier state. `None → Open` happens once; `Open → Proposed` happens once; `Proposed → Finalized|Disputed|Voided` happens once; `Disputed → Finalized|Voided` happens once. 2. **Immutable pool type** — `MatchInfo.poolType` is written at `registerMatch` and never modified. 3. **Immutable panel commitment** — `panelRoot`, `panelSize`, `quorumM` are written at `registerMatch` and never modified. 4. **Cap monotonicity** — `totalAccepted` only increases until finalization. 5. **Claim idempotency** — each claim path zeros the caller's stake before transferring, preventing double-claim via reentrancy. 6. **Dispute exclusivity** — a match has at most one open dispute (`_matchDispute[matchId] != 0` blocks re-opening). --- ## Events emitted per transition (reference) | Transition | Core event | |-----------|------------| | `None → Open` | `MatchRegistered`, `MatchStateTransitioned(None,Open)`, `VRFSeedRequested` | | `Open → Proposed` | `OutcomeProposed`, `MatchStateTransitioned(Open,Proposed)` | | `Proposed → Finalized` (natural) | `MatchFinalized`, `MatchStateTransitioned(Proposed,Finalized)` | | `Proposed → Disputed` | `MatchStateTransitioned(Proposed,Disputed)`, `DisputeEscalated` | | `Disputed → Finalized` | `MatchFinalized`, `MatchStateTransitioned(Disputed,Finalized)` | | `Disputed → Voided` | `MatchVoided`, `MatchStateTransitioned(Disputed,Voided)` | | `* → Voided` (governance) | `MatchVoided`, `MatchStateTransitioned(prev,Voided)` | | `* → Finalized` (arbiter) | `MatchFinalized`, `MatchStateTransitioned(prev,Finalized)` | | `lockMatch` | `MatchLocked` | | `releaseMatchLock` | `MatchUnlocked(bondSlashed)` | | `fulfillVRFSeed` | `VRFSeedFulfilled(fromFallback=false)` | | `seedFromBlockhashFallback` | `VRFSeedFulfilled(fromFallback=true)` | --- ## Prediction market lifecycle Prediction markets built on `PredictionMarketV2` register as PARIMUTUEL matches in Core. The market contract adds a metadata layer (question, outcome labels, resolution source) and a resolution sync bridge. The prediction market lifecycle maps onto Core's match state machine as follows. ### Prediction market states | PredictionMarketV2 Status | Core MatchState | Meaning | |---------------------------|-----------------|---------| | `Active` | `Open` | Market accepting trades via AMM | | `Paused` | `Open` (pause tier active) | Paused by match/category/global tier | | `ResolutionPending` | `Proposed` | Outcome proposed, challenge window running | | `Disputed` | `Disputed` | Bond-backed dispute raised | | `Resolved` | `Finalized` + synced | Resolution synced, redemptions enabled | | `Voided` | `Voided` + synced | Market voided, refunds enabled | | `ClosedAwaitingResolution` | `Open` (past resolution time) | Trading closed, waiting for grader proposal | ### Prediction market state diagram ```mermaid stateDiagram-v2 direction LR [*] --> Active : createMarket Active --> Paused : pause tier activated Paused --> Active : pause lifted Active --> ClosedAwaitingResolution : block.timestamp >= resolutionTime Active --> ResolutionPending : proposeOutcome (via Core) ClosedAwaitingResolution --> ResolutionPending : proposeOutcome ResolutionPending --> Disputed : openDispute (via Core) ResolutionPending --> Resolved : finalize + syncResolution Disputed --> Resolved : resolveDispute + syncResolution Disputed --> Voided : resolveDispute(void) + syncVoid Active --> Voided : voidMatch + syncVoid Resolved --> [*] : redeem / redeemFor / batchRedeemFor Voided --> [*] : refund / refundFor / batchRefundFor ``` ### Resolution sync bridge The prediction market does not resolve independently. Resolution flows through Core's two-phase finalization (grader proposal, challenge window, optional dispute), and `syncResolution` / `syncVoid` bridges the result into the market layer. ``` Core finalizes match │ │ permissionless call ▼ syncResolution(marketId) │ │ reads Core.getMatchState + Core.getMatchInfo │ writes: resolutionSynced=true, winningOutcome, resolvedAt=unlockAt │ ▼ AMM redemptions enabled ``` Key properties: - **Permissionless**: anyone can call `syncResolution` after Core finalization - **Idempotent**: calling twice is a no-op (critical for keeper safety) - **resolvedAt uses Core's `unlockAt`**: the timestamp when the challenge window closed and `finalize()` became callable, not the sync timestamp. This ensures the LP withdrawal grace period (30 days) measures from finalization eligibility, not from a potentially-late sync. ### matchId domain separation [Decision #14] Prediction markets use a domain-separated `coreMatchId` to avoid namespace collision with sportsbook match IDs: ```solidity coreMatchId = uint256(keccak256(abi.encode(PREDICTION_DOMAIN, marketId))) ``` Where `PREDICTION_DOMAIN = keccak256("SBET.PredictionMarket.v2")`. This is deterministic — the same `marketId` always maps to the same `coreMatchId`. Sportsbook match IDs occupy a separate namespace and cannot collide. --- ## Next - [Contracts reference](./contracts-reference.md) — full NatSpec for each function - [User flows](./user-flows.md) — concrete user journeys through this state machine - [Security model](./security-model.md) — attack scenarios against state transitions