---
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