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 → ACTIVEandACTIVE → ENDED— a single rawUPDATEonleaderboardkeyed offnow() vs start_date / end_date(leaderboard.repository.ts:65-90). Same SQL also stampsLeaderboardUser.finalPosition(the freeze-frame rank) at the moment ofENDED.ENDED → SETTLEMENT—updateSettlementLeaderboards(leaderboard.service.ts:93) iterates the top-N users (where N = max position in the prize map) and writesremainingUsdPrize,isWinner=trueper winner. Status flips after the last write.SETTLEMENT → FINISHED—finishSettledLeaderboards(leaderboard.repository.ts:92) flips status when everyLeaderboardUserrow 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 cap — LEADERBOARD_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:
- Look up the
LeaderboardUserrow. LeaderboardChecker.isUserPayable(leaderboard.checker.ts:19) returns true iffisWinner=true ∧ isPrizeReceived=false ∧ remainingUsdPrize > 0.- Convert the requested USD amount to the chosen currency via
ExchangeRatesService.toCurrency. - Emit
Transaction { type: DEPOSIT, tag: LEADERBOARD_PRIZE, currencyId: data.currency, amount: <converted>, adminUserId: admin.id }(leaderboard.service.ts:198). - Decrement
remainingUsdPrizeby the USD amount; if it reaches zero, setisPrizeReceived = 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 |
8. Related docs¶
bet-settlement.md— WhereLeaderboardService.handleBetis invoked fromwallet-and-balance.md— HowLEADERBOARD_PRIZEtransactions land in the player's balance../flows/dropbet-leaderboard.md— Player-facing leaderboard view../runbooks/bullmq-job-stuck.md— Recovery if scoring lags../data-model/—Leaderboard,LeaderboardSchedule,LeaderboardUser,LeaderboardUserPositionView