Skip to content

Flow: dropbet speed-roulette

Trace ID: 3a6e6edd6d57791d30ac82cfe7f6b774 (POST /bet · 25 spans · 88.75 ms · ebit-api only — see §6.1) Jaeger: http://localhost:16686/trace/3a6e6edd6d57791d30ac82cfe7f6b774 · E2E: tests-e2e/tests/dropbet-speed-roulette.spec.ts Generated: 2026-04-16 · Services involved (code): ebit-api + ebit-speed-roulette · Services in trace tree: ebit-api only Sample game: cmo1xd9mv002pp201i010bluh · roll=11 → RED · bet 0.10 DBC on RED · payout 0.20 DBC

1. User-visible contract

Speed-roulette is the only multiplayer house-game: all players share one global round. The FE hits ebit-api's thin proxy at apps/api/src/casino/house/speed-roulette-api/, which RPC-forwards to the dedicated speed-roulette NestJS app on port 4003.

  • Endpoints (JwtGuard; no @Throttle — concurrency gated by @PlaceBetLock):
  • GET /config{ minBet, maxBet, maxProfit, timeConfig: {ACCEPTING_BETS:15000, WAITING_BLOCK:2000, ROLLING:7000, FINISHED:3000, ERROR:1000}, eosExtraDelayMs }.
  • GET /info{ game, usersBets, latestGames, latestStats, timeToNextBlockMs }. game.secret is nulled when state !== FINISHED (api.dto.ts:26-32 @Transform) — the provably-fair commit-reveal gate.
  • POST /bet { currencyId, betAmount, color }201 { id, currencyId, betAmount, color, gameId, createdAt }.
  • Round lifecycle (BullMQ speed-roulette:state, concurrency=1): ACCEPTING_BETS (15 s, new game row with fresh secret + future eosBlockNum) → WAITING_BLOCK (2 s, waits for EOS block) → ROLLING (7 s, computes roll+color) → FINISHED (3 s, reveals secret, emits updates). Full cycle ≈ 27 s. Worker exception → ERROR (1 s) → per-user rollback.
  • Payouts (ColorToMultiplierMap, const.ts:34-40): GREEN 14×, RED_BAIT/BLACK_BAIT 7×, RED/BLACK 2×. ColorBaseToColorsMap groups bait cells with their base — a RED bet wins on RED or RED_BAIT.
  • Bet restrictions (bet.service.ts:56-71): within a round, a user's bets must share currency AND color-base. Throws SPEED_ROULETTE_BETS_SHOULD_BE_IN_SAME_{CURRENCY,COLOR}.

2. Sequence diagram

sequenceDiagram
  participant U as Browser (dropbet FE)
  participant API as ebit-api (:4000)
  participant RSUB as Redis (pub/sub)
  participant SR as speed-roulette (:4003)
  participant PG as Postgres
  participant BQ as Redis (BullMQ state + bet queues)
  participant RT as rt gateway (:4001)

  Note over BQ: state queue loops autonomously every ~27 s

  U->>API: GET /info (poll for ACCEPTING_BETS)
  API->>SR: RPC getRouletteInfo (ExternalControllerClient)
  SR-->>API: game {state, secretHash, secret:null}
  API-->>U: 200

  U->>API: POST /bet { DBC, 0.10, RED }
  API->>API: @PlaceBetLock(userId) — per-user Redis mutex
  API->>API: validateBetAmount (ExchangeRates.toUsd)
  API->>RSUB: publish speed_roulette.placeBet (NO traceparent — §6.1)
  RSUB->>SR: deliver
  SR->>SR: @WaitMutex speed-roulette:user-bet-<userId>
  SR->>PG: hasUserBetsInOtherCurrencies / InOtherColors
  SR->>API: RPC walletClient.play (BET debit) (pub/sub back to ebit-api)
  API->>PG: UPDATE user_balance · INSERT transaction · INSERT bet
  API-->>SR: ok
  SR->>PG: INSERT speed_roulette_bet
  SR-->>API: bet dto
  API-->>U: 201

  Note over SR,BQ: separate flow, not a child of the /bet trace
  BQ->>SR: state WAITING_BLOCK
  SR->>SR: eos.waitForBlock(eosBlockNum) → eosBlockId
  BQ->>SR: state ROLLING
  SR->>SR: HMAC-SHA256(secret).update(`${eosBlockId}:0:0`) → roll, color
  SR->>PG: UPDATE speed_roulette_game SET roll, color, endedAt
  BQ->>SR: state FINISHED
  SR->>SR: aggregateBets → per-user SettleUserBetsJob on bet queue
  BQ->>SR: SETTLE_USER_BETS
  SR->>API: RPC walletClient.play(WIN, payout)
  API->>PG: UPDATE user_balance · INSERT transaction · UPDATE bet
  SR->>RT: notifyRouletteStateUpdate (secret revealed)
  RT-->>U: ws SpeedRouletteStateUpdate

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.

Speed-roulette spans two Nest processes (ebit-api + ebit-speed-roulette) over the AF-2 trace-gap boundary, plus a BullMQ-driven state machine and an rt broadcast. Two diagrams because trying to render all 21 edges in one image stacks labels across the Nest microservice transport node. §3.1 covers steps (1)–(11) (synchronous place-bet leg); §3.2 covers steps (12)–(21) (round lifecycle + settlement + ws broadcast).

3.1 Place-bet leg (steps 1–11) — cross-process via AF-2 RPC

flowchart LR
    pg[("Postgres<br/>bet · transaction · user_balance<br/>· speed_roulette_bet")]
    rd[("Redis (cache)<br/>placebetlock + user-bet mutex")]

    subgraph fe["Browser"]
        client["SpeedRoulette queries<br/><i>POST /bet</i>"]
    end

    subgraph api["ebit-api :4000"]
        direction TB
        proxy["SpeedRouletteApiController<br/><i>@PlaceBetLock + @ExternalControllerClient</i>"]
        validate["BetAmountValidationService<br/><i>toUsd vs config</i>"]
        wallet["EvoGamesWalletGatewayController<br/><i>walletClient.play handler</i>"]
        betApi["BetService (shared ledger)"]
    end

    subgraph sr["ebit-speed-roulette :4004 (orphan trace after AF-2)"]
        direction TB
        gwCtl["SpeedRouletteGatewayController<br/><i>@MessagePattern placeBet</i>"]
        rouletteSvc["SpeedRouletteService<br/><i>@WaitMutex user-bet-<uid></i>"]
    end

    client -- "(1) POST /bet"                                   --> proxy
    proxy  -- "(2) SETNX placebetlock + EVALSHA"                --> rd
    proxy  -- "(3) validate min/max"                            --> validate
    proxy  == "(4) RPC placeBet<br/>Nest pub/sub · AF-2"        ==> gwCtl
    gwCtl  -- "(5) deliver + @WaitMutex"                        --> rouletteSvc
    rouletteSvc == "(6) RPC walletClient.play(BET)<br/>pub/sub back · AF-2" ==> wallet
    wallet -- "(7) createBet"                                   --> betApi
    betApi -- "(8) UPDATE user_balance"                         --> pg
    betApi -- "(9) INSERT transaction"                          --> pg
    betApi -- "(10) INSERT bet"                                 --> pg
    rouletteSvc -- "(11) INSERT speed_roulette_bet"             --> pg

    classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
    class pg,rd db;
    linkStyle 3,5 stroke:#cc0000,stroke-width:2.5px;

Red thick edges (4, 6) cross the AF-2 trace-gap (Nest Redis pub/sub microservice transport — no traceparent). Caller spans on ebit-api and callee spans on ebit-speed-roulette appear in Jaeger as separate orphan traces.

3.2 Round lifecycle + settlement + ws broadcast (steps 12–21)

flowchart LR
    pg[("Postgres<br/>speed_roulette_game · bet · transaction · user_balance")]
    rd[("Redis (cache)<br/>BullMQ speed-roulette:state + :bet")]

    subgraph fe["Browser"]
        client["SpeedRoulette client<br/><i>socket.io /events</i>"]
    end

    subgraph api["ebit-api :4000"]
        wallet["EvoGamesWalletGatewayController<br/><i>walletClient.play handler</i>"]
        betApi["BetService (shared ledger)"]
    end

    subgraph sr["ebit-speed-roulette :4004"]
        direction TB
        stateProc["SpeedRouletteStateProcessor<br/><i>BullMQ concurrency=1 · ~27s cycle</i>"]
        stateSvc["SpeedRouletteStateService<br/><i>processAccepting/Waiting/Rolling/Finished/Error</i>"]
        eos["EosService<br/><i>waitForBlock (HMAC anchor)</i>"]
        betProc["BetQueueProcessor<br/><i>BullMQ concurrency=3 · SETTLE/ROLLBACK</i>"]
        notify["SpeedRouletteNotifierService"]
    end

    subgraph rt["ebit-rt :4001"]
        rtGw["EventsGateway<br/><i>ws SPEED_ROULETTE_ROOM</i>"]
    end

    rd -- "(12) BullMQ state pull"                           --> stateProc
    stateProc -- "(13) process state"                        --> stateSvc
    stateSvc -- "(14) waitForBlock(eosBlockNum)"             --> eos
    stateSvc -- "(15) UPDATE speed_roulette_game"            --> pg
    stateSvc -- "(16) pushSettleUserBetsJob"                 --> rd
    rd -- "(17) SETTLE_USER_BETS pulled"                     --> betProc
    betProc == "(18) RPC walletClient.play(WIN)<br/>pub/sub · AF-2" ==> wallet
    wallet -- "(WIN leg) createBet update + ledger"          --> betApi
    betApi --> pg
    stateSvc -- "(19) notifyRouletteStateUpdate"             --> notify
    notify -- "(20) EventMessage"                            --> rtGw
    rtGw -- "(21) ws SpeedRouletteStateUpdate"               --> client

    classDef db fill:#1f4e79,stroke:#bbb,color:#fff;
    class pg,rd db;
    linkStyle 7 stroke:#cc0000,stroke-width:2.5px;

Edge (18) crosses AF-2 again (settlement WIN leg back into ebit-api's wallet handler), surfacing as another orphan trace. The "WIN leg" arrow after (18) and into Postgres is the same shared-ledger path from §3.1 (steps 7-10) — drawn here without a number to show the closure of the round.

4. Per-step walkthrough

Section headers below mirror the diagram step numbers in §3 — each §4.N covers (N) on the diagram. Captured trace 3a6e6edd…6b774 is 25 spans / 88.75 ms / services=["ebit-api"] and covers steps (1)–(4) only; the 11 middleware spans (query, expressInit, corsMiddleware, cookieParser, session, passport initialize/authenticate, jsonParser, urlencodedParser, plus two anonymous) are the standard /casino/* kernel and not repeated here. Steps (5)–(21) live in separate orphan traces under ebit-speed-roulette (AF-2, see §6.1) and the BullMQ-driven state cycle.

4.1 Step (1) — POST /bet reaches SpeedRouletteApiController

  • What: SpeedRouletteApiController.placeBet (84.4 ms, apps/api/src/casino/house/speed-roulette-api/speed-roulette-api.controller.ts:26) accepts { currencyId, betAmount, color } under JwtGuard. No @Throttle — concurrency is gated entirely by @PlaceBetLock.
  • Spans: root POST /casino/house/speed-roulette/bet parents SpeedRouletteApiController.placeBet which then calls SpeedRouletteApiService.placeBet (78.2 ms wrapper span).

4.2 Step (2) — @PlaceBetLock(userId) acquire

  • 5 ioredis spans (get, get, zscore, evalsha, set) implement the Lua SET-NX mutex from libs/shared/src/security/ — shared with every house game, so a user mid-dice cannot also fire a speed-roulette bet. ~4 ms even on the happy path.
  • The mirror unlink releasing the key fires at t=86 ms, just before the 201 response.

4.3 Step (3) — BetAmountValidationService.validate

  • CPU-only, un-instrumented (no Prisma / no Redis), so it leaves no span. Reads the cached /config min/max, converts betAmount to USD via ExchangeRates.toUsd, throws if out of bounds before any RPC fires.

4.4 Step (4) — RPC speed_roulette.placeBetAF-2 trace cutover

  • Cross-service RPC via @ExternalControllerClient (speed-roulette-api.service.ts). Two spans on the ebit-api side at t≈10 ms: ioredis subscribe (primes the reply channel) + publish (speed_roulette.placeBet envelope with the bet DTO).
  • Trace tree ends here. The Nest Redis pub/sub transport does not serialise the W3C traceparent into the message envelope, so the callee-side work that follows (steps 5–11) emits under ebit-speed-roulette as orphan root traces, not as children of this span. The 76 ms gap between publish and unlink on the captured trace is callee work that never attaches. See AF-2 in ../weaknesses-register.md and project_otel_microservice_transport_gap.md.

4.5 Steps (5)–(6) — speed-roulette accepts the bet (orphan trace)

  • Step (5): the Nest microservice transport delivers the envelope to SpeedRouletteGatewayController (apps/speed-roulette/src/roulette/roulette.gateway.controller.ts) via the @MessagePattern handler — a fresh root span under ebit-speed-roulette.
  • Step (6): SpeedRouletteService.placeBetBetService.createBet (apps/speed-roulette/src/bet/bet.service.ts:42-43) takes the @WaitMutex speed-roulette:user-bet-<userId> Redis lock (bet.service.ts:53-55), runs the two bet-restriction SELECTs (hasUserBetsInOtherCurrencies / hasUserBetsInOtherColors, bet.service.ts:56-71) and throws SPEED_ROULETTE_BETS_SHOULD_BE_IN_SAME_{CURRENCY,COLOR} on mismatch.

4.6 Steps (7)–(8) — RPC walletClient.play(BET) back to ebit-api — AF-2 again

  • Step (7): the speed-roulette BetService calls walletClient.play({ action: BET }) over the same Nest Redis pub/sub transport, this time pointed back at ebit-api's EvoGamesWalletGatewayController. Same AF-2 break — the return leg roots yet another orphan trace.
  • Step (8): the pub/sub channel delivers the envelope to EvoGamesWalletGatewayController (apps/api/src/casino/slots/providers/evogames/wallet/) inside ebit-api.

4.7 Step (9) — wallet handler invokes the shared ledger

  • The wallet gateway handler calls BetService.createBet (apps/api/src/bet/bet.service.ts) — the same shared bet/transaction/user_balance writer used by dice, limbo, mines and every other house game. Reuses createOrFindTransaction, the WITHDRAW guard, and the @@unique([roundId, userId]) backstop documented in dropbet-bet-place.md §4.4.

4.8 Step (10) — ledger writes (user_balance / transaction / bet)

  • Inside the wallet RPC handler's PrismaTransactional: UPDATE user_balance (deduct, guarded by amount >= betAmount), INSERT transaction (WITHDRAW, tag=BET), INSERT bet (status=SETTLED for the debit row). Exact same shape as dropbet-bet-place.md §4.4, just driven by a different controller.

4.9 Step (11) — INSERT speed_roulette_bet

  • Back on the speed-roulette side, once the wallet RPC returns ok, BetService.createBet inserts the speed_roulette_bet row (id, user_id, game_id, amount, color, currency_id, is_settled=false, payout=null) and replies to the original speed_roulette.placeBet envelope. The reply traverses pub/sub a third time back to ebit-api, where step (2)'s unlink releases the lock and the 201 returns to the browser.

4.10 Steps (12)–(13) — SpeedRouletteStateProcessor (BullMQ, concurrency=1)

  • Separate trace family, not a child of any POST /bet. SpeedRouletteStateProcessor (apps/speed-roulette/src/roulette/state/roulette-state.processor.ts:23, @Processor(..., { concurrency: 1 })) pulls the next state job and dispatches to processAcceptingBets / processWaitingBlock / processRolling / processFinished / processError on SpeedRouletteStateService. The state machine self-perpetuates: each handler re-enqueues the next state with the configured delay (ACCEPTING_BETS:15000, WAITING_BLOCK:2000, ROLLING:7000, FINISHED:3000, ERROR:1000). startIfNotStarted only bootstraps on an empty queue, so a dangling failed job is a deadlock (see §6.3).

4.11 Step (14) — EosService.waitForBlock (provably-fair anchor)

  • processWaitingBlock (roulette-state.service.ts:70-92) calls eosService.waitForBlock(eosBlockNum, 2000) to fetch the canonical eosBlockId for the round's future block. This is the unpredictable seed that closes the commit-reveal: secretHash was committed when the game row was created in processAcceptingBets; the EOS block hash is the public randomness mixed in at processRolling. EosWaitBlockTimeoutError / EosCurrentBlockOutdatedError are logged at warn only (roulette-state.processor.ts:90-103); 10 retries then routes to ERROR + per-user rollback.

4.12 Step (15) — UPDATE speed_roulette_game (roll + color + endedAt)

  • processRolling runs RngGames.getRandomSpeedRoulette({secret, eosBlockId}) (roulette-state.service.ts:140-153): HMAC-SHA256(secret, ${eosBlockId}:0:0), first 4 bytes as base-256 fraction × 15, floored → roll ∈ [0,14], mapped to colour via ColorToMultiplierMap / ColorBaseToColorsMap (GREEN 14×, RED_BAIT/BLACK_BAIT 7×, RED/BLACK 2×). The E2E recomputes this HMAC client-side and asserts equality (dropbet-speed-roulette.spec.ts:50-55). processFinished then UPDATEs speed_roulette_game.{roll, color, endedAt} and reveals secret (the @Transform at api.dto.ts:26-32 stops nulling it once state is FINISHED).

4.13 Step (16) — pushSettleUserBetsJob enqueues per-user settlement

  • processFinished aggregates bets per user and enqueues one SETTLE_USER_BETS job per user on the speed-roulette:bet queue (BullMQ payload { jobId, gameId, userId }, 10 retries, exponential backoff). The wider concurrency=3 on the bet queue (vs concurrency=1 on the state queue) is what lets settlement fan-out across many users without serialising the round loop.

4.14 Steps (17)–(18) — settlement WIN/BET leg — AF-2 again

  • Step (17): BetQueueProcessor (apps/speed-roulette/src/bet/bet-queue.processor.ts, concurrency=3) pulls the job.
  • Step (18): for each user the processor calls walletClient.play({ action: payout > 0 ? WIN : BET, finished: true }) over the Nest Redis pub/sub transport — credit leg lands in EvoGamesWalletGatewayControllerBetServiceUPDATE user_balance + INSERT transaction (DEPOSIT) + UPDATE bet. Third AF-2 hop: the credit-leg span tree is again orphan from the original /bet trace, so end-to-end "click → settled" latency can only be reconstructed via Loki correlation on userId + gameId + timestamp.

4.15 Steps (19)–(21) — rt broadcast (secret reveal + result)

  • Step (19): SpeedRouletteStateService calls SpeedRouletteNotifierService.notifyRouletteStateUpdate on each state transition (roulette-notifier.service.ts).
  • Step (20): the notifier emits an EventMessage via the gateway client to ebit-rt's EventsGateway (libs/gateway/src/events.gateway.ts) — carries SpeedRouletteStateUpdate / NewBet payloads (and BalanceUpdated for per-user rooms).
  • Step (21): EventsGateway fans out over socket.io to clients in SPEED_ROULETTE_ROOM. Browser receives the reveal including the now-non-null secret and can independently verify the HMAC. End-to-end rt verification is out of scope here (E2E is REST-only); task #36 covers the rt flow.

5. Data model

Store Key / table R/W Fields touched Source
Postgres speed_roulette_game R+W id, state, secret, secret_hash, eos_block_num, eos_block_timestamp, eos_block_id, roll, color, ended_at, created_at libs/_prisma/src/schema/speed_roulette.prisma
Postgres speed_roulette_bet R+W id, user_id, game_id, amount, color, currency_id, is_settled, payout, created_at libs/_prisma/src/schema/speed_roulette.prisma
Postgres speed_roulette_user R (denormalised for rt broadcasts) id, username, avatar_url, level libs/_prisma/src/schema/speed_roulette.prisma
Postgres bet, transaction, user_balance R+W (via walletClient.play RPC back into ebit-api) same as every other house-game bet — the shared ledger libs/_prisma/src/schema/api.prisma
Redis (cache) place-bet:<userId> R+W @PlaceBetLock shared across all house games libs/shared/src/security/
Redis (cache) speed-roulette:user-bet-<userId> R+W @WaitMutex guarding the two bet-restriction SELECTs + INSERT bet.service.ts:53-55
Redis (cache) bull:speed-roulette:state:* R+W state-machine job payloads ({jobId, gameId, state:{current, delayMs}}) roulette-state.processor.ts
Redis (cache) bull:speed-roulette:bet:* R+W SETTLE_USER_BETS / ROLLBACK_BETS job payloads, 10 retries, exponential backoff bet-queue.processor.ts
Redis (pub/sub) Nest microservice channels W → R speed_roulette.placeBet, evogames.wallet.play, replies — no traceparent @app/gateway/ms-controller

6. Failure modes

  1. OTel traceparent does not cross the microservice transport. @ExternalControllerClient (speed-roulette-api.service.ts, bet.service.ts:42-43) uses the Nest Redis pub/sub transport. Verified on 3a6e6edd…6b774: 25 spans, services=["ebit-api"], 76 ms gap between publish and PlaceBetLock release. Callee-side spans emit under ebit-speed-roulette but as orphan root traces (prisma:client:operation) — the wallet-play return leg is also lost. No end-to-end latency breakdown across the three hops; debugging a speed-roulette 500 from an ebit-api trace requires Loki correlation by userId + timestamp. Saved as project_otel_microservice_transport_gap.md.
  2. EOS blockchain is a hard external dependency. processWaitingBlock (roulette-state.service.ts:70-92) calls eosService.waitForBlock(eosBlockNum, 2000). On block miss the job retries 10× then routes to ERROR which refunds via rollbackBetsInJob. EosWaitBlockTimeoutError / EosCurrentBlockOutdatedError are logged at warn only (roulette-state.processor.ts:90-103) — a sustained EOS outage stalls every round and only metrics would surface it.
  3. State processor is concurrency=1, single-worker. @Processor(..., { concurrency: 1 }) at roulette-state.processor.ts:23. Any unhandled path that exhausts retries without re-adding a follow-up job hangs the queue — startIfNotStarted only bootstraps on an empty queue, so a dangling failed job is a deadlock.
  4. No mid-round recovery for the bettor. No /getActiveState equivalent: clients rely on the rt socket SpeedRouletteStateUpdate / NewBet (roulette-notifier.service.ts). A disconnect after 201 loses the visualisation but settlement still runs via the bet-queue processor — players who close the tab won't see their win animation.
  5. Settlement is async and decoupled from the bet trace. The /bet 201 confirms the debit only. The credit leg rides a BullMQ SETTLE_USER_BETS job that calls walletClient.play({action: WIN, finished:true}). 10 retries + exponential backoff, but no explicit DLQ beyond OnWorkerEvent('failed') logs.

7. Unresolved

  • WIN-path settlement never linked to bet trace (§6.1). Capturing needs poll-by-timestamp + gameId correlation.
  • ERROR / rollback path un-traced. processError + rollbackBetsInJob documented from source only; no E2E induces an EOS failure.
  • rt broadcast verification out of scope — E2E is REST-only. Task #36 should cover SpeedRouletteStateUpdate/NewBet subscriptions end-to-end.
  • OTel gap fix not tracked under Phase 1/2; candidate is a Nest microservice interceptor that serialises OTel context into the pub/sub envelope. Raise as follow-up during Phase 3 (#42).