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
1 change: 1 addition & 0 deletions contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ members = [
"sponsor-pool",
"sponsorship-ledger",
"treasury-safeguard",
"ladder-seasons",
]

exclude = [
Expand Down
14 changes: 14 additions & 0 deletions contracts/ladder-seasons/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "stellarcade-ladder-seasons"
version = "0.1.0"
edition = "2021"
publish = false

[dependencies]
soroban-sdk = "25.1.1"

[dev-dependencies]
soroban-sdk = { version = "25.1.1", features = ["testutils"] }

[lib]
crate-type = ["cdylib"]
179 changes: 179 additions & 0 deletions contracts/ladder-seasons/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//! Stellarcade Ladder Seasons Contract
//!
//! Manages per-season ladder state including transition snapshots and
//! demotion-cutoff configuration.
//!
//! ## Read-only accessors
//! - `season_transition_snapshot(season_id)` — returns end-of-season metrics.
//! - `demotion_cutoff(season_id)` — returns the demotion boundary for a season.
//!
//! ## Zero-state behaviour
//! Both accessors return `exists = false` with zeroed numeric fields when the
//! requested `season_id` has not been written to storage, so callers never
//! need to handle a missing-key error.

#![no_std]
#![allow(unexpected_cfgs)]

mod storage;
mod types;

use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env};

pub use types::{DemotionCutoff, SeasonRecord, SeasonTransitionSnapshot};

pub const PERSISTENT_BUMP_LEDGERS: u32 = 518_400;

// ---------------------------------------------------------------------------
// Storage keys
// ---------------------------------------------------------------------------

#[contracttype]
#[derive(Clone)]
pub enum DataKey {
Admin,
Season(u32),
}

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum Error {
AlreadyInitialized = 1,
NotInitialized = 2,
NotAuthorized = 3,
}

// ---------------------------------------------------------------------------
// Contract
// ---------------------------------------------------------------------------

#[contract]
pub struct LadderSeasons;

#[contractimpl]
impl LadderSeasons {
/// Initialize the contract. May only be called once.
pub fn init(env: Env, admin: Address) -> Result<(), Error> {
if env.storage().instance().has(&DataKey::Admin) {
return Err(Error::AlreadyInitialized);
}
admin.require_auth();
env.storage().instance().set(&DataKey::Admin, &admin);
Ok(())
}

/// Write or update a season record. Admin only.
///
/// Existing records are fully replaced so callers can update individual
/// fields by reading first and re-submitting the modified struct.
pub fn upsert_season(
env: Env,
admin: Address,
season_id: u32,
total_participants: u32,
top_score: u32,
ended_at_ledger: u32,
was_paused: bool,
cutoff_score: u32,
cutoff_rank: u32,
demotion_window_end: u32,
demotion_window_active: bool,
) -> Result<(), Error> {
require_admin(&env, &admin)?;
storage::set_season(
&env,
season_id,
&SeasonRecord {
total_participants,
top_score,
ended_at_ledger,
was_paused,
cutoff_score,
cutoff_rank,
demotion_window_end,
demotion_window_active,
},
);
Ok(())
}

/// Return a transition snapshot for `season_id`.
///
/// Unknown season ids return `exists = false` with zeroed numeric fields.
/// Paused seasons are surfaced via `was_paused = true`.
pub fn season_transition_snapshot(env: Env, season_id: u32) -> SeasonTransitionSnapshot {
match storage::get_season(&env, season_id) {
Some(record) => SeasonTransitionSnapshot {
season_id,
exists: true,
ended_at_ledger: record.ended_at_ledger,
total_participants: record.total_participants,
top_score: record.top_score,
was_paused: record.was_paused,
},
None => SeasonTransitionSnapshot {
season_id,
exists: false,
ended_at_ledger: 0,
total_participants: 0,
top_score: 0,
was_paused: false,
},
}
}

/// Return the demotion cutoff for `season_id`.
///
/// Unknown season ids return `exists = false` with zeroed numeric fields.
/// When `demotion_window_active` is `false` the window has closed and no
/// demotions will be processed.
pub fn demotion_cutoff(env: Env, season_id: u32) -> DemotionCutoff {
match storage::get_season(&env, season_id) {
Some(record) => DemotionCutoff {
season_id,
exists: true,
cutoff_score: record.cutoff_score,
cutoff_rank: record.cutoff_rank,
demotion_window_end: record.demotion_window_end,
window_active: record.demotion_window_active,
},
None => DemotionCutoff {
season_id,
exists: false,
cutoff_score: 0,
cutoff_rank: 0,
demotion_window_end: 0,
window_active: false,
},
}
}
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn require_admin(env: &Env, caller: &Address) -> Result<(), Error> {
let admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::NotInitialized)?;
caller.require_auth();
if caller != &admin {
return Err(Error::NotAuthorized);
}
Ok(())
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod test;
20 changes: 20 additions & 0 deletions contracts/ladder-seasons/src/storage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
use soroban_sdk::Env;

use crate::{DataKey, types::SeasonRecord};

pub const PERSISTENT_BUMP_LEDGERS: u32 = 518_400;

pub fn get_season(env: &Env, season_id: u32) -> Option<SeasonRecord> {
env.storage().persistent().get(&DataKey::Season(season_id))
}

pub fn set_season(env: &Env, season_id: u32, record: &SeasonRecord) {
env.storage()
.persistent()
.set(&DataKey::Season(season_id), record);
env.storage().persistent().extend_ttl(
&DataKey::Season(season_id),
PERSISTENT_BUMP_LEDGERS,
PERSISTENT_BUMP_LEDGERS,
);
}
134 changes: 134 additions & 0 deletions contracts/ladder-seasons/src/test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#![cfg(test)]

use soroban_sdk::{testutils::Address as _, Address, Env};

use super::*;

fn setup(env: &Env) -> (LadderSeasonsClient<'_>, Address) {
let admin = Address::generate(env);
let contract_id = env.register(LadderSeasons, ());
let client = LadderSeasonsClient::new(env, &contract_id);
env.mock_all_auths();
client.init(&admin);
(client, admin)
}

// ---------------------------------------------------------------------------
// season_transition_snapshot — success path
// ---------------------------------------------------------------------------

#[test]
fn test_season_transition_snapshot_success() {
let env = Env::default();
let (client, admin) = setup(&env);

client.upsert_season(
&admin, &1, &500, &9800, &1_000_000, &false,
&7500, &50, &1_100_000, &true,
);

let snap = client.season_transition_snapshot(&1);
assert!(snap.exists);
assert_eq!(snap.season_id, 1);
assert_eq!(snap.total_participants, 500);
assert_eq!(snap.top_score, 9800);
assert_eq!(snap.ended_at_ledger, 1_000_000);
assert!(!snap.was_paused);
}

// ---------------------------------------------------------------------------
// season_transition_snapshot — paused season
// ---------------------------------------------------------------------------

#[test]
fn test_season_transition_snapshot_paused() {
let env = Env::default();
let (client, admin) = setup(&env);

client.upsert_season(
&admin, &2, &120, &4200, &900_000, &true,
&3000, &20, &950_000, &false,
);

let snap = client.season_transition_snapshot(&2);
assert!(snap.exists);
assert!(snap.was_paused);
}

// ---------------------------------------------------------------------------
// season_transition_snapshot — missing season returns zero-state
// ---------------------------------------------------------------------------

#[test]
fn test_season_transition_snapshot_missing() {
let env = Env::default();
let (client, _) = setup(&env);

let snap = client.season_transition_snapshot(&99);
assert!(!snap.exists);
assert_eq!(snap.season_id, 99);
assert_eq!(snap.ended_at_ledger, 0);
assert_eq!(snap.total_participants, 0);
assert_eq!(snap.top_score, 0);
assert!(!snap.was_paused);
}

// ---------------------------------------------------------------------------
// demotion_cutoff — success path
// ---------------------------------------------------------------------------

#[test]
fn test_demotion_cutoff_success() {
let env = Env::default();
let (client, admin) = setup(&env);

client.upsert_season(
&admin, &3, &300, &8000, &2_000_000, &false,
&5000, &100, &2_100_000, &true,
);

let cutoff = client.demotion_cutoff(&3);
assert!(cutoff.exists);
assert_eq!(cutoff.season_id, 3);
assert_eq!(cutoff.cutoff_score, 5000);
assert_eq!(cutoff.cutoff_rank, 100);
assert_eq!(cutoff.demotion_window_end, 2_100_000);
assert!(cutoff.window_active);
}

// ---------------------------------------------------------------------------
// demotion_cutoff — window closed
// ---------------------------------------------------------------------------

#[test]
fn test_demotion_cutoff_window_closed() {
let env = Env::default();
let (client, admin) = setup(&env);

client.upsert_season(
&admin, &4, &200, &6000, &1_500_000, &false,
&4000, &80, &1_600_000, &false,
);

let cutoff = client.demotion_cutoff(&4);
assert!(cutoff.exists);
assert!(!cutoff.window_active);
}

// ---------------------------------------------------------------------------
// demotion_cutoff — missing season returns zero-state
// ---------------------------------------------------------------------------

#[test]
fn test_demotion_cutoff_missing() {
let env = Env::default();
let (client, _) = setup(&env);

let cutoff = client.demotion_cutoff(&999);
assert!(!cutoff.exists);
assert_eq!(cutoff.season_id, 999);
assert_eq!(cutoff.cutoff_score, 0);
assert_eq!(cutoff.cutoff_rank, 0);
assert_eq!(cutoff.demotion_window_end, 0);
assert!(!cutoff.window_active);
}
Loading
Loading