--- title: SBET V2 User Flows description: End-user scenarios for SBET V2 — place a bet, claim payout, dispute an outcome, recover a voided bet, opt into gasless auto-claim. canonical: https://sbettoken.org/docs/protocol-v2/user-flows version: 2.0.0 updated: 2026-04-05 --- # User Flows Concrete, end-to-end scenarios for people using SBET V2 — bettors, challengers, and integrators building dapps on top of the protocol. Each flow lists the exact contract calls, required approvals, expected events, and common failure modes. If you want the state-machine view of these flows, see [state-machine.md](./state-machine.md). If you want the function signatures, see [contracts-reference.md](./contracts-reference.md). --- ## Flow 1: Place a bet (parimutuel) ### Scenario Alice wants to bet $100 USDC that the Lakers beat the Celtics. The match is registered as a parimutuel pool with 2 outcomes: `0 = Lakers win`, `1 = Celtics win`. ### Steps ```mermaid sequenceDiagram participant Alice participant USDC as USDC (ERC-20) participant Core as SBETCoreV2 participant T as SBETTreasuryV2 Alice->>USDC: approve(Treasury, 100e6) Alice->>Core: placeBet(matchId, 0, USDC, 100e6) Core->>T: receiveStake(USDC, Alice, 100e6, matchId) T->>USDC: safeTransferFrom(Alice, T, 100e6) T-->>Core: return Core->>Core: emit BetPlaced(...) Core-->>Alice: success ``` ### Code ```javascript // 1. Approve Treasury to pull USDC await usdc.approve(treasuryAddress, parseUnits("100", 6)); // 2. Place the bet await sbetCore.placeBet( matchId, // uint256 0, // outcome (Lakers) usdcAddress, // token parseUnits("100", 6) // 100 USDC ); ``` ### Events emitted - `StakeHeld(USDC, Alice, 100e6, matchId)` (Treasury) - `BetPlaced(matchId, Alice, USDC, 0, 100e6, totalAcceptedAfter)` (Core) ### Common reverts | Error | Meaning | |-------|---------| | `MatchNotOpen(matchId)` | Match has moved past `Open` state | | `InvalidOutcome(outcome, numOutcomes)` | `outcome` is out of range | | `WrongPoolType(PARIMUTUEL, SIGNED_POSITION)` | Wrong function for this match — use `openSignedPosition` | | `ExistingPositionDifferentOutcome` | Alice already has a stake on a different outcome | | `PerMatchCapExceeded(requested, remaining)` | Alice's bet would push the pot past the cap | | `GlobalPaused` / `CategoryPaused` / `MatchPaused` | A pause tier blocks the match | ### Required approvals - **USDC** approved to **Treasury** (not Core). Treasury pulls via `safeTransferFrom`. --- ## Flow 2: Open a signed position ### Scenario Bob wants to bet $500 SBET that Arsenal beats Chelsea in a binary signed-position match (`PoolType.SIGNED_POSITION`, `numOutcomes == 2`). "Arsenal wins" = outcome 1 = `Direction.LONG`. ### Steps ```javascript // 1. Approve Treasury await sbet.approve(treasuryAddress, parseEther("500")); // 2. Open the long position await sbetCore.openSignedPosition( matchId, 0, // Direction.LONG sbetAddress, parseEther("500") ); ``` ### Dual-position hedge Bob may also open a short position on the same match: ```javascript await sbetCore.openSignedPosition(matchId, 1 /* Direction.SHORT */, sbetAddress, parseEther("200")); ``` Both positions coexist. At finalization, `claimSignedPosition` settles both in a single call — the user receives `longPayoff + shortPayoff`. ### Exposure cap For signed-position matches, the per-match cap bounds `max(longPool, shortPool)`, not the sum. Long and short offset at settlement. ### Events emitted - `StakeHeld(SBET, Bob, 500e18, matchId)` (Treasury) - `SignedPositionOpened(matchId, Bob, SBET, LONG, 500e18, directionPoolAfter)` (Core) --- ## Flow 3: Watch a match finalize ### Scenario Alice placed a bet; the match ends; she wants to know when her claim is available. ### State transitions to watch ```mermaid stateDiagram-v2 direction LR Open --> Proposed : OutcomeProposed Proposed --> Finalized : MatchFinalized Proposed --> Disputed : DisputeEscalated ``` ### Off-chain observation Subscribe to these events (via an indexer or direct WS subscription): ```solidity event OutcomeProposed( uint256 indexed matchId, uint8 outcome, uint8 tier, // 0=Fast, 1=QuorumClean, 2=OneDissent, 3=Contentious uint32 unlockAt, // unix timestamp uint16 confidenceBps, address indexed proposer ); event MatchFinalized( uint256 indexed matchId, uint8 outcome, uint32 finalizedAt ); ``` When `OutcomeProposed` fires, Alice's UI should show: - **Proposed outcome** (did she win or lose?) - **Challenge window deadline** = `unlockAt` — until this time, anyone can dispute. The wait depends on tier: | Tier | Duration | |------|---------:| | 0 Fast | 90 seconds | | 1 QuorumClean | 15 minutes | | 2 OneDissent | 2 hours | | 3 Contentious | 4 hours | - **Confidence score** — user-friendly indicator of grader agreement. ### Triggering finalization `finalize` is permissionless. In production, dapp operators or anyone with a gas budget triggers it: ```javascript // Anyone can call this once block.timestamp >= unlockAt await sbetCore.finalize(matchId); ``` Alice's dapp should either (a) call `finalize` herself, (b) wait for a relayer to call it, or (c) let the claim call trigger a prior `finalize` — but note **claims require `state == Finalized`**, not `Proposed`. --- ## Flow 4: Claim a parimutuel payout ### Scenario Alice bet 100 USDC on outcome 0 (Lakers). Lakers won. Total pot = 500 USDC, of which 200 USDC was on Lakers. She wants her share. ### Calculation ``` payout = userStake × totalAccepted / winningOutcomeStake = 100 × 500 / 200 = 250 USDC ``` ### Steps ```javascript await sbetCore.claimParimutuel(matchId, usdcAddress); ``` Alice's stake is zeroed before Treasury releases the payout, so reentrancy is not a concern. ### Events emitted - `PayoutReleased(USDC, Alice, 250e6, matchId, autoClaim=false)` (Treasury) ### Losing side If Alice had bet on outcome 1, `claimParimutuel` still succeeds but `payout == 0` — her stake is zeroed and no transfer happens. This is intentional: it prevents a losing bettor from re-claiming in the future. ### If the match is not yet finalized `claimParimutuel` reverts with `MatchNotFinalized(matchId)`. ### If pool type is wrong `claimParimutuel` on a `SIGNED_POSITION` match reverts with `WrongPoolType(PARIMUTUEL, SIGNED_POSITION)`. Use `claimSignedPosition` instead. --- ## Flow 5: Claim a signed-position payout ### Scenario Bob opened `LONG 500 SBET` and `SHORT 200 SBET` on a binary match. Arsenal won (`outcome 1 → finalPrice = MAX_PRICE = 1e9`). ### Calculation ``` finalPrice = outcome × MAX_PRICE / (numOutcomes - 1) = 1 × 1e9 / 1 = 1e9 payoff_long = 500 × 1e9 / 1e9 = 500 payoff_short = 200 × (1e9 - 1e9) / 1e9 = 0 totalPayout = 500 SBET ``` ### Steps ```javascript await sbetCore.claimSignedPosition(matchId, sbetAddress); ``` Both long and short are settled in a single call. Both positions are zeroed before Treasury transfer. ### Events emitted - `PayoutReleased(SBET, Bob, 500e18, matchId, autoClaim=false)` (Treasury) --- ## Flow 6: Open a dispute ### Scenario The grader panel proposed `outcome 0` (Lakers), but Carol has a match recording showing Celtics won (`outcome 1`). The match has `totalAccepted = $100k USDC`; the challenge window is active. ### Initial bond calculation ``` bond = max(5_000e18, min(50_000e18, 100_000e18 × 100 / 10_000)) = max(5_000e18, min(50_000e18, 1_000e18)) = 5_000e18 // floor clamp ``` So Carol needs to approve **$5,000** in the bond token (SBET by default). ### Steps ```javascript // 1. Approve DisputeManager (NOT Core, NOT Treasury) for the bond await sbet.approve(disputeManagerAddress, parseEther("5000")); // 2. Open the dispute via Core const disputeId = await sbetCore.openDispute(matchId, 1 /* counterOutcome = Celtics */); ``` Core forwards the call to DisputeManager, which pulls the bond via `safeTransferFrom(Carol, DM, 5_000e18)`. ### Events emitted - `MatchStateTransitioned(matchId, Proposed, Disputed)` (Core) - `DisputeEscalated(matchId, disputeManager, 5_000e18)` (Core) - `DisputeOpened(disputeId, matchId, Carol, 5_000e18, 1)` (DisputeManager) ### What happens next Match state → `Disputed`. Claims are frozen (only `claimVoidRefund` works if the arbiter later voids). The dispute goes through up to 3 rounds of bond escalation before the 5-of-9 arbiter multisig rules. See [security-model.md#dispute-bond-escalation](./security-model.md#dispute-bond-escalation) for the full escalation ladder and expected-value math. ### Common reverts | Error | Meaning | |-------|---------| | `MatchNotProposed(matchId)` | Match is not in `Proposed` state (already finalized or not yet proposed) | | `ChallengeWindowActive(matchId, unlockAt)` | Window has already elapsed — too late to dispute | | `InvalidOutcome(counterOutcome, numOutcomes)` | `counterOutcome` is out of range or equals the proposed outcome | | `DisputeAlreadyExists(matchId)` | Another dispute was already opened on this match | --- ## Flow 7: Respond to a dispute (as the opposing side) ### Scenario Carol opened a dispute. Dan believes the original `outcome 0` (Lakers) was correct. He wants to defend it. ### Bond calculation Round 1 bond = `initialBond × 2 = 10_000e18`. ### Steps ```javascript // 1. Approve DisputeManager for round-1 bond await sbet.approve(disputeManagerAddress, parseEther("10000")); // 2. Respond to the dispute await disputeManager.respond(disputeId); ``` Dan is locked in as the defender. State → `Escalated`, round = 1, `awaiting = Challenger`. ### Subsequent rounds If Carol responds: she posts `20_000e18`. Round 2, awaiting defender. If Dan responds again: he posts `40_000e18`. Round 3, state → `AwaitingArbiter`. ### Missing a deadline If either side fails to respond within 24h: ```javascript // Anyone (including the opposing side) can call await disputeManager.claimByDefault(disputeId); ``` The defaulting side loses all contributions to the winner. The default-ruling propagates to Core via `resolveDispute` (counterparty outcome applies). --- ## Flow 8: Recover a voided bet ### Scenario A match was voided (`VoidReason.NATURAL` — game cancelled due to weather). Alice's $100 USDC stake should be refunded. ### Steps ```javascript await sbetCore.claimVoidRefund(matchId, usdcAddress); ``` No approvals needed — this is a withdrawal from Treasury. ### Events emitted - `VoidRefund(USDC, Alice, 100e6, matchId)` (Treasury) ### What gets refunded - **Parimutuel**: `_stakes[matchId][user][token]` — the user's original stake - **Signed-position**: `longPos + shortPos` — both directions in one call Per [Decision #9], void refunds always return principal + fees. (The fee portion is pending implementation until the per-`PoolType` fee schedule is finalized.) ### Valid void reasons | Reason | Who triggers it | When | |--------|----------------|------| | `NATURAL` | `GOVERNANCE_ROLE` via `voidMatch` | Game cancelled, postponed, never happened | | `PROTOCOL` | `GOVERNANCE_ROLE` via `voidMatch` | Protocol-level abort (e.g. paused match that expired without resolution) | | `DISPUTE` | DisputeManager via `resolveDispute` | Arbiter ruled the outcome unresolvable | --- ## Flow 9: Opt into gasless auto-claim ### Scenario Erin doesn't want to pay gas to claim her winnings. She opts into the auto-claim meta-tx relay; when she wins, her payout arrives automatically minus a small fee. ### Fee schedule | Token | Fee | |-------|----:| | SBET | 2% | | Other tokens (USDC, DAI, etc.) | 3.5% | Cap: governance cannot raise any fee above **5%** (`AUTO_CLAIM_FEE_CAP_BPS = 500`). ### Opt-in ```javascript await sbetTreasury.setAutoClaimOptIn(true); ``` This sets `_autoClaimOptIn[Erin] = true`. The opt-in can also be done via meta-tx through the ERC-2771 trusted forwarder (Biconomy). ### How auto-claim fires When Erin has a winning position on a finalized match: 1. A relayer monitors for claimable positions and submits a meta-tx to Treasury's trusted forwarder. 2. The forwarder calls Treasury's `autoClaim(Erin, token, amount, matchId)` on Erin's behalf. 3. Treasury deducts the fee (e.g. `200 bps of 500 SBET = 10 SBET`), transfers the net (`490 SBET`) to Erin, and notifies StakerRewards of the fee. Events: - `PayoutReleased(SBET, Erin, 490e18, matchId, autoClaim=true)` (Treasury) - `RewardNotified(SBET, 10e18, newAccPerShare)` (StakerRewards) ### Opt-out ```javascript await sbetTreasury.setAutoClaimOptIn(false); ``` After opting out, Erin must claim manually via `claimParimutuel` / `claimSignedPosition`. ### Relayer guarantees The **trusted forwarder is immutable** (set at Treasury construction via `ERC2771Context`). The relayer cannot be rotated; a forwarder change requires a full Treasury redeploy. This eliminates signer-rotation replay vectors. --- ## Flow 10: Monitor the protocol ### For integrators Subscribe to these event streams to build real-time indicators: | Event | Contract | Meaning | |-------|----------|---------| | `MatchRegistered` | Core | New match available for betting | | `BetPlaced` / `SignedPositionOpened` | Core | Bet/position placed | | `OutcomeProposed` | Core | Grader consensus reached; challenge window starting | | `MatchFinalized` | Core | Claims are now enabled | | `MatchVoided` | Core | Refunds are now enabled | | `DisputeOpened` | DisputeManager | Dispute raised; match frozen | | `DisputeResolved` | DisputeManager | Arbiter has ruled | | `GraderSlashed` | GraderRegistryV2 | Grader lost stake | | `GlobalPauseActivated` / `CategoryPauseActivated` | GuardianCouncil | Pause engaged | | `MarketCreated` | PredictionMarketV2 | New prediction market created | | `MarketResolved` / `MarketVoided` | PredictionMarketV2 | Market resolution synced from Core | | `TradeExecuted` | PredictionAMMV2 | AMM share trade (buy or sell) | | `AutoDistributed` / `AutoRefunded` | PredictionAMMV2 | Keeper-pushed batch payout complete | | `RedeemForFailed` / `RefundForFailed` | PredictionAMMV2 | Individual payout failed in batch | ### For security monitors Watch for: - `MatchLocked` without expected governance pattern → potential griefing - `OutcomeProposed` with `tier == 3 Contentious` → high-dissent outcome - `VRFSeedFulfilled(fromFallback=true)` → match using blockhash fallback (higher risk) - `DisputeEscalatedToArbiter` → high-value dispute pending arbiter resolution --- ## Flow 11: Create a prediction market ### Scenario Dave wants to create a binary prediction market: "Will ETH be above $5000 on 2026-07-01?" with USDC collateral, 0.3% creator fee, and $10,000 initial liquidity. ### Prerequisites - PredictionMarketV2 must have `MARKET_CREATOR_ROLE` on Core (or `permissionlessCreation` must be `true`) - USDC must be on Treasury's token allowlist - Dave must approve Treasury for the Core bond (100 SBET) and PredictionMarketV2 for the initial liquidity (10,000 USDC) ### Steps ```javascript // 1. Approve Treasury for the Core match registration bond (100 SBET) await sbet.approve(treasuryAddress, parseEther("100")); // 2. Approve PredictionMarketV2 for initial liquidity await usdc.approve(predictionMarketAddress, parseUnits("10000", 6)); // 3. Create the market const marketId = await predictionMarket.createMarket({ question: "Will ETH be above $5000 on 2026-07-01?", outcomeLabels: ["Yes", "No"], category: "crypto", resolutionSource: "CoinGecko ETH/USD spot price", resolutionTime: 1751328000, // 2026-07-01 00:00 UTC expiryTime: 1751414400, // 2026-07-02 00:00 UTC token: usdcAddress, creatorFeeBps: 30, // 0.3% initialLiquidity: parseUnits("10000", 6), liquidityParam: 0 // auto-derive b from liquidity/outcomes }); ``` ### What happens internally 1. PredictionMarketV2 validates parameters and assigns a sequential `marketId` 2. Derives `coreMatchId = keccak256(PREDICTION_DOMAIN, marketId)` (domain-separated) 3. Calls `Core.registerMatch(coreMatchId, categoryHash, 2, PARIMUTUEL, ...)` 4. Transfers `initialLiquidity` from Dave to PredictionAMMV2 5. Calls `amm.seedLiquidity(marketId, 10000e6, b, Dave)` ### Events emitted - `MarketCreated(marketId, coreMatchId, Dave, question, 2)` (PredictionMarketV2) - `MatchRegistered(coreMatchId, ...)` (Core) - `LiquiditySeeded(marketId, Dave, 10000e6, b)` (PredictionAMMV2) ### Common reverts | Error | Meaning | |-------|---------| | `TooFewOutcomes` | Less than 2 outcome labels | | `TooManyOutcomes` | More than 10 outcome labels | | `PastResolutionTime` | `resolutionTime <= block.timestamp` | | `ExpiryBeforeResolution` | `expiryTime <= resolutionTime` | | `CreatorFeeTooHigh(30, 50)` | Creator fee exceeds 0.5% cap | | `TokenNotAllowed(token)` | Token not on Treasury allowlist | | `NotMarketCreator` | Caller lacks role and permissionless creation is disabled | --- ## Flow 12: Buy shares on a prediction market ### Scenario Eve thinks ETH will be above $5000. She wants to buy 100 "Yes" shares on Dave's market. ### Steps ```javascript // 1. Approve PredictionAMMV2 (NOT Treasury) for the max cost // Cost depends on LMSR pricing — estimate via getOutcomePrices first const prices = await amm.getOutcomePrices(marketId); const estimatedCost = 100e6; // ~$100 USDC at current price await usdc.approve(ammAddress, parseUnits("105", 6)); // 5% slippage buffer // 2. Buy shares await amm.buy( marketId, 0, // outcomeIndex (0 = "Yes") parseEther("100"), // 100 shares (1e18 scale) parseUnits("105", 6) // maxCost ); ``` ### Events emitted - `SharesBought(marketId, 0, Eve, 100e18, cost)` (AMM) - `TradeExecuted(marketId, 0, Eve, true, 100e18, cost, pFee, cFee, price)` (AMM) - `VolumeUpdated(marketId, newVolume)` (AMM) ### Required approvals - **USDC** approved to **PredictionAMMV2** (not Treasury, not Core). --- ## Flow 13: Sell shares on a prediction market ### Scenario Eve changes her mind and wants to sell 50 "Yes" shares. ### Steps ```javascript // No approval needed — AMM burns the ERC-1155 shares and sends collateral back await amm.sell( marketId, 0, // outcomeIndex (0 = "Yes") parseEther("50"), // 50 shares parseUnits("40", 6) // minPayout ); ``` ### Fee computation Sell fees are computed on the **average of pre-trade and post-trade price** (P0-3 hardening). This prevents sandwich attacks where a trader manipulates the price before selling. ### Events emitted - `SharesSold(marketId, 0, Eve, 50e18, payout)` (AMM) - `TradeExecuted(marketId, 0, Eve, false, 50e18, payout, pFee, cFee, price)` (AMM) --- ## Flow 14: Redeem after market resolution ### Scenario ETH is above $5000 on July 1. The grader panel proposes outcome 0 ("Yes"), the challenge window elapses, `finalize` is called. Eve holds 50 "Yes" shares and wants her payout. ### Option A: Eve redeems manually ```javascript // 1. Sync resolution (permissionless — anyone can call) await predictionMarket.syncResolution(marketId); // 2. Redeem winning shares await amm.redeem(marketId); ``` ### Option B: Keeper pushes payout (autonomous) Eve does nothing. A keeper bot detects Core finalization and calls: ```javascript // Single tx: sync + batch payout for all holders await predictionMarket.syncAndDistribute(marketId, [eveAddress, ...otherHolders]); ``` Eve receives her pro-rata share of pool collateral automatically. ### Payout formula ``` payout = (eveShares / totalWinningShares) * pool.collateral ``` `totalWinningShares` is lazily snapshotted on the first redemption call to prevent burn-reduces-totalSupply accounting drift. ### Events emitted - `MarketResolved(marketId, 0, resolvedAt)` (Market — on sync) - `SharesRedeemed(marketId, Eve, payout)` (AMM) - `RedeemedFor(marketId, Eve, payout, keeper)` (AMM — if keeper-pushed) --- ## Flow 15: Refund on a voided prediction market ### Scenario A prediction market is voided (game cancelled, unresolvable question). All shareholders get a pro-rata refund. ### Option A: User refunds manually ```javascript await predictionMarket.syncVoid(marketId); await amm.refund(marketId); ``` ### Option B: Keeper pushes refund (autonomous) ```javascript await predictionMarket.syncAndRefund(marketId, [holder1, holder2, ...]); ``` ### Refund formula All outcomes are refundable (not just the winning one): ``` refund = (holderTotalShares / grandTotalAllOutcomes) * pool.collateral ``` The grand total is snapshotted on the first refund call to prevent drift. ### Events emitted - `MarketVoided(marketId)` (Market — on sync) - `SharesRefunded(marketId, holder, amount)` (AMM) - `RefundedFor(marketId, holder, amount, keeper)` (AMM — if keeper-pushed) --- ## Approval cheat-sheet | Action | Approve this contract | For this token | Why | |--------|----------------------|----------------|-----| | `placeBet` / `openSignedPosition` | Treasury | bet token | Treasury pulls the stake | | `openDispute` | DisputeManager | SBET (bond token) | DM pulls the initial bond | | `respond` | DisputeManager | SBET (bond token) | DM pulls the round bond | | `lockMatch` | SBETCoreV2 | SBET | Core holds the match-lock bond | | `register` / `increaseStake` (grader) | GraderRegistryV2 | SBET | Registry holds grader stakes | | `stake` (SBET staker) | SBETStakerRewards | SBET | StakerRewards holds staked SBET | | `createMarket` (initial liquidity) | PredictionMarketV2 | collateral token | Market transfers to AMM for seeding | | `createMarket` (Core bond) | SBETTreasuryV2 | SBET | Core registration bond | | `buy` (prediction shares) | PredictionAMMV2 | collateral token | AMM holds trading collateral directly | --- ## Next - [State machine](./state-machine.md) — the full transition graph behind these flows - [Grader operator guide](./grader-operator-guide.md) — if you want to participate as a grader - [Security model](./security-model.md) — economic analysis of dispute-raising