Skip to main content

Bifrost PR#143 Follow-up Fixes

Branch: fix/bifrost-fixes from fix/disable-bifrost-auto-recovery Date: 2026-03-04 Issues reported by: Bifrost team after bhdev testing


Issues Summary

#SeverityIssueRoot CauseFix
1CRITICALBet failed, balance deducted, stuck foreverExposure guard outside refund try/catch + retry missing sportId + no timeoutStructural refactor + retry fix + startup reconciliation
2CRITICALmemberCode always 'forsyt' for all usersBifrostAdapter doesn't pass memberCode to placeBet()Pass order.userId as memberCode
3AVERAGEMax stake stale in betslip after WebSocket updatebetSlipStore snapshots maxStake at add-time, no sync from WSsyncMarketData() method bridging WS to betslip store
4COSMETICVoided bets shown as "Bet Rejected — try again"VOIDED mapped to cancelled (same as rejection), wrong toast + tabVoid as settlement outcome (status=settled, settlementOutcome=void)

Issue #1: Stuck Pending Order (CRITICAL)

Evidence

  • User: adsad (572b1b26-fd8c-45e5-89c2-02093427ef37)
  • Order: 2b6e8dc0-f64a-4675-bfdb-d3b1c38ee5d1
  • DB state: status=pending, bets_api_order_id=NULL, updated_at=created_at (never updated)
  • Balance: 0 (should be 5000)
  • Server log: Retry failed for order 2b6e8dc0: DOMAIN_INTEGRITY: sportId is missing on Bifrost order every 5 min

User 'adsad' Complete Ledger

TimeEventAmountBalance
13:17:58Point allocation+50005000
23:33:54Bet #1: BACK SA @ 97 (100pts)-1004900
23:38:35Bet #1: VOIDED by Bifrost, refunded+1005000
23:50:20Bet #2: BACK SA @ 97 (5000pts)-50000
NowStuck — no refund, no provider confirmation0

Root Cause (3 bugs)

Bug 1a — Structural gap in orderService.ts: The exposure guard (Redis lock + credit check, lines 516-531) was OUTSIDE the try/catch that contains the refund logic (line 534-738). Any error in the exposure guard after balance deduction left the order stuck in pending with no refund path.

Balance deducted (line 420)

Exposure guard (line 516-531) ← DANGER ZONE: errors here = no refund

try { PAL submission } catch { REFUND } ← refund only inside this block

Bug 1b — retryPendingOrders missing sportId: orderSyncJob.ts:187-199 retries pending orders but doesn't pass sportId, priceIndex, or line. BifrostAdapter validates sportId and throws. Retry fails forever.

Bug 1c — No timeout on stuck orders: retryPendingOrders has no max age threshold. Orders retry indefinitely with no escape hatch.

Fix

  1. Moved exposure guard inside the refund try/catch (orderService.ts): Any error in lock acquisition or exposure check now flows to the refund path.

  2. Fixed retryPendingOrders (orderSyncJob.ts): Added sportId: order.sportId ?? undefined to the retry payload.

  3. Added MAX_PENDING_AGE_MS (30 min) (orderSyncJob.ts): Orders older than 30 min are auto-refunded via refundStuckOrder() instead of retried.

  4. Added startup reconciliation (orderSyncJob.ts): reconcileStuckOrders() runs once at job startup to refund orders stuck from before a container restart/crash.

Files Changed

  • backend/src/services/orderService.ts — Moved exposure guard inside refund try/catch
  • backend/src/jobs/orderSyncJob.ts — Fixed retry, added auto-refund, added startup reconciliation

Issue #2: memberCode Always 'forsyt' (CRITICAL)

Evidence

  • backend/.env.bhdev: BIFROST_MEMBER_CODE=forsyt
  • BifrostClient.ts:97: Falls back to this.config.memberCode
  • BifrostAdapter.ts:660-672: placeBet() call didn't pass memberCode

Root Cause

BifrostAdapter.placeOrder() never passes memberCode to BifrostClient.placeBet(). The client falls back to config.memberCode which is the platform-level 'forsyt' from the env. Every user gets the same identifier — Bifrost can't do per-user risk management.

Fix

Added memberCode: order.userId to the placeBet() call in BifrostAdapter.placeOrder(). The UUID is a stable, unique per-user identifier that Bifrost uses as a risk management key.

Files Changed

  • backend/src/exchanges/adapters/bifrost/BifrostAdapter.ts — Pass memberCode: order.userId
  • backend/src/exchanges/adapters/bifrost/BifrostClient.ts — Accept optional memberCode param, fall back to config

Issue #3: Stale Max Stake in Betslip (AVERAGE)

Evidence

  • betSlipStore.ts:addBet() captures maxStake = depth[0].size at add-time
  • useWebSocket.ts:handleOddsUpdate updates React Query cache but not betslip store
  • No sync mechanism between the two stores

Root Cause

Betslip store and React Query cache are separate state stores with no bridge. When WebSocket sends updated depth data, the query cache updates but the betslip's stale maxStake persists.

Fix

  1. Added syncMarketData(fixtureId, markets) method to betSlipStore — updates maxStake, priceIndex, and line for matching betslip items from live depth data.

  2. Called syncMarketData() from handleOddsUpdate in useWebSocket.ts after updating the React Query cache.

Files Changed

  • frontend/src/store/betSlipStore.ts — Added syncMarketData() method
  • frontend/src/hooks/useWebSocket.ts — Calls syncMarketData() after odds cache update

Issue #4: Voided Bets Shown as "Rejected" (COSMETIC → UX)

Evidence

  • Order 84e2a8dc: status=cancelled, cancellation_reason=bifrost:VOIDED
  • BifrostMapper.ts:401-404: VOIDED → 'cancelled' (same as CANCELLED)
  • useWebSocket.ts:243: Terminal statuses toast "Bet Rejected"
  • my-bets/page.tsx:31: Voided tab queries wrong status

Root Cause

VOIDED was treated as a cancellation (Phase 1: acceptance), but void is actually a settlement outcome (Phase 2). A void means the bet WAS accepted and matched, then the market was invalidated. It's NOT a rejection.

Fix — Void as Settlement Outcome

Backend (BifrostBetConsumer.ts):

  • VOIDED snapshot → status: 'settled', settlementOutcome: 'void', profitLoss: 0, settledAt: now
  • Refund still happens (stake returned)
  • Redis notification includes settlementOutcome: 'void' and user-friendly reason

Frontend (useWebSocket.ts):

  • Void updates arrive as status: 'settled' + settlementOutcome: 'void'
  • Toast: "Bet Voided — Your stake has been refunded" (warning, not error)
  • Not in terminalStatuses array (not shown as "rejected")

Frontend (my-bets/page.tsx):

  • Voided tab queries status: "settled", client-side filters by settlementOutcome === "void"
  • OrderCard: detects settlementOutcome === "void" → shows purple "Voided" badge
  • Voided bets appear in both Settled tab (all settlements) and Voided tab (filtered)

Files Changed

  • backend/src/exchanges/adapters/bifrost/BifrostBetConsumer.ts — Void as settlement
  • frontend/src/hooks/useWebSocket.ts — Void-specific toast, settlementOutcome in type
  • frontend/src/app/my-bets/page.tsx — Voided tab query, OrderCard display

Also Improved

BifrostAdapter Ambiguous Failure Detection

Refined the error classification in BifrostAdapter.placeOrder() catch block:

  • Pre-send failures (ECONNREFUSED, ENOTFOUND): Request never reached Bifrost → safe to decline immediately
  • Post-send failures (ETIMEDOUT, ECONNRESET, socket hang up): Request may have been sent → mark as submitted for queue reconciliation
  • Previously ECONNREFUSED was treated as ambiguous (wrong — TCP refused means the request never reached the server)

Deployment Notes

  1. No schema changes — all fixes use existing DB columns
  2. Startup reconciliation will auto-refund user 'adsad' on first deploy (order is >30 min old)
  3. Test on bhdev first — merge to feat/bhdev and deploy
  4. Manual refund SQL is available if immediate refund is needed before deploy

Cross-System Impact

CROSS-SYSTEM IMPACT:
- backend: orderService (structural fix), orderSyncJob (retry+reconciliation), BifrostBetConsumer (void settlement), BifrostAdapter (memberCode + error classification)
- frontend: betSlipStore (sync), useWebSocket (void toast + market sync), my-bets (void display)
- services: none
- infra: none