Bet placement & settlement¶
Definition. Every player gambling action — house games, slots, sportsbook — is processed through a single
BetServicethat creates aBetrow, emits balance-changingTransactions, 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 games — betId = 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.
- Slots — betId = 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.
- Sportsbook — betId 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.mdone 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 = false → CASINO_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 |
11. Related docs¶
rakeback.md,vip-program.md,leaderboard.md,affiliate-program.md,bonuses-and-promos.md— Side-effect handlerswallet-and-balance.md— Underlying transaction & balance ops../flows/dropbet-bet-place.md— Player-facing place flow../flows/dropbet-house-game.md— House game specifics../flows/dropbet-blackjack.md— Multi-step round../runbooks/bullmq-job-stuck.md,../runbooks/speed-roulette-deadlock.md../onboarding/—anatomy-of-a-betwalkthrough../data-model/—Bet,Transaction,UserFairnessSeedsschema