From 04a73c989e3b2e60c343cb5e839327729a45e876 Mon Sep 17 00:00:00 2001 From: Nathaniel Nanle Date: Tue, 26 May 2026 18:32:12 +0100 Subject: [PATCH] feat(contracts): add season transition snapshot and demotion-cutoff accessor Closes #704 - season_transition_snapshot(season_id): returns ended_at_ledger, total_participants, top_score, was_paused - demotion_cutoff(season_id): returns cutoff_score, cutoff_rank, demotion_window_end, window_active - Unknown season ids return exists=false with zeroed fields - 6 unit tests: success path, paused season, missing id for both accessors --- contracts/Cargo.toml | 1 + contracts/ladder-seasons/Cargo.toml | 14 ++ contracts/ladder-seasons/src/lib.rs | 179 ++++++++++++++++++++++++ contracts/ladder-seasons/src/storage.rs | 20 +++ contracts/ladder-seasons/src/test.rs | 134 ++++++++++++++++++ contracts/ladder-seasons/src/types.rs | 55 ++++++++ 6 files changed, 403 insertions(+) create mode 100644 contracts/ladder-seasons/Cargo.toml create mode 100644 contracts/ladder-seasons/src/lib.rs create mode 100644 contracts/ladder-seasons/src/storage.rs create mode 100644 contracts/ladder-seasons/src/test.rs create mode 100644 contracts/ladder-seasons/src/types.rs diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 6a00bd4a..b2356f4d 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -109,6 +109,7 @@ members = [ "sponsor-pool", "sponsorship-ledger", "treasury-safeguard", + "ladder-seasons", ] exclude = [ diff --git a/contracts/ladder-seasons/Cargo.toml b/contracts/ladder-seasons/Cargo.toml new file mode 100644 index 00000000..9a8fc1f9 --- /dev/null +++ b/contracts/ladder-seasons/Cargo.toml @@ -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"] diff --git a/contracts/ladder-seasons/src/lib.rs b/contracts/ladder-seasons/src/lib.rs new file mode 100644 index 00000000..34e80c24 --- /dev/null +++ b/contracts/ladder-seasons/src/lib.rs @@ -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; diff --git a/contracts/ladder-seasons/src/storage.rs b/contracts/ladder-seasons/src/storage.rs new file mode 100644 index 00000000..130eb6fe --- /dev/null +++ b/contracts/ladder-seasons/src/storage.rs @@ -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 { + 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, + ); +} diff --git a/contracts/ladder-seasons/src/test.rs b/contracts/ladder-seasons/src/test.rs new file mode 100644 index 00000000..257802af --- /dev/null +++ b/contracts/ladder-seasons/src/test.rs @@ -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); +} diff --git a/contracts/ladder-seasons/src/types.rs b/contracts/ladder-seasons/src/types.rs new file mode 100644 index 00000000..2ef34a3a --- /dev/null +++ b/contracts/ladder-seasons/src/types.rs @@ -0,0 +1,55 @@ +use soroban_sdk::contracttype; + +/// Snapshot taken at the moment a season transitions to the next. +/// +/// Returned by `season_transition_snapshot`. When no season has been +/// configured yet, `exists` is `false` and all numeric fields are zero. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SeasonTransitionSnapshot { + pub season_id: u32, + /// `true` when the season_id exists in storage. + pub exists: bool, + /// Ledger sequence at which the season ended / transitioned. + pub ended_at_ledger: u32, + /// Total number of players who participated in this season. + pub total_participants: u32, + /// Score of the top-ranked player at close. + pub top_score: u32, + /// Whether the season was paused before it could complete normally. + pub was_paused: bool, +} + +/// Demotion cutoff details for a ladder season. +/// +/// Returned by `demotion_cutoff`. When the season or cutoff has not been +/// configured, `exists` is `false` and numeric fields are zero. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DemotionCutoff { + pub season_id: u32, + /// `true` when the season_id exists in storage. + pub exists: bool, + /// Minimum score a player must hold to avoid demotion. + pub cutoff_score: u32, + /// Rank boundary below which players are demoted. + pub cutoff_rank: u32, + /// Ledger sequence at which the demotion window closes. + pub demotion_window_end: u32, + /// Whether the demotion window is currently active. + pub window_active: bool, +} + +/// Persistent season record written by admin mutations. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SeasonRecord { + pub total_participants: u32, + pub top_score: u32, + pub ended_at_ledger: u32, + pub was_paused: bool, + pub cutoff_score: u32, + pub cutoff_rank: u32, + pub demotion_window_end: u32, + pub demotion_window_active: bool, +}