--- title: SBET V2 Security Model description: Economic security analysis for SBET V2 — grader stake, slashing math, challenge windows, per-match cap, dispute bond-escalation, emergency pause tiers, and worked attack scenarios. canonical: https://sbettoken.org/docs/protocol-v2/security-model version: 2.0.0 updated: 2026-04-05 --- # Security Model SBET V2's security is economic: attackers spend money to attack, honest parties earn money to defend, and every dishonest outcome is bounded. This document walks through each economic guard in the protocol, the math behind it, and then runs concrete attack scenarios against the design. Read this together with [architecture.md](./architecture.md) for component context and [state-machine.md](./state-machine.md) for the transition graph being defended. --- ## Threat model ### Assumptions - **EVM correctness** — the underlying chain executes bytecode correctly and finalizes blocks. - **VRF soundness** — Chainlink VRF v2.5 produces unbiased randomness (fallback uses `blockhash`, which is miner-influenceable within 256 blocks; we explicitly bound the blast radius). - **Guardian distribution** — the 5/9 guardians and 5/9 arbiter multisigs are not simultaneously compromised. - **Grader economic rationality** — graders act to maximize expected return on their $25k SBET stake. - **Bondholder liquidity** — challengers and defenders can source bond capital on demand. ### Adversary capabilities - Full control of any number of user wallets below the per-match cap. - Ability to fund up to `8× initial bond` in dispute escalation (~$400k max). - Ability to stake as a grader (registering $25k SBET) up to the size of the panel. - No privileged roles — no compromise of `GOVERNANCE_ROLE`, `ARBITER_ROLE`, `GLOBAL_GUARDIAN_ROLE`. ### Adversary goals 1. **Steal payouts** — finalize a match at a false outcome and claim. 2. **Censor payouts** — trigger pauses to freeze claims / refunds. 3. **Extract bond capital** — win a dispute by attrition (make counterparty default). 4. **Drain slash pool** — trigger unjustified slashing of honest graders. --- ## Per-match payout cap [Decision #4] ```solidity uint256 public constant ABSOLUTE_CAP = 225_000e18; // $225k function perMatchPayoutCap() public view returns (uint256) { uint256 floor_ = GRADERS.graderStakeFloor(); // default 25_000e18 uint256 candidate = floor_ * _quorumM; // 25k × 9 = 225k return candidate < ABSOLUTE_CAP ? candidate : ABSOLUTE_CAP; } ``` The per-match cap is the **max loss exposure of the protocol on a single match**. Two formulations depending on pool type: - **PARIMUTUEL** — cap bounds `totalAccepted` (full pot) (Bug Fix #4). - **SIGNED_POSITION** — cap bounds `max(longPool, shortPool)` (Bug Fix #4) — the two sides offset at settlement, so only the larger side represents uncollateralized exposure. The cap is **tied to grader collateral**: `stakeFloor × quorumM = 25k × 9 = $225k`. This means the quorum's combined stake exceeds the max loss the quorum can inflict on users in a single match. A fully-colluding quorum loses more money than it can steal. Enforcement at bet placement: ```solidity // Parimutuel uint256 newTotal = m.totalAccepted + amount; if (newTotal > cap) revert PerMatchCapExceeded(amount, cap - m.totalAccepted); // Signed-position uint256 exposure = newLong >= newShort ? newLong : newShort; if (exposure > cap) revert PerMatchCapExceeded(amount, cap); ``` --- ## Grader stake [Decision #3] - **Minimum stake**: $25,000 SBET (`DEFAULT_STAKE_FLOOR = 25_000e18`) - **Cooldown**: 7 days between `requestDeregister` and `executeDeregister` - **Governance-raisable**: yes, via `setGraderStakeFloor` Stake is held in `GraderRegistryV2` (sole custodian). Graders who fall below the floor after slashing move to `GraderStatus.Slashed` and cannot submit new grades until they top up. ### Why $25k? At panel size 13 / quorum 9, a malicious quorum stakes `9 × $25k = $225k`. This matches the per-match payout cap exactly. The invariant: **a colluding quorum cannot extract more than it has at risk.** Slashable fraction per match is set by `DisputeManager` (currently 100% of stake available in the registry pool, clamped to `info.stake` per grader). If a grader colludes on 2 matches and gets slashed on both, the second slash draws from remaining stake and the grader moves to `Slashed` status. --- ## Slashing economics [Decision #4] When DisputeManager calls `slashWithChallenger`, the slashed amount flows to Treasury and is distributed: | Recipient | Share | Purpose | |-----------|------:|---------| | Challenger | 50% | Reward for catching the cheating grader | | Treasury retained | 20% | Protocol revenue | | SBET stakers | 20% | Long-term alignment reward | | Community bounty | 10% | Incentivize monitoring | | **Total** | **100%** | **No burn** | ```solidity uint256 public constant SLASH_TO_CHALLENGER_BPS = 5_000; // 50% uint256 public constant SLASH_TO_TREASURY_BPS = 2_000; // 20% uint256 public constant SLASH_TO_STAKERS_BPS = 2_000; // 20% uint256 public constant SLASH_TO_BOUNTY_BPS = 1_000; // 10% // Asserted == 10_000 in Treasury constructor ``` **Rationale for 50% to challenger**: the challenger fronts the bond, carries the operational cost of monitoring, and bears the risk of losing the dispute. 50% of the slashed stake is the economic reward that makes dispute-raising positive-EV for honest actors. **Why no burn**: burning SBET would reduce the reward available to honest participants and does not improve security. The 10% bounty pool preserves long-term incentive for independent monitoring. --- ## Dynamic challenge windows [Decision #1] The challenge window is the time between `proposeOutcome` and `finalize` during which anyone can open a dispute. Window length scales with **consensus quality**: | Tier | Duration | Condition | Attack cost to delay resolution | |------|---------:|-----------|-------------------------------| | **Fast** | 90s | unanimous + confidence ≥ 95% + source agree ≥ 2/3 | ≥$5k dispute bond | | **QuorumClean** | 15m | M-of-N met, zero dissent, not fast-eligible | ≥$5k dispute bond | | **OneDissent** | 2h | exactly 1 dissenting grader | ≥$5k dispute bond | | **Contentious** | 4h | dissents ≥ 2 OR confidence < 60% | ≥$5k dispute bond | Thresholds (from `ChallengeWindowLib`): ```solidity uint16 CONFIDENCE_FAST_BPS = 9_500; // 95% uint16 CONFIDENCE_LOW_BPS = 6_000; // 60% uint8 SOURCE_AGREEMENT_FAST = 2; // ≥2/3 independent sources agree ``` ### Why scale the window? - **Fast path (90s)**: when the grader panel is unanimous, AI confidence is extreme, and multiple independent sources agree, the outcome is effectively certain. Delaying finalization only holds up user payouts for no security benefit. - **Contentious path (4h)**: any signal of disagreement — even a single low-confidence grade — extends the window to give human observers and dispute-openers time to react. A rational attacker must open a dispute (≥$5k bond) before `unlockAt` regardless of tier. **Shortening the window does not reduce dispute effectiveness** — it only reduces the delay for the 95%+ of matches that are uncontested. --- ## Dispute bond-escalation [Decision #7] ### Initial bond ```solidity initialBond = max(5_000e18, min(50_000e18, matchTvl * 100 / 10_000)) ``` - **1% of match TVL** — scales with stakes at risk - **Floor: $5,000** — makes spurious disputes expensive - **Ceiling: $50,000** — caps legitimate-dispute capital requirements ### Escalation ladder ``` Round 0 (open) : challenger posts initialBond Round 1 (respond) : defender posts 2 × initialBond (cumulative 3×) Round 2 (respond) : challenger posts 4 × initialBond (cumulative 7×) Round 3 (respond) : defender posts 8 × initialBond (cumulative 15×) Round 3+ : AwaitingArbiter — 5-of-9 rules ``` Per side cumulative bonds: | Round | Awaiting | Challenger total | Defender total | Pot total | |------:|----------|-----------------:|---------------:|----------:| | 0 | Defender | 1× | 0× | 1× | | 1 | Challenger | 1× | 2× | 3× | | 2 | Defender | 5× | 2× | 7× | | 3 | → Arbiter | 5× | 10× | 15× | At max escalation with ceiling-bound initial bond: `15 × $50k = $750k` of bond capital is locked in the dispute. The loser forfeits their contributions; winner takes the full pot (current implementation — future revision may route through the 50/20/20/10 split). ### Response windows - **24h per round** (`RESPONSE_WINDOW`) - Missing a deadline → `claimByDefault` is callable by anyone, defaulter loses everything, winner takes all ### Why bond escalation? 1. **Liveness by attrition** — every round 2× the stakes; only parties confident in their position will continue. 2. **Denial-of-service resistance** — an attacker who opens a dispute to censor finalization must keep paying in escalating bonds. 3. **Arbiter throttling** — the arbiter (5-of-9 multisig) only adjudicates disputes that survived 3 rounds of bond escalation, limiting multisig workload. --- ## Pause tiers [Decision #6] | Tier | Quorum | Max Duration | Pause trigger cost | Purpose | |------|-------:|-------------:|-------------------:|---------| | **Match** | 1 + bond | 24h | $1k SBET | Respond to match-specific anomaly (e.g. ambiguous outcome) | | **Category** | 3-of-5 | 72h | $0 (guardian only) | Sport/league-wide integrity issue | | **Global** | 5-of-9 | 14d | $0 (guardian only) | Protocol-wide emergency | ### Match lock ```solidity uint256 public constant MATCH_LOCK_BOND = 1_000e18; // $1k SBET uint256 public constant MATCH_LOCK_DURATION = 24 hours; ``` Permissionless: anyone can lock a single match by posting a $1k SBET bond. Lock auto-expires in 24h; bond is refunded on natural expiry. If governance rules the lock malicious, `releaseMatchLock(matchId, true)` slashes the bond to the Treasury bounty pool. **Why permissionless**: a high-confidence single observer (e.g. a referee update) can pause a match without waiting for guardian quorum. The $1k bond prevents spam. ### Global unpause timelock ```solidity uint256 public constant GLOBAL_UNPAUSE_TIMELOCK = 72 hours; ``` Even after 5-of-9 guardians vote to unpause, the actual unpause cannot execute for 72 hours. This prevents a compromised guardian quorum from pausing-then- unpausing rapidly to mask attacks. Exception: if the global pause auto-expires (14 days elapsed), anyone can unpause with no timelock — the pause was bounded at pause-time. --- ## VRF panel security [Decision #12] ### Why a randomly-drawn sub-panel? If every active grader had to sign every match, a single match outcome would require 13 signatures aggregated off-chain. With a VRF-drawn sub-panel: - **Colluders cannot pre-select their matches** — panel membership is committed after VRF seeding, using a random draw over the active grader pool. - **Failing/offline graders are tolerated** — quorum M=9 out of N=13 tolerates 4 dropouts. - **Sybil resistance improves** — minting 9 grader identities to collude on a specific match is blocked by the VRF draw. ### Attack: controlling the draw An attacker who controls the VRF seed could pre-compute which graders will be drawn and attempt to compromise exactly those 9. Protections: 1. **Chainlink VRF v2.5** — cryptographically unbiased randomness. 2. **Per-match seed** — each match has a distinct seed; compromising one panel does not compromise another. 3. **Blockhash fallback bounded** — if VRF unfulfilled after 24h, `blockhash(matchCreationBlock)` is used. Within 256 blocks miners can withhold/re-order, but the resulting panel still has to be compromised **after** the match is registered and bets are placed. Attack cost scales with match TVL. ### Merkle commitment `panelRoot` is a `keccak256` Merkle root of the sorted panel addresses. Graders supply proofs during grade submission; `verifyPanelMembership` validates. This commitment is immutable after `registerMatch`. --- ## Worked attack scenarios ### Scenario 1: Attacker controls 9-of-13 graders **Setup**: attacker has compromised 9 graders (full quorum), each staking $25k SBET → attacker has $225k locked. A match with TVL = $225k is registered. **Attacker play**: 9 compromised graders sign the wrong outcome (e.g. signing for outcome B when outcome A is correct). `proposeOutcome` fires with `signers=9, dissents=0, confidenceBps=10_000`, `sourceAgreement=3`. → Challenge window = **Fast tier = 90 seconds**. **Honest response**: within 90s, anyone with $5k (the floor dispute bond, or 1% of $225k TVL = $2.25k → clamped to $5k) calls `openDispute(matchId, A)`. → Dispute escalates. Attacker-defender posts $10k. Challenger posts $20k. Attacker-defender posts $40k. State → `AwaitingArbiter` with total $75k locked. **Arbiter ruling**: 5-of-9 Safe multisig reviews evidence, rules for challenger. - Challenger refunded their contributions ($25k). - Attacker-defender's contributions ($50k) flow to challenger. - Attacker's 9 grader stakes are slashed: 9 × $25k = **$225k** (up to full stake). - 50% to challenger = **$112.5k**. - Final challenger profit: $112.5k − $0 net bond cost (contributions refunded). **Attacker P&L**: | Revenue | Cost | |---------|-----:| | Payout captured by finalizing at wrong outcome: $0 (dispute prevented finalization) | 9 × $25k grader stakes lost: -$225k | | Defender bond contributions to winner: -$50k | | **Total** | **-$275k** | **Honest challenger P&L**: +$112.5k (plus dispute-bond refund). **Invariant preserved**: the attacker's max loss (~$275k) exceeds the max gain from a successful attack (capped at $225k by `ABSOLUTE_CAP`). ### Scenario 2: Attacker wins challenge-window race **Setup**: attacker has 9-of-13 compromised graders and a $225k match. Wants to **avoid** the dispute being opened in time. Unanimous grades with max confidence → challenge window = 90s. **Defense**: $5k dispute bond (1% of $225k TVL clamped to floor). A monitoring bot with a $5k hot wallet submits `openDispute` within 90s. **Attack cost to defeat**: attacker would need to prevent **all** monitoring bots from acting for 90 seconds. With public matches and open-source monitoring infrastructure, this requires a network-wide DoS. Even in a successful 90s censorship attack, the attacker still must survive subsequent arbiter review — the 5-of-9 Safe multisig can `overrideOutcome` at any time if state is in `{Open, Proposed, Disputed}`. Post-finalization, recovery requires a new governance action (not yet wired — see [open-questions](#open-questions)). ### Scenario 3: Griefer opens disputes to delay finalization **Setup**: honest grade is posted. A griefer wants to delay all user claims on a match with $10M TVL. Initial bond = `max(5k, min(50k, 10M × 1%)) = $50k` (ceiling). Griefer opens dispute, pays $50k. Defender posts $100k at round 1. Griefer must post $200k at round 2 — they **can't** and the deadline passes. → `claimByDefault` → griefer's $50k forfeited to defender, dispute expired, resolution propagates to Core at the defender's outcome. **Griefer cost per delay**: $50k for 24 hours of delay (to cause the first deadline to elapse). Amortized cost of delaying $10M TVL by 24h = 0.5% of TVL. **Mitigation**: the default ruling propagates to Core automatically — griefer delays finalization by exactly one 24h window, and loses $50k doing it. ### Scenario 4: Malicious match-lock spam **Setup**: attacker wants to pause many matches simultaneously. Each match-lock costs $1,000 SBET bond. Locking 100 matches = $100k capital. If governance deems the locks malicious, each bond is slashed to Treasury. Cost per malicious lock = $1k. Total cost for 100 malicious locks = $100k. **Defense**: governance triggers `releaseMatchLock(matchId, true)` to slash in batch. Slashed funds flow to Treasury bounty pool (10% distribution upstream). The attacker loses capital; the protocol gains revenue. **Attacker's only benefit**: 24h delay per match before natural expiry (if governance does not slash). Bounded damage; positive protocol revenue on slashing. ### Scenario 5: VRF fallback miner collusion **Setup**: VRF does not fulfill within 24h. Attacker is a miner who controls `blockhash(matchCreationBlock)` within the 256-block window. Attacker registers a match such that `blockhash(matchCreationBlock)` selects a panel they have compromised. **Defense**: matches with VRF fallback are visible on-chain (`VRFSeedFulfilled` event has `fromFallback=true`). Off-chain risk engines can flag these matches as higher-risk, and the 4h contentious challenge window gives dispute openers more runway. The compromised-quorum attack from Scenario 1 still applies — attacker loses $225k if a dispute is raised. **Residual risk**: the attacker gets one more attempt per match to control the panel. This is acceptable as a **liveness guarantee** rather than a security guarantee; if VRF remains unavailable, the alternative is halting the protocol entirely. --- ## Invariants (formal) 1. **Cap invariant** ``` ∀ match m, m.state ≠ None: m.poolType == PARIMUTUEL → m.totalAccepted ≤ perMatchPayoutCap() m.poolType == SIGNED_POSITION → max(m.longPool, m.shortPool) ≤ perMatchPayoutCap() ``` 2. **Collateralization invariant** ``` perMatchPayoutCap() ≤ graderStakeFloor × quorumM ``` A colluding quorum always stakes at least as much as they can extract. 3. **Slash distribution invariant** ``` SLASH_TO_CHALLENGER_BPS + SLASH_TO_TREASURY_BPS + SLASH_TO_STAKERS_BPS + SLASH_TO_BOUNTY_BPS == 10_000 ``` Asserted at Treasury construction. 4. **State monotonicity** ``` No external function may move state backwards in the {None, Open, Proposed, Disputed, Finalized, Voided} graph. ``` 5. **Immutable pool type** ``` m.poolType is written only inside registerMatch() and never read-modified-written thereafter. ``` 6. **Dispute uniqueness** ``` _matchDispute[matchId] != 0 ⇒ openDispute(matchId, *) reverts. ``` --- ## Prediction market security hardening PredictionMarketV2 (728 lines) and PredictionAMMV2 (991 lines) underwent a dedicated security review that identified 3 P0 (must-fix), 4 P1 (should-fix), and 4 P2 (consider) findings. All P0 and P1 findings have been remediated. ### P0 fixes (all remediated) | # | Finding | CVSS | Remediation | |---|---------|------|-------------| | P0-1 | LMSR `exp()` overflow — no lower bound on `b` parameter | 7.5 | Enforced `MIN_B_PARAM = 1e18` in `seedLiquidity()`. Recommended `SAFE_RECOMMENDED_B = 1000e18` for production pools. | | P0-2 | Fee-on-transfer tokens cause collateral accounting drift | 7.4 | Added `_safeTransferInExact()` — measures `balanceOf` before/after transfer, reverts if `received < amount`. Applied to `buy()`, `splitCollateral()`, and `seedLiquidity()`. | | P0-3 | Sell fee computed on stale pre-trade price (sandwich-exploitable) | 6.8 | Sell fees now computed on **average of pre-trade and post-trade price** via `_computeFeeAvgPrice()`. Sandwich attacks are unprofitable because the average incorporates the attacker's own impact. | | P0-4 | Refund pro-rata snapshot — `_burn` reduces `totalSupply` between calls | — | Added lazy-init snapshot for both redeem (`totalWinningShares`) and refund (`_refundGrandTotal`). Each uses a `bool` flag + first-call snapshot. Later callers get their correct pro-rata share. | | P0-5 | Sell CEI reorder — pool state updated before ERC-1155 burn | — | Burn shares FIRST, then update pool state. ERC-1155 `_burn` triggers `_afterTokenTransfer` callback; burning first means the callback sees shares removed but pool unchanged (safe). | ### HIGH-1 fix: governance-settable AMM reference The original design used an `immutable PREDICTION_MARKET` reference in PredictionAMMV2. A bug in PredictionMarketV2 would have required a full AMM redeploy — losing all in-flight pools. Fix: `PREDICTION_MARKET` is now governance-settable with a **48-hour timelock**: ```solidity uint256 public constant MARKET_CHANGE_DELAY = 48 hours; uint256 public constant MARKET_CHANGE_GRACE = 14 days; function proposePredictionMarketChange(address newMarket) external onlyRole(GOVERNANCE_ROLE); function executePredictionMarketChange() external onlyRole(GOVERNANCE_ROLE); function cancelPredictionMarketChange() external onlyRole(GOVERNANCE_ROLE); ``` The 48-hour delay prevents governance from instantaneously swapping to a malicious market contract. The 14-day grace window prevents indefinitely pending proposals from being executed after context has changed. ### P1 fixes (all remediated) | # | Finding | Remediation | |---|---------|-------------| | P1-1 | matchId namespace collision between sportsbook and prediction markets | Domain separation via `PREDICTION_DOMAIN`. `coreMatchId = keccak256(PREDICTION_DOMAIN, marketId)`. Deterministic, no coordination required. | | P1-2 | `resolvedAt` used sync timestamp instead of finalization timestamp | Now uses Core's `unlockAt` — the challenge window end time. LP grace period measures from finalization eligibility, not late sync. | | P1-3 | `ISBETCoreV2Registrar` inline interface — stale on Core changes | Replaced with `ISBETCoreV2.registerMatch()` from the shared interface. Single source of truth, compiler catches mismatches. | | P1-4 | No `MarketStatusChanged` event for indexers | Added `MarketStatusChanged(marketId, oldStatus, newStatus)` event to `syncResolution()` and `syncVoid()`. | ### P2 fixes (all remediated) | # | Finding | Remediation | |---|---------|-------------| | P2-1 | LP withdrawal drains collateral owed to unclaimed winners | LP now receives 0 collateral while winning shares remain. Pro-rata formula allocates 100% of remaining collateral to remaining winners. | | P2-2 | `MarketStatusChanged` event missing | Added (see P1-4 above). | | P2-3 | Minimum fee floor at extreme prices | `if (totalFee == 0 && amount > 0) totalFee = 1;` — prevents fee-free trades on near-certain outcomes. | | P2-4 | Pool struct packing | Reordered struct fields: `token` (20 bytes) + `active` (1 byte) + `creatorFeeBps` (2 bytes) = 23 bytes in slot 0. Reduced from 7 to 5 storage slots. | ### Slither findings (all resolved) - **Dead code**: removed unused internal functions - **Mul-after-div precision loss**: fee split formula rewrote to compute `creatorFee` independently from the same base terms, avoiding multiplication of an already-rounded `totalFee` - **CEI documentation**: all external calls in `buy()` and `sell()` follow Checks-Effects-Interactions, documented with inline `// CEI` markers ### Echidna invariant testing 20 property-based invariants tested via Echidna fuzzing. Key invariants: 1. **Pool collateral solvency**: `pool.collateral >= sum of all outstanding payouts` 2. **ERC-1155 supply consistency**: `totalSupply(tokenId) >= sum of all holder balances` 3. **LMSR cost non-negative**: `_computeCostToBuy` always returns `>= 0` 4. **Fee bounds**: `totalFee <= amount` for any trade 5. **Snapshot immutability**: `totalWinningShares[marketId]` never changes after first initialization 6. **Redemption fairness**: no holder can redeem more than `(shares / totalWinningShares) * collateral` Echidna found and we fixed a **zero-fee LMSR rounding arbitrage**: at extreme prices, the symmetric fee formula rounded to 0, allowing fee-free trades. The minimum-fee-floor fix (P2-3) eliminated this vector. Additionally, sell payout now **rounds DOWN** to prevent rounding arbitrage where a sequence of small sells could extract more than the total pool collateral. ### Autonomous payout failure isolation The `batchRedeemFor` / `batchRefundFor` functions use a try/catch pattern that isolates failures to individual holders: ```solidity for (uint256 i; i < holders.length; ) { try this._redeemForExternal(marketId, holders[i]) returns (uint256 payout) { // success path } catch (bytes memory reason) { ++failedCount; emit RedeemForFailed(marketId, holders[i], reason); } unchecked { ++i; } } ``` Properties: - A failed transfer to one holder does not revert the batch - `_redeemForExternal` is callable **only by `address(this)`** (self-call guard) - Failed holders can redeem individually later via `redeemFor` or `redeem` - Events track both successes and failures for keeper monitoring --- ## Open questions Flagged in source for follow-up: - **On-chain arbiter vote aggregation**. Today `ARBITER_ROLE` trusts the 5-of-9 Safe multisig to enforce quorum off-chain. Adding an on-chain vote phase before arbiter resolution would make quorum visible on-chain at the cost of more complex state. - **Dispute pot distribution**. Currently dispute bonds go 100% to the winner. A future revision may route through the 50/20/20/10 split to keep economic alignment consistent with slash distribution. - **Grader slashing on arbiter ruling**. `DisputeManager.arbiterResolve` includes a TODO for triggering `GraderRegistryV2.slashWithChallenger` — this requires DisputeManager to hold `SLASHER_ROLE` on the registry and a per-match signer-list view to identify which graders signed the losing proposal. - **VOID fee-rebate**. `claimVoidRefund` currently refunds principal only; the per-PoolType fee schedule is not yet locked. --- ## Next - [State machine](./state-machine.md) — transition graph being defended - [Grader operator guide](./grader-operator-guide.md) — running a grader safely - [User flows](./user-flows.md) — user-facing dispute opening