Skip to content

Leaderboard

Definition. A scheduled or one-off ranking of players by total USD wagered within a fixed window. Each window has a config-defined prize ladder (positions → USD payouts). Wagering counts in real time per settled bet; at window end the platform freezes positions and queues USD prizes for admin payout in the player's chosen currency.

1. Scoring formula

There is one scoring metric: cumulative USD-equivalent wager during the window.

// apps/api/src/leaderboard/leaderboard.service.ts:34
async handleBet(bet: BetDto, delayMs?: number) {
  await this.processUserAction({
    key:       bet.id,
    userId:    bet.userId,
    usdAmount: bet.usdAmount.toNumber(),
  });
}

bet.usdAmount is the bet's wager converted to USD via ExchangeRatesService (CoinGecko-backed; see wallet-and-balance.md) at the moment the bet was placed. There is no RTP weighting, no profit weighting, no volume × multiplier formula — every $1 wagered counts as 1 score point regardless of game, outcome, or VIP level.

score(player, leaderboard) = Σ bet.usdAmount  for all SETTLED bets
                              where bet.userId   = player
                                and bet.settledAt ∈ [leaderboard.startDate, endDate)

Increment is per-bet via LeaderboardRepository.incrementLeaderboardUserUsdAmount (leaderboard.repository.ts:237) — an upsert keyed on (userId, leaderboardId) that adds bet.usdAmount to the row's usdAmount. Position is computed by a Postgres view LeaderboardUserPositionView (api.prisma:1371) joined back via position (rank by usdAmount DESC).

1.1 What scope of bets counts?

processUserActionForLeaderboard (leaderboard.service.ts:246) writes to every active leaderboard — there is no per-leaderboard game-type filter in code. A wager on a slot, a roulette spin, and a sportsbook bet all increment the same row.

The aggregator iterates over findManyLeaderboardsActive (leaderboard.repository.ts:45), which has a 60-second cache (LEADERBOARD_ACTIVE_TTL at leaderboard/const.ts:2). New leaderboards take up to 60 seconds to start counting bets after activation.

2. Reset cadence

Leaderboards are driven by schedules (LeaderboardSchedule, api.prisma:1294). Each schedule has a LeaderboardType (DAILY | WEEKLY | MONTHLY) and a config blob with the name and prize ladder.

The activity loop runs every 5 minutes from the cron daemon:

// apps/api/src/jobs/jobs.service.ts:18-21
@Evo.Cron(CronExpression.EVERY_5_MINUTES)
async updateLeaderboardsActivityJob() {
  await this.leaderboardService.updateLeaderboards();
}

updateLeaderboards is wrapped with @IdempotencyLock (key updateLeaderboardsActivity, ttl 30 s; leaderboard.service.ts:43) so two pods do not race.

2.1 State machine

stateDiagram-v2
    [*] --> NOT_STARTED: scheduler creates leaderboard
    NOT_STARTED --> ACTIVE: now >= startDate
    ACTIVE --> ENDED: now >= endDate
    ENDED --> SETTLEMENT: settlement loop wrote prize amounts
    SETTLEMENT --> FINISHED: every winner has remainingUsdPrize=0 and isPrizeReceived=true

Leaderboard.status enum at api.prisma:1308. The transitions are driven by:

  • NOT_STARTED → ACTIVE and ACTIVE → ENDED — a single raw UPDATE on leaderboard keyed off now() vs start_date / end_date (leaderboard.repository.ts:65-90). Same SQL also stamps LeaderboardUser.finalPosition (the freeze-frame rank) at the moment of ENDED.
  • ENDED → SETTLEMENTupdateSettlementLeaderboards (leaderboard.service.ts:93) iterates the top-N users (where N = max position in the prize map) and writes remainingUsdPrize, isWinner=true per winner. Status flips after the last write.
  • SETTLEMENT → FINISHEDfinishSettledLeaderboards (leaderboard.repository.ts:92) flips status when every LeaderboardUser row is either a paid winner (remainingUsdPrize=0 ∧ isPrizeReceived=true) or a loser (isWinner=false).

2.2 Per-type window boundaries

createScheduledLeaderboard (leaderboard.repository.ts:179-196) creates the next leaderboard in a schedule with:

Type startDate endDate
DAILY now() − 5 min endOf('day')
WEEKLY now() − 5 min endOf('week')
MONTHLY now() − 5 min endOf('month')

The −5 min slack lets the just-created leaderboard pick up bets immediately on the next 5-minute scheduler tick.

A schedule produces exactly one leaderboard at a time — updateScheduledLeaderboards only creates a new row when the schedule has zero non-finished leaderboards (leaderboard.service.ts:72).

3. Prize distribution

Prize ladders live on LeaderboardSchedule.config.prizes (api.prisma:1300) as [ { position: number, usdPrize: number }, ... ]. The schedule controller (apps/api/src/leaderboard/admin.leaderboard.controller.ts) lets ops edit this list.

At freeze, updateSettlementLeaderboards builds a Map<position, usdPrize> and writes the prize to each winner:

// apps/api/src/leaderboard/leaderboard.service.ts:119
const prizeMap = new Map<number, number>(
  leaderboard.config.prizes.map((p) => [p.position, p.usdPrize])
);

const winners = await this.leaderboardRepository.findManyLeaderboardUsers(
  leaderboard.id,
  { take: maxPrizePosition, page: 1, withPosition: true },
);

for (const winner of winners.data) {
  const prize = prizeMap.get(winner.position!.position) ?? 0;
  await this.leaderboardRepository.settleLeaderboardUser({
    leaderboardId, userId,
    usdRemainingPrize: new Decimal(prize),
    finalPosition: winner.position!.position,
  });
}

If a position is missing from the prize map (e.g. ladder is [1: 100, 3: 25]), winners at position 2 get usdRemainingPrize = 0 and are ineligible for payout (see §4 below). Hard capLEADERBOARD_MAX_PRIZE_POSITION = 50 (leaderboard/const.ts:17); admin UI should reject prize entries beyond rank 50. {{TBD: confirm with product team that the ladder is configured as a sparse array of position→prize tuples and not as ranges.}}

3.1 Worked example

Schedule: WEEKLY, prizes [ {1, $1000}, {2, $500}, {3, $250}, {4, $100}, {5, $50} ].

Wagering at end of window:

Rank User usdAmount wagered Prize
1 alice $48,200 $1000
2 bob $33,150 $500
3 carol $9,400 $250
4 dave $6,800 $100
5 eve $1,250 $50
6 frank $400

maxPrizePosition = 5, only the top 5 rows enter the prize loop (the take: maxPrizePosition filter at leaderboard.service.ts:130). Frank does not get a LeaderboardUser settlement update — his row remains isWinner = false.

4. Payout flow

Payout is manual, performed by an admin via givePrize (leaderboard.service.ts:169). The mutex key is ${LEADERBOARD_USER_KEY_get(...)}:pay_prize (leaderboard/const.ts:9) — one in-flight payout per (leaderboard, user).

Per call:

  1. Look up the LeaderboardUser row.
  2. LeaderboardChecker.isUserPayable (leaderboard.checker.ts:19) returns true iff isWinner=true ∧ isPrizeReceived=false ∧ remainingUsdPrize > 0.
  3. Convert the requested USD amount to the chosen currency via ExchangeRatesService.toCurrency.
  4. Emit Transaction { type: DEPOSIT, tag: LEADERBOARD_PRIZE, currencyId: data.currency, amount: <converted>, adminUserId: admin.id } (leaderboard.service.ts:198).
  5. Decrement remainingUsdPrize by the USD amount; if it reaches zero, set isPrizeReceived = true (leaderboard.repository.ts:285-315).

The admin can pay in multiple installments (fullUsdAmount: false, usdAmount: <partial>) or one shot (fullUsdAmount: true). A toast message is auto-generated showing the placement and the conversion (leaderboard.service.ts:206).

sequenceDiagram
    participant Admin as Admin (UI)
    participant Ctl as AdminLeaderboardController
    participant LB as LeaderboardService
    participant Acc as AccountingService
    participant Repo as LeaderboardRepository
    participant DB as Postgres

    Admin->>Ctl: POST /admin/leaderboard/give-prize<br/>{leaderboardId, userId, currency, usdAmount}
    Ctl->>LB: givePrize(...)
    LB->>Repo: findUniqueLeaderboardUser
    LB->>LB: isUserPayable check
    LB->>Acc: createTransaction(DEPOSIT, LEADERBOARD_PRIZE)
    Acc->>DB: insert transaction + update balance
    LB->>Repo: givePrizeLeaderboardUser({paidUsdPrize})
    Repo->>DB: decrement remainingUsdPrize<br/>set isPrizeReceived if zero

5. Anti-collusion / safeguards

The model is intentionally simple — no automated anti-collusion logic in the leaderboard module. Defenses come from upstream:

Layer Defense Source
Bet idempotency @HandleBetOnce on every per-bet handler so a re-delivered bet doesn't double-score bet/utils/utils.ts:16
Same-user wash KYC + IP/device checks at signup; no automatic detection in leaderboard kyc-tiers.md
Game choice All games count equally — there is no farming-resistant weighting (e.g. excluding 100% RTP games would require a code change at processUserActionForLeaderboard). {{TBD: product team to confirm this is desired.}} leaderboard.service.ts:246
Prize-payout race Per-(leaderboard, user) mutex on givePrize leaderboard.service.ts:165
Manual payout Every prize is admin-approved; no automatic crediting on settlement leaderboard.service.ts:169

See ../runbooks/ for incident playbooks if a leaderboard is suspected of abuse.

6. Edge cases

Case Behavior Source
User wagers $0 (free spin) incrementUsdAmount = 0; no row change leaderboard.repository.ts:248
Rollback after settlement XP path doesn't reverse — no leaderboard reversal either. {{TBD.}} n/a
Tied scores at boundary Final position determined by SQL ROW_NUMBER() OVER (... ORDER BY usd_amount DESC) — first inserted wins on tie. leaderboard.repository.ts:74
Prize ladder with gaps Position-not-in-map → prize = 0; no settlement row written but user's isWinner stays false (default) leaderboard.service.ts:138
prizes empty Logs warning, flips status straight to SETTLEMENT, no payouts leaderboard.service.ts:107
Pod restart mid-loop The 5-minute cron is idempotent (@IdempotencyLock 30s); per-leaderboard for-loop is not transactional, so a partial settlement may leave LeaderboardStatus.ENDED and finish on the next tick leaderboard.service.ts:43,93
Cache staleness on findManyLeaderboardsActive 60s TTL; new leaderboard takes up to 1 min to start scoring leaderboard/const.ts:2
Currency conversion at payout time Uses payout-time rates, not freeze-time. A weakening crypto means the player gets fewer coins than the USD prize implied at freeze. leaderboard.service.ts:202

7. Admin overrides

Capability Code
Edit schedule (name, prizes, enabled) LeaderboardRepository.updateUniqueLeaderboardSchedule (leaderboard.repository.ts:436)
Disable a schedule (enabled = false) Stops new leaderboards being spawned in updateScheduledLeaderboards
Manual settlement payout LeaderboardService.givePrize
Force end via endDate edit updateLeaderboard admin endpoint
Inspect raw user rows findManyLeaderboardUsers