Skip to content

Bet placement & settlement

Definition. Every player gambling action — house games, slots, sportsbook — is processed through a single BetService that creates a Bet row, emits balance-changing Transactions, and (on settlement) hands off to a BullMQ queue that fans out to seven side-effect handlers (live feed, user XP, rakeback, leaderboard, affiliate, game stats). House games use a deterministic provably-fair RNG with reveal-on-rotation; provider games use the provider's own RNG and arrive via callbacks.

1. Bet model

// libs/_prisma/src/schema/api.prisma:625
enum BetStatus { CREATED  SETTLED  ERROR  ROLLBACK }

// api.prisma:635
model Bet {
  id, userId, status,
  amount, payout, multiplier, currencyId,
  usdAmount, usdPayout,                    // ← USD-equivalent at settle time
  commissionGgrUsdAmount, commissionGgrPercent,
  gameId, roundId,
  payload (JSON), errorDetails,
  ...
}

Identity rules (apps/api/src/bet/utils/bet-helper.utils.ts): - House gamesbetId = bet-{roundId}, where roundId = uuid(hash(seedData)) (bet-helper.utils.ts:40, 30). Identity is fully derived from the seed; replays produce the same id. - SlotsbetId = bet-{roundId}-{uuid(userId-currencyId-gameId)} (bet-helper.utils.ts:33). Slots' roundId comes from the provider; the suffix disambiguates concurrent bets on different slots. - SportsbookbetId is supplied by the sportsbook integration.

Transaction.id is tx-{txId}-{currencyId}-{gameId}-{betId} (bet-helper.utils.ts:44) — the natural idempotency key.

2. Lifecycle

stateDiagram-v2
    [*] --> CREATED: createBet (single-step games' open turn)
    [*] --> SETTLED: createAndSettleBet (one-shot)
    CREATED --> SETTLED: settleBet
    CREATED --> ROLLBACK: rollbackBet (provider rollback)
    CREATED --> CREATED: updateBet (mid-round update)
    SETTLED --> CREATED: cancelSettleBet (sportsbook only)
    SETTLED --> SETTLED: resettleBet (sportsbook payout correction)
    [*] --> ROLLBACK: rollbackBet (already-settled rollback path)

State machine entry points are all on BetService (apps/api/src/bet/bet.service.ts):

Method Use case Source
createBet Open a multi-step round (e.g. blackjack hit/stand) bet.service.ts:369
updateBet Mid-round amount/payout adjustment bet.service.ts:407
upsertBet Idempotent create-or-update by betId bet.service.ts:430
settleBet Close an existing CREATED bet bet.service.ts:473
createAndSettleBet One-shot games (limbo, dice, slot spin) bet.service.ts:560
rollbackBet Provider error / chargeback — reverse all round transactions bet.service.ts:599
refundBet Refund a single transaction inside a round bet.service.ts:637
resettleBet Sportsbook re-settle (e.g. void → win) bet.service.ts:510
cancelSettleBet Sportsbook cancel a settled bet bet.service.ts:536

Each is wrapped with @PrismaTransactional() — the bet row, all Transactions, and the balance update are one DB transaction.

3. Place flow — house game one-shot

sequenceDiagram
    participant FE as ebit-fe
    participant API as ebit-api /casino/games/house/...
    participant PF as ProvablyFairService
    participant Bet as BetService
    participant Acc as AccountingService
    participant Bal as UserBalanceRepository
    participant DB as Postgres
    participant Q as BullMQ BET_SETTLED_QUEUE
    participant Proc as BetQueueProcessor

    FE->>API: POST /casino/games/house/limbo (amount, target)
    API->>API: @PlaceBetLock(userId)<br/>WaitMutex bet-lock:<userId> ttl=5s
    API->>PF: popUserSeed(userId) → {serverSeed, clientSeed, nonce}
    API->>API: RngGames.getRandomLimbo(...)<br/>compute outcome locally
    API->>Bet: createAndSettleBet(identity, args)
    Bet->>Bet: checkCanUserBet (geo + self-exclusion)
    Bet->>Bet: beforeSettleBet → BetMaxProfitService.getMaxProfit
    Bet->>Acc: createOrFindTransaction (WITHDRAW, BET)
    Acc->>Bal: decrementBalance(amount)
    Acc->>DB: insert tx
    alt payout > 0
        Bet->>Acc: createOrFindTransaction (DEPOSIT, BET)
        Acc->>Bal: increaseBalance(payout)
        Acc->>DB: insert tx
    end
    Bet->>DB: insert Bet row (status=SETTLED)
    Bet->>Q: pushBet (BetStatus.SETTLED)
    Bet-->>API: BetOperationResult
    API-->>FE: {balance, payout, outcome}
    Q->>Proc: processJob (async)

The sequence diagram above is the ../flows/dropbet-bet-place.md one re-drawn — kept here for the §3.1-3.3 callouts below. When updating, sync both or move to a single source.

3.1 Bet lock

@PlaceBetLock (libs/shared/src/security/place-bet-lock.decorator.ts) wraps every house-game endpoint. Key bet-lock:<userId>, TTL 5 s, retry interval 100 ms, wait TTL 5 s. Concurrent bets for one user serialize, even across pods (Redis-backed). A second submission inside 5 s waits up to 5 s; if it can't acquire, the controller returns the error from WaitMutex.

3.2 Pre-checks

checkCanUserBet (bet.service.ts:342): - gamesService.getGameByIdCached(gameId) — game must be enabled = true - userLimits.canUserBet(userId) — checks self-exclusion / cool-off

processTransactions additionally calls geoCheckService.checkRestrictions() unless args.disableGeo === true (bet.service.ts:262).

3.3 Max-profit cap

Before settlement, BetMaxProfitService.getMaxProfit (apps/api/src/bet/bet-max-profit.service.ts) clamps the payout against the per-game max-profit ceiling. Stored on Game rows; admin-editable. Prevents a single bet from causing an oversized payout if a config is wrong. {{TBD: confirm with product team the canonical max-profit formula per game.}}

4. Provably-fair RNG (house games)

sequenceDiagram
    participant Player as Player
    participant API as ebit-api
    participant PF as ProvablyFairService
    participant DB as Postgres

    Note over Player,API: Bet 1 — current seed pair
    API->>PF: popUserSeed(userId)
    PF->>DB: get active seed, nonce++
    PF-->>API: {serverSeed, clientSeed, nonce, hashedServerSeed}
    Note over Player,API: Player rotates (or game logic forces rotation)
    Player->>API: POST /provably-fair/seed (newClientSeed)
    API->>PF: updateClientSeed
    PF->>PF: assert no active CREATED house games
    PF->>DB: deactivate old seed (reveal serverSeed)<br/>create new active seed
    PF-->>Player: {newHashedServerSeed, ...}
    Note over Player,API: Player verifies past bets
    Player->>API: GET /provably-fair/seed/:hashedServerSeed
    API->>DB: lookup inactive seed by hash
    API-->>Player: {serverSeed, clientSeed, nonce}

4.1 RNG primitive

Rng.createRandomGenerator (libs/games/src/rng/rng.ts:81):

const buffer = HMAC_SHA256(serverSeed, `${clientSeed}:${nonce}:${currentRound}`);
// emit 4 bytes per random sample, normalize:
randomSum = Σ (byte_i / 256^(i+1))   for i in 0..3
random    = floor(randomSum * limit)

Each getRandom(limit) returns a value in [0, limit). When more than 8 random samples are needed in a single bet (e.g. plinko with 32 rows), currentRound++ and a fresh HMAC buffer is computed (rng.ts:53-67). Cursor state is exposed via getCursor for multistage games that resume mid-round.

4.2 Per-game RNG wrappers

RngGames (libs/games/src/rng/rng.games.ts) — one method per game:

Game Method Range / use Source
Roulette getRandomRoulette [0, 37) (single zero) rng.games.ts:39
Speed roulette getRandomSpeedRoulette [0, SPEED_ROULETTE_CELLS_COUNT); clientSeed = EOS block id rng.games.ts:25
Dice getRandomDice randomInt / 100[0, 100.00) rng.games.ts:54
Limbo getRandomLimbo crash = floor((2^32 / safeRandom) × rtp × DIVISOR) / DIVISOR; max(crash, 1) rng.games.ts:87
Plinko getRandomPlinko rowsCount × getRandom(2) (0=left, 1=right) rng.games.ts:110
Keno getRandomKeno Fisher-Yates select gemsCount of tilesCount rng.games.ts:130
Mines getRandomMines Fisher-Yates select bombsCount of gridCellsCount rng.games.ts:155
Monkey-run getRandomMonkeyRun Multi-array shuffle keyed on difficulty rng.games.ts:179
Double-loyalty getRandomLoyalty [0, 2) — coin flip used for rakeback double-or-nothing rng.games.ts:69

Output of every game function is purely a function of (serverSeed, clientSeed, nonce, gameParams) — the player can verify any past bet by fetching the revealed seed via GET /provably-fair/seed/:hashedServerSeed (provably-fair.service.ts:80) and re-running the same code in the FE.

4.3 Speed roulette — external entropy

Speed roulette uses an EOS blockchain block id as clientSeed (rng.games.ts:31). The block is selected at round-spin time; the block id is published before the round closes, so the result is provably independent of platform-controlled inputs. See ../flows/dropbet-speed-roulette.md.

5. Slot / provider games

For provider games (BGaming, EvoGames, Softswiss, PM8, ST8), the provider runs the RNG. ebit-api just records the provider's reported outcome via callback:

Provider          ebit-api
  │  POST /wallet/withdraw (bet placed)
  ├──────────────────►   BetService.createBet (CREATED)
  │  POST /wallet/deposit (round won)
  ├──────────────────►   BetService.settleBet
  │  POST /wallet/rollback (cancel)
  ├──────────────────►   BetService.rollbackBet

Slot transactions use a different identity flavor (isSlots = true, see bet-helper.utils.ts:78) and a per-transaction slotOriginalId that maps the provider's tx id → Evospin's tx id idempotently (accounting.service.ts:findExistingTransactionResult). Withdraw/deposit pairs from the provider keep roundId constant; the bet row is upserted (upsertBet).

6. Side-effect fan-out

After every successful state-change, BetService.processSideEffects (bet.service.ts:105) pushes the bet to BullMQ if status is SETTLED or ROLLBACK:

// apps/api/src/bet/queue/bet.queue-producer.ts:20
buildJobOptions(bet) = {
  jobId:   `${bet.id}-${bet.status}-${attempt}`,   // generateBetJobId
  attempts: 10,
  backoff: { type: 'exponential', delay: 500 },
}

Queue name BET_SETTLED_QUEUE = 'bet_settled_queue' (apps/api/src/bet/queue/const.ts:1). Concurrency 2, stalled-interval 15 s, drain-delay 5 s, completed-jobs auto-removed after 300 s (bet.queue-processor.ts:21).

The processor (bet.queue-processor.ts:81) runs seven handlers in sequence, all guarded with @HandleBetOnce (15-min idempotency lock per (handler, bet.id)):

// bet.queue-processor.ts:100-110
if (bet.status === BetStatus.SETTLED) {
  await this.betEventService.emitBetEvent(bet);     // websocket fan-out
  await this.betService.handleSideEffects(bet);      // GGR commission, multiplier
  await this.liveBetsService.handleBet(bet);         // live bets feed
  await this.userService.handleBet(bet);             // XP / level-up — see vip-program.md
  await this.rakebackService.handleBet(bet);         // rakeback accrual — see rakeback.md
  await this.leaderboardService.handleBet(bet);      // leaderboard score — see leaderboard.md
  await this.affiliateService.handleBet(bet);        // affiliate commission — see affiliate-program.md
  await this.gamesService.handleBet(bet.gameId);     // game volume stats
}

Sequence is intentional: - userService runs before rakebackService so a level-up takes effect for the same bet. - rakebackService runs before leaderboardService and affiliateService because they share CashbackUtils with rakeback but read different stats.

ROLLBACK side effects are not implemented (bet.queue-processor.ts:118). A rollback enters the queue but the processor only handles SETTLED. {{TBD: confirm with product team this is intentional — rollbacks happen before any side-effect ran (idempotency lock fresh), but a delayed rollback after a bet has already been processed would not undo XP/rakeback/leaderboard.}}

7. GGR commission

After SETTLED side effects, betService.handleSideEffects (bet.service.ts:711):

const usdGgr     = bet.usdAmount.minus(bet.usdPayout);
const commission = await this.ggrCommissionService.calculateGgrCommission({
  gameId, usdGgr,
});
await this.betRepository.calculateSettledBet({
  id: bet.id,
  multiplier,
  commissionGgrUsdAmount: commission.usdAmount,
  commissionGgrPercent:   commission.percent,
});

Stored on the Bet row at commissionGgrUsdAmount / commissionGgrPercent for downstream finance reporting. Per-game commission rules live on Game and configured via GgrCommissionService. {{TBD: product team to confirm canonical commission rule mapping (provider vs house, by category).}}

8. Refund / rollback

Operation Effect Source
rollbackBet Reverse every transaction in the round; bet goes to ROLLBACK bet.service.ts:599
refundBet Reverse a single transaction; bet goes to ROLLBACK only if no pending bet transactions remain in the round bet.service.ts:637
Slot returnTransactionIfExists A second submission of the same provider tx-id returns the existing tx (idempotent) bet.service.ts:312

Both rollbackBet and refundBet call accountingService.rollbackTransactionsForBet / refundTransactionForBet (see wallet-and-balance.md). Transaction.tag becomes ROLLBACK_BET.

9. Edge cases

Case Behavior Source
Concurrent bets for one user Serialized by @PlaceBetLock (Redis Mutex, 5s TTL) place-bet-lock.decorator.ts
Idempotent re-submit createOrFindTransaction returns existing if id matches and consistency check passes accounting.service.ts:131
Inconsistent re-submit assertExistingTransactionConsistency throws ACCOUNTING_TRANSACTION_ALREADY_EXISTS accounting.service.ts:131
Self-exclusion active userLimits.canUserBet returns {can: false}; bet rejected with friendly message bet.service.ts:348
Geo restriction geoCheckService.checkRestrictions throws bet.service.ts:262
Game disabled mid-flight getGameByIdCached(...).enabled = falseCASINO_GAME_NOT_AVAILABLE bet.service.ts:343
Side-effect handler crashes Job retries up to 10 times with exp backoff base 500 ms bet.queue-producer.ts:23
Side-effect re-delivery @HandleBetOnce 15-min idempotency lock per handler bet/utils/utils.ts:32
Player updates client seed mid-round updateClientSeed rejects if there's an active CREATED house game provably-fair.service.ts:60
Slot rollback after long delay refundBet allows partial-round refunds; Bet stays CREATED until last pending tx is rolled back bet.service.ts:670
Sportsbook void → win resettleBet adjusts Transactions with tag = SPORTSBOOK_RESETTLE_BET and payload.reason ∈ {resettle, cancel} bet.service.ts:510, 213

10. Admin overrides

Capability Code
Inspect bet history (/admin/bet) apps/api/src/bet/admin.bet.controller.ts
Force settle a stuck CREATED bet Direct DB / debug endpoint; rare — use with caution
Adjust GGR commission rule GgrCommissionService admin endpoints
Adjust max-profit cap on a game GamesAdminService
Toggle game enabled GamesAdminService
Replay BullMQ side-effects BetQueueProducer.retry (bet.queue-producer.ts:48) — retries failed jobs
Inspect / replay specific job apps/api/src/bet/queue/admin.bet-queue.controller.ts