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.tsGenerated: 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/beton ebit-api, authenticated by theaccess_tokencookie. Guarded byJwtAuthGuard+UserHttpThrottlerGuardwith@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(actual201). - Post-conditions asserted by the E2E (DB via
docker exec ebit-db psql, queue viaredis-cli KEYS bull:bet_settled_queue:*, trace viawaitForTraceByRootSpan({ service: 'ebit-api', operation: 'POST /casino/games/house/dice/bet' })): Betrow,status=SETTLED,amount=0.1,payout=0.198,roundId=77ef9ea0-…,gameId=7.- Two
Transactionrows on the samegame_id:WITHDRAW 0.1 DBC(1000 → 999.9) thenDEPOSIT 0.198 DBC(999.9 → 1000.098). Bothstatus=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.bet → DiceService.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 handlerbetspan (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 callsDiceService.play(dto, request.user)atdice.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)infair/fairness.service.ts. Upsert auto-creates a seed on first use (why the E2E needs no fairness pre-seed), incrementsnonce, returnsserverSeed+hashedServerSeedfor HMAC-SHA256 RNG.GameIdentity· findUnique · 4.98 ms —BetHelper.getHouseGameIdentity('dice')resolvesGame.id=7for the FK.
4.3 Step (3) — enter createAndSettleBet + veto checks¶
UserSelfExclusion· findFirst · 2.45 ms —BetService.checkCanUserBetveto check; throwsUSER_SELF_EXCLUDEDbefore 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) — deductsbetAmountunder aWHERE amount >= betAmountguard; 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 bypayout > 0inbuildHouseTxArgs); the E2E covers both branches.UserPromoCode· findFirst · 3.14 ms — promo-turnover branch inbeforeSettleBet; no-op lookup for the seeded user.
4.5 Steps (6)–(7) — create + INSERT the Bet row¶
Bet· create · 9.44 ms —BetRepository.createBetwritesstatus=SETTLED,amount,payout,multiplier,usdAmount/usdPayout(fromprocessUsdAmount),commissionGgrUsdAmount=0,gameId=7,roundId=<uuid>, game-specificpayload.@@unique([roundId, userId])atapi.prisma:674prevents 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
evalshaspan againstbull:bet_settled_queue—BetQueueProducer.pushBet(bet/queue/bet.queue-producer.ts) enqueuesContextQueueDatawithjobId = bet-<uuid>-SETTLED-0,removeOnComplete.age=300, 10 attempts, exponential 500 ms backoff. CarriesnotificationDelayMs=100so the worker can stagger live-bet broadcasts.
Two adjacent spans you'll also see in the trace at this point:
- One
evalshaspan againstbull:updateSessionQueue— the same session-touch that sign-in fires, emitted because any authenticated request refreshes the session. - One
unlinkspan — releases the@PlaceBetLockmutex key after the handler returns.
4.7 Step (9) — PUBLISH BalanceUpdated to Redis pub/sub¶
- Two
publishspans to Redis channelserver_channel_event.BalanceUpdated— one per ledger entry (accounting.service.tsnotifyhook). 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.emitliveBetsService.handleBet(EventsGateway→BET_ROOM.LATEST_BETS/HIGH_ROLLERS/LUCKY_WINS/BIG_WINS)userService.handleBet(stats)rakebackServiceleaderboardServiceaffiliateServicegamesService.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¶
- Double-settle of the same round (SF-004).
@@unique([roundId, userId])onBetis the only backstop; the unique violation rolls the whole tx back (balance + ledger reverted). Client sees the genericApiCode.INTERNALinstead of aBET_DUPLICATE. - Fairness seed race (SF-005).
popUserSeedupserts and incrementsnonce.@PlaceBetLockserialises concurrent bets, but if the lock key ever TTL-expires mid-handler, two handlers could reuse the samenonceand produce identicalrandomValuefor different bets. Defence-in-depth: conditionalnonce++. - Insufficient-funds guard relies on rowcount (SF-006).
WHERE amount >= betAmountin the PrismaUPDATE; if a refactor drops it, balances go negative. A pgCHECK (amount >= 0)would catch it at the schema layer. - Side-effect queue is fire-and-forget (SF-007).
bet_settled_queueis enqueued inonClosed. On Redis outage the HTTP returns 201 but live-bets / rakeback / leaderboard / affiliate notifications silently don't run; onceremoveOnFail.ageelapses the side effect is lost. - Throttler bucket is shared.
@Throttle({ bets: { limit: 25, ttl: 5000 } })is per-user but shared across every house-game endpoint in thebetsbucket — 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_queueruns in the same process as the producer, but BullMQ's ioredis instrumentation doesn't propagate W3Ctraceparentvia job context — the worker trace is rooted independently. Fix: threadtrace.getActiveSpan().spanContext()throughContextQueueand restore it in the processor. - Rt websocket emit is inferred, not observed. We see the Redis
PUBLISH server_channel_event.BalanceUpdatedand 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.