Bifrost Integration — Drift Fixes & Production Hardening Design
Date: 2026-02-17 Status: Approved Scope: 10 fixes across 5 HIGH + 5 MEDIUM severity drifts from BIFROST_PAL_INTEGRATION_PLAN
Overview
Post-implementation audit of the Bifrost sportsbook integration revealed 15 drifts from the plan. This design addresses all HIGH and MEDIUM severity issues. The goal: production-grade RabbitMQ consumption, correct financial message handling, real-time odds/settlement flow, and proper PAL routing for Bifrost orders.
Decisions made during brainstorming:
- Keep protobuf (no JSON migration)
- Market-based routing via marketId prefix (
14.*= Bifrost,1.*= Betfair) - Numeric source field on CanonicalMarket (1=betfair, 2=pinnacle, 3=bifrost) — no provider strings to frontend
- PlatformSettings DB for HKD point value (admin-configurable)
- Skip BIFROST_INCLUDE_EXCHANGE_MARKETS toggle (not needed now)
Section 1: RabbitMQ Library Migration (HIGH)
Problem: amqplib requires DIY reconnection logic (exponential backoff, timer management). BifrostQueueManager has 100+ lines of connection management that rabbitmq-client handles natively.
Design:
- Replace
amqplibwithrabbitmq-client(zero deps, native TS, fibonacci backoff 1s→60s) - Use
Connectionclass withacquireConsumer()per queue - Return-value ack pattern: handler returns
0(ack),1(requeue),2(reject/DLQ) - Protobuf decoding stays — happens before the return-value ack
- Remove
scheduleReconnect(),reconnectAttempts,reconnectTimer,maxReconnectDelay— all handled by library - Keep
QueueManagerConfiginterface (just map torabbitmq-clientConnection options)
Files:
backend/src/exchanges/adapters/bifrost/BifrostQueueManager.ts— full rewritebackend/package.json— remove amqplib + @types/amqplib, add rabbitmq-client
Section 2: Error Propagation in Bet Handlers (HIGH — FINANCIAL)
Problem: handleBetSnapshot() and handleBetOutcome() in BifrostQueueManager catch errors and log but DON'T re-throw. The outer consumeQueue() sees success and ACKs. Financial messages permanently lost.
Design:
- With rabbitmq-client's return-value ack, handlers return
0/1/2directly - Wrap handler calls in try/catch: success →
return 0(ack), transient error →return 1(requeue), permanent error →return 2(reject to DLQ) - Classify errors: DB connection failure = transient (requeue), validation error = permanent (DLQ)
- Max requeue attempts tracked via
x-delivery-countheader (rabbitmq-client exposes this) - After 3 requeues, reject to DLQ with
FINANCIAL_INTEGRITYlog - Data queue handlers (category, event, market-catalogue, market-book) remain fire-and-forget (cache updates, not financial)
Files:
backend/src/exchanges/adapters/bifrost/BifrostQueueManager.ts
Section 3: Redis Pub/Sub for Real-Time Odds (HIGH)
Problem: BifrostAdapter's cache.on('marketUpdate') handler body is empty (stub). No Redis publish → no WebSocket broadcast → no live odds on frontend.
Design:
- Copy BetfairAdapter pattern exactly:
cache.on('marketUpdate', (marketId, book) => { ... })- Build odds invalidation payload with fixtureId, marketId, outcomes
redis.publish('odds:updated', JSON.stringify(payload))
- clientWs already subscribes to
odds:updatedand broadcasts — no changes needed there - Add
source: 3(bifrost) to the payload so frontend can distinguish if needed - Fixture room:
fixture:bfs_{eventId}— same room pattern as Betfair
Files:
backend/src/exchanges/adapters/bifrost/BifrostAdapter.ts— wire cache.on('marketUpdate')
Section 4: WebSocket Notifications for Bet Status/Settlement (HIGH)
Problem: BifrostBetConsumer updates DB but never publishes to Redis. Users don't see real-time status changes or settlement results.
Design:
- After successful DB update in
handleBetSnapshot():redis.publish('order:status', JSON.stringify({ userId, orderId, status, bookmaker: 'bifrost' }))
- After successful settlement in
handleBetOutcome():redis.publish('settlement:notification', JSON.stringify({ userId, orderId, outcome, pnlPoints }))redis.publish('balance:updated', JSON.stringify({ userId }))
- clientWs already handles
settlement:notificationandbalance:updated— no changes needed
Files:
backend/src/exchanges/adapters/bifrost/BifrostBetConsumer.ts
Section 5: Bifrost Order Routing via PAL (HIGH)
Problem: orderService derives bookmaker from routingStrategy.getRouteForOrder(sportId) which returns betfair-ex for cricket. Bifrost orders can't be placed via standard routing even though BOOKMAKER_MIN_STAKES has a bifrost: 1 entry.
Design:
- Add marketId-prefix check in orderService BEFORE sport-based routing:
if marketId starts with '14.' → bookmaker = 'bifrost', adapter = registry.get('bifrost')
else → existing sport-based routing (RoutingStrategy) isSportsbookMarket(marketId)already exists intypes.ts— reuse it- For Bifrost orders: skip Betfair-specific logic (testMode, acceptBetterLine)
- Exposure guard: call
checkBifrostExposureGuard()(already exists in providerExposureService, just not wired) - Store
requestIdon Order model (new column or use existingproviderOrderIdfield)
Files:
backend/src/services/orderService.ts— marketId prefix routing + exposure guardbackend/src/exchanges/adapters/bifrost/types.ts— exportisSportsbookMarketif not already
Section 6: Bifrost Exposure Guard (HIGH)
Problem: checkBifrostExposureGuard() exists in providerExposureService but is never called from orderService. No exposure protection for Bifrost credit account.
Design:
- Wire
checkBifrostExposureGuard()call in orderService when bookmaker is 'bifrost' - Same pattern as Pinnacle: check before placement, throw if 90% threshold breached, warn at 80%
PlatformProviderStatealready has a bifrost row (from seed.ts)- Redis cache key already defined:
PROVIDER_CACHE_KEYS.bifrost
Files:
backend/src/services/orderService.ts— add exposure guard call for bifrost
Section 7: Version Dedup in BetConsumer (MEDIUM)
Problem: Out-of-order messages could regress order status (e.g., PLACED arrives after FAILED). No sequence/version check.
Design:
- Define status progression weights:
pending:0, submitted:1, accepted:2, partially_accepted:2, declined:3, cancelled:3, lapsed:3, settled:4 - Before updating: if
weights[newStatus] <= weights[currentStatus], skip (log as stale) - Exception: refund statuses (FAILED, VOIDED, LAPSED, CANCELLED) always process if order isn't already settled/cancelled/declined
- This is lightweight and doesn't require schema changes
Files:
backend/src/exchanges/adapters/bifrost/BifrostBetConsumer.ts
Section 8: HKD Point Value in PlatformSettings (MEDIUM)
Problem: config.bifrost.pointValueHkd is env-var only. Changing it requires redeployment.
Design:
- Add
pointValueHkdto PlatformSettings table (key:bifrost.pointValueHkd, value:8) hkdToPoints()/pointsToHkd()read from DB with Redis cache (5min TTL)- Fallback to
config.bifrost.pointValueHkdenv var if DB row doesn't exist - Admin API endpoint:
PATCH /api/admin/settings/bifrost.pointValueHkd - Cache invalidation on update
Files:
backend/src/exchanges/adapters/bifrost/mappers/BifrostMapper.ts— cached DB lookupbackend/src/routes/admin.ts— settings endpoint (if not exists)- Prisma migration for PlatformSettings seed row
Section 9: Source Field on CanonicalMarket (MEDIUM)
Problem: Frontend can't distinguish which provider a market comes from. Needed for UI hints (e.g., different min stakes, no lay column for sportsbook).
Design:
- Add
source?: numbertoCanonicalMarkettype:1=betfair, 2=pinnacle, 3=bifrost - BetfairMapper sets
source: 1 - BifrostMapper sets
source: 3 - PinnacleMapper sets
source: 2 - Frontend uses source to:
- Hide lay column when
source === 3(sportsbook, back-only) - Show correct min stake warnings
- No provider name strings exposed to frontend
- Hide lay column when
Files:
backend/src/exchanges/core/models/canonical.ts— addsource?: numberbackend/src/exchanges/adapters/betfair/mappers/BetfairMapper.ts— set source: 1backend/src/exchanges/adapters/bifrost/mappers/BifrostMapper.ts— set source: 3- Frontend components that render markets (ExchangeGrid, etc.)
Section 10: requestId Storage + Refund Idempotency (MEDIUM)
Problem: BifrostAdapter generates requestId = uuidv4() at placement time but never stores it. If betsApiOrderId lookup fails, there's no fallback. Also, refunds have no idempotency check — requeued messages could double-refund.
Design:
- Store requestId: use Order's existing
metadataJSON field or addproviderRequestIdcolumn - BetConsumer: if
betsApiOrderIdlookup fails, fall back torequestIdlookup via metadata - Refund idempotency: before processing refund, check for existing Transaction with
type: 'bet_refund'and matching orderIdconst existing = await tx.transaction.findFirst({
where: { userId: order.userId, type: 'bet_refund', description: { contains: order.id } }
});
if (existing) { log + skip } - Settlement idempotency already exists (
order.status === 'settled'check)
Files:
backend/src/exchanges/adapters/bifrost/BifrostAdapter.ts— store requestId in metadatabackend/src/exchanges/adapters/bifrost/BifrostBetConsumer.ts— fallback lookup + refund idempotency- Possibly Prisma schema if adding
providerRequestIdcolumn
Implementation Order
- Phase 1 (Foundation): RabbitMQ migration (#1) + error propagation (#2) — everything depends on reliable message handling
- Phase 2 (Real-time): Redis pub/sub odds (#3) + WebSocket notifications (#4) — user-facing real-time flow
- Phase 3 (Routing): Order routing (#5) + exposure guard (#6) — enables Bifrost bet placement
- Phase 4 (Hardening): Version dedup (#7) + refund idempotency (#10) — financial safety nets
- Phase 5 (Config): HKD in PlatformSettings (#8) + source field (#9) — admin + frontend polish