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.tsGenerated: 2026-04-16 · Services involved (code):ebit-api+ebit-speed-roulette· Services in trace tree:ebit-apionly 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.secretis nulled whenstate !== 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 freshsecret+ futureeosBlockNum) →WAITING_BLOCK(2 s, waits for EOS block) →ROLLING(7 s, computesroll+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):GREEN14×,RED_BAIT/BLACK_BAIT7×,RED/BLACK2×.ColorBaseToColorsMapgroups bait cells with their base — aREDbet wins onREDorRED_BAIT. - Bet restrictions (
bet.service.ts:56-71): within a round, a user's bets must share currency AND color-base. ThrowsSPEED_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 onebit-apiand callee spans onebit-speed-rouletteappear 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 }underJwtGuard. No@Throttle— concurrency is gated entirely by@PlaceBetLock. - Spans: root
POST /casino/house/speed-roulette/betparentsSpeedRouletteApiController.placeBetwhich then callsSpeedRouletteApiService.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 fromlibs/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
unlinkreleasing 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
/configmin/max, convertsbetAmountto USD viaExchangeRates.toUsd, throws if out of bounds before any RPC fires.
4.4 Step (4) — RPC speed_roulette.placeBet — AF-2 trace cutover¶
- Cross-service RPC via
@ExternalControllerClient(speed-roulette-api.service.ts). Two spans on the ebit-api side at t≈10 ms: ioredissubscribe(primes the reply channel) +publish(speed_roulette.placeBetenvelope with the bet DTO). - Trace tree ends here. The Nest Redis pub/sub transport does not serialise the W3C
traceparentinto the message envelope, so the callee-side work that follows (steps 5–11) emits underebit-speed-rouletteas orphan root traces, not as children of this span. The 76 ms gap betweenpublishandunlinkon the captured trace is callee work that never attaches. See AF-2 in../weaknesses-register.mdandproject_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@MessagePatternhandler — a fresh root span underebit-speed-roulette. - Step (6):
SpeedRouletteService.placeBet→BetService.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 throwsSPEED_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
BetServicecallswalletClient.play({ action: BET })over the same Nest Redis pub/sub transport, this time pointed back at ebit-api'sEvoGamesWalletGatewayController. 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. ReusescreateOrFindTransaction, the WITHDRAW guard, and the@@unique([roundId, userId])backstop documented indropbet-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 byamount >= betAmount),INSERT transaction(WITHDRAW,tag=BET),INSERT bet(status=SETTLEDfor the debit row). Exact same shape asdropbet-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.createBetinserts thespeed_roulette_betrow (id, user_id, game_id, amount, color, currency_id, is_settled=false, payout=null) and replies to the originalspeed_roulette.placeBetenvelope. The reply traverses pub/sub a third time back to ebit-api, where step (2)'sunlinkreleases 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 toprocessAcceptingBets/processWaitingBlock/processRolling/processFinished/processErroronSpeedRouletteStateService. 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).startIfNotStartedonly 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) callseosService.waitForBlock(eosBlockNum, 2000)to fetch the canonicaleosBlockIdfor the round's future block. This is the unpredictable seed that closes the commit-reveal:secretHashwas committed when the game row was created inprocessAcceptingBets; the EOS block hash is the public randomness mixed in atprocessRolling.EosWaitBlockTimeoutError/EosCurrentBlockOutdatedErrorare logged atwarnonly (roulette-state.processor.ts:90-103); 10 retries then routes toERROR+ per-user rollback.
4.12 Step (15) — UPDATE speed_roulette_game (roll + color + endedAt)¶
processRollingrunsRngGames.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 viaColorToMultiplierMap/ColorBaseToColorsMap(GREEN14×,RED_BAIT/BLACK_BAIT7×,RED/BLACK2×). The E2E recomputes this HMAC client-side and asserts equality (dropbet-speed-roulette.spec.ts:50-55).processFinishedthenUPDATEsspeed_roulette_game.{roll, color, endedAt}and revealssecret(the@Transformatapi.dto.ts:26-32stops nulling it once state isFINISHED).
4.13 Step (16) — pushSettleUserBetsJob enqueues per-user settlement¶
processFinishedaggregates bets per user and enqueues oneSETTLE_USER_BETSjob per user on thespeed-roulette:betqueue (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 inEvoGamesWalletGatewayController→BetService→UPDATE user_balance+INSERT transaction(DEPOSIT) +UPDATE bet. Third AF-2 hop: the credit-leg span tree is again orphan from the original/bettrace, so end-to-end "click → settled" latency can only be reconstructed via Loki correlation onuserId + gameId + timestamp.
4.15 Steps (19)–(21) — rt broadcast (secret reveal + result)¶
- Step (19):
SpeedRouletteStateServicecallsSpeedRouletteNotifierService.notifyRouletteStateUpdateon each state transition (roulette-notifier.service.ts). - Step (20): the notifier emits an
EventMessagevia the gateway client toebit-rt'sEventsGateway(libs/gateway/src/events.gateway.ts) — carriesSpeedRouletteStateUpdate/NewBetpayloads (andBalanceUpdatedfor per-user rooms). - Step (21):
EventsGatewayfans out over socket.io to clients inSPEED_ROULETTE_ROOM. Browser receives the reveal including the now-non-nullsecretand 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¶
- 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 on3a6e6edd…6b774: 25 spans,services=["ebit-api"], 76 ms gap betweenpublishand PlaceBetLock release. Callee-side spans emit underebit-speed-roulettebut 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 asproject_otel_microservice_transport_gap.md. - EOS blockchain is a hard external dependency.
processWaitingBlock(roulette-state.service.ts:70-92) callseosService.waitForBlock(eosBlockNum, 2000). On block miss the job retries 10× then routes toERRORwhich refunds viarollbackBetsInJob.EosWaitBlockTimeoutError/EosCurrentBlockOutdatedErrorare logged atwarnonly (roulette-state.processor.ts:90-103) — a sustained EOS outage stalls every round and only metrics would surface it. - State processor is concurrency=1, single-worker.
@Processor(..., { concurrency: 1 })atroulette-state.processor.ts:23. Any unhandled path that exhausts retries without re-adding a follow-up job hangs the queue —startIfNotStartedonly bootstraps on an empty queue, so a dangling failed job is a deadlock. - No mid-round recovery for the bettor. No
/getActiveStateequivalent: clients rely on the rt socketSpeedRouletteStateUpdate/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. - Settlement is async and decoupled from the bet trace. The
/bet201 confirms the debit only. The credit leg rides a BullMQSETTLE_USER_BETSjob that callswalletClient.play({action: WIN, finished:true}). 10 retries + exponential backoff, but no explicit DLQ beyondOnWorkerEvent('failed')logs.
7. Unresolved¶
- WIN-path settlement never linked to bet trace (§6.1). Capturing needs poll-by-timestamp +
gameIdcorrelation. - ERROR / rollback path un-traced.
processError+rollbackBetsInJobdocumented from source only; no E2E induces an EOS failure. - rt broadcast verification out of scope — E2E is REST-only. Task #36 should cover
SpeedRouletteStateUpdate/NewBetsubscriptions 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).