Wallet and balance¶
Definition. Two-account ledger per (user, currency): a live balance spent on bets and a vault balance that is locked from gameplay and tippable transfers. Eleven currency symbols are supported (DBC + 10 cryptos). Every state change is a
Transactionrow with deterministicid, before/after balance snapshots, and a tag identifying the side-effect class (BET, RAKEBACK, AFFILIATE_CLAIMED, …). USD-equivalents are computed at write time using a 60-second-refreshed CoinGecko rate cache; admin/leaderboard payouts convert at payout time.
1. Currencies¶
// libs/accounting/src/currency/currency.const.ts
const Crypto = { BTC, ETH, LTC, TRX, POL, USDT, BNB, SOL, USDC, XRP, TETH };
const Balance = { DBC, ...Crypto };
CurrencySymbolBalance = DBC + 10 cryptos (used in the live wallet)
CurrencySymbolFiat = USD, EUR (display only)
CurrencySymbolPlatform = DBC (the platform's accounting unit)
USD_PRECISION = 2
DECIMAL_PRECISION = 18
DBC ("DropBet Coin") is the platform unit. It's stored in UserBalance like any other currency, but its issuance / sinks are governed by the platform (rakeback claims, leaderboard prizes, level-up bonuses). The other ten symbols are real cryptos held in custodial wallets.
Per-currency on/off switches live in CryptoCurrencyConfig (api.prisma:184):
- withdrawsEnabled
- depositsEnabled
- testNetwork (separates TETH from ETH operationally)
TETH (test ETH on a test network) exists for staging chains and is always behind a feature flag.
2. UserBalance schema¶
// libs/_prisma/src/schema/api.prisma:363
model UserBalance {
userId Int @map("user_id")
currencyId CurrencySymbol @map("currency_id")
amount Decimal @default(0) // ← live balance
vaultAmount Decimal @default(0) // ← vault (locked)
...
@@id([userId, currencyId])
}
A row exists per (user, currency). The natural key is (userId, currencyId). There is no separate "bonus" account — bonus credits land directly in amount with Transaction.tag = PROMO; bonus accounting is reconstructed from transaction history, not stored as a separate balance.
2.1 What "balance types" actually exist¶
The README brief mentions "real / bonus / locked / pending" — in code this maps to:
| Conceptual type | Storage | Source |
|---|---|---|
| Real (live) | UserBalance.amount |
api.prisma:366 |
| Vault (locked) | UserBalance.vaultAmount |
api.prisma:367 |
| Bonus | virtual — sum of Transaction { tag: PROMO, type: DEPOSIT } minus offsetting WITHDRAW |
promo-effect.service.ts |
| Pending withdrawal | Withdraw row in CREATED status; balance already debited |
payment/withdraw/ |
| Self-restricted | not a balance type — gating is via UserGamblingLimits |
apps/api/src/users-limits/ |
There is no separate "locked" sub-balance for bonus rollover. The bonus is in amount; the lock is at the action layer (no toVault, no withdraw) while UserPromoCode.isActive, see bonuses-and-promos.md.
3. Transaction model¶
// api.prisma:712
model Transaction {
id String // idempotency key
currencyId CurrencySymbol
userId Int
adminUserId Int? // who initiated, if admin-driven
amount Decimal // always positive
beforeBalance Decimal? // snapshot of UserBalance.amount before
afterBalance Decimal? // snapshot after
status TransactionStatus // CREATED | PENDING | COMPLETED | FAILED | CANCELED
type TransactionType // DEPOSIT | WITHDRAW | PREVENTING
tag TransactionTag // BET | PROMO | RAKEBACK | AFFILIATE_CLAIMED | ...
roundId String? // for bet transactions
payload Json? // free-form metadata
originalId String? // points at the tx this rolls back
slotOriginalId String? // unique tx-id from a slot provider
}
3.1 Type vs Tag¶
TransactionType is the balance impact (api.prisma:604):
- DEPOSIT — amount += transaction.amount
- WITHDRAW — amount -= transaction.amount
- PREVENTING — no balance impact; tombstone to block a competing rollback
TransactionTag is the business class (api.prisma:578):
- DEPOSIT, WITHDRAW — real money in/out via payment provider
- BET, ROLLBACK_BET, SPORTSBOOK_RESETTLE_BET — gameplay
- PROMO, RAKEBACK, LEADERBOARD_PRIZE, LOYALTY_BONUS, AFFILIATE_CLAIMED — platform-issued credits
- VAULT — internal transfer to/from vault (always paired)
- PM8_PROMO_WIN, ST8_PROMO_CREDIT — provider-issued promo settlements
3.2 Idempotency¶
Transactions are written through AccountingService.createOrFindTransaction (accounting.service.ts:193). The id is the natural idempotency key:
- For bets: tx-{txId}-{currencyId}-{gameId}-{betId} (bet-helper.utils.ts:44)
- For promos: processOnClaim lets DB generate UUID; the UserPromoCode row is the idempotency anchor
- For loyalty bonuses: user-<userId>-level-up-<levelName> (user.service.ts:1026)
- For deposits: provider's deposit reference
If a transaction with the same id already exists, assertExistingTransactionConsistency (accounting.service.ts:131) verifies userId/currencyId/type/tag/amount/roundId/slotOriginalId all match; mismatch → ACCOUNTING_TRANSACTION_ALREADY_EXISTS. Match → existing transaction returned.
4. Balance update flow¶
sequenceDiagram
participant Caller as Caller (BetService, PromoEffectService, ...)
participant Acc as AccountingService
participant Val as TransactionValidationService
participant H as Handler (DepositHandler / WithdrawHandler)
participant Bal as UserBalanceRepository
participant Repo as AccountingRepository
participant DB as Postgres
participant Evt as AccountingEventService
Caller->>Acc: createTransaction({type, tag, userId, currencyId, amount, ...})
Acc->>Acc: validate currency (isBalanceCurrency)
Acc->>Val: validate (no overdraw, KYC, ...)
Acc->>H: handle(args)
alt type = DEPOSIT
H->>Bal: increaseBalance(amount)
Bal->>DB: UPSERT user_balance amount += amount
else type = WITHDRAW
H->>Bal: decrementBalance(amount, allowNegativeBalance?)
Bal->>DB: UPDATE user_balance SET amount -= amount<br/>WHERE amount >= amount -- guards overdraw
end
Bal-->>H: {beforeBalance, afterBalance, ...}
H->>Repo: createTransaction(beforeBalance, afterBalance, ...)
Repo->>DB: INSERT transaction
Acc->>Evt: onTransactionEvent → fan out
Key invariants:
- The decrement query has a
WHERE amount >= args.amountguard (user-balance.repository.ts:103) — atomic overdraw protection at the row level. IfallowNegativeBalance = true(only legal for slot rollbacks), the guard is omitted. beforeBalanceandafterBalanceare written into theTransactionrow from the snapshot returned by the handler — full ledger history is reconstructable from any row.- Both the balance update and the transaction insert run inside a single Prisma transaction (callers are wrapped with
@PrismaTransactional).
5. Bet → wallet — the place-bet path¶
Most wallet writes flow through bets. See bet-settlement.md §3 for the sequence diagram. Highlights:
BetService.processTransactions(bet.service.ts:258) builds 1-2Transactionargs (one WITHDRAW for the wager, optionally one DEPOSIT for the payout).- Transactions go to
AccountingService.createOrFindTransactionwithtag = BETandroundId = identity.roundId. @PlaceBetLockon the controller serializes by user → no concurrent balance writes for the same user during a bet.
6. Vault¶
Vault is a per-currency lock that:
- Cannot be wagered.
- Cannot be tipped or withdrawn.
- Can be transferred in/out of amount by the player.
- Is blocked while a promo is ACTIVE.
Operations on UserBalanceService:
- toVault (user-balance.service.ts:81) — Transaction { type: WITHDRAW, tag: VAULT } from amount, plus internal increment of vaultAmount. Rejects with PROMO_CODE_IS_ACTIVE if any active promo.
- fromVault (user-balance.service.ts:102) — mirror: Transaction { type: DEPOSIT, tag: VAULT } to amount, decrement vaultAmount. Same promo block.
The VAULT-tagged transactions zero each other in long-run accounting (every toVault has a fromVault when the player withdraws); they are the audit trail of vault movements.
7. USD conversion¶
ExchangeRatesService (apps/api/src/exchange-rates/exchange-rates.service.ts) is a static-singleton wrapper used everywhere a USD-equivalent is needed (bet usdAmount, leaderboard scores, affiliate USD aggregate, KYC limits, withdrawal wager checks).
// exchange-rates.service.ts:42
static toUsd(amount: Decimal, currencyId: CurrencySymbol): Decimal {
const rates = _instance.exchangeRatesRepository.getManyFromMemory({ fiatCurrency: USD });
return amount.mul(rates[currencyId])
.toDecimalPlaces(DECIMAL_PRECISION, Decimal.ROUND_HALF_UP);
}
static toCurrency(usdAmount: Decimal, currencyId: CurrencySymbol): Decimal {
return usdAmount.div(rates[currencyId])
.toDecimalPlaces(DECIMAL_PRECISION, Decimal.ROUND_DOWN); // floor to avoid over-credit
}
Note the rounding asymmetry — USD-out rounds half-up (preserves USD value); USD-in rounds down (never over-credits coins). This biases conversion in the platform's favor by less than 1 unit at the precision boundary.
7.1 Rate cache¶
ExchangeRatesRepository (exchange-rates.repository.ts):
- Memory cache TTL: 5 min (
memoryCacheTtlSeconds = 60 * 5) - Memory cache update window: 10 s — within this window, return cached without re-fetching
- Redis long-term cache TTL: 2 min (
longTermCacheTtlSeconds = 2 * 60) - Periodic update interval: 60 s (
updateCacheIntervalSeconds) - Source: CoinGecko via
CoingeckoProviderApi(apps/api/src/exchange-rates/provider/coingecko/coingecko-provider.api.ts) - Fallback:
LOCAL_EXCHANGE_RATES(local.const.ts) — only inisLocalmode
If the memory cache is older than 5 min and Redis can't be read and CoinGecko can't be reached, every call to toUsd throws UNABLE_TO_GET_EXCHANGE_RATE. toUsdWithFallback returns the amount unchanged for USDT and USDC (assumed 1:1 with USD); for any other currency it re-throws.
A staleness warning fires once if the rates haven't refreshed in 3 minutes (exchange-rates.repository.ts:78).
7.2 Worked example — bet usdAmount¶
Player wagers 0.005 BTC. Memory cache has BTC = 60000 (USD per coin):
This usdAmount is what feeds into XP, leaderboard, affiliate stats, and rakeback claimable USD. If BTC then doubles to 120000, the player's accumulated XP is unchanged — XP is locked at write time.
7.3 Worked example — leaderboard payout¶
Leaderboard prize is $500. Player chose to receive in LTC. CoinGecko gives LTC = 80:
The DEPOSIT transaction is for 6.25 LTC. If LTC then halves before the player withdraws, they got the 6.25 LTC — payout was at "freeze-of-payout" time, not "freeze-of-leaderboard" time. See leaderboard.md §6.
8. Rollback and refund¶
| Operation | Effect | Source |
|---|---|---|
rollbackBalance (private) |
For each input tx, write a mirror tx (DEPOSIT ↔ WITHDRAW) and update amount accordingly |
accounting.service.ts:61 |
rollbackTransactionsForBet |
Public wrapper used by BetService.rollbackBet |
accounting.service.ts |
refundTransactionForBet |
Refund a single tx; called from BetService.refundBet |
accounting.service.ts |
decrementBalance(allowNegativeBalance=true) |
Used only for slot rollbacks where the player has already withdrawn the win | user-balance.repository.ts:71 |
Rollback writes new transactions with tag = ROLLBACK_BET, originalId = <original tx id>. The original tx remains; this preserves audit trail.
9. Multi-currency display¶
The FE displays balances in two ways:
- Per-currency rows — userBalanceService.findMany returns each UserBalance (user-balance.service.ts:46)
- USD aggregate — findUsdBalance sums ExchangeRatesService.toUsd(amount, currencyId) across all rows (user-balance.service.ts:52)
Currency display ordering: DBC first, then alphabetical (user-balance.repository.ts:27).
10. Edge cases¶
| Case | Behavior | Source |
|---|---|---|
| Concurrent debits | DB-level guard WHERE amount >= args.amount makes overdraw impossible at the SQL level |
user-balance.repository.ts:103 |
| Balance row missing | DEPOSIT upserts; WITHDRAW with allowNegativeBalance=false will fail because there's no row to update |
user-balance.repository.ts:45, 94 |
| Stale exchange rate | getManyFromMemory throws UNABLE_TO_GET_EXCHANGE_RATE after 5 min staleness |
exchange-rates.repository.ts:75 |
USDT / USDC rate fail |
toUsdWithFallback returns 1:1; other currencies re-throw |
exchange-rates.service.ts:56 |
| Currency disabled | CryptoCurrencyConfig.depositsEnabled = false blocks deposit at the payment-provider layer; existing balance unaffected |
payment provider services |
Promo active + toVault |
Throws PROMO_CODE_IS_ACTIVE |
user-balance.service.ts:84 |
| Slot-tx idempotency mismatch | Throws ACCOUNTING_TRANSACTION_ALREADY_EXISTS with the conflicting tx id |
accounting.service.ts:131 |
| Decimal overflow | All amounts are Decimal at 18dp; effectively unbounded for practical balances |
currency.const.ts:4 |
Negative amount argument |
BetService validates > 0; lower-level handlers do not — abuse vector if a caller doesn't validate |
bet.service.ts:128, 145 |
11. Admin overrides¶
| Capability | Code |
|---|---|
| Direct credit/debit (any tag) | AccountingService.createTransaction({ adminUserId, ... }) via admin endpoint |
Force allowNegativeBalance |
Same — flag on the tx args |
| Adjust vault | Same with tag = VAULT |
| Change currency on/off | CryptoCurrencyConfig admin update (apps/api/src/accounting/controllers/currency.controller.ts) |
| Inspect tx history | TransactionService.findManyTransactions |
| Inspect a player's balance + USD aggregate | findUsdBalance |
| Invalidate exchange-rate cache | Pod restart; no admin endpoint for cache flush |
12. Related docs¶
bet-settlement.md— Bet flow that produces 95% of wallet writesbonuses-and-promos.md— Promo-active locks on vaultkyc-tiers.md— KYC-gated withdrawal limits +WITHDRAW_WAGER_MULTIPLIERleaderboard.md,rakeback.md,affiliate-program.md— Tag producers../flows/dropbet-wallet.md— Player-facing wallet UI flow../recipes/change-currency-or-add-currency.md— How to onboard a new symbol../runbooks/db-high-load.md—user_balanceis the hottest table../data-model/—UserBalance,Transaction,CryptoCurrencyConfig