# Smart contract constraints (CasinoGameFacet + ICasinoGameV2) Know what your casino contract must quote, return, and enforce. Work through session hooks, payout limits, randomness rules, and the reserve checks behind every wager. :::note[Current SDK checkout] - `@chain-protocol/casino-sdk@0.7.0` This guidance reflects this checkout and may include unreleased changes. [Casino changelog](/sdk/casino/changelog/) ::: Summarized from `packages/contracts/contracts/Casino/CasinoGameFacet.sol`, `CasinoSessionBase.sol`, `CasinoRandomnessFacet.sol`, `CasinoSessionRecoveryFacet.sol`, `CasinoConfigFacet.sol`, `quote/QuoteFiguresLib.sol`, `pricing/PricingLib.sol`, `risk/VaultReserveLib.sol` and `Shared/Fee/FeeLib.sol`. On the diamond these facets share one storage layout and one ABI surface, so from a game's perspective they behave as a single host. Treat source files as authoritative if this doc drifts. ## Game interface Your game **must** implement [`ICasinoGameV2`](/sdk-source/fd19ebdcb1c8/ICasinoGameV2.sol.txt): - **`quoteCaps(wager, gameData)`** — `maxEscrowStake >= wager` or `openSession` reverts with `CasinoGameFacet__InvalidSessionCaps`. `maxReservedProfit` is the session's largest net win, which `openSession` holds to the single-win cap. - **`quoteRiskParams(wager, gameData)`** — Returns `maxPayout`, **`probabilityWad`** (1e18 = 100%), `expectedPayout`, `bodyVarianceScaled`. These four figures set your fees and your bet-size cap, and a quote that fails a guardrail is refused: see [What your quotes decide](#what-your-quotes-decide). - `probabilityWad` must be **≤ 1e18** or `openSession` reverts with `QuoteFiguresLib__ProbabilityAboveOne`. - `bodyVarianceScaled` was renamed from `subJackpotVarianceScaled`; same position and type, so the ABI encoding is unchanged. - **`onSessionStart` / `onPlayerAction` / `onRandomness`** — Pure/view step handlers; return `StepResult` with `newGameState`, `escrowDelta`, `reservedProfitDelta`, `nextPhase`, `requestRandomnessNow`, `payout`. - **`quoteForfeitPayout(ctx)`** — Current cash-out value (stake + accrued winnings) derived from `ctx.gameState`; return `0` when nothing is cashable mid-round. When a session is forfeited after the action deadline the facet pays the player this quote (clamped to `escrowedStake + reservedProfit`) minus a 10% cut (`FORFEIT_WINNINGS_CUT_BPS = 1000`). The call is defensive (gas-capped staticcall, 32-byte return): if it reverts, returns malformed data, or the game predates this method, the forfeit pays `0`. Quote a real value only if your game has a true anytime cash-out fully determined by already-revealed state (mines-style); if mid-round value depends on unresolved randomness or hidden state (blackjack-style), return `0` — any quote there is an adverse-selection exploit against the vault (see `CHAIN_WTF_CASINO_GAMES.md`). - **Independent sessions (MUST)** — Every session requests its own randomness, and its outcome depends only on that randomness, its own state and its own wagers. A game whose sessions settle on one shared draw (one roll, spin or deal that decides several sessions) is **not certified**: see [Reserve](#reserve). - **One random value per request (MUST)** — Only `onSessionStart` and `onPlayerAction` may move to `WAITING_RANDOMNESS`. If `onRandomness` does, the callback reverts with `CasinoGameFacet__RandomnessRequestedFromCallback`, the delivery fails and the session can only be cancelled with `cancelStuckRandomness`, which refunds its whole remaining escrow. Derive every random value a step needs from the one `bytes32` it receives: see [`CHAIN_WTF_CASINO_GAMES.md`](/sdk/casino/building-games/#one-random-value-per-request). - **Unbiased d6 from `bytes32` randomness (MUST)** — If you map VRF/facet bytes to faces `1..6`, you **must** use rejection sampling (`byte < 252` then `(byte % 6) + 1`). **Do not** use raw `(randomness[i] % 6) + 1` on a byte — that is modulo-biased. See [`RANDOMNESS_DICE.md`](/sdk/casino/randomness-dice/) for canonical Solidity/TypeScript and agent checklist. ## What your quotes decide `openSession` reads `quoteCaps` and `quoteRiskParams` once, for the wager and `gameData` of that session. The answers decide three things: | Decided at `openSession` | Read from | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Your game dev fee and every other fee of the session | `expectedPayout` | | The bet-size cap: the largest wager the vault accepts | `expectedPayout`, `maxPayout`, `probabilityWad`, `bodyVarianceScaled`, `maxReservedProfit` | | Whether the vault's reserve can take the session | `expectedPayout`, `maxPayout`, `probabilityWad`, `bodyVarianceScaled` | The quote at `openSession` covers the whole session. Stake added later, such as a blackjack double, adds no fee and is not priced again. So in a game with added stake, the four `quoteRiskParams` figures describe the **net payout** per opening wager: the payout less the stake added after `openSession`. A lost double nets -1 wager. Graph titles quote this way. ### What each return must mean | Return | It must be | | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `expectedPayout` | RTP × `wager`: the mean total payout of the session, in token base units. In a game with player choices it is the RTP **under the player's best policy**, not under average play. | | `maxPayout` | The largest total payout of the session, in token base units. Never below `expectedPayout`. | | `probabilityWad` | The probability that the session pays **exactly `maxPayout`**, in WAD (1e18 = 100%). Not the chance of any win. The same for every wager on one `gameData`. | | `bodyVarianceScaled` | The **body variance**: the variance of the payout with the `maxPayout` outcome excluded, in **wei² × 1e18**. It grows with the square of the wager. `0` only when `maxPayout` is the sole winning outcome, and then `expectedPayout` is `probabilityWad × maxPayout / 1e18`. | | `maxReservedProfit` (`quoteCaps`) | The session's largest net win: the most the vault can owe above the escrowed stake. | - Net payout can pull the body's mean below zero. The host saturates that mean at zero, which drops a term that raises the variance, so add the term back into `bodyVarianceScaled`. Call `probabilityWad × maxPayout / 1e18` the top mean. When the top mean exceeds `expectedPayout`, add `2 × topMean × (topMean − expectedPayout)` to the body variance before you scale it by 1e18. The [graph engine](/sdk/casino/graph-engine/) does this for every title in which a player can add stake. - Quote the same paytable `onRandomness` pays, for the same `gameData`. [`SLOTS_RISK_AND_RESERVES.md`](/sdk/casino/slots-risk-and-reserves/#body-variance) has the body variance formula and a worked example. - Fees are paid on the edge you quote, whatever players do. When best play returns more than `expectedPayout`, the vault pays fees on an edge it never earns. - `game-verify`, which the platform runs on every build you push, has a check named `quote-figures`. It compares the RTP and variance of your quote with what sampled sessions paid. A mismatch fails the build of a single-step game. On a game with player actions it marks the build for review instead, because the figures depend on how the sessions were played. ### Guardrails: the errors a bad quote raises The facet derives RTP (`expectedPayout / wager`), the maximum multiplier `M` (`maxPayout / wager`) and the plain variance from the quote, per unit of wager: ``` variance = bodyVariance + p · (1 − p) · M² − 2 · p · M · (RTP − p · M) ``` with `p = probabilityWad / 1e18` and `bodyVariance = bodyVarianceScaled / (wager² × 1e18)`. A quote that fails a guardrail reverts `openSession`: | Error | Raised when | | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `QuoteFiguresLib__RtpOutsideBand(rtpWad, minRtpWad, maxRtpWad)` | RTP is below the lower bound or above the upper bound of the council-set RTP band. A quote exactly at a bound passes. The default band is 60% to 99.9%, and its upper bound is always below 100%. | | `QuoteFiguresLib__ProbabilityAboveOne(probabilityWad)` | `probabilityWad` is above 1e18. | | `QuoteFiguresLib__MaxPayoutBelowExpectedPayout(maxPayout, expectedPayout)` | `maxPayout` is below `expectedPayout`. | | `QuoteFiguresLib__BodyVarianceMissing()` | `bodyVarianceScaled` is `0` and `expectedPayout` is above `probabilityWad × maxPayout / 1e18`: outcomes below `maxPayout` pay on average, so they have a variance to quote. A gap that comes from rounding `maxPayout` or `probabilityWad` down by less than one unit each passes. | | `QuoteFiguresLib__VarianceNotPositive()` | The plain variance is zero or negative. This happens when every draw pays the same amount, and when several tiers win and `bodyVarianceScaled` is far below their real variance. | - **Game approval runs the same guardrails** on your reference quote: `quoteRiskParams` at one whole token of each vault's asset (`10 ** decimals`; one 18-decimal token if no vault is registered yet) with empty `gameData`. A game that reverts on empty `gameData` is not checked at approval, and every one of its quotes is checked at `openSession`. - **A positive variance does not make a quote right.** A body variance quoted too low passes the guardrail, understates your game's risk and puts its bet-size cap above what the vault should accept. Quote it truthfully; `quote-figures` compares it with play. - **Very small wagers.** There is no minimum wager, but integer rounding can take the RTP of a wager of a few base units below the band, and that wager is refused. - The local simulator's host does not run the guardrails. ### Fee split Your game dev fee is a share of the session's **edge**, set at `openSession` and paid when the session settles, whether the player won or lost. ``` edge = wager − expectedPayout game dev fee = edge × game dev cut net edge = edge − game dev fee LP income = net edge × LP cut (stays in the vault) protocol fee = net edge × protocol fee rate operator fee = net edge − LP income − protocol fee ``` Worked example: a \$100 wager at 96% RTP, with a 10% game dev cut, the LP cut at 35% and a 5% protocol fee rate. | Amount | Value | | ------------ | ----: | | Edge | \$4.00 | | Game dev fee | \$0.40 | | Net edge | \$3.60 | | LP income | \$1.26 | | Protocol fee | \$0.18 | | Operator fee | \$2.16 | - **Your cut.** The game dev cut is 10% of the edge by default, and governance can set another rate for one game. `FeeFacet.getGameDevBps(game)` returns the rate in basis points. - **Who is paid.** The fee goes to the game's **game developer**, the wallet `FeeFacet.getGameDeveloper(game)` returns when the session settles. The creation call names it: anyone may name any developer, since the name only directs the fee, and with an authorship the named developer must be the authorship's writer. A session opened while the game has no game developer pays no game dev fee, whatever the rate. - **Paid at settlement.** The fee is credited to the game developer's claimable balance when the session settles or is forfeited. Read the balance with `FeeFacet.getClaimable(account, token)` and withdraw it with `FeeSettlementFacet.claim(token, to)`. - **A cancelled session pays no fee.** `cancelStuckRandomness` refunds the stake and credits no fee to anyone. - **The LP cut does not change your fee.** It moves between 0.1% and 35% of the net edge with the vault's recent risk, and the operator fee absorbs the difference. A session keeps the game dev cut, the LP cut and the protocol fee rate of its `openSession`. - **The player is paid first.** If the vault cannot fund the fees in full after the payout, they shrink in proportion and nothing is owed later. - Each settlement emits `CasinoSessionFeesSettled` with the four amounts. ### Bet-size cap **Do not compute the cap in your game.** It moves with the vault. Ask the host with `hostApi.getMaxWager({ gameData })`: see [Asking the host for the maximum wager](/sdk/casino/building-games/#asking-the-host-for-the-maximum-wager). The bet-size cap is the smallest of three limits. `openSession` admits a wager that is within its cap and that the vault's reserve can take. A wager above its cap reverts with the error of the smallest limit. Amounts in these errors are in token base units. | Check | What it bounds | Error | | --------------- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | | Single-win cap | `maxReservedProfit`, to a council-set share of the vault's `totalAssets()` (16.25% by default) over the drawdown factor. | `CasinoGameFacet__NetWinAboveSingleWinCap(maxReservedProfit, singleWinCap)` | | Risk budget | The wager. The limit falls as your variance rises, and rises with your edge and with the vault's size. | `CasinoGameFacet__WagerAboveRiskBudget(wager, riskBudget)` | | Break-even rule | The wager, to `2 × net edge rate / a` with `a = risk price × drawdown factor × variance / (2 × vault assets)`. | `CasinoGameFacet__WagerAboveBreakEvenRule(wager, breakEvenRule)` | | Reserve | Not part of the cap: the reserve of the open sessions plus this one must fit in the vault. See [Reserve](#reserve). | `CasinoGameFacet__InsufficientPortfolioReserve(requiredReserve, maxAllowedReserve)` | - `net edge rate = (1 − RTP) × (1 − game dev cut)`, and `variance` is the plain variance of [Guardrails](#guardrails-the-errors-a-bad-quote-raises). A higher variance, a lower edge and a smaller vault each lower the cap. - The risk price is a council-set figure per vault, 15 by default. - The drawdown factor is 1 while the vault's share price is at its drawdown reference, the price its losses are measured from. It rises as the share price falls below that, so caps shrink after the vault has lost money. - A falling cap leaves the game open at a smaller maximum, and there is no minimum wager. - The host reads `CasinoConfigFacet.getMaximumWager(vault, game, gameData)`, which returns the bet-size cap lowered to what the reserve can take, the limit that binds and the current LP cut. - `getMaximumWager` quotes your game at one whole token and scales the quote linearly. For a game whose quotes are not proportional to the wager its answer is an estimate; `openSession` is the authority. ## Facet orchestration - **Whitelist**: `openSession` and any action that adds escrow revert with `CasinoGameFacet__GameNotWhitelisted` if the game is not whitelisted. A raw-SDK game, created through the casino bytecode deployer, stays pending until the security council approves it; the guardian or the council can disable a game. A session opened before the game was disabled still takes actions that add no escrow, randomness callbacks, forfeits and stuck-randomness cancels, so it always plays out. - **Vault**: Must be a protocol vault or `CasinoGameFacet__UnsupportedVault`. A vault with no assets refuses every wager with `PricingLib__NoVaultAssets`. - **Wager**: A zero wager reverts with `CasinoGameFacet__InvalidWager`. - **Randomness provider** must be set on the facet or `CasinoGameFacet__RandomnessProviderNotSet`. - **`openSession` flow**: Calls `quoteCaps` and `quoteRiskParams`, checks the guardrails, sets the session's fees, checks the bet-size cap, commits the reserve, pulls the wager from the player, then calls **`onSessionStart` once** with the real session context. Quote, cap and reserve checks run first, so their errors take precedence over a revert from `onSessionStart`. - **Payout**: Capped at `escrowedStake + reservedProfit` with no slack (`CasinoGameFacet__InvalidPayout`) — see [Payout cap](#payout-cap). - **Escrow / reserved profit**: Only `onSessionStart` and `onPlayerAction` may change the escrow. A nonzero `escrowDelta` from `onRandomness` reverts the callback with `CasinoGameFacet__EscrowChangedFromCallback`, the delivery fails and the session can only be cancelled with `cancelStuckRandomness`. Caps enforced (`EscrowCapExceeded`, `ReservedProfitCapExceeded`). - **Phases**: Invalid transitions revert (`CasinoGameFacet__InvalidStepTransition`). Example: you cannot request randomness from a terminal phase incorrectly. - **Reentrancy**: All value-moving entrypoints (`openSession`, `submitAction`, `onCasinoRandomnessFulfilled`, `forfeitExpiredSession(s)`, `cancelStuckRandomness`) are `nonReentrant` (OpenZeppelin `ReentrancyGuardTransient`, a diamond-safe EIP-1153 guard), and settlement finalizes session state before any token transfer (checks-effects-interactions). Game step handlers are `view` (reached via `staticcall`), so a game contract cannot reenter. ## Payout cap Every step applies its `escrowDelta` and `reservedProfitDelta` to the session first. When the step ends the round (a terminal `nextPhase` such as `SETTLED`), the facet then requires: ``` payout <= escrowedStake + reservedProfit // after this step's deltas ``` Otherwise the step reverts with `CasinoGameFacet__InvalidPayout(maxAllowedPayout, payout)` (`LocalCasinoHost__InvalidPayout` in the simulator). On the randomness path, the fulfillment callback reverts, so the round stays in `WAITING_RANDOMNESS` until someone calls `cancelStuckRandomness`, which refunds only the stake. The player loses the win. Both traps below only surface on large wins, which are rare, so casual testing misses them. Test your top multiplier explicitly. ### Reserve and payout must agree to the wei **Symptom:** only wins at the top multiplier revert with `InvalidPayout`, and `maxAllowedPayout` is a few base units below `payout`. The reserved profit your `onSessionStart` commits (which must also stay within `quoteCaps`' `maxReservedProfit`) is the entire budget for the win. If `onRandomness` computes the payout through a separate formula, rounding can put it a base unit above that budget, and every top-multiplier win reverts. Route `quoteCaps`, `quoteRiskParams`, `onSessionStart` and `onRandomness` through one payout function so the numbers match exactly. ### Don't release reserved profit on the settling step **Symptom:** every win above 1x reverts with `InvalidPayout`, and `maxAllowedPayout` equals the stake. Returning a negative `reservedProfitDelta` (e.g. `-maxReservedProfit`) from the step that settles lowers the cap before the payout check, leaving only the stake. Return `reservedProfitDelta = 0` when settling: `_finalizeSession` releases the reserve itself. The same applies to a negative `escrowDelta` on the settling step. It refunds the stake and shrinks the cap by the same amount. Releasing reserve on a non-terminal step (e.g. a mines cash-out bound dropping) is allowed, but it lowers the cap for every later payout in that session. ## Default governance constants (facet) These are **configurable** by `SECURITY_COUNCIL_ROLE` but defaults matter for understanding behavior: | Constant | Default | Meaning (high level) | | --------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------- | | `DEFAULT_ACTION_TIMEOUT_SECONDS` | 86400 | Seconds the player has to act before the forfeit path applies (payout = 90% of `quoteForfeitPayout`). | | `MIN_RANDOMNESS_TIMEOUT_SECONDS` | 45 | Default and floor. Seconds before `cancelStuckRandomness` may refund the stake. A later delivery still settles. | | `DEFAULT_CONFIDENCE_MULTIPLIER_BPS` | 372 | VaR multiplier (~3.72 for 99.99% in their model). | | `DEFAULT_SAFETY_MULTIPLIER_BPS` | 10000 | 1.0× safety on VaR leg (no padding; the reserve is the solvency floor). | | `DEFAULT_MIN_RESERVE_RATIO_BPS` | 1500 | Minimum reserve ratio. | | `DEFAULT_HEAVY_TAIL_MULT_THRESHOLD` | 100 | `maxPayout/wager > this` participates in heavy-tail check. | | `DEFAULT_HEAVY_TAIL_PROB_THRESHOLD_WAD` | 1e15 | **0.1%** in WAD — jackpot probability below this triggers heavy-tail path together with multiplier threshold. | | `DEFAULT_MIN_RTP_WAD` | 0.60e18 | Lower bound of the RTP band a quote must fall within. | | `DEFAULT_MAX_RTP_WAD` | 0.999e18 | Upper bound of the RTP band. | | `DEFAULT_RISK_PRICE_WAD` | 15e18 | Risk price of a vault. Raising it lowers the risk budget and the break-even rule. | | `DEFAULT_SINGLE_WIN_CAP_SHARE_WAD` | 0.1625e18 | Share of a vault's `totalAssets()` that one session's net win may reach. | Heavy-tail detection in library: `VaultReserveLib.isHeavyTail(maxPayout, wager, probabilityWad, multiplierThreshold, probThresholdWad)`. ## Reserve The reserve is a requirement separate from the bet-size cap. A wager under its cap is still refused when the vault's available liquidity cannot cover the reserve of the open sessions plus the new one. - `CasinoGameFacet__InsufficientPortfolioReserve(requiredReserve, maxAllowedReserve)` when the reserve does not fit. - The reserve counts open sessions as independent. Sessions that settle on one shared draw win or lose together, and the reserve cannot count them as one position at their combined maximum payout, so a game built that way is not certified. - The reserve expects the vault to keep a session's LP income and nothing else of its edge, so `expectedPayout` moves the reserve as well as the fees. - Every heavy-tail session of a vault shares one jackpot tier, whatever its game or `gameData`. The tier holds the largest open `maxPayout - wager` times the 99.99% count of jackpots that may hit together. Each session adds its `probabilityWad` to that count weighted by its jackpot over the largest, so sessions may quote different probabilities. Heavy-tail liabilities stay out of the minimum reserve ratio floor (ADR 0026). ## Build In this monorepo, contracts are built with **`vp run build`** from `packages/contracts` (not raw `hardhat compile` only) so postbuild steps run — see root `AGENTS.md`.