diff --git a/contracts/voting/Cargo.toml b/contracts/voting/Cargo.toml new file mode 100644 index 00000000..9226965c --- /dev/null +++ b/contracts/voting/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "voting" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "0.8.4" + +[dev_dependencies] +soroban-sdk = { version = "0.8.4", features = ["testutils"] } +rstest = "0.17.0" + +[features] +testutils = ["soroban-sdk/testutils"] + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true diff --git a/contracts/voting/src/lib.rs b/contracts/voting/src/lib.rs new file mode 100644 index 00000000..64effcb1 --- /dev/null +++ b/contracts/voting/src/lib.rs @@ -0,0 +1,173 @@ +#![no_std] + +use soroban_sdk::{ + contracterror, contractimpl, contracttype, vec, Address, ConversionError, Env, Map, Symbol, +}; + +#[contracterror] +#[derive(Clone, Debug, Copy, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + Conversion = 1, + KeyExpected = 2, + NotFound = 3, + AlreadyVoted = 4, + DuplicatedEntity = 5, + Overflow = 6, + VotingClosed = 7, + NotValidID = 8, +} + +impl From for Error { + fn from(_: ConversionError) -> Self { + Error::Conversion + } +} + +#[contracttype] +#[derive(Clone, Copy)] +pub enum DataKey { + Admin = 0, + VoterList = 1, + Proposals = 2, +} + +pub struct ProposalVotingContract; + +#[contractimpl] +impl ProposalVotingContract { + pub fn init(env: Env, admin: Address) { + env.storage().set(&DataKey::Admin, &admin); + env.storage() + .set(&DataKey::Proposals, &Map::::new(&env)); + } + + pub fn create_proposal(env: Env, id: u64) -> Result<(), Error> { + let voting_period_secs = 3600; // one hour + let target_approval_rate_bps = 50_00; // At least 50% of the votes to be approved. + let total_voters = 1000; // Up to 1000 participants. + + Self::create_custom_proposal( + env, + id, + voting_period_secs, + target_approval_rate_bps, + total_voters, + ) + } + + pub fn create_custom_proposal( + env: Env, + id: u64, + voting_period_secs: u64, + target_approval_rate_bps: u32, + total_voters: u32, + ) -> Result<(), Error> { + env.storage() + .get::<_, Address>(&DataKey::Admin) + .ok_or(Error::KeyExpected)?? + .require_auth(); + + if id == 0 { + return Err(Error::NotValidID); + } + + let mut storage = env + .storage() + .get::<_, Map>(&DataKey::Proposals) + .ok_or(Error::KeyExpected)??; + + if storage.contains_key(id) { + return Err(Error::DuplicatedEntity); + } + + storage.set( + id, + Proposal { + id, + voting_end_time: env.ledger().timestamp() + voting_period_secs, + target_approval_rate_bps, + votes: 0, + voters: Map::::new(&env), + total_voters, + }, + ); + env.storage().set(&DataKey::Proposals, &storage); + Ok(()) + } + + pub fn vote(env: Env, voter: Address, id: u64) -> Result<(), Error> { + voter.require_auth(); + + let mut proposal_storage: Map = env + .storage() + .get(&DataKey::Proposals) + .ok_or(Error::KeyExpected)??; + + let mut proposal = proposal_storage.get(id).ok_or(Error::NotFound)??; + + proposal.vote(env.ledger().timestamp(), voter)?; + let updated_approval_rate = proposal.approval_rate_bps(); + proposal_storage.set(id, proposal); + + env.storage().set(&DataKey::Proposals, &proposal_storage); + + env.events() + .publish((Symbol::short("proposal_voted"), id), updated_approval_rate); + Ok(()) + } +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Proposal { + id: u64, + // Unix time in seconds. Voting ends at this time. + voting_end_time: u64, + votes: u32, + target_approval_rate_bps: u32, + total_voters: u32, + voters: Map, +} + +impl Proposal { + pub fn vote(&mut self, current_time: u64, voter: Address) -> Result<(), Error> { + if self.is_closed(current_time) { + return Err(Error::VotingClosed); + } + + if self.voters.get(voter.clone()).is_some() { + return Err(Error::AlreadyVoted); + } + + self.votes = self.votes.checked_add(1).ok_or(Error::Overflow)?; + self.voters.set(voter, true); + Ok(()) + } + + pub fn is_closed(&self, current_time: u64) -> bool { + current_time >= self.voting_end_time || self.voters.len() == self.total_voters + } + + /// It provides a calculation of the approval rate by using fixed point integer arithmetic of + /// 2 positions. It returns the basic points, which would need to be divided by 100 + /// in order to get the original approval percentage. i.e if this function returns 1043 bps, + /// the equivalent percentage would be 10,43% . + pub fn approval_rate_bps(&self) -> Result { + if self.votes == 0 { + return Ok(0); + } + self.votes + .checked_mul(10_000) + .ok_or(Error::Overflow)? + .checked_div(self.total_voters) + .ok_or(Error::Overflow) + } + + pub fn is_approved(&self) -> bool { + self.approval_rate_bps().unwrap() >= self.target_approval_rate_bps + } +} + +#[cfg(test)] +mod test; diff --git a/contracts/voting/src/test.rs b/contracts/voting/src/test.rs new file mode 100644 index 00000000..5f30669e --- /dev/null +++ b/contracts/voting/src/test.rs @@ -0,0 +1,166 @@ +#![cfg(test)] + +use crate::{Error, Proposal, ProposalVotingContract, ProposalVotingContractClient}; +use rstest::rstest; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, IntoVal, Map, Symbol, +}; + +#[test] +fn proposal_creation() { + let (env, client, admin) = setup_test(); + + env.mock_all_auths(); + + let id = 1001u64; + client.create_custom_proposal(&id, &3600, &50_00, &100); + + assert_eq!( + env.auths(), + [( + admin, + client.address.clone(), + Symbol::new(&env, "create_custom_proposal"), + (1001u64, 3600u64, 50_00u32, 100u32).into_val(&env) + )] + ); +} + +fn setup_test<'a>() -> (Env, ProposalVotingContractClient<'a>, Address) { + let env = Env::default(); + let contract_id = env.register_contract(None, ProposalVotingContract); + let client = ProposalVotingContractClient::new(&env, &contract_id); + let admin = Address::random(&env); + client.init(&admin); + + (env, client, admin) +} + +#[test] +#[should_panic(expected = "ContractError(5)")] +fn cannot_create_same_id_proposals() { + let (env, client, _) = setup_test(); + env.mock_all_auths(); + + let id = 1001u64; + client.create_custom_proposal(&id, &3600, &50_00, &2); + client.create_custom_proposal(&id, &3600, &50_00, &2); +} + +#[test] +#[should_panic(expected = "NotAuthorized")] +fn only_admin_can_create_proposals() { + let (_, client, _) = setup_test(); + client.create_custom_proposal(&1, &3600, &50_00, &2); +} + +#[test] +fn voter_can_vote_proposals() { + let (env, client, _) = setup_test(); + env.mock_all_auths(); + + let id = 12; + + client.create_custom_proposal(&id, &3600, &50_00, &2); + client.vote(&client.address, &id); +} + +#[test] +#[should_panic(expected = "ContractError(4)")] +fn voter_cannot_vote_a_proposal_twice() { + let (env, client, _) = setup_test(); + env.mock_all_auths(); + let prd_id = 12; + client.create_custom_proposal(&prd_id, &3600, &50_00, &2); + client.vote(&client.address, &prd_id); + client.vote(&client.address, &prd_id); // Double voting here. Expected panic. +} + +#[test] +fn cannot_vote_if_voting_time_exceeded() { + let (mut env, _, _) = setup_test(); + + let mut proposal = Proposal { + id: 1, + voting_end_time: env.ledger().timestamp() + 3600, + votes: 0, + voters: Map::::new(&env), + target_approval_rate_bps: 50_00, + total_voters: 2, + }; + + advance_ledger_time_in(3600, &mut env); + + let result = proposal.vote(env.ledger().timestamp(), Address::random(&env)); + + assert_eq!(Err(Error::VotingClosed), result) +} + +#[test] +fn cannot_vote_if_total_voters_reached() { + let (env, _, _) = setup_test(); + + let mut voters = Map::::new(&env); + + voters.set(Address::random(&env), true); // Dummy voters + voters.set(Address::random(&env), true); // Dummy voters + + let mut proposal = Proposal { + id: 1, + voting_end_time: env.ledger().timestamp() + 3600, + votes: 2, + voters, + target_approval_rate_bps: 50_00, + total_voters: 2, + }; + + let result = proposal.vote(env.ledger().timestamp(), Address::random(&env)); + assert_eq!(Err(Error::VotingClosed), result) +} + +fn advance_ledger_time_in(time: u64, env: &mut Env) { + let mut ledger_info = env.ledger().get(); + ledger_info.timestamp = ledger_info.timestamp + time; + env.ledger().set(ledger_info) +} + +#[rstest] +#[case::rate_50(2, 1, 50_00, true)] +#[case::precision_is_captured_in_bps(3, 1, 33_33, false)] +#[case::rate_100(2, 2, 100_00, true)] +#[case::no_votes_no_rate(0, 0, 0, false)] +fn proposal_calculate_approval_rate( + #[case] total_voters: u32, + #[case] votes: u32, + #[case] expected: u32, + #[case] is_approved: bool, +) { + let (env, _, _) = setup_test(); + + let mut voters = Map::::new(&env); + + for _ in 0..votes { + voters.set(Address::random(&env), true); // Dummy voters + } + + let proposal = Proposal { + id: 1, + voting_end_time: env.ledger().timestamp() + 3600, + votes, + target_approval_rate_bps: 50_00, + voters, + total_voters, + }; + + assert_eq!(Ok(expected), proposal.approval_rate_bps()); + assert!(is_approved == proposal.is_approved()); +} + +#[test] +#[should_panic(expected = "ContractError(8)")] +fn cannot_create_id0_proposals() { + let (env, client, _) = setup_test(); + env.mock_all_auths(); + client.create_custom_proposal(&0, &3600, &50_00, &2); +}