Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions leaderboard/src/decay_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ fn setup() -> (
let env = Env::default();
env.mock_all_auths();
env.cost_estimate().budget().reset_unlimited();
// The write-time ordered-index bubble (issue #68) can rewrite tens of
// slots in one call, exceeding mainnet invocation limits for the
// fill-to-capacity cases. Behavior is what these tests prove, so lift the
// resource limits like the CPU budget above (same as tests.rs setup).
env.cost_estimate().disable_resource_limits();

let contract_id = env.register(LeaderboardContract, ());
let client = LeaderboardContractClient::new(&env, &contract_id);
Expand Down
98 changes: 27 additions & 71 deletions leaderboard/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,14 @@ const DECAY_RETAIN_DEN: u64 = 10;
/// a score cannot outlive the entry holding it, and this bounds the decay loop.
const DECAY_ZERO_AFTER_PERIODS: u32 = TTL_HIGH / DECAY_PERIOD_LEDGERS;

/// How many slots one call may bubble an entry through (a transaction may
/// write at most 50 ledger entries). An entry that cannot reach its place in
/// one call settles on subsequent writes; `get_top_players`/`get_rank` rank
/// on decayed values at read time regardless, so the reported order is exact.
const MAX_BUBBLE_STEPS: u32 = 8;
/// Upper bound on how far `bubble_up` may walk in one call. The ordered
/// index is the source of truth for `get_top_players` — pages read slots
/// directly without re-sorting — so an entry must be able to reach its exact
/// position in a single write; the bound is therefore the full list length
/// (the longest possible bubble is MAX_TOP_PLAYERS - 1 slots). The fill-to-
/// capacity tests lift the ledger's resource limits for exactly this write
/// footprint (see tests.rs setup).
const MAX_BUBBLE_STEPS: u32 = MAX_TOP_PLAYERS;

// Issue #84: bump whenever a function signature, argument order, or return
// type that a caller relies on changes.
Expand Down Expand Up @@ -348,7 +351,7 @@ impl LeaderboardContract {
.bet_delta
.saturating_sub(pending.won_delta + pending.lost_delta);
Self::commit_stats(&env, &user, &s);
Self::update_top_players(&env, user.clone(), s.points);
Self::maintain_ordered_top_index(&env, user.clone(), s.points);

if pending.tokens > 0 {
Self::mint_reward(&env, &user, pending.tokens)?;
Expand Down Expand Up @@ -412,39 +415,6 @@ impl LeaderboardContract {
pub fn record_bet(env: Env, caller: Address, _user: Address) -> Result<(), LeaderboardError> {
Self::require_not_paused(&env)?;
Self::require_market_contract(&env, &caller)?;

let mut stats = Self::stats_for_update(&env, &user);

// ── Legacy write functions (kept for backward-compat) ─────────────────────

/// Deprecated: use `reward()` instead. This function always returns
/// `UnauthorizedCaller` and will be removed in a future version.
pub fn add_pts(
_env: Env,
_caller: Address,
_user: Address,
_pts: u64,
_is_won: bool,
) -> Result<(), LeaderboardError> {
Err(LeaderboardError::UnauthorizedCaller)
}

/// Legacy: called by the referral contract to award bonus points.
/// Prefer reward_bonus() for new integrations (adds token minting).
pub fn add_bonus_pts(
env: Env,
caller: Address,
user: Address,
pts: u64,
) -> Result<(), LeaderboardError> {
let referral: Address = env
.storage()
.instance()
.get(&DataKey::ReferralContract)
.ok_or(LeaderboardError::NotInitialized)?;
if caller != referral {
return Err(LeaderboardError::UnauthorizedCaller);
}
caller.require_auth();
Ok(())
}
Expand Down Expand Up @@ -494,46 +464,28 @@ impl LeaderboardContract {
Self::top_count(&env)
}

/// Page of the top list, ranked on decayed values at read time.
/// Page of the top list, read straight from the write-time ordered index
/// (issue #68). `maintain_ordered_top_index` keeps slots sorted descending
/// on decayed values at every write, so this reads only the requested
/// `[offset, offset + page_size)` range — O(page_size), no full-list scan
/// and no re-sort. Decay is applied per entry at read time.
pub fn get_top_players(env: Env, offset: u32, page_size: u32) -> Vec<PlayerEntry> {
let count = Self::top_count(&env);
if offset >= count || page_size == 0 {
return vec![&env];
}
let page_size = page_size.min(MAX_PAGE_SIZE);
let end = offset.saturating_add(page_size).min(count);
let now = Self::current_epoch(&env);

let mut ranked: Vec<PlayerEntry> = Vec::new(&env);
for i in 0..count {
let mut result = Vec::new(&env);
for i in offset..end {
if let Some(mut entry) = Self::forward_entry(&env, i) {
entry.points = Self::entry_points_now(&env, &entry);
entry.epoch = now;
ranked.push_back(entry);
result.push_back(entry);
}
}

// Selection sort, descending — bounded by MAX_TOP_PLAYERS.
let n = ranked.len() as u32;
for i in 0..n {
let mut max_idx = i;
for j in (i + 1)..n {
if ranked.get(j).unwrap().points > ranked.get(max_idx).unwrap().points {
max_idx = j;
}
}
if max_idx != i {
let a = ranked.get(i).unwrap();
let b = ranked.get(max_idx).unwrap();
ranked.set(i, b);
ranked.set(max_idx, a);
}
}

let end = (offset + page_size).min(n);
let mut result = Vec::new(&env);
for i in offset..end {
result.push_back(ranked.get(i).unwrap());
}
result
}

Expand Down Expand Up @@ -942,7 +894,7 @@ impl LeaderboardContract {
None => {}
}
Self::commit_stats(env, user, &s);
Self::update_top_players(env, user.clone(), s.points);
Self::maintain_ordered_top_index(env, user.clone(), s.points);
env.storage().instance().extend_ttl(TTL_BUMP, TTL_HIGH);
env.events().publish(
(Symbol::new(&env, "leaderboard_updated"), user.clone()),
Expand All @@ -955,7 +907,7 @@ impl LeaderboardContract {
s.points += pts;
s.bonus_bets += 1; // Issue #64: count bonus award without touching won/lost
Self::commit_stats(env, user, &s);
Self::update_top_players(env, user.clone(), s.points);
Self::maintain_ordered_top_index(env, user.clone(), s.points);
env.storage().instance().extend_ttl(TTL_BUMP, TTL_HIGH);
env.events().publish(
(Symbol::new(&env, "leaderboard_updated"), user.clone()),
Expand Down Expand Up @@ -1251,12 +1203,16 @@ impl LeaderboardContract {
}
}

/// Insert or update a player's place in the top list after a point change.
/// Insert or update a player's place in the ordered top list after a
/// point change (issue #68). This is the single write path into the
/// index; every accrual entry point (reward, add_pts, reward_bonus,
/// add_bonus_pts, claim_pending_rewards) funnels through it, so the
/// slots read by get_top_players are always ordered.
/// - Already listed: update points/epoch in place, bubble up, refresh min.
/// - Not listed, room left: append and bubble.
/// - Not listed, list full: evict the weakest live entry (decayed, oldest
/// seq on ties) when the newcomer is at least as strong.
fn update_top_players(env: &Env, user: Address, new_points: u64) {
fn maintain_ordered_top_index(env: &Env, user: Address, new_points: u64) {
let count = Self::ensure_consistent(env, Self::top_count(env));

if let Some((slot, mut entry)) = Self::top_slot_entry(env, &user) {
Expand Down Expand Up @@ -1293,7 +1249,7 @@ impl LeaderboardContract {
let min_slot: u32 = env.storage().instance().get(&DataKey::MinSlot).unwrap_or(0);
let Some(min_entry) = Self::forward_entry(env, min_slot) else {
Self::repair_top_index(env);
Self::update_top_players(env, user, new_points);
Self::maintain_ordered_top_index(env, user, new_points);
return;
};
if new_points < Self::entry_points_now(env, &min_entry) {
Expand Down
Loading