Skip to content

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.

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.

Your game must implement ICasinoGameV2:

  • 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.
    • 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.
  • 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.
  • 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 for canonical Solidity/TypeScript and agent checklist.

openSession reads quoteCaps and quoteRiskParams once, for the wager and gameData of that session. The answers decide three things:

Decided at openSessionRead from
Your game dev fee and every other fee of the sessionexpectedPayout
The bet-size cap: the largest wager the vault acceptsexpectedPayout, maxPayout, probabilityWad, bodyVarianceScaled, maxReservedProfit
Whether the vault’s reserve can take the sessionexpectedPayout, 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.

ReturnIt must be
expectedPayoutRTP × 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.
maxPayoutThe largest total payout of the session, in token base units. Never below expectedPayout.
probabilityWadThe 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.
bodyVarianceScaledThe 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 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 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.

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:

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

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.

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

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.

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.

CheckWhat it boundsError
Single-win capmaxReservedProfit, to a council-set share of the vault’s totalAssets() (16.25% by default) over the drawdown factor.CasinoGameFacet__NetWinAboveSingleWinCap(maxReservedProfit, singleWinCap)
Risk budgetThe 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 ruleThe wager, to 2 × net edge rate / a with a = risk price × drawdown factor × variance / (2 × vault assets).CasinoGameFacet__WagerAboveBreakEvenRule(wager, breakEvenRule)
ReserveNot part of the cap: the reserve of the open sessions plus this one must fit in the vault. See Reserve.CasinoGameFacet__InsufficientPortfolioReserve(requiredReserve, maxAllowedReserve)
  • net edge rate = (1 − RTP) × (1 − game dev cut), and variance is the plain variance of Guardrails. 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.
  • 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.
  • 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.

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.

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

Section titled “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.

These are configurable by SECURITY_COUNCIL_ROLE but defaults matter for understanding behavior:

ConstantDefaultMeaning (high level)
DEFAULT_ACTION_TIMEOUT_SECONDS86400Seconds the player has to act before the forfeit path applies (payout = 90% of quoteForfeitPayout).
MIN_RANDOMNESS_TIMEOUT_SECONDS45Default and floor. Seconds before cancelStuckRandomness may refund the stake. A later delivery still settles.
DEFAULT_CONFIDENCE_MULTIPLIER_BPS372VaR multiplier (~3.72 for 99.99% in their model).
DEFAULT_SAFETY_MULTIPLIER_BPS100001.0× safety on VaR leg (no padding; the reserve is the solvency floor).
DEFAULT_MIN_RESERVE_RATIO_BPS1500Minimum reserve ratio.
DEFAULT_HEAVY_TAIL_MULT_THRESHOLD100maxPayout/wager > this participates in heavy-tail check.
DEFAULT_HEAVY_TAIL_PROB_THRESHOLD_WAD1e150.1% in WAD — jackpot probability below this triggers heavy-tail path together with multiplier threshold.
DEFAULT_MIN_RTP_WAD0.60e18Lower bound of the RTP band a quote must fall within.
DEFAULT_MAX_RTP_WAD0.999e18Upper bound of the RTP band.
DEFAULT_RISK_PRICE_WAD15e18Risk price of a vault. Raising it lowers the risk budget and the break-even rule.
DEFAULT_SINGLE_WIN_CAP_SHARE_WAD0.1625e18Share 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).

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

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.