Skip to content

Slots: quoteRiskParams and tiered reserves

A jackpot changes more than the payout animation. Calculate the risk quotes and tiered reserves that shape slot fees, wager limits, and vault exposure.

Slot paytables are heavy-tailed: a tiny probability mass at huge multipliers dominates variance. The facet reads two quotes at openSession:

  1. quoteCaps — per-session escrow / reserved-profit ceilings. maxReservedProfit is held to the single-win cap.
  2. quoteRiskParams — the four figures that set the session’s fees, its bet-size cap and its share of the aggregate vault reserve (VaR + jackpot tier).

A quote that fails a guardrail is refused at whitelisting and at every openSession. A quote that passes and is still wrong pays fees on the wrong edge, gets the wrong maximum wager, and risks InsufficientPortfolioReserve at openSession.

This document covers the quote and the reserve. The fee split, the bet-size cap and their errors are in Contract Constraints → What your quotes decide.

What to return for a typical single-spin slot

Section titled “What to return for a typical single-spin slot”

Align with the interface in ICasinoGameV2.sol:

  • maxPayout: Largest total payout for the round (e.g. wager * topMultiplier), never below expectedPayout. Used for the bet-size cap, the high-multiplier floor and heavy-tail detection.
  • probabilityWad: Probability that the round pays exactly maxPayout, in WAD (1e18 = 100%): the jackpot (top) tier alone, not the chance of any win. The facet + VaultReserveLib use this for tiered jackpot pooling when isHeavyTail is true.
    • Must be ≤ 1 WAD.
  • expectedPayout: RTP × wager, the mean payout of the round. In a game with player choices, quote the RTP under the player’s best policy. The edge, wager − expectedPayout, is what every fee of the session is cut from: on the fictional paytable below (RTP 92%) a 10% game dev cut pays you 0.8% of every wager, won or lost. The LP income cut from the same edge is all the reserve expects the vault to keep of the session.
  • bodyVarianceScaled: the body variance — the variance of the round’s payout distribution with the top tier excluded, per bet, in wei² × 1e18 (see below). Return 0 only if the top tier is the game’s sole winning outcome (coinflip-style). A multi-tier paytable that returns 0 is under-measured: for a rich slot the body is ~89% of the variance.

The protocol pins one variance figure per bet at openSession:

pinnedVariance = bodyVariance + p_top · (1 − p_top) · maxPayout²
  • The top-tier binary term is computed on chain from your probabilityWad and maxPayout.
  • The body variance is yours to supply. It is counted for every bet, heavy-tail or not.
  • A quote is checked against guardrails at whitelisting and again at every openSession. Each has its own error:
    • QuoteFiguresLib__RtpOutsideBand — RTP (expectedPayout / wager) is outside the council-set RTP band, 60% to 99.9% by default.
    • QuoteFiguresLib__MaxPayoutBelowExpectedPayout — maxPayout is below expectedPayout.
    • QuoteFiguresLib__BodyVarianceMissing — the body variance is 0 although expectedPayout is above probabilityWad × maxPayout / 1e18, so tiers below the top one pay.
    • QuoteFiguresLib__VarianceNotPositive — the plain variance of the whole payout is not positive. It goes negative when several tiers win and the body variance is quoted far too low.
  • The plain variance formula and what whitelisting quotes are in Contract Constraints → Guardrails.
  • A body variance quoted too low can still pass. It understates the game’s risk and raises its bet-size cap above what the vault should accept, so quote it truthfully: the quote-figures check of game-verify, run on every build you push, compares the quoted RTP and variance with sampled play.

Units. The reserve stores variance in wei² × 1e18 so that sqrt(sum / 1e18) is in wei. For a body standard deviation of σ_body tokens per unit wager:

uint256 bodySigma = Math.mulDiv(wager, bodySigmaWad, 1e18); // wei
bodyVarianceScaled = bodySigma * bodySigma * 1e18;

where bodySigmaWad = σ_body × 1e18. Equivalently, from the paytable (unit wager, multipliers M_k, probabilities p_k, top tier excluded):

σ_body² = Σ_{k ≠ top} p_k · M_k² − (Σ_{k ≠ top} p_k · M_k)²

VaultReserveLib.isHeavyTail is roughly:

  • maxPayout / wager > governance multiplier threshold (default 100), and
  • probabilityWad < governance probability threshold (default 0.1% = 1e15 WAD).

Then the tiered model applies: explicit jackpot reserve + body-only VaR instead of naive normal VaR on the full tail. The top-tier binary term is swapped out of the VaR because the jackpot tier covers it; the body variance stays in.


Single-spin game: outcome is one row; payout = wager × multiplier (0 = loss). Probabilities sum to 1. RTP ≈ 92% (house edge 8%).

TierMultiplier MProbability pRTP contribution p·M
Jackpot5 000×2×10⁻⁶ (1 in 500 000)0.01
Major200×5×10⁻⁵0.01
Big50×0.0020.10
Small10×0.050.50
Mini2×0.150.30
Loss0×0.7959480.00
Σ1.000000RTP = 0.92

Checklist for risk:

  • maxPayout / wager = 5 000 → above default multiplier threshold 100.
  • Jackpot probability = 2×10⁻⁶ < 0.001 (10⁻³) → below default probability threshold when expressed in WAD (2×10⁻⁶ × 1e18 = 2×10¹² WAD, and 1e15 WAD = 0.1% — 2×10¹² < 1e15). So isHeavyTail is true with defaults: tiered reserve applies on-chain.

Example: tiered VaR (how the protocol uses your quote)

Section titled “Example: tiered VaR (how the protocol uses your quote)”

Numbers are illustrative (governance multipliers, N, and w are examples).

Given

  • Top tier: multiplier Mmax = 5 000, probability pJ = 2×10⁻⁶ (this is what you expose as probabilityWad = 2e12 in WAD terms: p_J * 1e18).
  • Suppose N = 100 concurrent heavy-tail sessions on the vault, all on this game, w = 10 tokens per spin (largest active wager wmax = 10 for the illustration).
  • Confidence z = 3.72 (the portfolio default, confidenceMultiplierBps = 372 → 99.99%). It drives the Tier-2 VaR. The jackpot count below holds the same 99.99%.

Tier 1 — jackpot reserve

A vault’s heavy-tail sessions share one jackpot tier, whatever their game or gameData. The tier holds the vault’s largest open jackpot liability, maxPayout - wager, times a count of simultaneous jackpots. The count is Poisson with mean λ: each session’s top-tier probability weighted by its jackpot over the largest, summed. With every jackpot equal, as here, λ = N · pJ. The reserve holds the smallest count kj whose Poisson probability of at most kj jackpots is 99.99% or more, floored at 1 and clamped to N (you can never have more concurrent jackpots than open bets):

λ=N⋅pJ=100⋅2×10−6=2×10−4\lambda = N \cdot p_J = 100 \cdot 2\times 10^{-6} = 2\times 10^{-4} P(0)=e−λ≈0.99980<0.9999,P(≤1)=e−λ(1+λ)≈0.99999998≥0.9999 ⇒ kj=1P(0) = e^{-\lambda} \approx 0.99980 < 0.9999,\qquad P(\le 1) = e^{-\lambda}(1 + \lambda) \approx 0.99999998 \ge 0.9999 \ \Rightarrow\ k_j = 1

Above an expected count of 30 the count is the normal bound with its skew correction:

kj=⌈λ+zλ+z2−16⌉k_j = \left\lceil \lambda + z\sqrt{\lambda} + \frac{z^2 - 1}{6} \right\rceil jackpotReserve≈kj⋅(Mmax⁡−1)⋅wmax⁡=1⋅4999⋅10=49990 tokens\text{jackpotReserve} \approx k_j \cdot (M_{\max} - 1) \cdot w_{\max} = 1 \cdot 4999 \cdot 10 = 49990\ \text{tokens}

So one full jackpot-sized loss to the vault is covered before applying normal-VaR on the rest of the distribution. Unlike a flat coverage factor, this self-adjusts to traffic: as N (hence λ) grows, the reserved jackpot count rises with it at the same 99.99%, and it never exceeds the live session count.

Tier 2 — body (sub-jackpot) mass

The jackpot tier contributes pJ·Mmax² = 2×10⁻⁶ × 5 000² = 50 to the second moment of the multiplier. The protocol swaps that binary term out of the VaR (the jackpot tier covers it) and runs the portfolio VaR on the body variance you quoted in bodyVarianceScaled. For the fictional paytable, per unit wager and top tier excluded:

σbody2=∑k≠JpkMk2−(∑k≠JpkMk)2=(2+5+5+0.6)−0.912≈11.77\sigma_{\text{body}}^2 = \sum_{k \ne J} p_k M_k^2 - \Big(\sum_{k \ne J} p_k M_k\Big)^2 = (2 + 5 + 5 + 0.6) - 0.91^2 \approx 11.77

so with w = 10 the body standard deviation is ≈ 3.43 × 10 = 34.3 tokens per spin and, over a six-decimal token such as USDC, the quote is bodyVarianceScaled = (34.3e6)² × 1e18. If you return 0 here, the Tier-2 VaR sees no variance at all for this slot.

The VaR is z · σ of the open sessions less what the vault expects to keep of them. That is the LP income each session pinned at openSession: the LP cut’s share of the edge left after the game dev fee. The game dev fee, the protocol fee and the operator fee are paid out whatever the outcome, so the reserve does not count them. With a 10% game dev cut and the LP cut at 35%, a 10-token spin at 92% RTP pins 10 × 8% × 90% × 35% = 0.252 tokens:

Tier 2=max⁡(0, zN σbody w−N⋅0.252)=3.72⋅10⋅34.3−25.2≈1251 tokens\text{Tier 2} = \max\big(0,\ z \sqrt{N}\, \sigma_{\text{body}}\, w - N \cdot 0.252\big) = 3.72 \cdot 10 \cdot 34.3 - 25.2 \approx 1251\ \text{tokens}

Contrast — naive worst case

100 spins × worst loss ≈ 100 × (5 000 × 10) = 5 000 000 tokens reserved if you reserved full worst-case per seat — the tiered model avoids that by separating the rare jackpot from the bulk of the paytable.


For a single-spin slot, the player posts wager once; there is no extra stake mid-round.

  • maxEscrowStake: at least wager (typically maxEscrowStake = wager).
  • maxReservedProfit: worst-case vault liability above the wager for that round: max(0, maxPayout - wager) when the worst case is a single-line jackpot on wager.

If gameData selects paylines or denomination, decode it the same way in quoteCaps and quoteRiskParams so caps and risk always match the round you will run in onSessionStart / onRandomness.

function quoteCaps(uint256 wager, bytes calldata gameData)
external
view
returns (uint256 maxEscrowStake, uint256 maxReservedProfit)
{
Paytable memory t = paytableFor(gameData);
uint256 maxPayout = wager * t.jackpotMultiplier; // e.g. 5000 * wager for top tier
maxEscrowStake = wager;
maxReservedProfit = maxPayout > wager ? maxPayout - wager : 0;
}

(Adjust naming/scaling to your storage; the idea is: maxReservedProfit = peak profit the vault might owe beyond escrowed stake for this session.)


Contract surface (from ICasinoGameV2):

  • maxPayout: worst-case token payout for the round (same units as wager).
  • probabilityWad: pjackpot × 1e18 for the top tier only (the tier with largest multiplier): the probability of exactly maxPayout.
  • expectedPayout: RTP × wager in token units, i.e. ∑kpk⋅payoutk\sum_k p_k \cdot \text{payout}_{k} for one spin. It sets the session’s edge and so its fees.
  • bodyVarianceScaled: the body variance of one spin with the top tier removed, in wei² × 1e18 (see Body variance). 0 only for a single-tier game.

Worked mapping for the fictional paytable (one spin, wager = w):

Return fieldValue
maxPayoutw * 5000
probabilityWad2e12 — i.e. (2e-6) * 1e18
expectedPayout(w * 9200) / 10000 if RTP is exactly 92% = 9200 BPS
bodyVarianceScaledbodySigma² * 1e18 with bodySigma = w * 3.43e18 / 1e18 (σbody ≈ 3.43 per unit wager)
uint256 internal constant WAD = 1e18;
uint16 private constant RTP_BPS = 9200; // 92.00%
// pJackpot = 2e-6 => probabilityWad = 2e12
uint256 private constant JACKPOT_PROB_WAD = 2e12;
// sigma of the paytable with the jackpot tier removed, per unit wager (WAD): sqrt(11.77) ~= 3.43
uint256 private constant BODY_SIGMA_WAD = 3.43e18;
function quoteRiskParams(uint256 wager, bytes calldata gameData)
external
view
returns (
uint256 maxPayout,
uint256 probabilityWad,
uint256 expectedPayout,
uint256 bodyVarianceScaled
)
{
Paytable memory t = paytableFor(gameData);
maxPayout = Math.mulDiv(wager, t.jackpotMultiplier, 1); // e.g. 5000 * w
probabilityWad = JACKPOT_PROB_WAD; // must match actual top-tier probability
expectedPayout = Math.mulDiv(wager, RTP_BPS, 10_000);
uint256 bodySigma = Math.mulDiv(wager, BODY_SIGMA_WAD, WAD);
bodyVarianceScaled = bodySigma * bodySigma * WAD;
}

Invariants you must preserve

  1. probabilityWad is exactly the marginal probability of the highest-multiplier winning outcome, the one that pays maxPayout — not “any win”, not average win chance. Return the same value for every wager on one gameData: the jackpot tier weights it by the session’s jackpot to count the jackpots that may hit together.
  2. expectedPayout must match the same paytable you simulate in onRandomness (same RTP). Fees are paid on the edge it implies, whatever the outcome.
  3. If you have multiple gameData variants (different RTP skins), recompute maxPayout, probabilityWad, expectedPayout and bodyVarianceScaled from the same tables.
  4. bodyVarianceScaled excludes exactly one tier — the one whose probability you return as probabilityWad — so the on-chain binary term and your body term never double count.

Implementing session phases (typical RNG slot)

Section titled “Implementing session phases (typical RNG slot)”
  1. onSessionStart: Decode ctx.gameData, commit newGameState (e.g. pending spin), set nextPhase = WAITING_RANDOMNESS, requestRandomnessNow = true, reservedProfitDelta per your game’s pattern (see CoinflipGame / facet for how reserved profit interacts with quoteCaps).
  2. onRandomness: Map randomness to an outcome index using a reproducible function (uniform draw over cumulative weights), compute payout, set SETTLED, requestRandomnessNow = false.

onPlayerAction can revert if the slot has no mid-round choices.


ICasinoGameV2 + CasinoGameFacet + QuoteFiguresLib + VaultReserveLib are the source of truth for exact field names and units; this document describes how they use your quote.