# House Protection — Apogee Crash Games

A deep-dive on every mechanism that protects the operator's bankroll in a
Bustabit-family crash game, what can go wrong if you skip one, and what
Apogee does about it.

## 1. The core math: why a naive crash formula is dangerous

The classic Bustabit formula is:

```
h   = HMAC_SHA256(serverSeed, clientSeed + ":" + nonce)
h52 = first 52 bits of h
e   = 2^52
crash = floor((100*e − h52) / (e − h52)) / 100
```

The distribution has `P(crash < x) = 1 − 1/x` (for x ≥ 1), which means:

* Expected crash point is **infinite** (E[crash] = ∫ x d(1−1/x) diverges).
* The mean is dominated by extreme tail events.
* A single 10,000× round can erase months of edge if someone bet big on it.

You **cannot** run this formula unmodified. The edge has to come from
somewhere, and the tail has to be bounded. That's what every item below
exists to do.

## 2. The 13 things that can go wrong

### 2.1 No house edge at all
**Mistake.** Running the pure Bustabit formula with no instant-bust and
no cap. RTP is effectively 100% and variance eventually eats you.

**Fix.** Insert an "instant crash" at 1.00× with probability 1/N where
N = round(1 / houseEdge). For a 3% edge, N=33 — roughly 3% of rounds end
at 1.00×. See `computeCrash()` below.

### 2.2 Instant-bust correlated with crash formula
**Mistake.** Using the *same* 52-bit integer for both the mod-33 check
and the crash formula. On rounds that are NOT instant-busts, the h52
values are biased away from multiples of 33, which subtly biases the
crash distribution. Tiny effect but detectable by a determined attacker
over millions of rounds.

**Fix.** Use **two independent byte windows** of the HMAC output. First
8 bytes → instant-bust coin flip. Next 7 bytes → crash formula. They are
cryptographically independent.

### 2.3 Predictable server seed
**Mistake.** Using `Math.random()` or a timestamp-based PRNG for the
server seed. A smart attacker can predict upcoming rounds and bet
accordingly.

**Fix.** `crypto.getRandomValues(new Uint8Array(32))` on both client and
server. Never use a user-supplied value. The server seed is a 256-bit
secret until the round completes.

### 2.4 Seed revealed before round commits
**Mistake.** Operator logs the server seed in plaintext to an audit
system before the round starts. Insider reads it, places a winning bet.

**Fix.** Publish `SHA256(serverSeed)` before bets open. Keep `serverSeed`
in memory until the round ends. Reveal `serverSeed` AFTER the round so
players can verify. This is commit-reveal.

### 2.5 Uncapped max crash / max win
**Mistake.** A single round goes to 5,000× and a whale who bet 100,000
minor units on it cashes out for 500,000,000. Operator goes insolvent.

**Fix.** Three separate caps:
* `MAX_CRASH_MULT` — hard ceiling on the crash point itself (we use 10,000).
* `maxWinPerRound` — max absolute win per round per player (per merchant).
* `maxStakePerRound` — max single bet (per merchant).

### 2.6 Uncapped aggregate round exposure
**Mistake.** 200 players each bet the merchant's `maxStakePerRound`
limit on the same round. The round goes to 50×. Operator owes 10,000 ×
maxStakePerRound on a single round.

**Fix.** Track `Σ stakes` across all active bets in the current round.
If the sum would exceed `maxRoundExposure`, reject the incoming bet with
`round_full`. Players see "High-stakes round — try the next one".

### 2.7 Martingale grinding
**Mistake.** A player uses Martingale (double stake on each loss) with
target 1.01×. They slowly grind the house edge because their eventual
win covers all losses and more.

**Fix.** Three layers:
1. **Max-stake cap** that's tight enough to break the doubling chain.
2. **Progressive stake throttle**: after N consecutive losses, the player's
   max stake is cut in half until they cash out a win.
3. **Target multiplier floor**: `autoCashout >= 1.01` is too dangerous on
   the formula — bump the minimum to 1.03.

### 2.8 Bankroll drain during variance spikes
**Mistake.** The fixed 3% edge holds *in expectation*, but a 6σ run of
bad luck can still bankrupt the operator. Without a circuit breaker the
problem compounds.

**Fix.** Real-time RTP canary:
* Track rolling RTP over the last 10,000 rounds.
* If actual RTP drifts above `targetRtp + 2%` (i.e. the house is LOSING
  worse than 1 / 1000 expected), page operations.
* If actual RTP drifts above `targetRtp + 5%`, auto-lower `maxStake` to
  50% of configured value until it normalises.

### 2.9 Collusion and information leakage
**Mistake.** An insider at the operator can see the in-memory
`serverSeed` and bet with knowledge of the outcome.

**Fix.**
* Commit-reveal (above) limits this — the hash is public, so any insider
  bet using the pre-revealed seed is detectable.
* Rotate the seed on every round; one leak only burns one round.
* Do not log the seed anywhere during the round. Write it to the audit
  log ONLY after the round ends.

### 2.10 Non-unique nonce / seed reuse
**Mistake.** Server seed is accidentally reused across rounds, or nonce
repeats. Attackers with one observed crash can predict the next.

**Fix.**
* New random `serverSeed` every round.
* Nonce is a monotonic counter; the tuple `(serverSeed, clientSeed, nonce)`
  is unique across the whole game history.
* Assertion in the round loop: `seed != previous_seed`.

### 2.11 Client trusted for the cashout multiplier
**Mistake.** Game client sends `{cashoutMult: 8.42}` and the server
pays out 8.42× the stake. A modified client claims any multiplier up to
the crash point.

**Fix.** Compute the effective multiplier server-side from
`phaseStart` timestamp + round's `serverSeed` + `nonce`. Take
`min(clientClaim, serverComputed)` so a cheating client can only ever
UNDERpay itself.

### 2.12 Auto-cashout target changed mid-round
**Mistake.** Player sets target 2× at bet time, then as the round
climbs past 2× they "edit" it to 50× to try for bigger wins.

**Fix.** Lock target at the moment the debit commits. Any UI change
after is rejected server-side.

### 2.13 No audit chain
**Mistake.** Operator or insider quietly inserts/deletes rounds in the
audit log to cover a manipulation.

**Fix.** Each round's record includes `prevHash = SHA256(prevRoundRecord)`,
forming a chain. Any tampering breaks the chain.

## 3. Apogee's current implementation

### `computeCrash(serverSeed, clientSeed, nonce)` — game.js

Returns the crash point for the round. Pure, deterministic, client can
verify after the seed is revealed.

```js
async function computeCrash(serverSeed, clientSeed, nonce) {
  const h = await hmacSha256Hex(serverSeed, `${clientSeed}:${nonce}`);

  // (2.2) Use TWO independent byte windows — not the same h52 twice.
  //   bytes  0..7  → instant-bust decision (64-bit integer)
  //   bytes  8..14 → crash formula (56-bit integer, > 52 required)
  const hInsta = BigInt("0x" + h.slice(0, 16));
  const hCrash = Number(BigInt("0x" + h.slice(16, 30)) & ((1n << 52n) - 1n));

  // (2.1) Instant-bust at 1/INSTANT_DIVISOR (configurable per merchant
  //       via /v1/merchants/:id/rtp?currency=X).
  if (hInsta % BigInt(INSTANT_DIVISOR) === 0n) return 1.00;

  const e = Math.pow(2, 52);
  const crash = Math.floor((100 * e - hCrash) / (e - hCrash)) / 100;
  return Math.min(MAX_CRASH, Math.max(1.00, crash));
}
```

The `MAX_CRASH = 10000` ceiling bounds the distribution's tail.
`INSTANT_DIVISOR` is derived from the merchant's effective RTP at boot,
queried once from `/v1/merchants/:id/rtp?currency=X&gameId=Y`.

### Server-side verification — apogee-api

The game client computes the crash and posts the completed round to
`POST /v1/rounds`. The API **independently recomputes** the crash from
the provided `serverSeed` and compares. Mismatches are rejected and
logged for fraud review. Rounds whose claimed crash does not match the
HMAC are dropped from billing.

### Aggregate round-exposure cap — apogee-api

`/v1/wallet/debit` accumulates stakes into a per-round counter keyed by
`(merchantId, roundId)`. When a new bet would push the counter above
`merchant.maxRoundExposure` (default 5,000,000 minor units), we reject
with `402 round_full`. Counter auto-expires 90 s after the last update.

### RTP canary — apogee-api

A background job reads `transactions/` collection every minute and
computes a rolling RTP for each merchant over the last 10,000 rounds.
If `actualRtp > targetRtp + 2%`, a `wallet_rtp_drift` event fires to
the merchant's webhook URL and a warning surfaces in the admin panel.

### Round audit chain — apogee-api

Each `rounds/{roundId}` document stores:

```json
{
  "roundId":         "rnd_...",
  "serverSeed":      "...",      // revealed AFTER the round
  "serverSeedHash":  "...",      // committed BEFORE the round
  "clientSeed":      "apogee",
  "nonce":           123,
  "crashPoint":      2.47,
  "merchantId":      "mrc_edilbet",
  "gameId":          "skyward",
  "wagered":         180000,
  "returned":        54000,
  "prevRoundId":     "rnd_...",
  "prevHash":        "sha256(prevRoundRecord)"
}
```

The `prevHash` field chains rounds together. A verification script can
re-walk the chain and prove no round was inserted or altered.

## 4. Caps summary — what to configure per merchant

| Field | Default | Purpose |
|---|---|---|
| `defaultRtp` | 97 | Target RTP percentage |
| `rtpConfig[CCY]` | — | Per-currency RTP override |
| `maxStake` | 500,000 minor | Max single bet |
| `maxWin` | 100,000,000 minor | Max single-round win |
| `maxRoundExposure` | 5,000,000 minor | Max Σ-stakes per round per merchant |
| `maxConsecutiveLosses` | 8 | After N losses stake is halved |
| `minAutoTarget` | 1.03 | Floor on auto-cashout multiplier |

All configurable via `PATCH /v1/merchants/:id`.

Contrail adds its own env-var caps on top — see §4.1.3.

## 4.1 Contrail

Contrail's math is **different** from Skyward — cleaner, strategy-independent, and runs at 99% RTP by default. Every Skyward protection above still applies (nonce uniqueness, audit chain, client-trusted-cashout, max-win, max-stake, exposure caps, RTP canary, seed from CSPRNG), but the core formula and instant-bust mechanism are different. Worth calling out.

### 4.1.1 The inverse-CDF formula

```
h      = first 52 bits of HMAC_SHA256(serverSeed, "apogee-contrail-"+round)
cents  = floor( (α_bps/100) · 2^52  /  (h + 1) )
crash  = cents < 100  ?  1.00  :  min( cents/100, MAX_MULT )
```

Where `α_bps = CONTRAIL_RTP_ALPHA_BPS`, default `9900`.

**Why this and not Bustabit?** The classic `(100·E − h)/(E − h)` formula drifts — its RTP depends on the player's cashout target (97.0% at 1.01×, 96.0% at 100×). A sophisticated player who auto-cashouts at 1.5× and a one who farms the tail don't experience the same house edge. Inverse-CDF is flat: every cashout target gets exactly α. This removes an entire strategy surface the house used to have to defend.

**Why no `1/N` instant-bust coin flip?** Because the inverse-CDF formula already produces 1.00× with probability `1 − α` by construction (whenever `h + 1 > α · 2^52`, which is `U > α`). No separate coin flip, no second byte window, nothing to correlate. The instant-crash fraction is **exactly** the house edge. With α=99% that's 1% of rounds at 1.00×.

### 4.1.2 Fairness commitment is epoch-level, not per-round

Contrail commits the **entire future** of an epoch (10,000 rounds) at service boot via a hash chain:

```
h[N]    = SHA256(seeds[N-1])
h[i]    = SHA256(h[i+1])       for i = N-1 down to 0
genesis = h[0]                  published immediately at /contrail/genesis
```

Rounds consume seeds in reverse order (round 0 → `seeds[N-1]`). Verifiers walk `SHA256` forward from any revealed seed and must reach the committed genesis. Contrail cannot alter a past round without rebuilding the chain, which changes the published genesis.

See `docs/PROVABLY-FAIR.md §Contrail` for the full verifier code.

### 4.1.3 Contrail-specific caps (enforced in `apps/contrail/server.js`)

| Env var | Default | Purpose |
|---|---|---|
| `CONTRAIL_RTP_ALPHA_BPS` | 9900 | RTP in bps (9900 = 99.00%) |
| `CONTRAIL_MAX_BET_MINOR` | 10_000_000 | Max single bet (100,000.00 major — hard ceiling, operators tighten via merchant rules) |
| `CONTRAIL_MIN_BET_MINOR` | 10 | Min single bet (0.10 major) |
| `CONTRAIL_MAX_PAYOUT_MINOR` | 10_000_000_000 | Cap per-bet cashout (100,000,000.00 major — hard ceiling) |
| `CONTRAIL_MAX_MULT` | 10_000 | Crash-point ceiling |
| `CONTRAIL_BET_WINDOW_MS` | 5000 | BETTING phase length |
| `CONTRAIL_LOCK_MS` | 500 | Locked-in phase before flight |
| `CONTRAIL_CRASH_MS` | 2500 | Post-crash reveal length |
| `CONTRAIL_CHAIN_SIZE` | 10000 | Hash-chain depth (rounds per epoch) |

`MAX_BETS_PER_CLIENT = 2` is hard-coded (the dual A/B bet panel is the only place a client can place concurrent stakes).

### 4.1.4 Wallet bridge — no Contrail bypass

`apogee-contrail` **does not** manage its own balances. Every bet hits `apogee-api POST /v1/wallet/debit` and every cashout hits `apogee-api POST /v1/wallet/credit` via an HTTP bridge in `apps/contrail/server.js::apogeeFetch()`. That means every Contrail bet passes through the same guards as Skyward:

- idempotent `txId` cache
- 8 s hard timeout with automatic rollback
- non-integer amount rejection (`400 bad_amount`)
- per-session async mutex (no double-debit)
- operator-side `walletUrl` proxying, HMAC signing, exponential backoff
- per-merchant `maxStake`, `maxWin`, `maxRoundExposure`
- transaction audit log in Firestore `transactions/{txId}`
- RTP canary job (same rolling 10,000-round window)

Contrail's own `MAX_BET_MINOR` / `MAX_PAYOUT_MINOR` are **additional fast-path caps** to reject obvious violations before the `apogee-api` round-trip. The authoritative limits live in `apogee-api` and are per-merchant.

### 4.1.5 Preflight on Contrail

`apps/contrail/server.js::preflightBet()` runs all 14 checks from `docs/BET-PREFLIGHT.md` server-side before the wallet debit is dispatched. The client mirrors a subset for UX (gray out the button, show red toast), but the server-side list is authoritative. Failing bets never reach `apogee-api` and never occupy the per-session mutex.

## 4.2 Apogee Hot 5 (slots)

Apogee's first slot, hosted on the dedicated `apogee-slots` Cloud
Run service. Stateless, scale-to-zero, no WebSocket round loop.
Every `/spin` is a self-contained HTTP request that goes through
`apogee-api`'s authoritative wallet for debit and credit.

### 4.2.1 Math summary

- **Target RTP:** 96.5% ± 0.5pp (Monte-Carlo verified at 96.41%
  over 1M spins and 96.59% over 10M spins — within tolerance)
- **Hit frequency:** 12.16% (medium-high volatility band)
- **Max win:** 2000× total bet, hard-clamped server-side
- **Reel strips:** 5 reels × 50 positions, **uniformly sampled**
  (no virtual-reel near-miss mapping)
- **Paylines:** 10 fixed, left-to-right, 3-of-a-kind minimum
- **Multiplier reel:** 10-position strip `[1,1,1,1,1,2,2,2,3,5]`
  applied to total line pay (not per line), E[multiplier] = 1.90
- **Sticky respin:** 5-of-a-kind triggers exactly one free respin
  with the matched symbol locked; respin cannot chain

Full math, sim results, and verification code in
`docs/APOGEEHOT5.md`.

### 4.2.2 Why uniform strips matter

Commercial slot math usually uses **virtual reels** — a second
layer that maps the physical-reel stops to a smaller number of
"visible" symbols, over-representing high-paying symbols near
paylines to create near-miss drama. This is:

1. Mathematically dishonest (the visible probability ≠ the
   published strip probability).
2. Regulatory grey area (several jurisdictions now require
   uniform strips on provably-fair products).
3. Exploits a known cognitive bias — the near-miss effect, which
   triggers reward circuitry [Clark et al., Neuron 2009] and
   increases time-on-device.

Apogee Hot 5 does **none** of this. Every reel stop is equally
likely. The published reel counts are the sampling distribution.
You can compute `P(5-of-a-kind)` from the strip counts and it
will match the simulated distribution exactly.

### 4.2.3 Slot-specific tail-risk controls

In addition to all the apogee-api wallet guards:

| Env var | Default | DE mode | Purpose |
|---|---:|---:|---|
| `SLOTS_CHAIN_SIZE` | 10000 | — | Hash-chain depth |
| `SLOTS_DE_MODE` | false | **true** | GlüStV 2021 €1 stake cap |
| `MIN_BET_MINOR` | 20 | 20 | Min stake (0.20) |
| `MAX_BET_MINOR` | 10000 | **100** | Max stake (100.00 or 1.00) |
| `MAX_WIN_X_BET` | 2000 | 2000 | Max payout vs stake |
| `RATE_BURST_PER_5S` | 8 | 8 | Per-session /spin burst |

The `MAX_WIN_X_BET = 2000` clamp is **independent** of the
per-merchant `maxWin` configured in apogee-api. The slot server
clamps first, then the debit/credit pair still hits the
per-merchant cap. Two layers of defense.

### 4.2.4 Wallet bridge — same model as Contrail

`apogee-slots` **does not** maintain balances. Every spin:

1. `GET /v1/wallet/balance?session=…` on apogee-api (token validation)
2. `POST /v1/wallet/debit` (stake, idempotent `txId`)
3. Math is evaluated locally
4. `POST /v1/wallet/credit` (clamped payout, idempotent `txId.win`)
5. `rounds/{id}` audit doc written to Firestore best-effort

All the protections from `docs/INTEGRATION-PITFALLS.md` — idempotent
txId replay cache, 8s timeout + signed rollback, non-integer
rejection, per-session mutex, operator HMAC signing, max-stake and
max-win caps — apply automatically because they live in apogee-api.
`apogee-slots` is a thin client.

### 4.2.5 LDW defence and ethical defaults

Apogee Hot 5 ships with the same ethical defaults as Skyward and
Contrail:

- **No losses disguised as wins.** Spin results where the payout
  is ≤ the stake trigger a neutral toast, not a celebration
  animation or a win sound. Players see their net P/L in the
  top bar at all times.
- **No turbo mode, no autoplay without caps.** Autoplay is
  limited to 100 spins and stops on any player-set condition.
- **No near-miss reel mapping.** Uniform strips, see §4.2.2.
- **No "one more spin" pulsing** on the spin button between
  rounds.
- **RTP shown in-UI.** Footer and `GET /paytable`.

Full list in `docs/APOGEEHOT5.md §Dark patterns avoided`.

## 5. Red flags to watch for in production

1. **Same player, same stake, same target, back-to-back rounds** → bot.
2. **Rolling RTP drifts > 1% above target over 5,000+ rounds** → algo bug.
3. **Round with `wagered > maxRoundExposure`** → exposure cap bypass.
4. **Round where `claimedMult > crashPoint`** → client tampering.
5. **Round missing from the audit chain** → tampering or bug.
6. **Operator wallet rejects `/debit` at >5% rate over 1min** → wallet
   side outage.

The admin panel's **Reports → House Health** tab surfaces all of these
in real time.
