Skip to content
Open
58 changes: 30 additions & 28 deletions leaderboard/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use soroban_sdk::{
};

const MAX_TOP_PLAYERS: u32 = 50;
const MAX_PAGE_SIZE: u32 = 20;
const TTL_BUMP: u32 = 3_153_600;
const TTL_HIGH: u32 = 6_307_200;

Expand Down Expand Up @@ -229,7 +230,7 @@ impl LeaderboardContract {
env.storage().persistent().set(&DataKey::Stats(user.clone()), &stats);
env.storage().persistent().extend_ttl(&DataKey::Stats(user.clone()), TTL_BUMP, TTL_HIGH);

Self::update_top_players(&env, user.clone(), stats.points);
Self::maintain_ordered_top_index(&env, user.clone(), stats.points);
// Instance storage (TopPlayerCount, MinPoints, MinSlot, Admin, etc.)
// has its own TTL that is never bumped by persistent-key writes above —
// refresh it on every write so the leaderboard's cached min survives.
Expand Down Expand Up @@ -290,7 +291,7 @@ impl LeaderboardContract {
env.storage().persistent().set(&DataKey::Stats(user.clone()), &stats);
env.storage().persistent().extend_ttl(&DataKey::Stats(user.clone()), TTL_BUMP, TTL_HIGH);

Self::update_top_players(&env, user.clone(), stats.points);
Self::maintain_ordered_top_index(&env, user.clone(), stats.points);
env.storage().instance().extend_ttl(TTL_BUMP, TTL_HIGH);

if tokens > 0 {
Expand Down Expand Up @@ -340,7 +341,7 @@ impl LeaderboardContract {
env.storage().persistent().set(&DataKey::Stats(user.clone()), &stats);
env.storage().persistent().extend_ttl(&DataKey::Stats(user.clone()), TTL_BUMP, TTL_HIGH);

Self::update_top_players(&env, user.clone(), stats.points);
Self::maintain_ordered_top_index(&env, user.clone(), stats.points);
env.storage().instance().extend_ttl(TTL_BUMP, TTL_HIGH);

if tokens > 0 {
Expand Down Expand Up @@ -403,7 +404,7 @@ impl LeaderboardContract {
env.storage().persistent().set(&DataKey::Stats(user.clone()), &stats);
env.storage().persistent().extend_ttl(&DataKey::Stats(user.clone()), TTL_BUMP, TTL_HIGH);

Self::update_top_players(&env, user.clone(), stats.points);
Self::maintain_ordered_top_index(&env, user.clone(), stats.points);
env.storage().instance().extend_ttl(TTL_BUMP, TTL_HIGH);
env.events().publish(
(Symbol::new(&env, "leaderboard_updated"), user),
Expand Down Expand Up @@ -443,7 +444,11 @@ impl LeaderboardContract {
return vec![&env];
}

let end = (offset + page_size).min(count);
// Read only a bounded range from the write-time ordered index. The
// saturating addition also keeps an untrusted offset from overflowing
// before it is clamped to the current player count.
let page_size = page_size.min(MAX_PAGE_SIZE);
let end = offset.saturating_add(page_size).min(count);
let mut result = Vec::new(&env);
for i in offset..end {
if let Some(entry) = env.storage().persistent().get(&DataKey::TopPlayerAt(i)) {
Expand Down Expand Up @@ -563,11 +568,21 @@ impl LeaderboardContract {
}
}

/// Write one entry into the forward and reverse indexes as one logical
/// operation. Every write path goes through this helper, so the slots
/// consumed by get_top_players are always kept in sync with lookups.
fn write_ordered_entry(env: &Env, slot: u32, entry: &PlayerEntry) {
let key = DataKey::TopPlayerAt(slot);
env.storage().persistent().set(&key, entry);
env.storage()
.persistent()
.set(&DataKey::TopPlayerSlot(entry.address.clone()), &slot);
}

/// Bubbles a (possibly new) entry up from `slot` until the list is
/// descending again. Forward and reverse indexes are always written
/// together so the pair cannot drift apart; TTL freshness is refreshed
/// at the owner-touch points (insert / update / eviction) instead of
/// per swap, to keep the write footprint bounded.
/// together; TTL freshness is refreshed at the owner-touch points
/// (insert / update / eviction) instead of per swap.
fn bubble_up(env: &Env, entry: &PlayerEntry, mut slot: u32) {
while slot > 0 {
let prev: Option<PlayerEntry> =
Expand All @@ -579,18 +594,8 @@ impl LeaderboardContract {
// and a bubble can rewrite dozens of slots in one call.
// TTL freshness is maintained at the owner-touch points
// (insert / in-place update / eviction) instead.
let key_hi = DataKey::TopPlayerAt(slot - 1);
let key_lo = DataKey::TopPlayerAt(slot);
env.storage().persistent().set(&key_hi, entry);
env.storage().persistent().set(&key_lo, &prev);
env.storage().persistent().set(
&DataKey::TopPlayerSlot(entry.address.clone()),
&(slot - 1),
);
env.storage().persistent().set(
&DataKey::TopPlayerSlot(prev.address.clone()),
&slot,
);
Self::write_ordered_entry(env, slot - 1, entry);
Self::write_ordered_entry(env, slot, &prev);
slot -= 1;
}
// A missing entry above means the list has a TTL-expired hole;
Expand All @@ -609,8 +614,7 @@ impl LeaderboardContract {
points,
};
let key = DataKey::TopPlayerAt(slot);
env.storage().persistent().set(&key, &entry);
env.storage().persistent().set(&DataKey::TopPlayerSlot(user.clone()), &slot);
Self::write_ordered_entry(env, slot, &entry);
env.storage().persistent().extend_ttl(&key, TTL_BUMP, TTL_HIGH);
env.storage()
.persistent()
Expand Down Expand Up @@ -673,9 +677,8 @@ impl LeaderboardContract {
for slot in 0..n {
let entry = entries.get(slot).unwrap();
let key = DataKey::TopPlayerAt(slot);
env.storage().persistent().set(&key, &entry);
Self::write_ordered_entry(env, slot, &entry);
env.storage().persistent().extend_ttl(&key, TTL_BUMP, TTL_HIGH);
env.storage().persistent().set(&DataKey::TopPlayerSlot(entry.address.clone()), &slot);
env.storage().persistent().extend_ttl(
&DataKey::TopPlayerSlot(entry.address.clone()),
TTL_BUMP,
Expand All @@ -696,13 +699,13 @@ impl LeaderboardContract {
n
}

fn update_top_players(env: &Env, user: Address, new_points: u64) {
fn maintain_ordered_top_index(env: &Env, user: Address, new_points: u64) {
// Fast path: the user is already in the list — in-place update backed
// by a validated reverse lookup (issue #67).
if let Some((slot, mut entry)) = Self::top_slot_entry(env, &user) {
entry.points = new_points;
let key = DataKey::TopPlayerAt(slot);
env.storage().persistent().set(&key, &entry);
Self::write_ordered_entry(env, slot, &entry);
env.storage().persistent().extend_ttl(&key, TTL_BUMP, TTL_HIGH);

Self::bubble_up(env, &entry, slot);
Expand Down Expand Up @@ -767,8 +770,7 @@ impl LeaderboardContract {
points: new_points,
};
let key = DataKey::TopPlayerAt(min_slot);
env.storage().persistent().set(&key, &new_entry);
env.storage().persistent().set(&DataKey::TopPlayerSlot(user.clone()), &min_slot);
Self::write_ordered_entry(env, min_slot, &new_entry);
env.storage().persistent().extend_ttl(&key, TTL_BUMP, TTL_HIGH);
env.storage().persistent().extend_ttl(
&DataKey::TopPlayerSlot(user.clone()),
Expand Down
30 changes: 30 additions & 0 deletions leaderboard/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,36 @@ fn test_top_players_capped_at_50() {
assert_eq!(client.get_top_player_count(), 50);
}

#[test]
fn test_pagination_reads_the_persistent_ordered_index() {
let (env, client, _admin, market, _referral) = setup();
let points = [10_u64, 50, 30, 40, 20];

for points in points {
let user = Address::generate(&env);
client.add_pts(&market, &user, &points, &true);
}

// The page is returned directly from slots 1 and 2 of the write-time
// ordered index, rather than rebuilding the complete ranking on read.
let page = client.get_top_players(&1_u32, &2_u32);
assert_eq!(page.len(), 2);
assert_eq!(page.get(0).unwrap().points, 40);
assert_eq!(page.get(1).unwrap().points, 30);
}

#[test]
fn test_pagination_caps_page_size_without_overflowing_offset() {
let (env, client, _admin, market, _referral) = setup();
let user = Address::generate(&env);
client.add_pts(&market, &user, &100_u64, &true);

// A caller cannot turn one view request into an unbounded storage read,
// and a maximal offset remains a safe empty page.
assert_eq!(client.get_top_players(&0_u32, &u32::MAX).len(), 1);
assert_eq!(client.get_top_players(&u32::MAX, &u32::MAX).len(), 0);
}

#[test]
fn test_pagination_offset_beyond_count() {
let (env, client, _admin, market, _referral) = setup();
Expand Down