-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
370 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |