Bifrost PR#143 Follow-up Fixes
Branch:
fix/bifrost-fixesfromfix/disable-bifrost-auto-recoveryDate: 2026-03-04 Issues reported by: Bifrost team after bhdev testing
Issues Summary
| # | Severity | Issue | Root Cause | Fix |
|---|---|---|---|---|
| 1 | CRITICAL | Bet failed, balance deducted, stuck forever | Exposure guard outside refund try/catch + retry missing sportId + no timeout | Structural refactor + retry fix + startup reconciliation |
| 2 | CRITICAL | memberCode always 'forsyt' for all users | BifrostAdapter doesn't pass memberCode to placeBet() | Pass order.userId as memberCode |
| 3 | AVERAGE | Max stake stale in betslip after WebSocket update | betSlipStore snapshots maxStake at add-time, no sync from WS | syncMarketData() method bridging WS to betslip store |
| 4 | COSMETIC | Voided bets shown as "Bet Rejected — try again" | VOIDED mapped to cancelled (same as rejection), wrong toast + tab | Void 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 orderevery 5 min
User 'adsad' Complete Ledger
| Time | Event | Amount | Balance |
|---|---|---|---|
| 13:17:58 | Point allocation | +5000 | 5000 |
| 23:33:54 | Bet #1: BACK SA @ 97 (100pts) | -100 | 4900 |
| 23:38:35 | Bet #1: VOIDED by Bifrost, refunded | +100 | 5000 |
| 23:50:20 | Bet #2: BACK SA @ 97 (5000pts) | -5000 | 0 |
| Now | Stuck — no refund, no provider confirmation | — | 0 |
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
-
Moved exposure guard inside the refund try/catch (
orderService.ts): Any error in lock acquisition or exposure check now flows to the refund path. -
Fixed retryPendingOrders (
orderSyncJob.ts): AddedsportId: order.sportId ?? undefinedto the retry payload. -
Added MAX_PENDING_AGE_MS (30 min) (
orderSyncJob.ts): Orders older than 30 min are auto-refunded viarefundStuckOrder()instead of retried. -
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/catchbackend/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=forsytBifrostClient.ts:97: Falls back tothis.config.memberCodeBifrostAdapter.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— PassmemberCode: order.userIdbackend/src/exchanges/adapters/bifrost/BifrostClient.ts— Accept optionalmemberCodeparam, fall back to config
Issue #3: Stale Max Stake in Betslip (AVERAGE)
Evidence
betSlipStore.ts:addBet()capturesmaxStake = depth[0].sizeat add-timeuseWebSocket.ts:handleOddsUpdateupdates 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
-
Added
syncMarketData(fixtureId, markets)method tobetSlipStore— updatesmaxStake,priceIndex, andlinefor matching betslip items from live depth data. -
Called
syncMarketData()fromhandleOddsUpdateinuseWebSocket.tsafter updating the React Query cache.
Files Changed
frontend/src/store/betSlipStore.ts— AddedsyncMarketData()methodfrontend/src/hooks/useWebSocket.ts— CallssyncMarketData()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 bysettlementOutcome === "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 settlementfrontend/src/hooks/useWebSocket.ts— Void-specific toast, settlementOutcome in typefrontend/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
- No schema changes — all fixes use existing DB columns
- Startup reconciliation will auto-refund user 'adsad' on first deploy (order is >30 min old)
- Test on bhdev first — merge to
feat/bhdevand deploy - 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