Skip to content

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.ts Generated: 2026-04-16 · Services touched: ebit-api (only)

1. User-visible contract

  • Endpoints (all JwtGuard + UserHttpThrottlerGuard except config):
  • GET /casino/games/house/blackjack/config{ minBet, maxBet, maxProfit }. No rtp — 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 /getActiveState200 { data: BlackjackStateDto | null }, throttle 5/5s.
  • Payouts (constants.ts BlackjackMultipliers): 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 === 1 until settlement.
  • Session contract: one unfinished round per user. /init while one exists throws HOUSE_BLACKJACK_ACTIVE_GAME_ALREADY_EXIST; /handleAction without one throws HOUSE_BLACKJACK_GAME_DOES_NOT_EXIST. E2E drains stale rounds before /init for idempotent re-runs.
  • Concurrency fence: @PlaceBetLock({ userId }) wraps both initGame and handleBlackjackAction (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:&lt;uid&gt; 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&gt;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 :&lt;uid&gt;" --> 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&gt;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.initGameBlackjackService

  • What: Nest wrapper accepts POST /casino/games/house/blackjack/init (throttle 5/5s, JwtGuard + UserHttpThrottlerGuard), sets EvoContext.setWindowId, and hands off to the service.
  • Spans: BlackjackController.initGame (59.6 ms, nestjs.type: request_context) parents initGame (54.0 ms, nestjs.type: handler).
  • Source: apps/api/src/casino/house/blackjack/blackjack.controller.ts; handler at blackjack.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 (INSERT is_finished=true + BetService.createAndSettleBet 2.5× payout in a single shot).
  • PENDINGcreateBlackjackGame (INSERT is_finished=false + BetService.createBet withdraw 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 GameIdentityfindFirst UserSelfExclusionUPDATE 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:

  1. Re-acquires @PlaceBetLock (same mutex as step 2).
  2. Opens its own @PrismaTransactional()/init and each /handleAction are separate transactions (blackjack.service.ts:308-310); there is no long-running tx held across the player's think-time.
  3. findFirst HouseGameBlackjack resolves the active round; absence throws HOUSE_BLACKJACK_GAME_DOES_NOT_EXIST.
  4. Switches on action and routes to the matching handler (handleStandAction, handleHitAction, etc.). Non-terminal actions UPDATE the JSONB payload on house_game_blackjack and return without touching Bet / 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: calculateOutcomeAndFinishGameMainpickUntilDealerSoftOrMore (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 itebit-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

  1. @PlaceBetLock is 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.
  2. Abandoned hand locks player funds indefinitely. /init writes user_balance -= bet + INSERT transaction(tag=BET), then the round sits is_finished=false until played out. No TTL, no cron auto-resolution — a user who closes mid-hand has the wager held until they return. getActiveState is the only recovery path.
  3. Fat-hydration on every /init. BetService.createBet triggers game_identity → {game_provider, game_in_category, promo_game_whitelist} side-loads plus user_self_exclusion + user_promo_code — 6+ SELECTs when only game_id is load-bearing. Same pattern flagged in docs/flows/dropbet-bet-place.md.
  4. Orphan ebit-bj NestJS 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 itebit-fe/src/components/originalGames/Blackjack/utils/queries.ts hits 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.
  5. payload is 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 is bet.amount / bet.payout / bet.multiplier.
  6. No settlement push channel. The rt service publishes other casino events but blackjack relies on the /handleAction response body as its sole settlement signal. A POST timeout mid-tx leaves the client with no recovery beyond polling /getActiveState, which returns null once settled — no payout info.

7. Unresolved

  • Side-bet and insurance branches unobserved. The E2E rejects insurance when eligible and never sets sideBetAmount, so createSideBetRoundId, createAndSettleBet(sideBetIdentity), and BOUGHT_PAYS_OUT / BOUGHT_DOES_NOT_PAY_OUT transitions have no trace sample here (blackjack.service.ts:412-463).
  • SPLIT / DOUBLE_DOWN second-debit path un-traced. handleSplitAction / handleDoubleDownAction at blackjack.service.ts:353-409, 613-681 emit an extra updateBetuser_balance UPDATE + transaction INSERT 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.