Skip to main content

GAP Casino — Hannibal Integration Plan

How Hannibal's internal points system (balancePoints / FP) integrates with GAP's seamless wallet using fiat currency + conversion rate.

Architecture Overview

GAP is a seamless wallet integration. Unlike Bifrost (which uses RabbitMQ for async updates), GAP calls our HTTP endpoints synchronously during gameplay. We are the "operator" — GAP is the casino aggregator.

┌─────────────┐         ┌─────────────┐         ┌──────────────┐
│ Hannibal │◄───────►│ GAP │◄───────►│ Game Provider│
│ (Operator) │ HTTP │ (Aggregator)│ HTTP │ (Ezugi, etc) │
│ │ │ │ │ │
│ balancePoints│ │ fiat (HKD) │ │ game rounds │
│ + exchange │ │ │ │ │
│ rate │ │ │ │ │
└─────────────┘ └─────────────┘ └──────────────┘

Data Flow

1. User clicks "Play Casino" in Hannibal frontend
2. Hannibal backend calls GAP POST /api/operator/login
- currency: "HKD" (configured fiat currency)
- balance: userBalancePoints / exchangeRate (e.g., 1000 FP / 10 = 100 HKD)
⚠ MUST be accurate — some providers (JiLi) require it at login
- userId: user's UUID
3. GAP returns game URL + token (valid 6 hours, hard expiry, no refresh)
4. Frontend opens game in iframe via returned URL
5. During gameplay, GAP calls Hannibal's wallet endpoints:
- POST /balance → return balancePoints / exchangeRate
- POST /betrequest → debit balancePoints by (debitAmount * exchangeRate)
- POST /resultrequest → credit balancePoints by (creditAmount * exchangeRate)
- POST /rollbackrequest → reverse a previous debit
6. All amounts in GAP requests/responses are in HKD (fiat)
7. All internal Hannibal storage remains in balancePoints (FP)

Critical Constraints (Confirmed by GAP)

ConstraintDetailImplication
4-second timeoutBalance + bet endpoints must respond in <4sNo retries on balance/bet — must be fast
Result/rollback retries10 retries on failureIdempotency is non-negotiable
Single sessionNo multi-session per userMust invalidate previous session on new login
No token refresh6hr hard expiryUser must re-login after expiry
No session end callbackGAP doesn't notify on session endNeed TTL-based cleanup (6hr)
Login balance mattersJiLi and some providers use itMust fetch fresh DB balance at login time
HKD provider supportNot all providers support HKDFilter game list to HKD-compatible providers only

Currency & Conversion

Pattern: Same as Bifrost

BifrostGAP Casino
External currencyHKDHKD (or other fiat)
Internal currencybalancePoints (FP)balancePoints (FP)
Rate storageproviders table exchange_rateproviders table exchange_rate
ConversionFP / rate = HKDFP / rate = HKD
Examplerate=10: 1000 FP = 100 HKDrate=10: 1000 FP = 100 HKD

Conversion Formulas

FP → Fiat:  fiatAmount = balancePoints / exchangeRate
Fiat → FP: fpAmount = fiatAmount * exchangeRate

Example (rate = 10):
User has 5000 FP
GAP sees: 5000 / 10 = 500 HKD
User bets 50 HKD in casino
Hannibal debits: 50 * 10 = 500 FP
User wins 120 HKD
Hannibal credits: 120 * 10 = 1200 FP

Config

A new row in the providers table:

providerId: "gap-casino"
name: "GAP Casino"
exchange_rate: 10 (configurable per environment, same as Bifrost)
currency: "HKD"
enabled: true

Endpoints Hannibal Must Implement

These are HTTP POST endpoints that GAP will call on our server.

1. POST /api/gap/balance

SLA: <4 seconds. No retries. Failure = game action blocked.

// GAP sends: { operatorId, token, userId }
// We return: { balance: number, status: "OP_SUCCESS" }

// Logic:
// 1. Validate signature (RSA-SHA256 with GAP's public key) → OP_INVALID_SIGNATURE on failure
// 2. Validate token (check session store, 6hr expiry) → OP_TOKEN_EXPIRED / OP_TOKEN_NOT_FOUND
// 3. Fetch user's balancePoints from DB (real-time, NOT cached)
// 4. Return balancePoints / exchangeRate
//
// Performance: must complete in <4s. Direct DB query, no joins.
// No retry from GAP — if we fail, the game action is blocked.

2. POST /api/gap/betrequest

SLA: <4 seconds. No retries. If timeout → bet is FAILED on GAP side.

// GAP sends: { operatorId, token, userId, transactionId, debitAmount, gameId, roundId, ... }
// We return: { balance: number, status: "OP_SUCCESS" | "OP_INSUFFICIENT_FUNDS" | ... }

// Logic:
// 1. Validate signature → OP_INVALID_SIGNATURE
// 2. Validate token → OP_TOKEN_EXPIRED / OP_TOKEN_NOT_FOUND
// 3. Check idempotency (transactionId already processed? return same response)
// 4. Convert: fpAmount = debitAmount * exchangeRate (Decimal.js)
// 5. Inside prisma.$transaction:
// a. Check balance >= fpAmount → OP_INSUFFICIENT_FUNDS if not
// b. Deduct balancePoints
// c. Create Transaction record (type: 'casino_bet', amount: fpAmount)
// d. Create CasinoTransaction record (GAP-specific: gameId, roundId, transactionId)
// 6. Invalidate balance cache + WebSocket balance update
// 7. Return new balance / exchangeRate
//
// CRITICAL: If we debit but GAP doesn't receive response (4s timeout),
// GAP considers bet FAILED. We'll have debited but no result will come.
// → Need a reconciliation mechanism (see Reconciliation section)

3. POST /api/gap/resultrequest

SLA: <4 seconds. GAP retries up to 10x on failure. Idempotency is CRITICAL.

// GAP sends: { operatorId, token, userId, transactionId, creditAmount, gameId, roundId, ... }
// We return: { balance: number, status: "OP_SUCCESS" }

// Logic:
// 1. Validate signature → OP_INVALID_SIGNATURE
// 2. Validate token → OP_TOKEN_EXPIRED / OP_TOKEN_NOT_FOUND
// 3. Check idempotency (transactionId already processed? return SAME response)
// 4. Convert: fpAmount = creditAmount * exchangeRate (Decimal.js)
// 5. Inside prisma.$transaction:
// a. Credit balancePoints
// b. Create Transaction record (type: 'casino_win', amount: fpAmount)
// c. Create/update CasinoTransaction record
// 6. Invalidate balance cache + WebSocket balance update
// 7. Return new balance / exchangeRate
//
// GAP retries 10x — if we credit on first try but fail to respond,
// retry will hit idempotency check and return same balance. SAFE.

4. POST /api/gap/rollbackrequest

SLA: <4 seconds. GAP retries up to 10x on failure. Idempotency is CRITICAL.

// GAP sends: { operatorId, token, userId, transactionId, rollbackAmount, ... }
// We return: { balance: number, status: "OP_SUCCESS" }

// Logic:
// 1. Validate signature → OP_INVALID_SIGNATURE
// 2. Validate token → OP_TOKEN_EXPIRED / OP_TOKEN_NOT_FOUND
// 3. Find original transaction by transactionId
// 4. If already rolled back, return success (idempotent)
// 5. If original transaction not found, return OP_TRANSACTION_NOT_FOUND
// 6. Convert: fpAmount = rollbackAmount * exchangeRate (Decimal.js)
// ⚠ rollbackAmount can be NEGATIVE (see sports example: -150)
// Must handle: abs(rollbackAmount) for the credit
// 7. Inside prisma.$transaction:
// a. Credit balancePoints (reverse the debit)
// b. Create Transaction record (type: 'casino_rollback', amount: fpAmount)
// c. Mark original CasinoTransaction as rolled back
// 8. Invalidate balance cache + WebSocket balance update
// 9. Return new balance / exchangeRate

Endpoints Hannibal Calls on GAP

1. POST /api/operator/login (game launch)

Called when user wants to play a casino game.

// IMPORTANT: Single session only. Must invalidate any existing session first.
// Login balance MUST be accurate (JiLi and some providers use it).

// Logic:
// 1. Check for existing active CasinoSession for this user
// → If exists, mark it expired (GAP doesn't notify on end)
// 2. Fetch fresh balancePoints from DB (NOT cache)
// 3. Call GAP:

{
operatorId: config.gap.operatorId,
userId: user.id, // UUID
username: user.displayName,
platformId: "DESKTOP" | "MOBILE",
currency: config.gap.currency, // "HKD"
balance: userBalancePoints / exchangeRate, // MUST be accurate
gameId: selectedGameId,
clientIp: req.ip,
lobby: true | false,
redirectUrl: config.gap.redirectUrl // back to Hannibal
}

// 4. On success: create CasinoSession with token, 6hr expiry, snapshot exchangeRate
// 5. Return { url, token } to frontend
// 6. Frontend opens `url` in iframe

// Response error handling:
// status 7 ("Provider Details not Found for Currency") → game doesn't support HKD
// → Filter game list to only show HKD-compatible games

2. POST /api/operator/get-games-list

Called to populate the casino game catalog.

Security Implementation

RSA-SHA256 Signature

  • Generate RSA key pair (2048-bit minimum)
  • Share public key with GAP, receive GAP's public key
  • Sign all outgoing requests with our private key
  • Verify all incoming requests with GAP's public key
  • Signature goes in Signature HTTP header

IP Whitelisting

  • Whitelist GAP's server IPs in our firewall
  • Provide our server IP to GAP for their whitelist

Token Management

  • Store GAP session tokens with 6-hour TTL
  • Map token → userId for incoming wallet requests
  • Tokens expire regardless of user activity (hard 6hr, no refresh)
  • Single session enforcement: On new login, mark previous session as expired
  • GAP does NOT notify us on session end — cleanup via TTL-based job

Session Enforcement

GAP confirmed: multi-session is NOT feasible. We must enforce this:

On operator/login:
1. Query CasinoSession WHERE userId = X AND expiresAt > now()
2. If active session exists → mark as expired
3. Create new session

Frontend should also prevent opening multiple casino tabs (show warning if session exists).

Reconciliation (Critical)

The 4-second timeout on betrequest with no retries creates a dangerous edge case:

Timeline:
1. GAP calls /betrequest (debitAmount: 50 HKD)
2. We receive it, debit 500 FP, create transaction
3. Our response takes 4.1 seconds (slow DB, network)
4. GAP times out → considers bet FAILED
5. No resultrequest will ever come for this bet
6. User lost 500 FP with no game result

Mitigation:

  • Track all casino bets that have no corresponding result/rollback
  • Cron job: find CasinoTransactions with type='bet', no matching result/rollback, older than X minutes
  • For stale bets: either auto-refund or flag for manual review
  • GAP has get-bet-info API (see doc 11) — can query to verify bet status

Alternative: Use get-bet-details report API to reconcile periodically.

Database Schema (New)

model CasinoSession {
id String @id @default(uuid())
userId String
token String @unique // GAP session token
gameId String
providerId String // game provider (Ezugi, Evolution, etc.)
currency String // fiat currency used (HKD)
exchangeRate Decimal // snapshot at session creation
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
}

model CasinoTransaction {
id String @id @default(uuid())
sessionId String
userId String
transactionId String @unique // GAP's transactionId (idempotency key)
reqId String // GAP's reqId
type String // 'bet', 'result', 'rollback'
gameId String
roundId String
fiatAmount Decimal // amount in fiat (HKD)
fpAmount Decimal // amount in balancePoints
exchangeRate Decimal // rate at transaction time
rolledBack Boolean @default(false)
originalTxId String? // for rollbacks: the original transactionId
createdAt DateTime @default(now())
session CasinoSession @relation(fields: [sessionId], references: [id])
user User @relation(fields: [userId], references: [id])
}

Frontend Integration

The casino integration is primarily an iframe on the frontend side:

  1. User navigates to Casino section
  2. Fetches game list from GAP via backend proxy
  3. Clicks a game → backend calls GAP login → returns game URL
  4. Frontend renders game URL in iframe
  5. Game plays entirely within iframe (GAP + game provider handle UI)
  6. Balance updates happen via wallet API calls (backend ↔ GAP)
  7. Frontend polls or receives WebSocket updates for balance changes

Resolved Questions (from GAP team)

#QuestionAnswerImplication
1HKD provider supportWill share list. Login fails if provider doesn't support it.Filter game catalog by currency
2Dev credentialsNeed our IP, endpoint URL, public key firstACTION: provide these to GAP
3Timeout / retry4s timeout. No retry on balance/bet. 10 retries on result/rollback.Must be fast. Idempotency critical.
4Login balanceSome providers (JiLi) need it. Not purely informational.Always send accurate balance
5Multi-sessionNot feasibleEnforce single session
6Token refreshNot possible, must re-login6hr hard limit
7Session end callbackNoTTL-based cleanup
8Min/Max configBackOffice during operator creationProvide desired values during setup
9round_closedOnly supported by few providersDon't rely on it for logic
10Signature failureReturn OP_INVALID_SIGNATURESpecific error code

Open Questions for GAP Team (Round 3)

These should be resolved before implementation:

  1. Bet timeout = money lost? When GAP calls /betrequest and we debit the user but our response takes >4s, GAP considers the bet failed. Does GAP send a rollback for this, or is the debit orphaned? This is a critical money safety question.

  2. What happens to the old session on new login? If user has an active session and calls operator/login again, does GAP terminate the old session? Or does it reject the new login? We need to know to handle our side correctly.

  3. Endpoint path customization — Does GAP call exactly /balance, /betrequest, /resultrequest, /rollbackrequest on our base URL? Or can we specify custom paths like /api/gap/balance?

  4. Result retry interval — The 10 retries on result/rollback — what's the retry interval? Immediate? Exponential backoff? Over what total time window?

  5. HKD provider list — Waiting on the list of providers that support HKD. Needed to filter our game catalog.

  6. GAP's server IPs — What IPs should we whitelist for incoming wallet requests from GAP?

  7. Reconciliation API — Can we use get-bet-info (doc 11) or get-bet-details (doc 05) to reconcile transactions? What's the rate limit on these?

  8. BackOffice access — Will we get access to the GAP BackOffice at dev.dreamdelhi.com? Needed for test case execution (doc 09).

  9. rollbackAmount sign — In the sports example (doc 06), rollbackAmount is -150 (negative). For casino rollbacks, is it always positive, or can it be negative too? Should we use abs()?

  10. Zero-amount bets — Docs say "zero bet is possible for bonuses/rewards/freebets". Does GAP send these for casino too? Should we accept debitAmount: 0?