Skip to content

Commit

Permalink
Add simple proposal voting example
Browse files Browse the repository at this point in the history
  • Loading branch information
eloylp committed Jul 7, 2023
1 parent 4bc3aeb commit 675b8b3
Show file tree
Hide file tree
Showing 3 changed files with 370 additions and 0 deletions.
31 changes: 31 additions & 0 deletions contracts/voting/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
173 changes: 173 additions & 0 deletions contracts/voting/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<ConversionError> 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::<u64, Proposal>::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<u64, Proposal>>(&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::<Address, bool>::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<u64, Proposal> = 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<Address, bool>,
}

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<u32, Error> {
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;
166 changes: 166 additions & 0 deletions contracts/voting/src/test.rs
Original file line number Diff line number Diff line change
@@ -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::<Address, bool>::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::<Address, bool>::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::<Address, bool>::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);
}

0 comments on commit 675b8b3

Please sign in to comment.