Skip to content

Flow: dropbet bet placement (shared pipeline via dice vector)

Trace ID: 8aaa902b3964af1d33dec7000bb36e02 · Jaeger: http://localhost:16686/trace/8aaa902b3964af1d33dec7000bb36e02 · E2E: tests-e2e/tests/dropbet-bet-place.spec.ts Generated: 2026-04-16 · Author: prisma-otel-engineer · Services traced: ebit-api (+ Redis/BullMQ via ioredis, Postgres via @prisma/instrumentation). Browser trace omitted — the bet POST is direct browser→:4000 fetch. Scope: this doc is the shared house-game bet pipeline as exercised by dice. Game-specific mechanics (dice RNG vs limbo, threshold math, house edge) are a separate flow — see task #35.

1. User-visible contract

From tests-e2e/tests/dropbet-bet-place.spec.ts:9-83 (after UI sign-in via support/signin.ts):

  • Entry: POST /casino/games/house/dice/bet on ebit-api, authenticated by the access_token cookie. Guarded by JwtAuthGuard + UserHttpThrottlerGuard with @Throttle({ bets: { limit: 25, ttl: 5000 } }) (dice.controller.ts:23-37).
  • Body DiceBetRequestDto (dto/dice.dto.ts): currencyId: CurrencySymbolBalance (e.g. 'DBC'), betAmount: Decimal, threshold: number (0.01–99.99), above: boolean.
  • Response DiceBetResponseDto: betId, currencyId, betAmount, payout, multiplier, randomValue (0–100, provably-fair HMAC), threshold, above, didWin. Status < 300 (actual 201).
  • Post-conditions asserted by the E2E (DB via docker exec ebit-db psql, queue via redis-cli KEYS bull:bet_settled_queue:*, trace via waitForTraceByRootSpan({ service: 'ebit-api', operation: 'POST /casino/games/house/dice/bet' })):
  • Bet row, status=SETTLED, amount=0.1, payout=0.198, roundId=77ef9ea0-…, gameId=7.
  • Two Transaction rows on the same game_id: WITHDRAW 0.1 DBC (1000 → 999.9) then DEPOSIT 0.198 DBC (999.9 → 1000.098). Both status=COMPLETED, tag=BET.
  • UserBalance[user=2, currency=DBC].amount = 1000.098.
  • One BullMQ job on bet_settled_queue, jobId = bet-<uuid>-SETTLED-0, 10 attempts, exponential 500 ms backoff.
  • Two Redis pub/sub messages on server_channel_event.BalanceUpdated.

Win/loss self-consistency assertion: payout > 0 ⇔ didWin === true. RNG outputs are server-derived from UserFairnessSeeds, so the test only checks shape.

2. Sequence diagram

Built from the 102 spans in 8aaa902b…. Service-method spans (DiceController.bet, bet) are emitted by @opentelemetry/instrumentation-nestjs-core 0.43.0; Prisma spans come from @prisma/instrumentation wired in libs/shared/src/basic/pre/pre-otel.main.ts (task #20).

sequenceDiagram
  participant U as Browser (authenticated)
  participant MW as Express middleware + JwtGuard
  participant D as DiceService.play
  participant B as BetService.createAndSettleBet
  participant A as AccountingService
  participant R as Redis (cache)
  participant PG as Postgres (Prisma tx)
  participant BMQ as BullMQ (bet_settled_queue)
  U->>MW: POST /casino/games/house/dice/bet (cookie: access_token)
  MW->>MW: cors, cookieParser, session, jsonParser, authenticate (JWT)
  MW->>D: @PlaceBetLock (SETNX placebetlock:<uid>)
  D->>PG: upsert UserFairnessSeeds (popUserSeed)
  D->>D: RngGames.getRandomDice + calculateDiceMultiplier
  D->>PG: findUnique GameIdentity (houseGameIdentity)
  D->>B: createAndSettleBet(user, payload, houseOperation)
  B->>PG: findFirst UserSelfExclusion (checkCanUserBet)
  B->>A: buildHouseTxArgs + createOrFindTransaction (WITHDRAW 0.1)
  A->>PG: update UserBalance (deduct 0.1)
  A->>PG: create Transaction (WITHDRAW, COMPLETED)
  B->>A: createOrFindTransaction (DEPOSIT 0.198 — won)
  A->>PG: upsert UserBalance (credit 0.198)
  A->>PG: create Transaction (DEPOSIT, COMPLETED)
  B->>PG: findFirst UserPromoCode (commission calc)
  B->>PG: create Bet (status=SETTLED)
  PG->>PG: COMMIT
  Note over B,A: PrismaTransactional.onClosed fires post-commit
  A->>R: PUBLISH server_channel_event.BalanceUpdated (WITHDRAW)
  A->>R: PUBLISH server_channel_event.BalanceUpdated (DEPOSIT)
  B->>BMQ: EVALSHA queue.add bet_settled_queue (jobId=bet-<uuid>-SETTLED-0)
  D->>R: UNLINK placebetlock:<uid>
  B-->>U: 201 + DiceBetResponseDto JSON

3. Component diagram

Edges are numbered in request-flow order. Section §4 below has the same numbers — each (N) on the diagram has its own §4.N subsection, so you can click straight through.

flowchart TD
    %% Datastores
    pg[("Postgres<br/>Bet · Transaction · UserBalance<br/>· UserFairnessSeeds")]
    rd[("Redis (cache)<br/>placebetlock · BullMQ · BalanceUpdated pub/sub")]

    %% NestJS process
    subgraph api["ebit-api (NestJS)"]
        ctrl["DiceController.bet<br/><i>POST /casino/games/house/dice/bet</i>"]
        dice["DiceService.play<br/><i>@PlaceBetLock + PrismaTransactional</i>"]
        rng["RngGames + BetHelper<br/><i>provably-fair RNG</i>"]
        bet["BetService<br/>.createAndSettleBet"]
        acc["AccountingService<br/><i>WITHDRAW + DEPOSIT ledger</i>"]
        repo["BetRepository.createBet<br/><i>Prisma Bet create</i>"]
        prod["BetQueueProducer.pushBet<br/><i>ContextQueue → BullMQ</i>"]
    end

    %% Bet worker subgraph — same process, separate queue consumer
    subgraph wk["Bet worker (same process, separate consumer)"]
        proc["BetQueueProcessor<br/><i>@Processor bet_settled_queue</i>"]
        live["LiveBetsNotificationService<br/><i>emits LATEST/HIGH/LUCKY/BIG</i>"]
    end

    %% (1)-(3) Request path enters and resolves RNG
    ctrl -- "(1) play(dto, user)" --> dice
    dice -- "(2) seed + multiplier" --> rng
    dice -- "(3) createAndSettleBet()" --> bet

    %% (4)-(7) Prisma-transactional writes (§4.2)
    bet -- "(4) WITHDRAW + DEPOSIT" --> acc
    acc -- "(5) ledger writes" --> pg
    bet -- "(6) createBet(SETTLED)" --> repo
    repo -- "(7) INSERT Bet" --> pg

    %% (8)-(10) onClosed side effects (§4.3)
    bet -- "(8) pushBet (onClosed)" --> prod
    acc -- "(9) PUBLISH BalanceUpdated" --> rd
    prod -- "(10) EVALSHA bull:bet_settled_queue" --> rd

    %% (11)-(12) Separate worker trace
    rd -- "(11) job pulled" --> proc
    proc -- "(12) notify LiveBets" --> live

    %% 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. Total request duration 108.5 ms (root POST /casino/games/house/dice/bet). The 12-span middleware prefix (~18 ms) is identical to sign-in (covered in dropbet-sign-in.md) and not repeated here.

4.1 Step (1) — DiceController.betDiceService.play

  • What: Nest wrapper accepts POST /casino/games/house/dice/bet, hands off to the service.
  • Spans: DiceController.bet (90.74 ms, nestjs.type: request_context) parents the handler bet span (81.55 ms, nestjs.type: handler).
  • Tags: nestjs.callback = bet, http.route = /casino/games/house/dice/bet.
  • Source: apps/api/src/casino/house/dice/dice.controller.ts:31-37. Handler body calls DiceService.play(dto, request.user) at dice.service.ts:34-96.

4.2 Step (2) — RNG: fairness seed + game identity

The handler body is wrapped by @PlaceBetLock(user.id) (Redis mutex via @bebkovan/server-core/WaitMutex) and then by PrismaTransactional.execute, which opens a prisma:client:transaction span. Steps (2)–(7) all live inside that single transaction.

Spans contributing to step (2):

  • UserFairnessSeeds · upsert · 7.97 ms — popUserSeed(user.id) in fair/fairness.service.ts. Upsert auto-creates a seed on first use (why the E2E needs no fairness pre-seed), increments nonce, returns serverSeed + hashedServerSeed for HMAC-SHA256 RNG.
  • GameIdentity · findUnique · 4.98 ms — BetHelper.getHouseGameIdentity('dice') resolves Game.id=7 for the FK.

4.3 Step (3) — enter createAndSettleBet + veto checks

  • UserSelfExclusion · findFirst · 2.45 ms — BetService.checkCanUserBet veto check; throws USER_SELF_EXCLUDED before any balance mutation on fail.

4.4 Steps (4)–(5) — WITHDRAW + DEPOSIT ledger writes

AccountingService.createOrFindTransaction (accounting.service.ts:193) writes both legs in the same transaction.

  • UserBalance · update · 3.13 ms (WITHDRAW leg) — deducts betAmount under a WHERE amount >= betAmount guard; rowcount = 0 ⇒ USER_INSUFFICIENT_FUNDS. 1000 → 999.9.
  • Transaction · create · 5.37 ms — tx-withdraw-DBC-7-bet-<uuid>, status=COMPLETED, tag=BET, game_id=<roundId>.
  • UserBalance · upsert · 2.88 ms (DEPOSIT leg) — payout credit (won, payout=0.198). Upsert rather than update so balances for rarely-used currencies seed on demand. 999.9 → 1000.098.
  • Transaction · create · 2.70 ms — tx-deposit-DBC-7-bet-<uuid>. On a loss this span is absent (DEPOSIT leg gated by payout > 0 in buildHouseTxArgs); the E2E covers both branches.
  • UserPromoCode · findFirst · 3.14 ms — promo-turnover branch in beforeSettleBet; no-op lookup for the seeded user.

4.5 Steps (6)–(7) — create + INSERT the Bet row

  • Bet · create · 9.44 ms — BetRepository.createBet writes status=SETTLED, amount, payout, multiplier, usdAmount/usdPayout (from processUsdAmount), commissionGgrUsdAmount=0, gameId=7, roundId=<uuid>, game-specific payload. @@unique([roundId, userId]) at api.prisma:674 prevents double-settle.

After this row insert, the wrapper commits: prisma:engine:commit_transaction fires, then PrismaTransactional.onClosed callbacks run post-commit (so side effects below are only visible if the DB write succeeded).

4.6 Step (8) — pushBet (onClosed) enqueues bet-settled job

  • One evalsha span against bull:bet_settled_queueBetQueueProducer.pushBet (bet/queue/bet.queue-producer.ts) enqueues ContextQueueData with jobId = bet-<uuid>-SETTLED-0, removeOnComplete.age=300, 10 attempts, exponential 500 ms backoff. Carries notificationDelayMs=100 so the worker can stagger live-bet broadcasts.

Two adjacent spans you'll also see in the trace at this point:

  • One evalsha span against bull:updateSessionQueue — the same session-touch that sign-in fires, emitted because any authenticated request refreshes the session.
  • One unlink span — releases the @PlaceBetLock mutex key after the handler returns.

4.7 Step (9) — PUBLISH BalanceUpdated to Redis pub/sub

  • Two publish spans to Redis channel server_channel_event.BalanceUpdated — one per ledger entry (accounting.service.ts notify hook). The rt service's balance subscriber relays these to the user's socket room.

4.8 Step (10) — EVALSHA into bull:bet_settled_queue

Same evalsha covered in §4.6 step (8); from Redis's perspective the script call lands here. The diagram splits the NestJS side (step 8: BetQueueProducer issues the call) from the Redis side (step 10: EVALSHA executes) for clarity, but they're the same span in the trace.

4.9 Steps (11)–(12) — separate worker trace

Processing of the enqueued job is a separate trace rooted on BullMQJob bet_settled_queue (no traceparent is stored in the job payload — see AF-2 in ../weaknesses-register.md).

Fan-out from the worker:

  • betEventService.emit
  • liveBetsService.handleBet (EventsGatewayBET_ROOM.LATEST_BETS / HIGH_ROLLERS / LUCKY_WINS / BIG_WINS)
  • userService.handleBet (stats)
  • rakebackService
  • leaderboardService
  • affiliateService
  • gamesService.handleBet(gameId)

5. Data model

Table / key R/W Fields touched Schema / file
Bet (Postgres) W id, userId, status=SETTLED, amount, payout, multiplier, currencyId, usdAmount, usdPayout, commissionGgrUsdAmount, gameId, roundId, payload libs/_prisma/src/schema/api.prisma:635-675; @@unique([roundId,userId])
Transaction (Postgres) W ×2 id (deterministic tx-<dir>-<cur>-<gameId>-<betId>), type WITHDRAW/DEPOSIT, status COMPLETED, tag BET, amount, before_balance, after_balance, game_id (= roundId), payload api.prisma:712-752
UserBalance (Postgres) W ×2 composite PK [userId,currencyId], amount, optional vaultAmount api.prisma:363-377; conditional update guarded by amount >= betAmount
UserFairnessSeeds (Postgres) W serverSeed, hashedServerSeed, clientSeed, nonce++ upsert in fairness.service.ts
GameIdentity (Postgres) R id=7, code=dice read-only lookup
UserSelfExclusion (Postgres) R userId, category, activeUntil veto check
UserPromoCode (Postgres) R userId, turnover, currency commission branch
placebetlock:<uid> (Redis) W/del SETNX flag with TTL (mutex) @PlaceBetLock decorator
BullMQ bet_settled_queue (Redis) W BetSettledJobData (serialized Bet + notificationDelayMs) bet/queue/bet.queue-producer.ts; processed by bet.queue-processor.ts
Redis pub/sub server_channel_event.BalanceUpdated Publish ×2 {userId, currencyId, amount, vaultAmount} accounting.service.ts notify onClosed
BullMQ updateSessionQueue (Redis) W session touch on authenticated request shared with sign-in flow

6. Failure modes

  1. Double-settle of the same round (SF-004). @@unique([roundId, userId]) on Bet is the only backstop; the unique violation rolls the whole tx back (balance + ledger reverted). Client sees the generic ApiCode.INTERNAL instead of a BET_DUPLICATE.
  2. Fairness seed race (SF-005). popUserSeed upserts and increments nonce. @PlaceBetLock serialises concurrent bets, but if the lock key ever TTL-expires mid-handler, two handlers could reuse the same nonce and produce identical randomValue for different bets. Defence-in-depth: conditional nonce++.
  3. Insufficient-funds guard relies on rowcount (SF-006). WHERE amount >= betAmount in the Prisma UPDATE; if a refactor drops it, balances go negative. A pg CHECK (amount >= 0) would catch it at the schema layer.
  4. Side-effect queue is fire-and-forget (SF-007). bet_settled_queue is enqueued in onClosed. On Redis outage the HTTP returns 201 but live-bets / rakeback / leaderboard / affiliate notifications silently don't run; once removeOnFail.age elapses the side effect is lost.
  5. Throttler bucket is shared. @Throttle({ bets: { limit: 25, ttl: 5000 } }) is per-user but shared across every house-game endpoint in the bets bucket — an attacker spraying dice + limbo + mines hits the same 25/5 s envelope. Intentional; called out so readers don't mistake it for per-endpoint.

7. Unresolved

  • No cross-service trace into the worker. bet_settled_queue runs in the same process as the producer, but BullMQ's ioredis instrumentation doesn't propagate W3C traceparent via job context — the worker trace is rooted independently. Fix: thread trace.getActiveSpan().spanContext() through ContextQueue and restore it in the processor.
  • Rt websocket emit is inferred, not observed. We see the Redis PUBLISH server_channel_event.BalanceUpdated and the BullMQ enqueue, but the rt service on :4001 has its own process/trace. End-to-end click→socket linkage lands once task #36 (rt flow doc) writes the relay span.
  • Dice-specific mechanics out of scope. Threshold math, house edge, RNG algorithm belong in the dice-vs-limbo doc (task #35). This flow only demonstrates that dice uses the shared pipeline.