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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: CI

# Scoped to `leaderboard` only for now, not the whole workspace. `cargo test
# --workspace` currently fails for reasons that have nothing to do with any
# one PR: `referral_registry/src/lib.rs` was emptied by a bad merge (see
# issue tracker / PR #185 discussion) and `prediction_market`'s test suite
# depends on it as a dev-dependency. Gate the whole workspace here once that
# is repaired — until then a workspace-wide job would be red on every PR
# regardless of what it actually changes, which trains everyone to ignore CI
# rather than trust it.

on:
push:
branches: [main]
pull_request:

jobs:
test-leaderboard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# No explicit toolchain action: ubuntu-latest ships rustup, and it
# picks up this repo's rust-toolchain.toml (channel = "1.91.0")
# automatically on the first cargo invocation.
- run: rustup show
- uses: Swatinem/rust-cache@v2
- run: cargo test -p leaderboard --verbose
98 changes: 64 additions & 34 deletions leaderboard/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,42 @@ impl LeaderboardContract {
Ok(())
}

// ── Issue #24: an explicit way to reduce a player's points ───────────────
// A loss no longer has to be point-positive. This is decay-aware (the
// deduction lands on the player's current, decayed score, not a stale
// stored one) and saturates at zero rather than underflowing. Deliberately
// scoped to the market contract only, mirroring reward()/add_pts() — the
// caller who can award points is the one trusted to take them away.
//
// Safe with respect to the top-list invariants: get_top_players and
// get_rank already recompute rank from live (decayed) values on every
// call instead of trusting stored order (see entry_points_now +
// selection-sort in get_top_players, and the exhaustive scan in
// get_rank), and recompute_min is an unconditional full scan. So a score
// moving down through update_top_players' existing "already listed"
// branch is exactly as safe as it moving up: bubble_up simply no-ops when
// the entry no longer beats its predecessor, and the cached min is
// recomputed correctly regardless of direction. A penalized player who
// isn't currently ranked never touches the top list at all — see
// debit_points — so a penalty can never insert an unranked player into
// the leaderboard or bump a ranked one out on their behalf.
pub fn penalize(
env: Env,
caller: Address,
user: Address,
pts: u64,
) -> Result<(), LeaderboardError> {
Self::require_not_paused(&env)?;
Self::require_market_contract(&env, &caller)?;
caller.require_auth();
if pts == 0 {
return Err(LeaderboardError::InvalidPoints);
}
Self::require_not_banned(&env, &user)?;
Self::debit_points(&env, &user, pts);
Ok(())
}

// ── Pull-based reward flow (issue #86) ───────────────────────────────────

/// Queue a settled-bet reward for later claim. A banned player is rejected
Expand Down Expand Up @@ -412,39 +448,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 @@ -963,6 +966,31 @@ impl LeaderboardContract {
);
}

/// Reduce a player's (decay-forwarded) points by `pts`, saturating at
/// zero. Deliberately does not touch won_bets/lost_bets/bonus_bets —
/// those are activity counters, not points, and the loss itself is
/// already recorded by whichever add_pts/reward call reported it.
///
/// Only reconciles the top list if the player is currently ranked. An
/// unranked player's Stats just get a lower number; penalizing them must
/// never be the reason they newly appear in (or displace someone from)
/// the top list, so update_top_players is skipped entirely when they
/// aren't already in it — mirroring how a penalty can't create rank, only
/// remove it.
fn debit_points(env: &Env, user: &Address, pts: u64) {
let mut s = Self::stats_for_update(env, user);
s.points = s.points.saturating_sub(pts);
Self::commit_stats(env, user, &s);
if Self::top_slot_entry(env, user).is_some() {
Self::update_top_players(env, user.clone(), s.points);
}
env.storage().instance().extend_ttl(TTL_BUMP, TTL_HIGH);
env.events().publish(
(Symbol::new(&env, "leaderboard_penalized"), user.clone()),
s.points,
);
}

fn accumulate_pending(
env: &Env,
user: &Address,
Expand Down Expand Up @@ -1321,4 +1349,6 @@ mod tests;
#[cfg(test)]
mod ttl_tests;
#[cfg(test)]
mod admin_tests;
mod admin_tests;
#[cfg(test)]
mod penalty_tests;
Loading