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",
"map-voting",
]

exclude = [
Expand Down
14 changes: 14 additions & 0 deletions contracts/map-voting/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"]
191 changes: 191 additions & 0 deletions contracts/map-voting/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
22 changes: 22 additions & 0 deletions contracts/map-voting/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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<VotingRoundRecord> {
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,
);
}
160 changes: 160 additions & 0 deletions contracts/map-voting/src/test.rs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading