# Bet Preflight Checklist

Every bet placed in an Apogee game MUST pass a preflight checklist before
a single byte is sent to the wallet API. This document is the canonical
list. If you're writing a new game, porting one, or auditing an existing
one, every item below must be verifiable in the source and every item
must fail the bet closed when it doesn't hold.

The preflight is designed to be **cheap** (runs in <1 ms) and
**defense-in-depth** — every check is also enforced server-side by
apogee-api, but we fail fast on the client to avoid wasted round-trips
and to give the player an actionable error.

## Prerequisite: merchant rules loaded at boot

Before the first bet can be accepted, the game MUST call
`GET /v1/merchants/:id/rules?currency=X&gameId=Y` exactly once during
boot and cache the response. The endpoint returns the consolidated
ruleset for this merchant, currency, and game:

```json
{
  "merchantId": "mrc_edilbet",
  "gameAllowed": true,
  "gameStatus":  "live",
  "rules": {
    "minStake":          100,
    "maxStake":          500000,
    "maxWin":            100000000,
    "maxRoundExposure":  5000000,
    "minAutoTarget":     1.01,
    "maxAutoTarget":     10000,
    "maxConsecutiveLosses": 0,
    "rateBurstPerSec":   10,
    "rateSustainedPerSec": 5
  },
  "source": { "minStake": "default", "maxStake": "merchant", ... }
}
```

All amounts are in **INTEGER minor units**. The client converts to major
units with `CURRENCY_SCALE = 10 ** decimals` when comparing against a
player's entered stake.

If the fetch fails, games fall back to conservative baked-in defaults
(the ones you'd see in `MERCHANT_RULES` at the top of `game.js`). Games
MUST never send a bet to the wallet API without at least the defaults
enforced — there is no "no limits" mode.

**Rules cache lifetime.** Rules are fetched once at boot. A merchant
change (via `PATCH /v1/merchants/:id`) does not re-push — players have
to re-launch the game to pick it up. This is intentional: stable session
rules prevent rug-pull scenarios where a merchant tightens limits
mid-round and fails in-flight bets.

## The 14+ checks

Bets are rejected with a specific `code` for each failure. The game UI
shows a red toast with the friendly message and the code is logged to
both the client audit buffer and the server audit log.

| # | Check | Code on failure | Source of limit |
|---|---|---|---|
| 1 | Wallet not in fatal-blocked state | `wallet_blocked` | client |
| 2 | Wallet ready (initial balance loaded) | `wallet_not_ready` | client |
| 3 | No other wallet op in flight for this bet panel | `wallet_busy` | client |
| 4 | Game in `betting` phase | `wrong_phase` | client |
| 5 | Session token present in launch URL | `no_session` | client |
| 6 | Currency resolved with valid decimals (0-8) | `bad_currency` | currency meta |
| 6a | Merchant allows this game | `game_not_allowed` | **merchant rules** |
| 6b | Game status is `live` or `beta` | `game_disabled` | **merchant rules** |
| 7 | Stake is a finite number > 0 | `bad_stake` | client |
| 8 | Stake ≥ merchant minimum | `below_min_stake` | **merchant rules** |
| 9 | Stake ≤ merchant maximum | `above_max_stake` | **merchant rules** |
| 9a | Auto-cashout target ≥ merchant minimum | `auto_target_too_low` | **merchant rules** |
| 9b | Auto-cashout target ≤ merchant maximum | `auto_target_too_high` | **merchant rules** |
| 9c | Potential max win (stake × maxAutoTarget) ≤ merchant cap | `max_win_exceeded` | **merchant rules** |
| 10 | Stake is an integer when expressed in minor units | `non_integer_stake` | API |
| 11 | Current known balance ≥ stake | `insufficient_balance` | client |
| 12 | Not in cashout-cooldown window (1s after manual cashout) | `cashout_cooldown` | client |
| 12b | Not in rejection-cooldown window (2s after a rejected debit) | `rejected_cooldown` | client |
| 13 | Bet count for this panel ≤ `MAX_BETS_PER_PANEL` | `too_many_bets` | client |
| 14 | No previously-placed bet on this panel is still in `pending` | `bet_in_progress` | client |

Rows marked **merchant rules** come from `/v1/merchants/:id/rules` and
are enforced identically on both client and server. A merchant can
tighten any limit via `PATCH /v1/merchants/:id`:

```bash
curl -X PATCH https://api.apogeetech.net/v1/merchants/mrc_edilbet \
  -H "X-Apogee-Admin-Token: $APOGEE_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "minStake": 500,
    "maxStake": 200000,
    "maxWin":   50000000,
    "minAutoTarget": 1.03,
    "rulesConfig": {
      "ETB": { "minStake": 100, "maxStake": 1000000 }
    }
  }'
```

Per-currency overrides live under `rulesConfig[CCY]` and win over the
merchant-wide values when a session's currency matches.

## Implementation reference

### Skyward (single-player, HTTP wallet)

`game.js::preflightBet(id)` returns `{ ok, code?, message? }` and is
called as the first line of the BET branch in `handleAction`:

```js
const pf = preflightBet(id);
if (!pf.ok) {
  showBetError(pf.code, pf.message);
  return;
}
```

It checks conditions 1–14 in order and exits on the first failure.
Every rejection logs to `audit("preflight.fail", {...})`.

### Contrail (multiplayer, WebSocket wallet)

Contrail has a **dual preflight** — one on the client (fast-path UX)
and one on the `apogee-contrail` backend (authoritative). The client
check prevents the WebSocket `bet` message from even being sent; the
backend check rejects the message with `bet_rejected` if the client
bypasses its own checks.

Client: `contrail.js::preflightBet(slotKey)`
Backend: `apps/contrail/server.js::handleBet()` — all checks run
before `apogeeWalletDebit()` is called.

### Apogee Hot 5 (slot, stateless HTTP)

Apogee Hot 5 is a stateless request/response slot hosted on
`apogee-slots`. Every `/spin` is an HTTP POST that passes through
the same 14-check preflight, adapted for slot semantics:

- Check 4 (`wrong_phase`) is replaced by **no phase check** — a
  slot has no betting-window gate, the /spin endpoint is available
  24/7.
- Check 12 (`cashout_cooldown`) is replaced by a **per-spin
  rate-limit**: `RATE_BURST_PER_5S = 8`, enforced by an in-memory
  token bucket keyed by session token. Excess requests return
  `429 rate_limited`.
- Check 13 (`too_many_bets`) is unused — a single /spin request
  is atomic, there are no concurrent slots to track per session.

Client: `apogeehot5.js::preflightSpin()` (gates the spin button
and surfaces the red toast)
Backend: `apps/slots/server.js::handleSpin()` — all checks run
before `apogeeWalletDebit()` is called.

The slot-specific `MAX_WIN_X_BET = 2000` clamp runs **after** the
math evaluates and **before** the credit is dispatched, so a
combination of stacked wilds + 5× multiplier + sticky respin can
never escape the cap.

## Rules for new games

If you're adding a new game to the catalog (Thermal, Apex, Ion, etc.)
you MUST implement preflight. Copy the reference implementation from
`game.js` and adapt the state shape. Do not invent your own check list
— use the 14 above verbatim.

Before the first bet handler in your game calls any wallet function
(whether it's `apogeeWalletDebit`, `walletDebit`, or an internal state
mutation), preflight must run and pass. A failing preflight blocks the
bet AND pushes an audit record so the incident is visible in the
per-session trace at `/v1/audit/session/:token`.

## Testing a new game's preflight

1. **Rapid click test.** Fire 50 BET clicks in 500ms. Exactly one bet
   should commit. The other 49 must be rejected with `wallet_busy` or
   `bet_in_progress`. No concurrent debits may reach the wallet API.
2. **Float stake test.** Type `10.5` into the stake input. Preflight
   must reject with `non_integer_stake` before reaching the wallet.
3. **Zero balance test.** Spend down to 0 and attempt a 1-unit bet.
   Preflight must reject with `insufficient_balance` without a
   round-trip.
4. **Phase boundary test.** Place a bet on the last 100 ms of the
   betting window. If the round transitions to flying before the
   wallet response arrives, the bet must refund automatically.
5. **Wallet fatal test.** Simulate a 500 from apogee-api during
   `walletFetchBalance`. The game must show the red fatal overlay
   and `handleAction` must return immediately on every subsequent
   click with `wallet_blocked`.
6. **Decimals test.** Switch currencies between EUR (2), JPY (0),
   and BTC (8). Verify the preflight integer check adapts and
   displayed balances are correct in all three.

All six tests must be rerun after any change to the wallet layer.

## Invariants (never violate)

These are the guarantees preflight exists to uphold. If any of them
ever fails in production, treat it as a Sev-1:

1. **Exactly-once.** Every successful `BET` click results in exactly
   one authoritative wallet debit. Zero on rejection.
2. **Refunds mirror debits.** For every commit-then-cancel cycle, the
   net wallet movement is zero. No leakage.
3. **Balance never drifts.** Client-displayed balance matches the
   server-side session balance within one SSE tick (~50 ms).
4. **No optimistic holes.** The player can never place a bet whose
   stake exceeds the authoritative balance at the time of debit.
5. **No double-spend.** Two concurrent bets placed on the same
   balance can never both succeed — the session mutex serializes them.
6. **Every wallet write carries a stable `txId`.** Generated
   client-side. Retries reuse the same `txId` so the operator's
   idempotency cache replays the cached response instead of
   double-charging. Lesson from the 2026-04-11 cancel-bug.
7. **Every debit carries a `roundId`.** Even if the round is just a
   counter and the bet never makes it to flight. Without it, forensic
   reconciliation against the operator's ledger is blind.
8. **Every refund carries `refTxId`** pointing at the original debit.
   The operator ledger pairs the pair; without it, a failed refund
   retry books as a fresh credit and drifts the ledger.
9. **Refunds are always `await`ed, never fire-and-forget.** A
   fire-and-forget refund is a wallet-leak vector: if the credit
   call fails, nothing retries and nothing alerts.
10. **A failed refund hard-blocks the wallet.** Set
    `wallet.blocked = true` and show the fatal overlay. Do NOT
    silently continue — the player's balance is now in an unknown
    state and any further bet could leak.
11. **Cancel must work during `pending` state**, not only `active`.
    The UI affordance for cancel is already visible during the
    debit round-trip; the handler must honor the tap by setting
    `b.cancelRequested = true` and having the commit path refund
    instead of promoting to active.

## Changelog

- **2026-04-11 — v1** — Initial preflight specification. First shipped
  in `apogee-web:v32` (Skyward) and `apogee-contrail:v4` (Contrail).

- **2026-04-11 — v2** — Cancel-bug incident. Added invariants 6–11
  after reconciling a 12,200 ETB refund with zplay. Root cause was
  the client silently swallowing cancel taps during `b.state ===
  "pending"` while the UI rendered a CANCEL button, plus
  fire-and-forget refund credits with no `txId`/`refTxId`/`roundId`.
  First shipped in `apogee-web:v28`.

- **2026-04-11 — v3** — `roundId` wire-type incident. 300 debits
  rejected in 4 minutes on a single Skyward session because the client
  sent `roundId` as an integer and edil.bet's Go wallet unmarshalled
  `invalid_body: json: cannot unmarshal number into Go struct field
  WalletRequest.roundId of type string`. Two fixes:
  1. **Server**: `apps/api/server.js` coerces `roundId` to
     `String(roundId)` in every operator forwarder (`/debit`, `/credit`,
     `/rollback`). First shipped in `apogee-api:v18`.
  2. **Client**: added check **12b** — a rejected debit trips a 2s
     cooldown (`b.cooldownUntil + b.cooldownReason = "rejected"`).
     Frustration-mashing is throttled to 1 request per 2 seconds per
     panel. First shipped in `apogee-web:v41`.
  Defense-in-depth extended in `apogee-web:v44`: the game client also
  coerces `roundId` to string at `walletDebit`/`walletCredit` so the
  wire always sends a string regardless of server coercion state.
  See [INTEGRATION-PITFALLS.md §3b](./INTEGRATION-PITFALLS.md#3b-roundid--txid-wire-type-drift--fixed-2026-04-11).

- **Current deployed versions** (2026-04-12): `apogee-api:v18`,
  `apogee-web:v45`, `apogee-contrail:v7`, `apogee-admin:v14`.
  Check each game's footer `build-chip` (bottom-right of Skyward fair
  panel, bottom of Contrail footer, topbar of Apogee Hot 5) for the
  exact per-game bundle version a player is running.
