From 1bf1bebb93276b3a53004c571da9cef52e3be58d Mon Sep 17 00:00:00 2001 From: Nathaniel Nanle Date: Tue, 26 May 2026 18:35:54 +0100 Subject: [PATCH] feat(contracts): add ballot participation snapshot and tiebreak-window accessor Closes #696 - ballot_participation_snapshot(round_id): returns eligible_voters, votes_cast, participation_bps (votes*10000/eligible, 0 when unconfigured), round_active - tiebreak_window(round_id): returns window_start, window_end, tiebreak_required, window_open (live, computed at read time) - Unknown round ids return exists=false with zeroed fields - 8 unit tests: success, full participation, zero eligible, missing, window open/closed/not-required --- contracts/Cargo.toml | 1 + contracts/map-voting/Cargo.toml | 14 ++ contracts/map-voting/src/lib.rs | 191 ++++++++++++++++++++++++++++ contracts/map-voting/src/storage.rs | 22 ++++ contracts/map-voting/src/test.rs | 160 +++++++++++++++++++++++ contracts/map-voting/src/types.rs | 54 ++++++++ 6 files changed, 442 insertions(+) create mode 100644 contracts/map-voting/Cargo.toml create mode 100644 contracts/map-voting/src/lib.rs create mode 100644 contracts/map-voting/src/storage.rs create mode 100644 contracts/map-voting/src/test.rs create mode 100644 contracts/map-voting/src/types.rs diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 6a00bd4a..cf809717 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -109,6 +109,7 @@ members = [ "sponsor-pool", "sponsorship-ledger", "treasury-safeguard", + "map-voting", ] exclude = [ diff --git a/contracts/map-voting/Cargo.toml b/contracts/map-voting/Cargo.toml new file mode 100644 index 00000000..b709bb18 --- /dev/null +++ b/contracts/map-voting/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "stellarcade-map-voting" +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/map-voting/src/lib.rs b/contracts/map-voting/src/lib.rs new file mode 100644 index 00000000..98e4bcf8 --- /dev/null +++ b/contracts/map-voting/src/lib.rs @@ -0,0 +1,191 @@ +//! Stellarcade Map Voting Contract +//! +//! Manages map-voting rounds with ballot participation snapshots and +//! tiebreak-window accessors. +//! +//! ## Read-only accessors +//! - `ballot_participation_snapshot(round_id)` — eligible voters, votes cast, +//! participation rate in basis points, and active state. +//! - `tiebreak_window(round_id)` — tiebreak window ledger range, required flag, +//! and live open/closed status. +//! +//! ## Zero-state behaviour +//! Both accessors return `exists = false` with zeroed numeric fields for +//! unknown round ids so callers never need to handle a missing-key error. +//! +//! ## Rounding conventions +//! `participation_bps` = `votes_cast * 10_000 / eligible_voters`, truncated. +//! Returns 0 when `eligible_voters` is zero. + +#![no_std] +#![allow(unexpected_cfgs)] + +mod storage; +mod types; + +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env}; + +pub use types::{BallotParticipationSnapshot, TiebreakWindow, VotingRoundRecord}; + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + Round(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 MapVoting; + +#[contractimpl] +impl MapVoting { + /// 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 voting round. Admin only. + pub fn upsert_round( + env: Env, + admin: Address, + round_id: u32, + eligible_voters: u32, + votes_cast: u32, + round_active: bool, + tiebreak_required: bool, + tiebreak_window_start: u32, + tiebreak_window_end: u32, + ) -> Result<(), Error> { + require_admin(&env, &admin)?; + storage::set_round( + &env, + round_id, + &VotingRoundRecord { + eligible_voters, + votes_cast, + round_active, + tiebreak_required, + tiebreak_window_start, + tiebreak_window_end, + }, + ); + Ok(()) + } + + /// Return a ballot participation snapshot for `round_id`. + /// + /// Unknown round ids return `exists = false` with zeroed fields. + /// `participation_bps` is 0 when `eligible_voters` is zero. + pub fn ballot_participation_snapshot( + env: Env, + round_id: u32, + ) -> BallotParticipationSnapshot { + match storage::get_round(&env, round_id) { + Some(record) => { + let participation_bps = if record.eligible_voters == 0 { + 0 + } else { + ((record.votes_cast as u64 * 10_000) / record.eligible_voters as u64) as u32 + }; + BallotParticipationSnapshot { + round_id, + exists: true, + eligible_voters: record.eligible_voters, + votes_cast: record.votes_cast, + participation_bps, + round_active: record.round_active, + } + } + None => BallotParticipationSnapshot { + round_id, + exists: false, + eligible_voters: 0, + votes_cast: 0, + participation_bps: 0, + round_active: false, + }, + } + } + + /// Return tiebreak-window details for `round_id`. + /// + /// Unknown round ids return `exists = false` with zeroed fields. + /// `window_open` is computed against the current ledger sequence: + /// `window_start <= current_ledger < window_end`. + pub fn tiebreak_window(env: Env, round_id: u32) -> TiebreakWindow { + match storage::get_round(&env, round_id) { + Some(record) => { + let current = env.ledger().sequence(); + let window_open = record.tiebreak_required + && current >= record.tiebreak_window_start + && current < record.tiebreak_window_end; + TiebreakWindow { + round_id, + exists: true, + window_start: record.tiebreak_window_start, + window_end: record.tiebreak_window_end, + tiebreak_required: record.tiebreak_required, + window_open, + } + } + None => TiebreakWindow { + round_id, + exists: false, + window_start: 0, + window_end: 0, + tiebreak_required: false, + window_open: 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/map-voting/src/storage.rs b/contracts/map-voting/src/storage.rs new file mode 100644 index 00000000..dd9702aa --- /dev/null +++ b/contracts/map-voting/src/storage.rs @@ -0,0 +1,22 @@ +use soroban_sdk::Env; + +use crate::{DataKey, types::VotingRoundRecord}; + +pub const PERSISTENT_BUMP_LEDGERS: u32 = 518_400; + +pub fn get_round(env: &Env, round_id: u32) -> Option { + env.storage() + .persistent() + .get(&DataKey::Round(round_id)) +} + +pub fn set_round(env: &Env, round_id: u32, record: &VotingRoundRecord) { + env.storage() + .persistent() + .set(&DataKey::Round(round_id), record); + env.storage().persistent().extend_ttl( + &DataKey::Round(round_id), + PERSISTENT_BUMP_LEDGERS, + PERSISTENT_BUMP_LEDGERS, + ); +} diff --git a/contracts/map-voting/src/test.rs b/contracts/map-voting/src/test.rs new file mode 100644 index 00000000..9eb3a434 --- /dev/null +++ b/contracts/map-voting/src/test.rs @@ -0,0 +1,160 @@ +#![cfg(test)] + +use soroban_sdk::{testutils::{Address as _, Ledger as _}, Address, Env}; + +use super::*; + +fn setup(env: &Env) -> (MapVotingClient<'_>, Address) { + let admin = Address::generate(env); + let contract_id = env.register(MapVoting, ()); + let client = MapVotingClient::new(env, &contract_id); + env.mock_all_auths(); + client.init(&admin); + (client, admin) +} + +// --------------------------------------------------------------------------- +// ballot_participation_snapshot — success path +// --------------------------------------------------------------------------- + +#[test] +fn test_ballot_participation_snapshot_success() { + let env = Env::default(); + let (client, admin) = setup(&env); + + // 400 of 1000 eligible voters have voted → 40% = 4000 bps + client.upsert_round(&admin, &1, &1000, &400, &true, &false, &0, &0); + + let snap = client.ballot_participation_snapshot(&1); + assert!(snap.exists); + assert_eq!(snap.round_id, 1); + assert_eq!(snap.eligible_voters, 1000); + assert_eq!(snap.votes_cast, 400); + assert_eq!(snap.participation_bps, 4000); + assert!(snap.round_active); +} + +// --------------------------------------------------------------------------- +// ballot_participation_snapshot — full participation +// --------------------------------------------------------------------------- + +#[test] +fn test_ballot_participation_snapshot_full() { + let env = Env::default(); + let (client, admin) = setup(&env); + + client.upsert_round(&admin, &2, &500, &500, &false, &false, &0, &0); + + let snap = client.ballot_participation_snapshot(&2); + assert!(snap.exists); + assert_eq!(snap.participation_bps, 10_000); + assert!(!snap.round_active); +} + +// --------------------------------------------------------------------------- +// ballot_participation_snapshot — zero eligible voters +// --------------------------------------------------------------------------- + +#[test] +fn test_ballot_participation_snapshot_zero_eligible() { + let env = Env::default(); + let (client, admin) = setup(&env); + + client.upsert_round(&admin, &3, &0, &0, &false, &false, &0, &0); + + let snap = client.ballot_participation_snapshot(&3); + assert!(snap.exists); + assert_eq!(snap.participation_bps, 0); +} + +// --------------------------------------------------------------------------- +// ballot_participation_snapshot — missing round returns zero-state +// --------------------------------------------------------------------------- + +#[test] +fn test_ballot_participation_snapshot_missing() { + let env = Env::default(); + let (client, _) = setup(&env); + + let snap = client.ballot_participation_snapshot(&99); + assert!(!snap.exists); + assert_eq!(snap.round_id, 99); + assert_eq!(snap.eligible_voters, 0); + assert_eq!(snap.votes_cast, 0); + assert_eq!(snap.participation_bps, 0); + assert!(!snap.round_active); +} + +// --------------------------------------------------------------------------- +// tiebreak_window — window open +// --------------------------------------------------------------------------- + +#[test] +fn test_tiebreak_window_open() { + let env = Env::default(); + env.ledger().set_sequence_number(150); + let (client, admin) = setup(&env); + + client.upsert_round(&admin, &4, &200, &200, &false, &true, &100, &200); + + let tw = client.tiebreak_window(&4); + assert!(tw.exists); + assert!(tw.tiebreak_required); + assert!(tw.window_open); + assert_eq!(tw.window_start, 100); + assert_eq!(tw.window_end, 200); +} + +// --------------------------------------------------------------------------- +// tiebreak_window — window closed (past end) +// --------------------------------------------------------------------------- + +#[test] +fn test_tiebreak_window_closed_past_end() { + let env = Env::default(); + env.ledger().set_sequence_number(250); + let (client, admin) = setup(&env); + + client.upsert_round(&admin, &5, &200, &200, &false, &true, &100, &200); + + let tw = client.tiebreak_window(&5); + assert!(tw.exists); + assert!(tw.tiebreak_required); + assert!(!tw.window_open); // past window_end +} + +// --------------------------------------------------------------------------- +// tiebreak_window — no tiebreak required +// --------------------------------------------------------------------------- + +#[test] +fn test_tiebreak_window_not_required() { + let env = Env::default(); + env.ledger().set_sequence_number(150); + let (client, admin) = setup(&env); + + client.upsert_round(&admin, &6, &300, &300, &false, &false, &100, &200); + + let tw = client.tiebreak_window(&6); + assert!(tw.exists); + assert!(!tw.tiebreak_required); + assert!(!tw.window_open); +} + +// --------------------------------------------------------------------------- +// tiebreak_window — missing round returns zero-state +// --------------------------------------------------------------------------- + +#[test] +fn test_tiebreak_window_missing() { + let env = Env::default(); + let (client, _) = setup(&env); + + let tw = client.tiebreak_window(&999); + assert!(!tw.exists); + assert_eq!(tw.round_id, 999); + assert_eq!(tw.window_start, 0); + assert_eq!(tw.window_end, 0); + assert!(!tw.tiebreak_required); + assert!(!tw.window_open); +} diff --git a/contracts/map-voting/src/types.rs b/contracts/map-voting/src/types.rs new file mode 100644 index 00000000..20efb899 --- /dev/null +++ b/contracts/map-voting/src/types.rs @@ -0,0 +1,54 @@ +use soroban_sdk::contracttype; + +/// Snapshot of ballot participation for a voting round. +/// +/// Returned by `ballot_participation_snapshot`. When the round has not been +/// configured, `exists` is `false` and all numeric fields are zero. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BallotParticipationSnapshot { + pub round_id: u32, + /// `true` when the round_id exists in storage. + pub exists: bool, + /// Total number of eligible voters for this round. + pub eligible_voters: u32, + /// Number of votes cast so far. + pub votes_cast: u32, + /// Participation rate in basis points (votes_cast * 10_000 / eligible_voters). + /// Zero when `eligible_voters` is zero. + pub participation_bps: u32, + /// Whether the voting round is currently active. + pub round_active: bool, +} + +/// Tiebreak-window details for a voting round. +/// +/// Returned by `tiebreak_window`. When the round has not been configured, +/// `exists` is `false` and timing fields are zero. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TiebreakWindow { + pub round_id: u32, + /// `true` when the round_id exists in storage. + pub exists: bool, + /// Ledger sequence at which the tiebreak window opens. + pub window_start: u32, + /// Ledger sequence at which the tiebreak window closes. + pub window_end: u32, + /// Whether a tiebreak is currently required. + pub tiebreak_required: bool, + /// Whether the tiebreak window is currently open (based on current ledger). + pub window_open: bool, +} + +/// Persistent voting-round record. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VotingRoundRecord { + pub eligible_voters: u32, + pub votes_cast: u32, + pub round_active: bool, + pub tiebreak_required: bool, + pub tiebreak_window_start: u32, + pub tiebreak_window_end: u32, +}