Wallet Engine

Player balance: credit, debit, hold. Built on Microsoft Orleans (.NET).

Phase 2
Spec
.NET / Orleans
Wallet Engine is the single place where a player's balance is stored and modified. No other component writes to the balance directly. All money movement -- deposit, bet, win, withdrawal -- flows through one place with atomic operations and guaranteed ordering. Built on .NET + Microsoft Orleans.
In Phase 2 only credit (deposit) and debit (bet/game) + getBalance are implemented. hold/release/finalizeWithdrawal operations are Phase 3. But the full interface is designed now to avoid refactoring later.
1.

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.

2.

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).

available

Amount the player can spend right now. Used by game engine for bet validation and by withdrawal flow for balance check.

locked

Amount reserved for a pending withdrawal. Cannot be bet or spent. Released back to available if withdrawal fails, or deducted permanently on COMPLETED.

total

available + locked. What the player "owns" in total. Shown in the UI header balance.

total = available + locked

Example: player deposits $100, bets $50, withdraws $30

EventOperationavailablelockedtotal
Initial state--$0$0$0
Deposit $100credit(100)$100$0$100
Bet $50debit(50)$50$0$50
Withdrawal hold $30hold(30)$20$30$50
Withdrawal completedfinalizeWithdrawal(30)$20$0$20
3.

Core Operations

credit
Credit
Idempotent
credit(playerId, amount, currency, txId): WalletResult

Adds funds to available balance. Called when a deposit reaches COMPLETED status.

Callers

Orchestrator (on deposit COMPLETED webhook)

Balance effectavailable balance + amount credited
debit
Debit
Idempotent
debit(playerId, amount, currency, reason, txId): WalletResult

Subtracts from available balance. Used for game bets and fee deductions. Fails atomically if available < amount.

Callers

Game engine (bet placement), fee service

Balance effectavailable balance - amount debited (rejected if balance insufficient)
hold
Hold
Idempotent
hold(playerId, amount, currency, withdrawalTxId): WalletResult

Moves amount from available to locked. Called at the start of a withdrawal flow before PSP is contacted.

Callers

Withdrawal flow (Phase 3), before Orchestrator call

Balance effectavailable balance - amount, locked balance + amount
release
Release
Idempotent
release(playerId, amount, currency, withdrawalTxId): WalletResult

Moves amount back from locked to available. Called when a withdrawal fails or is cancelled.

Callers

Withdrawal flow on FAILED / CANCELLED status

Balance effectlocked balance - amount, available balance + amount returned
finalizeWithdrawal
Debit
Idempotent
finalizeWithdrawal(playerId, amount, currency, withdrawalTxId): WalletResult

Permanently removes locked funds when withdrawal reaches COMPLETED. The funds leave the platform.

Callers

Withdrawal flow on COMPLETED status

Balance effectlocked balance - amount withdrawn (total balance decreases)
getBalance
Read
Idempotent
getBalance(playerId, currency): BalanceSnapshot

Returns current available, locked and total for a given currency. Read-only, no state change.

Callers

Frontend (balance display), Router (withdrawal pre-check), game engine

Balance effectNo state change
4.

Integration Points

CallerEventOperationPhase
OrchestratorDeposit webhook received, status → COMPLETEDcredit
Phase 2
Game enginePlayer places a betdebit
Phase 2
Game enginePlayer wins -- payout from gamecredit
Phase 2
FrontendPlayer opens cashier / any page showing balancegetBalance
Phase 2
Withdrawal flowWithdrawal request createdhold
Phase 3
Withdrawal flowWithdrawal FAILED or CANCELLEDrelease
Phase 3
Withdrawal flowWithdrawal COMPLETEDfinalizeWithdrawal
Phase 3
5.

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)

// Balance: $100Thread A: read balance → $100Thread B: read balance → $100Thread A: write $100 - $50 = $50Thread B: write $100 - $60 = $40// Result: $40 instead of -$10 (wrong!)

With Orleans (guaranteed queue)

// Balance: $100Grain queue: [debit $50, debit $60]Step 1: $100 - $50 = $50 ✓Step 2: $50 - $60 = INSUFFICIENT ✗// Result: correct balance $50

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)
6.

Error Codes

CodeOperationReasonRetry?
INSUFFICIENT_BALANCEdebit, holdavailable < requested amountNo
INVALID_AMOUNTcredit, debit, holdamount <= 0 or non-integerNo
CURRENCY_MISMATCHallOperation currency does not match wallet currencyNo
DUPLICATE_TXall write opstxId already processed -- idempotency guard, not an errorNo
HOLD_NOT_FOUNDrelease, finalizeNo active hold found for this withdrawalTxIdNo
GRAIN_UNAVAILABLEallOrleans silo unreachable or restarting (transient)Yes
PERSISTENCE_FAILEDall write opsState could not be persisted to storage after operationYes
DEPO44 | WALLET ENGINE SPEC v1 | PHASE 2