Flow: dropbet blackjack (house-game)¶
Trace IDs:
a2518d8986cc0faddcc8abf0fba62143(init · 103 spans · 64.774 ms) ·938cd678acd52284800fed1bf122d8ad(STAND → settle · 70 spans · 43.289 ms) Jaeger: http://localhost:16686/trace/a2518d8986cc0faddcc8abf0fba62143 · http://localhost:16686/trace/938cd678acd52284800fed1bf122d8ad · E2E:tests-e2e/tests/dropbet-blackjack.spec.tsGenerated: 2026-04-16 · Services touched:ebit-api(only)
1. User-visible contract¶
- Endpoints (all
JwtGuard + UserHttpThrottlerGuardexceptconfig): GET /casino/games/house/blackjack/config→{ minBet, maxBet, maxProfit }. Nortp— payouts are fixed per outcome.POST /init{ currencyId, betAmount, sideBetAmount? }→ 201{ data: BlackjackStateDto }, throttle 5/5s.POST /handleAction{ action ∈ HIT|STAND|DOUBLE_DOWN|SPLIT|BUY_INSURANCE|REJECT_INSURANCE }→ 201, throttle 10/5s.GET /getActiveState→ 200{ data: BlackjackStateDto | null }, throttle 5/5s.- Payouts (
constants.tsBlackjackMultipliers): LOSS 0×, PUSH 1×, WIN 2×, BLACKJACK 2.5×, DOUBLE_DOWN_WIN 4×, DOUBLE_DOWN_PUSH 2×, INSURED 1×. - Dealer hole card is hidden:
adjustStateDealerHand(blackjack.service.ts:348-351) splices index 1 while the round is PENDING —dealerHand.length === 1until settlement. - Session contract: one unfinished round per user.
/initwhile one exists throwsHOUSE_BLACKJACK_ACTIVE_GAME_ALREADY_EXIST;/handleActionwithout one throwsHOUSE_BLACKJACK_GAME_DOES_NOT_EXIST. E2E drains stale rounds before/initfor idempotent re-runs. - Concurrency fence:
@PlaceBetLock({ userId })wraps bothinitGameandhandleBlackjackAction(blackjack.service.ts:159-161, 308-310). The per-user mutex is shared with every other house-game bet (dice/limbo/mines/plinko), so two tabs cannot deal in parallel.
2. Sequence diagram¶
sequenceDiagram
participant U as Browser (dropbet FE)
participant API as ebit-api
participant R as Redis (cache)
participant PG as Postgres
U->>API: GET /casino/games/house/blackjack/config
API-->>U: { minBet, maxBet, maxProfit }
U->>API: POST /init { currencyId, betAmount }
API->>R: PlaceBetLock.acquire(userId)
API->>PG: findFirst house_game_blackjack WHERE is_finished=false
API->>API: BEGIN (PrismaTransactional)
API->>PG: UPSERT user_fairness_seeds (nonce += 1)
API->>API: shuffleDeck + deal 2 player + 2 dealer cards
alt natural blackjack
API->>PG: INSERT house_game_blackjack (is_finished=true)
API->>PG: createAndSettleBet (withdraw + 2.5× payout)
else PENDING
API->>PG: INSERT house_game_blackjack (is_finished=false)
API->>PG: createBet (withdraw only)
end
API->>PG: UPDATE user_balance, INSERT transaction, INSERT bet
API->>R: PlaceBetLock.release
API-->>U: 201 (dealer up-card only)
loop until mainHandOutcome settled
U->>API: POST /handleAction { STAND | REJECT_INSURANCE | ... }
API->>R: PlaceBetLock.acquire
API->>PG: BEGIN — findFirst round — apply action
opt STAND terminal
API->>API: dealer hits until soft≥17 — compute outcome
API->>PG: UPDATE bet.status/payout — UPDATE house_game_blackjack is_finished=true
end
API->>R: PlaceBetLock.release
API-->>U: 201 { state }
end
U->>API: GET /getActiveState → 200 { data: null }
3. Component diagram¶
Edges are numbered in request-flow order across the three phases (init → action loop → settle, then the read-side getActiveState). Section §4 below has the same numbers — each (N) on the diagram has its own §4.N subsection, so you can click straight through. The /handleAction loop fires N times per round (one POST per HIT / STAND / DOUBLE_DOWN / SPLIT / *INSURANCE); the ×N annotation on edge (10) marks that multiplicity. Settlement edges (11)–(14) only fire on the terminal action of the loop.
flowchart TD
%% Datastores
pg[("Postgres<br/>house_game_blackjack · Bet · Transaction · UserBalance<br/>· UserFairnessSeeds · GameIdentity · UserSelfExclusion")]
rd[("Redis (cache)<br/>place-bet:<uid> mutex · session bump")]
%% NestJS process — ebit-api :4000
subgraph api["ebit-api (NestJS :4000)"]
ctrl["BlackjackController<br/><i>POST /init · /handleAction · GET /getActiveState</i>"]
svc["BlackjackService<br/><i>@PlaceBetLock + @PrismaTransactional per request</i>"]
repo["BlackjackRepository<br/><i>house_game_blackjack CRUD</i>"]
fair["ProvablyFairService.popUserSeed<br/><i>UPSERT seeds, nonce += 1</i>"]
bet["BetService<br/><i>createBet · settleBet · createAndSettleBet</i>"]
acc["AccountingService<br/><i>WITHDRAW on /init · DEPOSIT on settle (payout>0)</i>"]
end
%% Orphan ebit-bj — receives zero dropbet traffic (AF-4)
subgraph bj["ebit-bj (NestJS :4002) — orphan, AF-4"]
bjOrphan["BlackjackGameController (apps/bj/)<br/><i>own session-token + EVO-wallet RPC · NO inbound traffic from dropbet</i>"]
end
%% (1)-(7) /init request path
ctrl -- "(1) initGame(dto, user)" --> svc
svc -- "(2) PlaceBetLock SET NX :<uid>" --> rd
svc -- "(3) popUserSeed (nonce++)" --> fair
fair -- "(4) UPSERT user_fairness_seeds" --> pg
svc -- "(5) createBlackjackGame / findFirst active round" --> repo
repo -- "(6) INSERT house_game_blackjack (is_finished=false)" --> pg
svc -- "(7) createBet (WITHDRAW leg)" --> bet
bet -- "(8) WITHDRAW ledger + INSERT Bet (PENDING)" --> acc
acc -- "(9) UPDATE user_balance · INSERT transaction · INSERT bet" --> pg
%% (10) /handleAction loop — ×N per round
ctrl -- "(10) handleBlackjackAction(action, user) ×N" --> svc
%% (11)-(14) STAND terminal — settlement
svc -- "(11) calculateOutcomeAndFinishGameMain + dealer pickUntilSoft≥17" --> svc
svc -- "(12) settleBet(depositPayout) — DEPOSIT only when payout>0" --> bet
bet -- "(13) UPDATE Bet status=SETTLED (+ DEPOSIT ledger if win)" --> acc
svc -- "(14) finishGame UPDATE is_finished=true" --> repo
%% (15) /getActiveState read-side
ctrl -- "(15) tryGetActiveState — findFirst, no lock" --> svc
%% (16) Orphan callout
ctrl -. "(16) no traffic — AF-4" .-> bjOrphan
%% Style: datastores stand out
classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
class pg,rd db;
4. Per-step walkthrough¶
Section headers below mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. Two traces are captured: a2518d8986cc0faddcc8abf0fba62143 (init · 103 spans · 64.774 ms) covers steps (1)–(9), and 938cd678acd52284800fed1bf122d8ad (STAND → settle · 70 spans · 43.289 ms) covers steps (10)–(14). The 12-span middleware prefix on each request is identical to other house-game endpoints and not repeated here.
4.1 Step (1) — BlackjackController.initGame → BlackjackService¶
- What: Nest wrapper accepts
POST /casino/games/house/blackjack/init(throttle 5/5s,JwtGuard + UserHttpThrottlerGuard), setsEvoContext.setWindowId, and hands off to the service. - Spans:
BlackjackController.initGame(59.6 ms,nestjs.type: request_context) parentsinitGame(54.0 ms,nestjs.type: handler). - Source:
apps/api/src/casino/house/blackjack/blackjack.controller.ts; handler atblackjack.service.ts:159-228.
4.2 Step (2) — @PlaceBetLock acquire on Redis¶
The handler is wrapped by @PlaceBetLock({ userId }) (blackjack.service.ts:159-161), a per-user Lua SET-NX mutex visible as three ioredis spans (get, zscore, evalsha). The mutex is shared across every house-game endpoint (dice / limbo / mines / plinko / blackjack), so two tabs cannot deal in parallel — see §6 #1.
4.3 Steps (3)–(4) — popUserSeed UPSERT (nonce += 1)¶
Inside PrismaTransactional.execute (the 50 ms transaction-bound span), ProvablyFairService.popUserSeed issues UPSERT user_fairness_seeds ON CONFLICT DO UPDATE SET nonce = nonce + 1 RETURNING * — atomic nonce advance in the same tx as the round insert. shuffleDeck(seed) + getInitialCards(deck) + createInitialGameState run CPU-only (un-instrumented) and set mainHandOutcome = BLACKJACK | PENDING.
Before the UPSERT, findFirst HouseGameBlackjack guards against a concurrent active round and throws HOUSE_BLACKJACK_ACTIVE_GAME_ALREADY_EXIST if one exists, and validateBetAmount (CPU only) uses ExchangeRatesService.toUsd against settings.{minBet,maxBet}.
4.4 Steps (5)–(6) — INSERT the HouseGameBlackjack round¶
Branch at blackjack.service.ts:215-226:
- non-PENDING (natural blackjack) →
createAndSettleBlackjackGame(INSERTis_finished=true+BetService.createAndSettleBet2.5× payout in a single shot). - PENDING →
createBlackjackGame(INSERTis_finished=false+BetService.createBetwithdraw leg only).
The captured init trace took the PENDING branch: 17 prisma:engine:db_query spans inside the transaction.
4.5 Steps (7)–(9) — BetService.createBet WITHDRAW + INSERT Bet¶
BetService.createBet fan-out: findUnique GameIdentity → findFirst UserSelfExclusion → UPDATE user_balance (guarded amount >= withdraw) → INSERT transaction (tag=BET) → INSERT bet (status PENDING, payout 0). The game_identity row triggers the side-loads of game_provider, game_in_category, and promo_game_whitelist flagged in §6 #3. After commit the lock releases and the controller returns 201 with the dealer up-card only (hole card spliced by adjustStateDealerHand, blackjack.service.ts:348-351).
4.6 Step (10) — handleAction loop (×N per round)¶
POST /handleAction { action } (throttle 10/5s) — fires once per player decision (HIT, STAND, DOUBLE_DOWN, SPLIT, BUY_INSURANCE, REJECT_INSURANCE). Each call:
- Re-acquires
@PlaceBetLock(same mutex as step 2). - Opens its own
@PrismaTransactional()—/initand each/handleActionare separate transactions (blackjack.service.ts:308-310); there is no long-running tx held across the player's think-time. findFirst HouseGameBlackjackresolves the active round; absence throwsHOUSE_BLACKJACK_GAME_DOES_NOT_EXIST.- Switches on
actionand routes to the matching handler (handleStandAction,handleHitAction, etc.). Non-terminal actions UPDATE the JSONBpayloadonhouse_game_blackjackand return without touchingBet/Transaction/UserBalance— only the terminal action triggers settlement (steps 11–14).
The STAND-trace 938cd678… captures handleBlackjackAction at 33.4 ms inside the 43.3 ms request.
4.7 Step (11) — dealer plays out + outcome resolution (CPU)¶
On STAND terminal, non-split: calculateOutcomeAndFinishGameMain → pickUntilDealerSoftOrMore (dealer hits until soft ≥ 17) → getOutcomeByPlayerAndDealerHand. CPU-only, un-instrumented. Resolves to one of LOSS / PUSH / WIN / BLACKJACK / DOUBLE_DOWN_WIN / DOUBLE_DOWN_PUSH / INSURED with the multipliers from BlackjackMultipliers (constants.ts).
4.8 Steps (12)–(13) — settleBet(depositPayout) + DEPOSIT ledger¶
BetService.settleBet updates the existing PENDING Bet row (status=SETTLED, payout, multiplier, usdPayout) and — only when payout > 0 — calls AccountingService to credit UserBalance and INSERT transaction (DEPOSIT leg, tag=BET).
Captured Prisma ops for the LOSS outcome the trace landed on: findFirst HouseGameBlackjack · findUnique Bet · findUnique UserBalance · update Bet · findFirst UserPromoCode · update HouseGameBlackjack — 8 db_query spans. No transaction INSERT and no user_balance UPDATE on LOSS: the debit already landed in step (9) on /init; zero payout means nothing to emit on the credit side.
4.9 Step (14) — finishGame archives the round¶
UPDATE house_game_blackjack SET is_finished=true (via BlackjackRepository.finishGame) in the same /handleAction transaction. After commit the lock releases and the controller returns 201 with the final state (full dealer hand, payout, outcome).
4.10 Step (15) — GET /getActiveState¶
Single findFirst HouseGameBlackjack + adjustStateDealerHand. No @PlaceBetLock (read-only). Returns null once is_finished=true. The FE uses it to resume a mid-hand round after a refresh; the E2E uses it both to drain stale rounds before /init and to assert round archival post-settlement.
4.11 Step (16) — Orphan ebit-bj callout (separate trace · zero traffic)¶
apps/bj/ on port 4002 ships a second, fuller blackjack implementation with its own session-token scheme (apps/bj/src/session/session.service.ts: randomBytes(18) → session:token:<t>, 30 min sliding TTL) and EVO-Games external-wallet RPC. The dotted edge on the diagram marks that dropbet never reaches it — ebit-fe/src/components/originalGames/Blackjack/utils/queries.ts hits ebit-api's /casino/games/house/blackjack/* exclusively. Compose exposes 4002; the service receives zero in-repo traffic. See AF-4 in ../weaknesses-register.md and §6 #4 below.
5. Data model¶
| Store | Key / table | R/W | Fields touched | Source |
|---|---|---|---|---|
| Postgres | house_game_blackjack |
R+W | round_id, user_id, payload (JSONB), is_finished, created_at, updated_at |
libs/_prisma/src/schema/blackjack.prisma |
| Postgres | user_fairness_seeds |
R+W (UPSERT, nonce+=1) | server_seed, hashed_seed, client_seed, nonce, next_* |
libs/_prisma/src/schema/api.prisma |
| Postgres | bet |
R+W | id, user_id, status, amount, payout, multiplier, currency_id, usd_amount, usd_payout, commission_ggr_*, game_id, round_id |
libs/_prisma/src/schema/api.prisma |
| Postgres | transaction |
W (on /init only; settlement on LOSS writes nothing) |
id, user_id, amount, before_balance, after_balance, status, type, tag=BET, game_id, payload |
libs/_prisma/src/schema/api.prisma |
| Postgres | user_balance |
W (debit on /init) / R (lookup on settle) |
amount, updated_at (conditional amount >= withdraw) |
libs/_prisma/src/schema/api.prisma |
| Postgres | game_identity, game_provider, game_in_category, promo_game_whitelist, user_self_exclusion, user_promo_code |
R | side-loaded on every /init via BetService |
libs/_prisma/src/schema/api.prisma |
| Redis (cache) | place-bet:<userId> (approx) |
R+W | @PlaceBetLock per-user mutex — shared across all house games |
libs/shared/src/security/place-bet-lock.decorator.ts |
payload is a JSONB blob BlackjackPayloadDto containing the full deck, both hands, action history, side bet, seed info, and computed payout — not normalised into columns.
6. Failure modes¶
@PlaceBetLockis per-user, not per-game. The same Redis mutex guards/init, every/handleAction, and every dice/limbo/mines/plinko bet. A player with a PENDING blackjack round running aggressive dice auto-bet serialises every dice POST behind each blackjack action. Source:blackjack.service.ts:159-161, 308-310.- Abandoned hand locks player funds indefinitely.
/initwritesuser_balance -= bet+INSERT transaction(tag=BET), then the round sitsis_finished=falseuntil played out. No TTL, no cron auto-resolution — a user who closes mid-hand has the wager held until they return.getActiveStateis the only recovery path. - Fat-hydration on every
/init.BetService.createBettriggersgame_identity → {game_provider, game_in_category, promo_game_whitelist}side-loads plususer_self_exclusion+user_promo_code— 6+ SELECTs when onlygame_idis load-bearing. Same pattern flagged indocs/flows/dropbet-bet-place.md. - Orphan
ebit-bjNestJS app.apps/bj/ships a second, fuller blackjack implementation on port 4002 with its own session-token scheme (apps/bj/src/session/session.service.ts:randomBytes(18)→session:token:<t>, 30 min sliding TTL) and EVO-Games external-wallet RPC. No dropbet client reaches it —ebit-fe/src/components/originalGames/Blackjack/utils/queries.tshits ebit-api's/casino/games/house/blackjack/*exclusively. Compose exposes 4002; the service receives no in-repo traffic. Task #33 mandated documenting this rather than faking success: the ebit-bj app is architecturally orphaned from the dropbet flow and this E2E deliberately does not exercise it. payloadis opaque JSONB. Outcome, multiplier, insurance, split/double rates all live inside the JSONB blob — no covering index. Historical analytics must read and parse per row. The only normalised projection isbet.amount/bet.payout/bet.multiplier.- No settlement push channel. The rt service publishes other casino events but blackjack relies on the
/handleActionresponse body as its sole settlement signal. A POST timeout mid-tx leaves the client with no recovery beyond polling/getActiveState, which returnsnullonce settled — no payout info.
7. Unresolved¶
- Side-bet and insurance branches unobserved. The E2E rejects insurance when eligible and never sets
sideBetAmount, socreateSideBetRoundId,createAndSettleBet(sideBetIdentity), andBOUGHT_PAYS_OUT/BOUGHT_DOES_NOT_PAY_OUTtransitions have no trace sample here (blackjack.service.ts:412-463). - SPLIT / DOUBLE_DOWN second-debit path un-traced.
handleSplitAction/handleDoubleDownActionatblackjack.service.ts:353-409, 613-681emit an extraupdateBet→user_balanceUPDATE +transactionINSERT mid-round. §6's "no ledger write on LOSS" note is non-split-only. - ebit-bj disposition is an open architectural question (no equivalent to task #21's RMQ call). Deletion vs wiring into a proxied
/casino/games/bj/*is out of scope.