Skip to content

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 Transaction row with deterministic id, 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): - DEPOSITamount += transaction.amount - WITHDRAWamount -= 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:

  1. The decrement query has a WHERE amount >= args.amount guard (user-balance.repository.ts:103) — atomic overdraw protection at the row level. If allowNegativeBalance = true (only legal for slot rollbacks), the guard is omitted.
  2. beforeBalance and afterBalance are written into the Transaction row from the snapshot returned by the handler — full ledger history is reconstructable from any row.
  3. 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-2 Transaction args (one WITHDRAW for the wager, optionally one DEPOSIT for the payout).
  • Transactions go to AccountingService.createOrFindTransaction with tag = BET and roundId = identity.roundId.
  • @PlaceBetLock on 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 in isLocal mode

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

usdAmount = 0.005 × 60000 = 300.000000000000000000   (HALF_UP at 18dp)
bet.usdAmount = "300.00"

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:

ltcAmount = 500 / 80 = 6.250000000000000000   (ROUND_DOWN at 18dp)

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 rowsuserBalanceService.findMany returns each UserBalance (user-balance.service.ts:46) - USD aggregatefindUsdBalance 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