Skip to main content

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 amqplib with rabbitmq-client (zero deps, native TS, fibonacci backoff 1s→60s)
  • Use Connection class with acquireConsumer() 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 QueueManagerConfig interface (just map to rabbitmq-client Connection options)

Files:

  • backend/src/exchanges/adapters/bifrost/BifrostQueueManager.ts — full rewrite
  • backend/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/2 directly
  • 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-count header (rabbitmq-client exposes this)
  • After 3 requeues, reject to DLQ with FINANCIAL_INTEGRITY log
  • 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:
    1. cache.on('marketUpdate', (marketId, book) => { ... })
    2. Build odds invalidation payload with fixtureId, marketId, outcomes
    3. redis.publish('odds:updated', JSON.stringify(payload))
  • clientWs already subscribes to odds:updated and 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:notification and balance: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 in types.ts — reuse it
  • For Bifrost orders: skip Betfair-specific logic (testMode, acceptBetterLine)
  • Exposure guard: call checkBifrostExposureGuard() (already exists in providerExposureService, just not wired)
  • Store requestId on Order model (new column or use existing providerOrderId field)

Files:

  • backend/src/services/orderService.ts — marketId prefix routing + exposure guard
  • backend/src/exchanges/adapters/bifrost/types.ts — export isSportsbookMarket if 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%
  • PlatformProviderState already 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 pointValueHkd to PlatformSettings table (key: bifrost.pointValueHkd, value: 8)
  • hkdToPoints() / pointsToHkd() read from DB with Redis cache (5min TTL)
  • Fallback to config.bifrost.pointValueHkd env 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 lookup
  • backend/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?: number to CanonicalMarket type: 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

Files:

  • backend/src/exchanges/core/models/canonical.ts — add source?: number
  • backend/src/exchanges/adapters/betfair/mappers/BetfairMapper.ts — set source: 1
  • backend/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 metadata JSON field or add providerRequestId column
  • BetConsumer: if betsApiOrderId lookup fails, fall back to requestId lookup via metadata
  • Refund idempotency: before processing refund, check for existing Transaction with type: 'bet_refund' and matching orderId
    const 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 metadata
  • backend/src/exchanges/adapters/bifrost/BifrostBetConsumer.ts — fallback lookup + refund idempotency
  • Possibly Prisma schema if adding providerRequestId column

Implementation Order

  1. Phase 1 (Foundation): RabbitMQ migration (#1) + error propagation (#2) — everything depends on reliable message handling
  2. Phase 2 (Real-time): Redis pub/sub odds (#3) + WebSocket notifications (#4) — user-facing real-time flow
  3. Phase 3 (Routing): Order routing (#5) + exposure guard (#6) — enables Bifrost bet placement
  4. Phase 4 (Hardening): Version dedup (#7) + refund idempotency (#10) — financial safety nets
  5. Phase 5 (Config): HKD in PlatformSettings (#8) + source field (#9) — admin + frontend polish