Wallet Engine
Player balance: credit, debit, hold. Built on Microsoft Orleans (.NET).
Microsoft Orleans
Orleans is an actor-based framework by Microsoft for .NET. The core idea: each player has their own "actor" (Grain) that processes all balance operations strictly in sequence, one at a time.
Grain = Player wallet
PlayerWalletGrain is keyed by player_id. One grain instance per player, always. Orleans activates it on first call and keeps it in memory.
Single-threaded per grain
Orleans guarantees that only one operation runs inside a grain at a time. Concurrent calls are queued automatically. No locks, no race conditions by design.
Persistent state
Grain state (available, locked) is persisted to storage (SQL or Redis) after every write. If the server restarts, Orleans reactivates the grain and reloads state from storage.
For a product manager and frontend developer, Orleans is an implementation detail. What matters: any call to Wallet Engine is atomic and cannot result in an incorrect balance state regardless of concurrent load.
Balance Structure
A player's balance is not a single number. It consists of three values, each stored separately. Monetary amounts are always bigint in the smallest currency unit (cents for USD).
availableAmount the player can spend right now. Used by game engine for bet validation and by withdrawal flow for balance check.
lockedAmount reserved for a pending withdrawal. Cannot be bet or spent. Released back to available if withdrawal fails, or deducted permanently on COMPLETED.
totalavailable + locked. What the player "owns" in total. Shown in the UI header balance.
total = available + lockedExample: player deposits $100, bets $50, withdraws $30
| Event | Operation | available | locked | total |
|---|---|---|---|---|
| Initial state | -- | $0 | $0 | $0 |
| Deposit $100 | credit(100) | $100 | $0 | $100 |
| Bet $50 | debit(50) | $50 | $0 | $50 |
| Withdrawal hold $30 | hold(30) | $20 | $30 | $50 |
| Withdrawal completed | finalizeWithdrawal(30) | $20 | $0 | $20 |
Core Operations
creditcredit(playerId, amount, currency, txId): WalletResultAdds funds to available balance. Called when a deposit reaches COMPLETED status.
Orchestrator (on deposit COMPLETED webhook)
available balance + amount crediteddebitdebit(playerId, amount, currency, reason, txId): WalletResultSubtracts from available balance. Used for game bets and fee deductions. Fails atomically if available < amount.
Game engine (bet placement), fee service
available balance - amount debited (rejected if balance insufficient)holdhold(playerId, amount, currency, withdrawalTxId): WalletResultMoves amount from available to locked. Called at the start of a withdrawal flow before PSP is contacted.
Withdrawal flow (Phase 3), before Orchestrator call
available balance - amount, locked balance + amountreleaserelease(playerId, amount, currency, withdrawalTxId): WalletResultMoves amount back from locked to available. Called when a withdrawal fails or is cancelled.
Withdrawal flow on FAILED / CANCELLED status
locked balance - amount, available balance + amount returnedfinalizeWithdrawalfinalizeWithdrawal(playerId, amount, currency, withdrawalTxId): WalletResultPermanently removes locked funds when withdrawal reaches COMPLETED. The funds leave the platform.
Withdrawal flow on COMPLETED status
locked balance - amount withdrawn (total balance decreases)getBalancegetBalance(playerId, currency): BalanceSnapshotReturns current available, locked and total for a given currency. Read-only, no state change.
Frontend (balance display), Router (withdrawal pre-check), game engine
No state changeIntegration Points
| Caller | Event | Operation | Phase |
|---|---|---|---|
| Orchestrator | Deposit webhook received, status → COMPLETED | credit | Phase 2 |
| Game engine | Player places a bet | debit | Phase 2 |
| Game engine | Player wins -- payout from game | credit | Phase 2 |
| Frontend | Player opens cashier / any page showing balance | getBalance | Phase 2 |
| Withdrawal flow | Withdrawal request created | hold | Phase 3 |
| Withdrawal flow | Withdrawal FAILED or CANCELLED | release | Phase 3 |
| Withdrawal flow | Withdrawal COMPLETED | finalizeWithdrawal | Phase 3 |
Concurrency Model
The most dangerous scenario in a wallet is two simultaneous debits. For example, a player places a bet while a deposit confirmation webhook arrives. Without protection, the balance can become incorrect.
Without Orleans (race condition)
With Orleans (guaranteed queue)
Idempotency via txId
Every operation accepts a txId (transaction UUID). If the same operation arrives twice (e.g. webhook redelivered) -- the Grain checks that txId was already processed and returns the previous result without re-debiting/re-crediting.
// Grain internal check
if (this.state.processedTxIds.has(txId)) {
return this.state.processedTxIds.get(txId) // cached result
}
// ... process and store result
this.state.processedTxIds.set(txId, result)Error Codes
| Code | Operation | Reason | Retry? |
|---|---|---|---|
INSUFFICIENT_BALANCE | debit, hold | available < requested amount | No |
INVALID_AMOUNT | credit, debit, hold | amount <= 0 or non-integer | No |
CURRENCY_MISMATCH | all | Operation currency does not match wallet currency | No |
DUPLICATE_TX | all write ops | txId already processed -- idempotency guard, not an error | No |
HOLD_NOT_FOUND | release, finalize | No active hold found for this withdrawalTxId | No |
GRAIN_UNAVAILABLE | all | Orleans silo unreachable or restarting (transient) | Yes |
PERSISTENCE_FAILED | all write ops | State could not be persisted to storage after operation | Yes |