From 5c1e339494f676219df3f66645ebf49590cba1b6 Mon Sep 17 00:00:00 2001 From: chalda Date: Wed, 12 Apr 2023 17:59:45 +0200 Subject: [PATCH] Governance: MultiChoice type Weighted (#3721) Signed-off-by: dependabot[bot] Co-authored-by: Sebastian.Bor Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- governance/program/src/error.rs | 34 +- governance/program/src/state/proposal.rs | 548 +++++++++++++- governance/program/src/state/vote_record.rs | 93 ++- .../use_proposals_with_multiple_options.rs | 697 +++++++++++++++++- 4 files changed, 1333 insertions(+), 39 deletions(-) diff --git a/governance/program/src/error.rs b/governance/program/src/error.rs index 3a0a5079b3f..c17456dde1e 100644 --- a/governance/program/src/error.rs +++ b/governance/program/src/error.rs @@ -355,9 +355,9 @@ pub enum GovernanceError { #[error("Proposal is not not executable")] ProposalIsNotExecutable, // 584 - /// Invalid vote - #[error("Invalid vote")] - InvalidVote, // 585 + /// Deny vote is not allowed + #[error("Deny vote is not allowed")] + DenyVoteIsNotAllowed, // 585 /// Cannot execute defeated option #[error("Cannot execute defeated option")] @@ -470,6 +470,34 @@ pub enum GovernanceError { ///Invalid state for proposal state transition to Completed #[error("Invalid state for proposal state transition to Completed")] InvalidStateToCompleteProposal, // 613 + + /// Invalid number of vote choices + #[error("Invalid number of vote choices")] + InvalidNumberOfVoteChoices, // 614 + + /// Ranked vote is not supported + #[error("Ranked vote is not supported")] + RankedVoteIsNotSupported, // 615 + + /// Choice weight must be 100% + #[error("Choice weight must be 100%")] + ChoiceWeightMustBe100Percent, // 616 + + /// Single choice only is allowed + #[error("Single choice only is allowed")] + SingleChoiceOnlyIsAllowed, // 617 + + /// At least single choice is required + #[error("At least single choice is required")] + AtLeastSingleChoiceIsRequired, // 618 + + /// Total vote weight must be 100% + #[error("Total vote weight must be 100%")] + TotalVoteWeightMustBe100Percent, // 619 + + /// Invalid multi choice proposal parameters + #[error("Invalid multi choice proposal parameters")] + InvalidMultiChoiceProposalParameters, // 620 } impl PrintProgramError for GovernanceError { diff --git a/governance/program/src/state/proposal.rs b/governance/program/src/state/proposal.rs index 3cb7d4410a8..f4992497eca 100644 --- a/governance/program/src/state/proposal.rs +++ b/governance/program/src/state/proposal.rs @@ -87,21 +87,45 @@ pub enum VoteType { /// Ex. voters are given 5 options, can choose up to 3 (max_voter_options) /// and only 1 (max_winning_options) option can win and be executed MultiChoice { + /// Type of MultiChoice + #[allow(dead_code)] + choice_type: MultiChoiceType, + + /// The min number of options a voter must choose + /// + /// Note: In the current version the limit is not supported and not enforced + /// and must always be set to 1 + #[allow(dead_code)] + min_voter_options: u8, + /// The max number of options a voter can choose - /// By default it equals to the number of available options - /// Note: In the current version the limit is not supported and not enforced yet + /// + /// Note: In the current version the limit is not supported and not enforced + /// and must always be set to the number of available options #[allow(dead_code)] max_voter_options: u8, /// The max number of wining options /// For executable proposals it limits how many options can be executed for a Proposal - /// By default it equals to the number of available options - /// Note: In the current version the limit is not supported and not enforced yet + /// + /// Note: In the current version the limit is not supported and not enforced + /// and must always be set to the number of available options #[allow(dead_code)] max_winning_options: u8, }, } +/// Type of MultiChoice. +#[derive(Clone, Debug, PartialEq, Eq, BorshDeserialize, BorshSerialize, BorshSchema)] +pub enum MultiChoiceType { + /// Multiple options can be approved with full weight allocated to each approved option + FullWeight, + + /// Multiple options can be approved with weight allocated proportionally to the percentage of the total weight + /// The full weight has to be voted among the approved options, i.e., 100% of the weight has to be allocated + Weighted, +} + /// Governance Proposal #[derive(Clone, Debug, PartialEq, Eq, BorshDeserialize, BorshSerialize, BorshSchema)] pub struct ProposalV2 { @@ -213,7 +237,7 @@ pub struct ProposalV2 { impl AccountMaxSize for ProposalV2 { fn get_max_size(&self) -> Option { let options_size: usize = self.options.iter().map(|o| o.label.len() + 19).sum(); - Some(self.name.len() + self.description_link.len() + options_size + 295) + Some(self.name.len() + self.description_link.len() + options_size + 297) } } @@ -444,7 +468,7 @@ impl ProposalV2 { // If none of the individual options succeeded then the proposal as a whole is defeated ProposalState::Defeated } else { - match self.vote_type { + match &self.vote_type { VoteType::SingleChoice => { let proposal_state = if best_succeeded_option_count > 1 { // If there is more than one winning option then the single choice proposal is considered as defeated @@ -466,8 +490,10 @@ impl ProposalV2 { proposal_state } VoteType::MultiChoice { - max_voter_options: _n, - max_winning_options: _m, + choice_type: _, + max_voter_options: _, + max_winning_options: _, + min_voter_options: _, } => { // If any option succeeded for multi choice then the proposal as a whole succeeded as well ProposalState::Succeeded @@ -840,42 +866,78 @@ impl ProposalV2 { match vote { Vote::Approve(choices) => { if self.options.len() != choices.len() { - return Err(GovernanceError::InvalidVote.into()); + return Err(GovernanceError::InvalidNumberOfVoteChoices.into()); } let mut choice_count = 0u16; + let mut total_choice_weight_percentage = 0u8; for choice in choices { if choice.rank > 0 { - return Err(GovernanceError::InvalidVote.into()); + return Err(GovernanceError::RankedVoteIsNotSupported.into()); } - if choice.weight_percentage == 100 { + if choice.weight_percentage > 0 { choice_count = choice_count.checked_add(1).unwrap(); - } else if choice.weight_percentage != 0 { - return Err(GovernanceError::InvalidVote.into()); + + match self.vote_type { + VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: _, + max_voter_options: _, + max_winning_options: _, + } => { + // Calculate the total percentage for all choices for weighted choice vote + // The total must add up to exactly 100% + total_choice_weight_percentage = total_choice_weight_percentage + .checked_add(choice.weight_percentage) + .ok_or(GovernanceError::TotalVoteWeightMustBe100Percent)?; + } + _ => { + if choice.weight_percentage != 100 { + return Err( + GovernanceError::ChoiceWeightMustBe100Percent.into() + ); + } + } + } } } match self.vote_type { VoteType::SingleChoice => { if choice_count != 1 { - return Err(GovernanceError::InvalidVote.into()); + return Err(GovernanceError::SingleChoiceOnlyIsAllowed.into()); + } + } + VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: _, + max_voter_options: _, + max_winning_options: _, + } => { + if choice_count == 0 { + return Err(GovernanceError::AtLeastSingleChoiceIsRequired.into()); } } VoteType::MultiChoice { - max_voter_options: _n, - max_winning_options: _m, + choice_type: MultiChoiceType::Weighted, + min_voter_options: _, + max_voter_options: _, + max_winning_options: _, } => { if choice_count == 0 { - return Err(GovernanceError::InvalidVote.into()); + return Err(GovernanceError::AtLeastSingleChoiceIsRequired.into()); + } + if total_choice_weight_percentage != 100 { + return Err(GovernanceError::TotalVoteWeightMustBe100Percent.into()); } } } } Vote::Deny => { if self.deny_vote_weight.is_none() { - return Err(GovernanceError::InvalidVote.into()); + return Err(GovernanceError::DenyVoteIsNotAllowed.into()); } } Vote::Abstain => { @@ -1110,15 +1172,18 @@ pub fn assert_valid_proposal_options( } if let VoteType::MultiChoice { + choice_type: _, + min_voter_options, max_voter_options, max_winning_options, - } = *vote_type + } = vote_type { if options.len() == 1 - || max_voter_options as usize != options.len() - || max_winning_options as usize != options.len() + || *max_voter_options as usize != options.len() + || *max_winning_options as usize != options.len() + || *min_voter_options != 1 { - return Err(GovernanceError::InvalidProposalOptions.into()); + return Err(GovernanceError::InvalidMultiChoiceProposalParameters.into()); } } @@ -1269,6 +1334,8 @@ mod test { fn test_max_size() { let mut proposal = create_test_proposal(); proposal.vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_voter_options: 1, max_winning_options: 1, }; @@ -1282,6 +1349,8 @@ mod test { fn test_multi_option_proposal_max_size() { let mut proposal = create_test_multi_option_proposal(); proposal.vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_voter_options: 3, max_winning_options: 3, }; @@ -2442,7 +2511,7 @@ mod test { let result = proposal.assert_valid_vote(&vote); // Assert - assert_eq!(result, Err(GovernanceError::InvalidVote.into())); + assert_eq!(result, Err(GovernanceError::DenyVoteIsNotAllowed.into())); } #[test] @@ -2470,7 +2539,10 @@ mod test { let result = proposal.assert_valid_vote(&vote); // Assert - assert_eq!(result, Err(GovernanceError::InvalidVote.into())); + assert_eq!( + result, + Err(GovernanceError::InvalidNumberOfVoteChoices.into()) + ); } #[test] @@ -2492,7 +2564,10 @@ mod test { let result = proposal.assert_valid_vote(&vote); // Assert - assert_eq!(result, Err(GovernanceError::InvalidVote.into())); + assert_eq!( + result, + Err(GovernanceError::SingleChoiceOnlyIsAllowed.into()) + ); } #[test] @@ -2523,7 +2598,47 @@ mod test { let result = proposal.assert_valid_vote(&vote); // Assert - assert_eq!(result, Err(GovernanceError::InvalidVote.into())); + assert_eq!( + result, + Err(GovernanceError::SingleChoiceOnlyIsAllowed.into()) + ); + } + + #[test] + pub fn test_assert_valid_multi_choice_full_weight_vote() { + // Arrange + let mut proposal = create_test_multi_option_proposal(); + proposal.vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, + max_voter_options: 4, + max_winning_options: 4, + }; + let choices = vec![ + VoteChoice { + rank: 0, + weight_percentage: 100, + }, + VoteChoice { + rank: 0, + weight_percentage: 100, + }, + VoteChoice { + rank: 0, + weight_percentage: 100, + }, + ]; + + let vote = Vote::Approve(choices.clone()); + + // Ensure + assert_eq!(proposal.options.len(), choices.len()); + + // Act + let result = proposal.assert_valid_vote(&vote); + + // Assert + assert_eq!(result, Ok(())); } #[test] @@ -2531,6 +2646,8 @@ mod test { // Arrange let mut proposal = create_test_multi_option_proposal(); proposal.vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_voter_options: 3, max_winning_options: 3, }; @@ -2559,7 +2676,51 @@ mod test { let result = proposal.assert_valid_vote(&vote); // Assert - assert_eq!(result, Err(GovernanceError::InvalidVote.into())); + assert_eq!( + result, + Err(GovernanceError::AtLeastSingleChoiceIsRequired.into()) + ); + } + + #[test] + pub fn test_assert_valid_vote_with_choice_weight_not_100_percent_error() { + // Arrange + let mut proposal = create_test_multi_option_proposal(); + proposal.vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, + max_voter_options: 3, + max_winning_options: 3, + }; + + let choices = vec![ + VoteChoice { + rank: 0, + weight_percentage: 50, + }, + VoteChoice { + rank: 0, + weight_percentage: 50, + }, + VoteChoice { + rank: 0, + weight_percentage: 0, + }, + ]; + + let vote = Vote::Approve(choices.clone()); + + // Ensure + assert_eq!(proposal.options.len(), choices.len()); + + // Act + let result = proposal.assert_valid_vote(&vote); + + // Assert + assert_eq!( + result, + Err(GovernanceError::ChoiceWeightMustBe100Percent.into()) + ); } #[test] @@ -2567,6 +2728,8 @@ mod test { ) { // Arrange let vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_voter_options: 3, max_winning_options: 3, }; @@ -2577,13 +2740,18 @@ mod test { let result = assert_valid_proposal_options(&options, &vote_type); // Assert - assert_eq!(result, Err(GovernanceError::InvalidProposalOptions.into())); + assert_eq!( + result, + Err(GovernanceError::InvalidMultiChoiceProposalParameters.into()) + ); } #[test] pub fn test_assert_valid_proposal_options_with_no_options_for_multi_choice_vote_error() { // Arrange let vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_voter_options: 3, max_winning_options: 3, }; @@ -2615,6 +2783,8 @@ mod test { pub fn test_assert_valid_proposal_options_for_multi_choice_vote() { // Arrange let vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_voter_options: 3, max_winning_options: 3, }; @@ -2636,6 +2806,297 @@ mod test { pub fn test_assert_valid_proposal_options_for_multi_choice_vote_with_empty_option_error() { // Arrange let vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, + max_voter_options: 3, + max_winning_options: 3, + }; + + let options = vec![ + "".to_string(), + "option 2".to_string(), + "option 3".to_string(), + ]; + + // Act + let result = assert_valid_proposal_options(&options, &vote_type); + + // Assert + assert_eq!(result, Err(GovernanceError::InvalidProposalOptions.into())); + } + + #[test] + pub fn test_assert_valid_vote_for_multi_weighted_choice() { + // Multi weighted choice may be weighted but sum of choices has to be 100% + // Arrange + let mut proposal = create_test_multi_option_proposal(); + proposal.vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_voter_options: 3, + max_winning_options: 3, + }; + + let choices = vec![ + VoteChoice { + rank: 0, + weight_percentage: 42, + }, + VoteChoice { + rank: 0, + weight_percentage: 42, + }, + VoteChoice { + rank: 0, + weight_percentage: 16, + }, + ]; + let vote = Vote::Approve(choices.clone()); + + // Ensure + assert_eq!(proposal.options.len(), choices.len()); + + // Act + let result = proposal.assert_valid_vote(&vote); + + // Assert + assert_eq!(result, Ok(())); + } + + #[test] + pub fn test_assert_valid_full_vote_for_multi_weighted_choice() { + // Multi weighted choice may be weighted to 100% and 0% rest + // Arrange + let mut proposal = create_test_multi_option_proposal(); + proposal.vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_voter_options: 3, + max_winning_options: 3, + }; + + let choices = vec![ + VoteChoice { + rank: 0, + weight_percentage: 0, + }, + VoteChoice { + rank: 0, + weight_percentage: 100, + }, + VoteChoice { + rank: 0, + weight_percentage: 0, + }, + ]; + let vote = Vote::Approve(choices.clone()); + + // Ensure + assert_eq!(proposal.options.len(), choices.len()); + + // Act + let result = proposal.assert_valid_vote(&vote); + + // Assert + assert_eq!(result, Ok(())); + } + + #[test] + pub fn test_assert_valid_vote_with_total_vote_weight_above_100_percent_for_multi_weighted_choice_error( + ) { + // Arrange + let mut proposal = create_test_multi_option_proposal(); + proposal.vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_voter_options: 2, + max_winning_options: 2, + }; + + let choices = vec![ + VoteChoice { + rank: 0, + weight_percentage: 34, + }, + VoteChoice { + rank: 0, + weight_percentage: 34, + }, + VoteChoice { + rank: 0, + weight_percentage: 34, + }, + ]; + let vote = Vote::Approve(choices.clone()); + + // Ensure + assert_eq!(proposal.options.len(), choices.len()); + + // Act + let result = proposal.assert_valid_vote(&vote); + + // Assert + assert_eq!( + result, + Err(GovernanceError::TotalVoteWeightMustBe100Percent.into()) + ); + } + + #[test] + pub fn test_assert_valid_vote_with_over_percentage_for_multi_weighted_choice_error() { + // Multi weighted choice does not permit vote with sum weight over 100% + // Arrange + let mut proposal = create_test_multi_option_proposal(); + proposal.vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_voter_options: 3, + max_winning_options: 3, + }; + + let choices = vec![ + VoteChoice { + rank: 0, + weight_percentage: 34, + }, + VoteChoice { + rank: 0, + weight_percentage: 34, + }, + VoteChoice { + rank: 0, + weight_percentage: 34, + }, + ]; + let vote = Vote::Approve(choices.clone()); + + // Ensure + assert_eq!(proposal.options.len(), choices.len()); + + // Act + let result = proposal.assert_valid_vote(&vote); + + // Assert + assert_eq!( + result, + Err(GovernanceError::TotalVoteWeightMustBe100Percent.into()) + ); + } + + #[test] + pub fn test_assert_valid_vote_with_overflow_weight_for_multi_weighted_choice_error() { + // Multi weighted choice does not permit vote with sum weight over 100% + // Arrange + let mut proposal = create_test_multi_option_proposal(); + proposal.vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_voter_options: 3, + max_winning_options: 3, + }; + + let choices = vec![ + VoteChoice { + rank: 0, + weight_percentage: 100, + }, + VoteChoice { + rank: 0, + weight_percentage: 100, + }, + VoteChoice { + rank: 0, + weight_percentage: 100, + }, + ]; + let vote = Vote::Approve(choices.clone()); + + // Ensure + assert_eq!(proposal.options.len(), choices.len()); + + // Act + let result = proposal.assert_valid_vote(&vote); + + // Assert + assert_eq!( + result, + Err(GovernanceError::TotalVoteWeightMustBe100Percent.into()) + ); + } + + #[test] + pub fn test_assert_valid_proposal_options_with_invalid_choice_number_for_multi_weighted_choice_vote_error( + ) { + // Arrange + let vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_voter_options: 3, + max_winning_options: 3, + }; + + let options = vec!["option 1".to_string(), "option 2".to_string()]; + + // Act + let result = assert_valid_proposal_options(&options, &vote_type); + + // Assert + assert_eq!( + result, + Err(GovernanceError::InvalidMultiChoiceProposalParameters.into()) + ); + } + + #[test] + pub fn test_assert_valid_proposal_options_with_no_options_for_multi_weighted_choice_vote_error() + { + // Arrange + let vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_voter_options: 3, + max_winning_options: 3, + }; + + let options = vec![]; + + // Act + let result = assert_valid_proposal_options(&options, &vote_type); + + // Assert + assert_eq!(result, Err(GovernanceError::InvalidProposalOptions.into())); + } + + #[test] + pub fn test_assert_valid_proposal_options_for_multi_weighted_choice_vote() { + // Arrange + let vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_voter_options: 3, + max_winning_options: 3, + }; + + let options = vec![ + "option 1".to_string(), + "option 2".to_string(), + "option 3".to_string(), + ]; + + // Act + let result = assert_valid_proposal_options(&options, &vote_type); + + // Assert + assert_eq!(result, Ok(())); + } + + #[test] + pub fn test_assert_valid_proposal_options_for_multi_weighted_choice_vote_with_empty_option_error( + ) { + // Arrange + let vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, max_voter_options: 3, max_winning_options: 3, }; @@ -2653,6 +3114,37 @@ mod test { assert_eq!(result, Err(GovernanceError::InvalidProposalOptions.into())); } + #[test] + pub fn test_assert_more_than_ten_proposal_options_for_multi_weighted_choice_error() { + // Arrange + let vote_type = VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_voter_options: 3, + max_winning_options: 3, + }; + + let options = vec![ + "option 1".to_string(), + "option 2".to_string(), + "option 3".to_string(), + "option 4".to_string(), + "option 5".to_string(), + "option 6".to_string(), + "option 7".to_string(), + "option 8".to_string(), + "option 9".to_string(), + "option 10".to_string(), + "option 11".to_string(), + ]; + + // Act + let result = assert_valid_proposal_options(&options, &vote_type); + + // Assert + assert_eq!(result, Err(GovernanceError::InvalidProposalOptions.into())); + } + #[test] fn test_proposal_v1_to_v2_serialisation_roundtrip() { // Arrange diff --git a/governance/program/src/state/vote_record.rs b/governance/program/src/state/vote_record.rs index 245b3706041..b46cf6bb48f 100644 --- a/governance/program/src/state/vote_record.rs +++ b/governance/program/src/state/vote_record.rs @@ -22,8 +22,8 @@ use crate::state::{ }; /// Voter choice for a proposal option -/// In the current version only 1) Single choice and 2) Multiple choices proposals are supported -/// In the future versions we can add support for 1) Quadratic voting, 2) Ranked choice voting and 3) Weighted voting +/// In the current version only 1) Single choice, 2) Multiple choices proposals and 3) Weighted voting are supported +/// In the future versions we can add support for 1) Quadratic voting and 2) Ranked choice voting #[derive(Clone, Debug, PartialEq, Eq, BorshDeserialize, BorshSerialize, BorshSchema)] pub struct VoteChoice { /// The rank given to the choice by voter @@ -38,8 +38,14 @@ impl VoteChoice { /// Returns the choice weight given the voter's weight pub fn get_choice_weight(&self, voter_weight: u64) -> Result { Ok(match self.weight_percentage { + // Avoid any rounding errors for full weight 100 => voter_weight, - 0 => 0, + // Note: The total weight for all choices might not equal voter_weight due to rounding errors + 0..=99 => (voter_weight as u128) + .checked_mul(self.weight_percentage as u128) + .unwrap() + .checked_div(100) + .unwrap() as u64, _ => return Err(GovernanceError::InvalidVoteChoiceWeightPercentage.into()), }) } @@ -309,4 +315,85 @@ mod test { assert_eq!(vote_record_v1_source, vote_record_v1_target) } + + #[test] + fn test_get_choice_weight_with_invalid_weight_percentage_error() { + // Arrange + let vote_choice = VoteChoice { + rank: 0, + weight_percentage: 127, + }; + + // Act + let result = vote_choice.get_choice_weight(100); + + // Assert + assert_eq!( + Err(GovernanceError::InvalidVoteChoiceWeightPercentage.into()), + result + ); + } + + #[test] + fn test_get_choice_weight() { + // Arrange + let vote_choice = VoteChoice { + rank: 0, + weight_percentage: 100, + }; + + // Act + let result = vote_choice.get_choice_weight(100); + + // Assert + assert_eq!(Ok(100_u64), result); + + // Arrange + let vote_choice = VoteChoice { + rank: 0, + weight_percentage: 0, + }; + + // Act + let result = vote_choice.get_choice_weight(100); + + // Assert + assert_eq!(Ok(0_u64), result); + + // Arrange + let vote_choice = VoteChoice { + rank: 0, + weight_percentage: 33, + }; + + // Act + let result = vote_choice.get_choice_weight(100); + + // Assert + assert_eq!(Ok(33_u64), result); + + // Arrange + let vote_choice = VoteChoice { + rank: 0, + weight_percentage: 34, + }; + + // Act + let result = vote_choice.get_choice_weight(100); + + // Assert + assert_eq!(Ok(34_u64), result); + + // Arrange + let vote_choice = VoteChoice { + rank: 0, + weight_percentage: 50, + }; + + // Act + let result = vote_choice.get_choice_weight(u64::MAX); + + // Assert + assert_eq!(Ok(u64::MAX / 2), result); + } } diff --git a/governance/program/tests/use_proposals_with_multiple_options.rs b/governance/program/tests/use_proposals_with_multiple_options.rs index 30b5ea4b21b..8e74f599762 100644 --- a/governance/program/tests/use_proposals_with_multiple_options.rs +++ b/governance/program/tests/use_proposals_with_multiple_options.rs @@ -5,6 +5,7 @@ use solana_program_test::*; mod program_test; use program_test::*; +use spl_governance::state::proposal::MultiChoiceType; use spl_governance::{ error::GovernanceError, state::{ @@ -92,6 +93,8 @@ async fn test_create_proposal_with_multiple_choice_options_and_without_deny_opti options, false, VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_winning_options: 2, max_voter_options: 2, }, @@ -106,6 +109,8 @@ async fn test_create_proposal_with_multiple_choice_options_and_without_deny_opti assert_eq!( proposal_account.vote_type, VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_winning_options: 2, max_voter_options: 2, } @@ -295,7 +300,7 @@ async fn test_vote_on_none_executable_single_choice_proposal_with_multiple_optio .await .unwrap(); - // Advance timestamp past max_voting_time + // Advance timestamp past voting_base_time governance_test .advance_clock_past_timestamp( governance_cookie.account.config.voting_base_time as i64 + clock.unix_timestamp, @@ -360,6 +365,8 @@ async fn test_vote_on_none_executable_multi_choice_proposal_with_multiple_option ], false, VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_winning_options: 3, max_voter_options: 3, }, @@ -400,7 +407,7 @@ async fn test_vote_on_none_executable_multi_choice_proposal_with_multiple_option .await .unwrap(); - // Advance timestamp past max_voting_time + // Advance timestamp past voting_base_time governance_test .advance_clock_past_timestamp( governance_cookie.account.config.voting_base_time as i64 + clock.unix_timestamp, @@ -488,6 +495,8 @@ async fn test_vote_on_executable_proposal_with_multiple_options_and_partial_succ ], true, VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_winning_options: 3, max_voter_options: 3, }, @@ -560,7 +569,7 @@ async fn test_vote_on_executable_proposal_with_multiple_options_and_partial_succ .await .unwrap(); - // Advance timestamp past max_voting_time + // Advance timestamp past voting_base_time governance_test .advance_clock_past_timestamp( governance_cookie.account.config.voting_base_time as i64 + clock.unix_timestamp, @@ -649,6 +658,8 @@ async fn test_execute_proposal_with_multiple_options_and_partial_success() { ], true, VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_winning_options: 3, max_voter_options: 3, }, @@ -753,7 +764,7 @@ async fn test_execute_proposal_with_multiple_options_and_partial_success() { .await .unwrap(); - // Advance timestamp past max_voting_time + // Advance timestamp past voting_base_time governance_test .advance_clock_by_min_timespan(governance_cookie.account.config.voting_base_time as u64) .await; @@ -856,6 +867,8 @@ async fn test_try_execute_proposal_with_multiple_options_and_full_deny() { ], true, VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_winning_options: 3, max_voter_options: 3, }, @@ -919,7 +932,7 @@ async fn test_try_execute_proposal_with_multiple_options_and_full_deny() { .await .unwrap(); - // Advance timestamp past max_voting_time + // Advance timestamp past voting_base_time governance_test .advance_clock_by_min_timespan(governance_cookie.account.config.voting_base_time as u64) .await; @@ -1021,6 +1034,8 @@ async fn test_create_proposal_with_10_options_and_cast_vote() { options, false, VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_winning_options: options_len, max_voter_options: options_len, }, @@ -1071,6 +1086,8 @@ async fn test_create_proposal_with_10_options_and_cast_vote() { assert_eq!( proposal_account.vote_type, VoteType::MultiChoice { + choice_type: MultiChoiceType::FullWeight, + min_voter_options: 1, max_winning_options: options_len, max_voter_options: options_len, } @@ -1079,3 +1096,673 @@ async fn test_create_proposal_with_10_options_and_cast_vote() { assert_eq!(ProposalState::Completed, proposal_account.state); } + +#[tokio::test] +async fn test_vote_multi_weighted_choice_proposal_non_executable() { + // Arrange + let mut governance_test = GovernanceProgramTest::start_new().await; + + let realm_cookie = governance_test.with_realm().await; + let governed_account_cookie = governance_test.with_governed_account().await; + + let token_owner_record_cookie = governance_test + .with_community_token_deposit(&realm_cookie) + .await + .unwrap(); + + let mut governance_config = governance_test.get_default_governance_config(); + governance_config.community_vote_threshold = VoteThreshold::YesVotePercentage(30); + + let mut governance_cookie = governance_test + .with_governance_using_config( + &realm_cookie, + &governed_account_cookie, + &token_owner_record_cookie, + &governance_config, + ) + .await + .unwrap(); + + let proposal_cookie = governance_test + .with_multi_option_proposal( + &token_owner_record_cookie, + &mut governance_cookie, + vec![ + "option 1".to_string(), + "option 2".to_string(), + "option 3".to_string(), + "option 4".to_string(), + ], + false, + VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_winning_options: 4, + max_voter_options: 4, + }, + ) + .await + .unwrap(); + + let clock = governance_test.bench.get_clock().await; + + governance_test + .sign_off_proposal_by_owner(&proposal_cookie, &token_owner_record_cookie) + .await + .unwrap(); + + let vote = Vote::Approve(vec![ + VoteChoice { + rank: 0, + weight_percentage: 30, + }, + VoteChoice { + rank: 0, + weight_percentage: 29, + }, + VoteChoice { + rank: 0, + weight_percentage: 41, + }, + VoteChoice { + rank: 0, + weight_percentage: 0, + }, + ]); + + // Act + governance_test + .with_cast_vote(&proposal_cookie, &token_owner_record_cookie, vote) + .await + .unwrap(); + + governance_test + .advance_clock_past_timestamp( + governance_cookie.account.config.voting_base_time as i64 + clock.unix_timestamp, + ) + .await; + + governance_test + .finalize_vote(&realm_cookie, &proposal_cookie, None) + .await + .unwrap(); + + // Assert + let proposal_account = governance_test + .get_proposal_account(&proposal_cookie.address) + .await; + + assert_eq!( + OptionVoteResult::Succeeded, + proposal_account.options[0].vote_result + ); + assert_eq!( + OptionVoteResult::Defeated, + proposal_account.options[1].vote_result + ); + assert_eq!( + OptionVoteResult::Succeeded, + proposal_account.options[2].vote_result + ); + assert_eq!( + OptionVoteResult::Defeated, + proposal_account.options[3].vote_result + ); + assert_eq!( + (token_owner_record_cookie.token_source_amount as f32 * 0.3) as u64, + proposal_account.options[0].vote_weight + ); + assert_eq!( + (token_owner_record_cookie.token_source_amount as f32 * 0.29) as u64, + proposal_account.options[1].vote_weight + ); + assert_eq!( + (token_owner_record_cookie.token_source_amount as f32 * 0.41) as u64, + proposal_account.options[2].vote_weight + ); + assert_eq!(0_u64, proposal_account.options[3].vote_weight); + + // None executable proposal transitions to Completed when vote is finalized + assert_eq!(ProposalState::Completed, proposal_account.state); +} + +#[tokio::test] +async fn test_vote_multi_weighted_choice_proposal_with_partial_success() { + // Arrange + let mut governance_test = GovernanceProgramTest::start_new().await; + + let realm_cookie = governance_test.with_realm().await; + let governed_mint_cookie = governance_test.with_governed_mint().await; + + // 100 tokens each, sum 300 tokens + let token_owner_record_cookie1 = governance_test + .with_community_token_deposit(&realm_cookie) + .await + .unwrap(); + let token_owner_record_cookie2 = governance_test + .with_community_token_deposit(&realm_cookie) + .await + .unwrap(); + let token_owner_record_cookie3 = governance_test + .with_community_token_deposit(&realm_cookie) + .await + .unwrap(); + + // 60 tokes approval quorum as 20% of 300 is 60 + let mut governance_config = governance_test.get_default_governance_config(); + governance_config.community_vote_threshold = VoteThreshold::YesVotePercentage(20); + + let mut governance_cookie = governance_test + .with_mint_governance_using_config( + &realm_cookie, + &governed_mint_cookie, + &token_owner_record_cookie1, + &governance_config, + ) + .await + .unwrap(); + + let mut proposal_cookie = governance_test + .with_multi_option_proposal( + &token_owner_record_cookie1, + &mut governance_cookie, + vec![ + "option 1".to_string(), + "option 2".to_string(), + "option 3".to_string(), + "option 4".to_string(), + ], + true, + VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_winning_options: 4, + max_voter_options: 4, + }, + ) + .await + .unwrap(); + + let proposal_transaction_cookie1 = governance_test + .with_mint_tokens_transaction( + &governed_mint_cookie, + &mut proposal_cookie, + &token_owner_record_cookie1, + 0, + Some(0), + None, + ) + .await + .unwrap(); + let proposal_transaction_cookie2 = governance_test + .with_mint_tokens_transaction( + &governed_mint_cookie, + &mut proposal_cookie, + &token_owner_record_cookie1, + 1, + Some(0), + None, + ) + .await + .unwrap(); + let proposal_transaction_cookie3 = governance_test + .with_mint_tokens_transaction( + &governed_mint_cookie, + &mut proposal_cookie, + &token_owner_record_cookie1, + 2, + Some(0), + None, + ) + .await + .unwrap(); + let proposal_transaction_cookie4 = governance_test + .with_mint_tokens_transaction( + &governed_mint_cookie, + &mut proposal_cookie, + &token_owner_record_cookie1, + 3, + Some(0), + None, + ) + .await + .unwrap(); + + governance_test + .sign_off_proposal_by_owner(&proposal_cookie, &token_owner_record_cookie1) + .await + .unwrap(); + + // vote1: + // deny: 100 + // vote2 + vote3: + // choice 1: 0 -> Defeated + // choice 2: 91 -> Defeated (91 is over 60, 20% from 300, but deny overrules) + // choice 3: 101 -> Success + // choice 4: 8 -> Defeated (below of 60) + + let vote1 = Vote::Approve(vec![ + VoteChoice { + rank: 0, + weight_percentage: 0, + }, + VoteChoice { + rank: 0, + weight_percentage: 30, + }, + VoteChoice { + rank: 0, + weight_percentage: 70, + }, + VoteChoice { + rank: 0, + weight_percentage: 0, + }, + ]); + governance_test + .with_cast_vote(&proposal_cookie, &token_owner_record_cookie1, vote1) + .await + .expect("Voting the vote 1 of owner 1 should succeed"); + + let vote2 = Vote::Approve(vec![ + VoteChoice { + rank: 0, + weight_percentage: 0, + }, + VoteChoice { + rank: 0, + weight_percentage: 61, + }, + VoteChoice { + rank: 0, + weight_percentage: 31, + }, + VoteChoice { + rank: 0, + weight_percentage: 8, + }, + ]); + governance_test + .with_cast_vote(&proposal_cookie, &token_owner_record_cookie2, vote2) + .await + .expect("Voting the vote 1 of owner 1 should succeed"); + + governance_test + .with_cast_vote(&proposal_cookie, &token_owner_record_cookie3, Vote::Deny) + .await + .expect("Casting deny vote of owner 3 should succeed"); + + let clock = governance_test.bench.get_clock().await; + governance_test + .advance_clock_past_timestamp( + governance_cookie.account.config.voting_base_time as i64 + clock.unix_timestamp, + ) + .await; + governance_test + .finalize_vote(&realm_cookie, &proposal_cookie, None) + .await + .unwrap(); + // Advance timestamp past hold_up_time + governance_test + .advance_clock_by_min_timespan(proposal_transaction_cookie1.account.hold_up_time as u64) + .await; + + let mut proposal_account = governance_test + .get_proposal_account(&proposal_cookie.address) + .await; + + assert_eq!(ProposalState::Succeeded, proposal_account.state); + + // Act + let transaction1_err = governance_test + .execute_proposal_transaction(&proposal_cookie, &proposal_transaction_cookie1) + .await + .expect_err("Choice 1 should fail to execute, it hasn't got enough votes"); + let transaction2_err = governance_test + .execute_proposal_transaction(&proposal_cookie, &proposal_transaction_cookie2) + .await + .expect_err("Choice 2 should fail to execute, it hasn't got enough votes"); + governance_test + .execute_proposal_transaction(&proposal_cookie, &proposal_transaction_cookie3) + .await + .expect("Choice 3 should be executed as it won the poll"); + let transaction4_err = governance_test + .execute_proposal_transaction(&proposal_cookie, &proposal_transaction_cookie4) + .await + .expect_err("Choice 4 should be executed as the winner has been executed already"); + + // Assert + proposal_account = governance_test + .get_proposal_account(&proposal_cookie.address) + .await; + + assert_eq!(ProposalState::Completed, proposal_account.state); + + assert_eq!( + transaction1_err, + GovernanceError::CannotExecuteDefeatedOption.into() + ); + assert_eq!( + transaction2_err, + GovernanceError::CannotExecuteDefeatedOption.into() + ); + assert_eq!( + transaction4_err, + GovernanceError::InvalidStateCannotExecuteTransaction.into() + ); +} + +#[tokio::test] +async fn test_vote_multi_weighted_choice_proposal_with_multi_success() { + // Arrange + let mut governance_test = GovernanceProgramTest::start_new().await; + + let realm_cookie = governance_test.with_realm().await; + let governed_mint_cookie = governance_test.with_governed_mint().await; + + // 100 tokens each, sum 300 tokens + let token_owner_record_cookie1 = governance_test + .with_community_token_deposit(&realm_cookie) + .await + .unwrap(); + let token_owner_record_cookie2 = governance_test + .with_community_token_deposit(&realm_cookie) + .await + .unwrap(); + + // 60 tokes approval quorum as 30% of 200 is 60 + let mut governance_config = governance_test.get_default_governance_config(); + governance_config.community_vote_threshold = VoteThreshold::YesVotePercentage(30); + + let mut governance_cookie = governance_test + .with_mint_governance_using_config( + &realm_cookie, + &governed_mint_cookie, + &token_owner_record_cookie1, + &governance_config, + ) + .await + .unwrap(); + + let mut proposal_cookie = governance_test + .with_multi_option_proposal( + &token_owner_record_cookie1, + &mut governance_cookie, + vec![ + "option 1".to_string(), + "option 2".to_string(), + "option 3".to_string(), + ], + true, + VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_winning_options: 3, + max_voter_options: 3, + }, + ) + .await + .unwrap(); + + let proposal_transaction_cookie1 = governance_test + .with_mint_tokens_transaction( + &governed_mint_cookie, + &mut proposal_cookie, + &token_owner_record_cookie1, + 0, + Some(0), + None, + ) + .await + .unwrap(); + let proposal_transaction_cookie2 = governance_test + .with_mint_tokens_transaction( + &governed_mint_cookie, + &mut proposal_cookie, + &token_owner_record_cookie1, + 1, + Some(0), + None, + ) + .await + .unwrap(); + let proposal_transaction_cookie3 = governance_test + .with_mint_tokens_transaction( + &governed_mint_cookie, + &mut proposal_cookie, + &token_owner_record_cookie1, + 2, + Some(0), + None, + ) + .await + .unwrap(); + + governance_test + .sign_off_proposal_by_owner(&proposal_cookie, &token_owner_record_cookie1) + .await + .unwrap(); + + // vote1 + vote2: + // choice 1: 28 -> Defeated (below 60) + // choice 2: 105 -> Success + // choice 3: 61 -> Success + + let vote1 = Vote::Approve(vec![ + VoteChoice { + rank: 0, + weight_percentage: 14, + }, + VoteChoice { + rank: 0, + weight_percentage: 55, + }, + VoteChoice { + rank: 0, + weight_percentage: 31, + }, + ]); + governance_test + .with_cast_vote(&proposal_cookie, &token_owner_record_cookie1, vote1) + .await + .expect("Voting the vote 1 of owner 1 should succeed"); + + let vote2 = Vote::Approve(vec![ + VoteChoice { + rank: 0, + weight_percentage: 20, + }, + VoteChoice { + rank: 0, + weight_percentage: 50, + }, + VoteChoice { + rank: 0, + weight_percentage: 30, + }, + ]); + governance_test + .with_cast_vote(&proposal_cookie, &token_owner_record_cookie2, vote2) + .await + .expect("Voting the vote 1 of owner 1 should succeed"); + + // Advance timestamp past voting_base_time + let clock = governance_test.bench.get_clock().await; + governance_test + .advance_clock_past_timestamp( + governance_cookie.account.config.voting_base_time as i64 + clock.unix_timestamp, + ) + .await; + governance_test + .finalize_vote(&realm_cookie, &proposal_cookie, None) + .await + .unwrap(); + // Advance timestamp past hold_up_time + governance_test + .advance_clock_by_min_timespan(proposal_transaction_cookie1.account.hold_up_time as u64) + .await; + + let mut proposal_account = governance_test + .get_proposal_account(&proposal_cookie.address) + .await; + + assert_eq!(ProposalState::Succeeded, proposal_account.state); + + // Act + let transaction1_err = governance_test + .execute_proposal_transaction(&proposal_cookie, &proposal_transaction_cookie1) + .await + .expect_err("Choice 1 should fail to execute, it hasn't got enough votes"); + governance_test + .execute_proposal_transaction(&proposal_cookie, &proposal_transaction_cookie2) + .await + .expect("Choice 2 should be executed as it passed the poll"); + governance_test + .execute_proposal_transaction(&proposal_cookie, &proposal_transaction_cookie3) + .await + .expect("Choice 3 should be executed as it passed the poll"); + + // Assert + proposal_account = governance_test + .get_proposal_account(&proposal_cookie.address) + .await; + + assert_eq!(ProposalState::Completed, proposal_account.state); + + assert_eq!( + transaction1_err, + GovernanceError::CannotExecuteDefeatedOption.into() + ); +} + +#[tokio::test] +async fn test_vote_multi_weighted_choice_proposal_executable_with_full_deny() { + // Arrange + let mut governance_test = GovernanceProgramTest::start_new().await; + + let realm_cookie = governance_test.with_realm().await; + let governed_mint_cookie = governance_test.with_governed_mint().await; + + // 100 tokens + let token_owner_record_cookie1 = governance_test + .with_community_token_deposit(&realm_cookie) + .await + .unwrap(); + + // 100 tokens + let token_owner_record_cookie2 = governance_test + .with_community_token_deposit(&realm_cookie) + .await + .unwrap(); + + let mut governance_config = governance_test.get_default_governance_config(); + governance_config.community_vote_threshold = VoteThreshold::YesVotePercentage(3); + + let mut governance_cookie = governance_test + .with_mint_governance_using_config( + &realm_cookie, + &governed_mint_cookie, + &token_owner_record_cookie1, + &governance_config, + ) + .await + .unwrap(); + + let mut proposal_cookie = governance_test + .with_multi_option_proposal( + &token_owner_record_cookie1, + &mut governance_cookie, + vec!["option 1".to_string(), "option 2".to_string()], + true, + VoteType::MultiChoice { + choice_type: MultiChoiceType::Weighted, + min_voter_options: 1, + max_winning_options: 2, + max_voter_options: 2, + }, + ) + .await + .unwrap(); + + let proposal_transaction_cookie1 = governance_test + .with_mint_tokens_transaction( + &governed_mint_cookie, + &mut proposal_cookie, + &token_owner_record_cookie1, + 0, + Some(0), + None, + ) + .await + .unwrap(); + + let proposal_transaction_cookie2 = governance_test + .with_mint_tokens_transaction( + &governed_mint_cookie, + &mut proposal_cookie, + &token_owner_record_cookie1, + 1, + Some(0), + None, + ) + .await + .unwrap(); + + governance_test + .sign_off_proposal_by_owner(&proposal_cookie, &token_owner_record_cookie1) + .await + .unwrap(); + + governance_test + .with_cast_vote(&proposal_cookie, &token_owner_record_cookie1, Vote::Deny) + .await + .expect("Casting deny vote for owner 1 should succeed"); + governance_test + .with_cast_vote(&proposal_cookie, &token_owner_record_cookie2, Vote::Deny) + .await + .expect("Casting deny vote for owner 1 should succeed"); + + // Advance timestamp past voting_base_time + let clock = governance_test.bench.get_clock().await; + governance_test + .advance_clock_past_timestamp( + governance_cookie.account.config.voting_base_time as i64 + clock.unix_timestamp, + ) + .await; + + governance_test + .finalize_vote(&realm_cookie, &proposal_cookie, None) + .await + .unwrap(); + + // Advance timestamp past hold_up_time + governance_test + .advance_clock_by_min_timespan(proposal_transaction_cookie1.account.hold_up_time as u64) + .await; + + let proposal_account = governance_test + .get_proposal_account(&proposal_cookie.address) + .await; + + assert_eq!(ProposalState::Defeated, proposal_account.state); + + // Act + let transaction1_err = governance_test + .execute_proposal_transaction(&proposal_cookie, &proposal_transaction_cookie1) + .await + .expect_err("The proposal was denied, error on choice 1 execution expected"); + let transaction2_err = governance_test + .execute_proposal_transaction(&proposal_cookie, &proposal_transaction_cookie2) + .await + .expect_err("The proposal was denied, error on choice 2 execution expected"); + + // Assert + assert_eq!( + transaction1_err, + GovernanceError::InvalidStateCannotExecuteTransaction.into() + ); + assert_eq!( + transaction2_err, + GovernanceError::InvalidStateCannotExecuteTransaction.into() + ); +}