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)
| Constraint | Detail | Implication |
|---|---|---|
| 4-second timeout | Balance + bet endpoints must respond in <4s | No retries on balance/bet — must be fast |
| Result/rollback retries | 10 retries on failure | Idempotency is non-negotiable |
| Single session | No multi-session per user | Must invalidate previous session on new login |
| No token refresh | 6hr hard expiry | User must re-login after expiry |
| No session end callback | GAP doesn't notify on session end | Need TTL-based cleanup (6hr) |
| Login balance matters | JiLi and some providers use it | Must fetch fresh DB balance at login time |
| HKD provider support | Not all providers support HKD | Filter game list to HKD-compatible providers only |
Currency & Conversion
Pattern: Same as Bifrost
| Bifrost | GAP Casino | |
|---|---|---|
| External currency | HKD | HKD (or other fiat) |
| Internal currency | balancePoints (FP) | balancePoints (FP) |
| Rate storage | providers table exchange_rate | providers table exchange_rate |
| Conversion | FP / rate = HKD | FP / rate = HKD |
| Example | rate=10: 1000 FP = 100 HKD | rate=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
SignatureHTTP 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-infoAPI (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:
- User navigates to Casino section
- Fetches game list from GAP via backend proxy
- Clicks a game → backend calls GAP login → returns game URL
- Frontend renders game URL in iframe
- Game plays entirely within iframe (GAP + game provider handle UI)
- Balance updates happen via wallet API calls (backend ↔ GAP)
- Frontend polls or receives WebSocket updates for balance changes
Resolved Questions (from GAP team)
| # | Question | Answer | Implication |
|---|---|---|---|
| 1 | HKD provider support | Will share list. Login fails if provider doesn't support it. | Filter game catalog by currency |
| 2 | Dev credentials | Need our IP, endpoint URL, public key first | ACTION: provide these to GAP |
| 3 | Timeout / retry | 4s timeout. No retry on balance/bet. 10 retries on result/rollback. | Must be fast. Idempotency critical. |
| 4 | Login balance | Some providers (JiLi) need it. Not purely informational. | Always send accurate balance |
| 5 | Multi-session | Not feasible | Enforce single session |
| 6 | Token refresh | Not possible, must re-login | 6hr hard limit |
| 7 | Session end callback | No | TTL-based cleanup |
| 8 | Min/Max config | BackOffice during operator creation | Provide desired values during setup |
| 9 | round_closed | Only supported by few providers | Don't rely on it for logic |
| 10 | Signature failure | Return OP_INVALID_SIGNATURE | Specific error code |
Open Questions for GAP Team (Round 3)
These should be resolved before implementation:
-
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.
-
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.
-
Endpoint path customization — Does GAP call exactly
/balance,/betrequest,/resultrequest,/rollbackrequeston our base URL? Or can we specify custom paths like/api/gap/balance? -
Result retry interval — The 10 retries on result/rollback — what's the retry interval? Immediate? Exponential backoff? Over what total time window?
-
HKD provider list — Waiting on the list of providers that support HKD. Needed to filter our game catalog.
-
GAP's server IPs — What IPs should we whitelist for incoming wallet requests from GAP?
-
Reconciliation API — Can we use
get-bet-info(doc 11) orget-bet-details(doc 05) to reconcile transactions? What's the rate limit on these? -
BackOffice access — Will we get access to the GAP BackOffice at dev.dreamdelhi.com? Needed for test case execution (doc 09).
-
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()?
-
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?