//! Proposal Account use borsh::maybestd::io::Write; use solana_program::account_info::next_account_info; use std::cmp::Ordering; use std::slice::Iter; use solana_program::clock::{Slot, UnixTimestamp}; use solana_program::{ account_info::AccountInfo, program_error::ProgramError, program_pack::IsInitialized, pubkey::Pubkey, }; use spl_governance_tools::account::{get_account_data, get_account_type, AccountMaxSize}; use crate::addins::max_voter_weight::{ assert_is_valid_max_voter_weight, get_max_voter_weight_record_data_for_realm_and_governing_token_mint, }; use crate::state::legacy::ProposalV1; use crate::tools::spl_token::get_spl_token_mint_supply; use crate::{ error::GovernanceError, state::{ enums::{ GovernanceAccountType, InstructionExecutionFlags, MintMaxVoterWeightSource, ProposalState, TransactionExecutionStatus, VoteThreshold, VoteTipping, }, governance::GovernanceConfig, proposal_transaction::ProposalTransactionV2, realm::RealmV2, vote_record::Vote, vote_record::VoteKind, }, PROGRAM_AUTHORITY_SEED, }; use borsh::{BorshDeserialize, BorshSchema, BorshSerialize}; use crate::state::realm_config::RealmConfigAccount; /// Proposal option vote result #[derive(Clone, Debug, PartialEq, Eq, BorshDeserialize, BorshSerialize, BorshSchema)] pub enum OptionVoteResult { /// Vote on the option is not resolved yet None, /// Vote on the option is completed and the option passed Succeeded, /// Vote on the option is completed and the option was defeated Defeated, } /// Proposal Option #[derive(Clone, Debug, PartialEq, Eq, BorshDeserialize, BorshSerialize, BorshSchema)] pub struct ProposalOption { /// Option label pub label: String, /// Vote weight for the option pub vote_weight: u64, /// Vote result for the option pub vote_result: OptionVoteResult, /// The number of the transactions already executed pub transactions_executed_count: u16, /// The number of transactions included in the option pub transactions_count: u16, /// The index of the the next transaction to be added pub transactions_next_index: u16, } /// Proposal vote type #[derive(Clone, Debug, PartialEq, Eq, BorshDeserialize, BorshSerialize, BorshSchema)] pub enum VoteType { /// Single choice vote with mutually exclusive choices /// In the SingeChoice mode there can ever be a single winner /// If multiple options score the same highest vote then the Proposal is not resolved and considered as Failed /// Note: Yes/No vote is a single choice (Yes) vote with the deny option (No) SingleChoice, /// Multiple options can be selected with up to max_voter_options per voter /// and with up to max_winning_options of successful options /// 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 { /// 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 #[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 #[allow(dead_code)] max_winning_options: u8, }, } /// Governance Proposal #[derive(Clone, Debug, PartialEq, Eq, BorshDeserialize, BorshSerialize, BorshSchema)] pub struct ProposalV2 { /// Governance account type pub account_type: GovernanceAccountType, /// Governance account the Proposal belongs to pub governance: Pubkey, /// Indicates which Governing Token is used to vote on the Proposal /// Whether the general Community token owners or the Council tokens owners vote on this Proposal pub governing_token_mint: Pubkey, /// Current proposal state pub state: ProposalState, // TODO: add state_at timestamp to have single field to filter recent proposals in the UI /// The TokenOwnerRecord representing the user who created and owns this Proposal pub token_owner_record: Pubkey, /// The number of signatories assigned to the Proposal pub signatories_count: u8, /// The number of signatories who already signed pub signatories_signed_off_count: u8, /// Vote type pub vote_type: VoteType, /// Proposal options pub options: Vec, /// The total weight of the Proposal rejection votes /// If the proposal has no deny option then the weight is None /// Only proposals with the deny option can have executable instructions attached to them /// Without the deny option a proposal is only non executable survey pub deny_vote_weight: Option, /// Reserved space for future versions /// This field is a leftover from unused veto_vote_weight: Option pub reserved1: u8, /// The total weight of votes /// Note: Abstain is not supported in the current version pub abstain_vote_weight: Option, /// Optional start time if the Proposal should not enter voting state immediately after being signed off /// Note: start_at is not supported in the current version pub start_voting_at: Option, /// When the Proposal was created and entered Draft state pub draft_at: UnixTimestamp, /// When Signatories started signing off the Proposal pub signing_off_at: Option, /// When the Proposal began voting as UnixTimestamp pub voting_at: Option, /// When the Proposal began voting as Slot /// Note: The slot is not currently used but the exact slot is going to be required to support snapshot based vote weights pub voting_at_slot: Option, /// When the Proposal ended voting and entered either Succeeded or Defeated pub voting_completed_at: Option, /// When the Proposal entered Executing state pub executing_at: Option, /// When the Proposal entered final state Completed or Cancelled and was closed pub closed_at: Option, /// Instruction execution flag for ordered and transactional instructions /// Note: This field is not used in the current version pub execution_flags: InstructionExecutionFlags, /// The max vote weight for the Governing Token mint at the time Proposal was decided /// It's used to show correct vote results for historical proposals in cases when the mint supply or max weight source changed /// after vote was completed. pub max_vote_weight: Option, /// Max voting time for the proposal if different from parent Governance (only higher value possible) /// Note: This field is not used in the current version pub max_voting_time: Option, /// The vote threshold at the time Proposal was decided /// It's used to show correct vote results for historical proposals in cases when the threshold /// was changed for governance config after vote was completed. /// TODO: Use this field to override the threshold from parent Governance (only higher value possible) pub vote_threshold: Option, /// Reserved space for future versions pub reserved: [u8; 64], /// Proposal name pub name: String, /// Link to proposal's description pub description_link: String, /// The total weight of Veto votes pub veto_vote_weight: u64, } 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) } } impl IsInitialized for ProposalV2 { fn is_initialized(&self) -> bool { self.account_type == GovernanceAccountType::ProposalV2 } } impl ProposalV2 { /// Checks if Signatories can be edited (added or removed) for the Proposal in the given state pub fn assert_can_edit_signatories(&self) -> Result<(), ProgramError> { self.assert_is_draft_state() .map_err(|_| GovernanceError::InvalidStateCannotEditSignatories.into()) } /// Checks if Proposal can be singed off pub fn assert_can_sign_off(&self) -> Result<(), ProgramError> { match self.state { ProposalState::Draft | ProposalState::SigningOff => Ok(()), ProposalState::Executing | ProposalState::ExecutingWithErrors | ProposalState::Completed | ProposalState::Cancelled | ProposalState::Voting | ProposalState::Succeeded | ProposalState::Defeated | ProposalState::Vetoed => Err(GovernanceError::InvalidStateCannotSignOff.into()), } } /// Checks the Proposal is in Voting state fn assert_is_voting_state(&self) -> Result<(), ProgramError> { if self.state != ProposalState::Voting { return Err(GovernanceError::InvalidProposalState.into()); } Ok(()) } /// Checks the Proposal is in Draft state fn assert_is_draft_state(&self) -> Result<(), ProgramError> { if self.state != ProposalState::Draft { return Err(GovernanceError::InvalidProposalState.into()); } Ok(()) } /// Checks if Proposal can be voted on pub fn assert_can_cast_vote( &self, config: &GovernanceConfig, vote: &Vote, current_unix_timestamp: UnixTimestamp, ) -> Result<(), ProgramError> { self.assert_is_voting_state() .map_err(|_| GovernanceError::InvalidStateCannotVote)?; // Check if we are still within the configured max voting time period if self.has_voting_max_time_ended(config, current_unix_timestamp) { return Err(GovernanceError::ProposalVotingTimeExpired.into()); } match vote { Vote::Approve(_) | Vote::Abstain => { // Once the base voting time passes and we are in the voting cool off time approving votes are no longer accepted // Abstain is considered as positive vote because when attendance quorum is used it can tip the scales if self.has_voting_base_time_ended(config, current_unix_timestamp) { Err(GovernanceError::VoteNotAllowedInCoolOffTime.into()) } else { Ok(()) } } // Within voting cool off time only counter votes are allowed Vote::Deny | Vote::Veto => Ok(()), } } /// Checks if proposal has concluded so that security deposit is no longer needed pub fn assert_can_refund_proposal_deposit(&self) -> Result<(), ProgramError> { match self.state { ProposalState::Succeeded | ProposalState::Executing | ProposalState::Completed | ProposalState::Cancelled | ProposalState::Defeated | ProposalState::ExecutingWithErrors | ProposalState::Vetoed => Ok(()), ProposalState::Draft | ProposalState::SigningOff | ProposalState::Voting => { Err(GovernanceError::CannotRefundProposalDeposit.into()) } } } /// Expected base vote end time determined by the configured base_voting_time and actual voting start time pub fn voting_base_time_end(&self, config: &GovernanceConfig) -> UnixTimestamp { self.voting_at .unwrap() .checked_add(config.voting_base_time as i64) .unwrap() } /// Checks whether the base voting time has ended for the proposal pub fn has_voting_base_time_ended( &self, config: &GovernanceConfig, current_unix_timestamp: UnixTimestamp, ) -> bool { // Check if we passed the configured base vote end time self.voting_base_time_end(config) < current_unix_timestamp } /// Expected max vote end time determined by the configured base_voting_time, optional voting_cool_off_time and actual voting start time pub fn voting_max_time_end(&self, config: &GovernanceConfig) -> UnixTimestamp { self.voting_base_time_end(config) .checked_add(config.voting_cool_off_time as i64) .unwrap() } /// Checks whether the max voting time has ended for the proposal pub fn has_voting_max_time_ended( &self, config: &GovernanceConfig, current_unix_timestamp: UnixTimestamp, ) -> bool { // Check if we passed the max vote end time self.voting_max_time_end(config) < current_unix_timestamp } /// Checks if Proposal can be finalized pub fn assert_can_finalize_vote( &self, config: &GovernanceConfig, current_unix_timestamp: UnixTimestamp, ) -> Result<(), ProgramError> { self.assert_is_voting_state() .map_err(|_| GovernanceError::InvalidStateCannotFinalize)?; // We can only finalize the vote after the configured max_voting_time has expired and vote time ended if !self.has_voting_max_time_ended(config, current_unix_timestamp) { return Err(GovernanceError::CannotFinalizeVotingInProgress.into()); } Ok(()) } /// Finalizes vote by moving it to final state Succeeded or Defeated if max_voting_time has passed /// If Proposal is still within max_voting_time period then error is returned pub fn finalize_vote( &mut self, max_voter_weight: u64, config: &GovernanceConfig, current_unix_timestamp: UnixTimestamp, vote_threshold: &VoteThreshold, ) -> Result<(), ProgramError> { self.assert_can_finalize_vote(config, current_unix_timestamp)?; self.state = self.resolve_final_vote_state(max_voter_weight, vote_threshold)?; self.voting_completed_at = Some(self.voting_max_time_end(config)); // Capture vote params to correctly display historical results self.max_vote_weight = Some(max_voter_weight); self.vote_threshold = Some(vote_threshold.clone()); Ok(()) } /// Resolves final proposal state after vote ends /// It inspects all proposals options and resolves their final vote results fn resolve_final_vote_state( &mut self, max_vote_weight: u64, vote_threshold: &VoteThreshold, ) -> Result { // Get the min vote weight required for options to pass let min_vote_threshold_weight = get_min_vote_threshold_weight(vote_threshold, max_vote_weight).unwrap(); // If the proposal has a reject option then any other option must beat it regardless of the configured min_vote_threshold_weight let deny_vote_weight = self.deny_vote_weight.unwrap_or(0); let mut best_succeeded_option_weight = 0; let mut best_succeeded_option_count = 0u16; for option in self.options.iter_mut() { // Any positive vote (Yes) must be equal or above the required min_vote_threshold_weight and higher than the reject option vote (No) // The same number of positive (Yes) and rejecting (No) votes is a tie and resolved as Defeated // In other words +1 vote as a tie breaker is required to succeed for the positive option vote if option.vote_weight >= min_vote_threshold_weight && option.vote_weight > deny_vote_weight { option.vote_result = OptionVoteResult::Succeeded; match option.vote_weight.cmp(&best_succeeded_option_weight) { Ordering::Greater => { best_succeeded_option_weight = option.vote_weight; best_succeeded_option_count = 1; } Ordering::Equal => { best_succeeded_option_count = best_succeeded_option_count.checked_add(1).unwrap() } Ordering::Less => {} } } else { option.vote_result = OptionVoteResult::Defeated; } } let mut final_state = if best_succeeded_option_count == 0 { // If none of the individual options succeeded then the proposal as a whole is defeated ProposalState::Defeated } else { 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 best_succeeded_option_weight = u64::MAX; // no winning option ProposalState::Defeated } else { ProposalState::Succeeded }; // Coerce options vote results based on the winning score (best_succeeded_vote_weight) for option in self.options.iter_mut() { option.vote_result = if option.vote_weight == best_succeeded_option_weight { OptionVoteResult::Succeeded } else { OptionVoteResult::Defeated }; } proposal_state } VoteType::MultiChoice { max_voter_options: _n, max_winning_options: _m, } => { // If any option succeeded for multi choice then the proposal as a whole succeeded as well ProposalState::Succeeded } } }; // None executable proposal is just a survey and is considered Completed once the vote ends and no more actions are available // There is no overall Success or Failure status for the Proposal however individual options still have their own status if self.deny_vote_weight.is_none() { final_state = ProposalState::Completed; } Ok(final_state) } /// Calculates max voter weight for given mint supply and realm config fn get_max_voter_weight_from_mint_supply( &mut self, realm_data: &RealmV2, governing_token_mint: &Pubkey, governing_token_mint_supply: u64, vote_kind: &VoteKind, ) -> Result { // max vote weight fraction is only used for community mint if Some(*governing_token_mint) == realm_data.config.council_mint { return Ok(governing_token_mint_supply); } let max_voter_weight = match realm_data.config.community_mint_max_voter_weight_source { MintMaxVoterWeightSource::SupplyFraction(fraction) => { if fraction == MintMaxVoterWeightSource::SUPPLY_FRACTION_BASE { return Ok(governing_token_mint_supply); } (governing_token_mint_supply as u128) .checked_mul(fraction as u128) .unwrap() .checked_div(MintMaxVoterWeightSource::SUPPLY_FRACTION_BASE as u128) .unwrap() as u64 } MintMaxVoterWeightSource::Absolute(value) => value, }; // When the fraction or absolute value is used it's possible we can go over the calculated max_vote_weight // and we have to adjust it in case more votes have been cast Ok(self.coerce_max_voter_weight(max_voter_weight, vote_kind)) } /// Adjusts max voter weight to ensure it's not lower than total cast votes fn coerce_max_voter_weight(&self, max_voter_weight: u64, vote_kind: &VoteKind) -> u64 { let total_vote_weight = match vote_kind { VoteKind::Electorate => { let deny_vote_weight = self.deny_vote_weight.unwrap_or(0); let max_option_vote_weight = self.options.iter().map(|o| o.vote_weight).max().unwrap(); max_option_vote_weight .checked_add(deny_vote_weight) .unwrap() } VoteKind::Veto => self.veto_vote_weight, }; max_voter_weight.max(total_vote_weight) } /// Resolves max voter weight using either 1) voting governing_token_mint supply or 2) max voter weight if configured for the token mint #[allow(clippy::too_many_arguments)] pub fn resolve_max_voter_weight( &mut self, account_info_iter: &mut Iter, realm: &Pubkey, realm_data: &RealmV2, realm_config_data: &RealmConfigAccount, vote_governing_token_mint_info: &AccountInfo, vote_kind: &VoteKind, ) -> Result { // if the Realm is configured to use max voter weight for the given voting governing_token_mint then use the externally provided max_voter_weight // instead of the supply based max if let Some(max_voter_weight_addin) = realm_config_data .get_token_config(realm_data, vote_governing_token_mint_info.key)? .max_voter_weight_addin { let max_voter_weight_record_info = next_account_info(account_info_iter)?; let max_voter_weight_record_data = get_max_voter_weight_record_data_for_realm_and_governing_token_mint( &max_voter_weight_addin, max_voter_weight_record_info, realm, vote_governing_token_mint_info.key, )?; assert_is_valid_max_voter_weight(&max_voter_weight_record_data)?; // When the max voter weight addin is used it's possible it can be inaccurate and we can have more votes then the max provided by the addin // and we have to adjust it to whatever result is higher return Ok(self.coerce_max_voter_weight( max_voter_weight_record_data.max_voter_weight, vote_kind, )); } let vote_governing_token_mint_supply = get_spl_token_mint_supply(vote_governing_token_mint_info)?; let max_voter_weight = self.get_max_voter_weight_from_mint_supply( realm_data, vote_governing_token_mint_info.key, vote_governing_token_mint_supply, vote_kind, )?; Ok(max_voter_weight) } /// Checks if vote can be tipped and automatically transitioned to Succeeded or Defeated state /// If the conditions are met the state is updated accordingly pub fn try_tip_vote( &mut self, max_voter_weight: u64, vote_tipping: &VoteTipping, current_unix_timestamp: UnixTimestamp, vote_threshold: &VoteThreshold, vote_kind: &VoteKind, ) -> Result { if let Some(tipped_state) = self.try_get_tipped_vote_state( max_voter_weight, vote_tipping, vote_threshold, vote_kind, ) { self.state = tipped_state; self.voting_completed_at = Some(current_unix_timestamp); // Capture vote params to correctly display historical results // Note: For Veto vote the captured params are from the Veto config self.max_vote_weight = Some(max_voter_weight); self.vote_threshold = Some(vote_threshold.clone()); Ok(true) } else { Ok(false) } } /// Checks if vote can be tipped and automatically transitioned to Succeeded, Defeated or Vetoed state /// If yes then Some(ProposalState) is returned and None otherwise pub fn try_get_tipped_vote_state( &mut self, max_voter_weight: u64, vote_tipping: &VoteTipping, vote_threshold: &VoteThreshold, vote_kind: &VoteKind, ) -> Option { let min_vote_threshold_weight = get_min_vote_threshold_weight(vote_threshold, max_voter_weight).unwrap(); match vote_kind { VoteKind::Electorate => self.try_get_tipped_electorate_vote_state( max_voter_weight, vote_tipping, min_vote_threshold_weight, ), VoteKind::Veto => self.try_get_tipped_veto_vote_state(min_vote_threshold_weight), } } /// Checks if Electorate vote can be tipped and automatically transitioned to Succeeded or Defeated state /// If yes then Some(ProposalState) is returned and None otherwise fn try_get_tipped_electorate_vote_state( &mut self, max_voter_weight: u64, vote_tipping: &VoteTipping, min_vote_threshold_weight: u64, ) -> Option { // Vote tipping is currently supported for SingleChoice votes with single Yes and No (rejection) options only // Note: Tipping for multiple options (single choice and multiple choices) should be possible but it requires a great deal of considerations // and I decided to fight it another day if self.vote_type != VoteType::SingleChoice // Tipping should not be allowed for opinion only proposals (surveys without rejection) to allow everybody's voice to be heard || self.deny_vote_weight.is_none() || self.options.len() != 1 { return None; }; let mut yes_option = &mut self.options[0]; let yes_vote_weight = yes_option.vote_weight; let deny_vote_weight = self.deny_vote_weight.unwrap(); match vote_tipping { VoteTipping::Disabled => {} VoteTipping::Strict => { if yes_vote_weight >= min_vote_threshold_weight && yes_vote_weight > (max_voter_weight.saturating_sub(yes_vote_weight)) { yes_option.vote_result = OptionVoteResult::Succeeded; return Some(ProposalState::Succeeded); } } VoteTipping::Early => { if yes_vote_weight >= min_vote_threshold_weight && yes_vote_weight > deny_vote_weight { yes_option.vote_result = OptionVoteResult::Succeeded; return Some(ProposalState::Succeeded); } } } // If vote tipping isn't disabled entirely, allow a vote to complete as // "defeated" if there is no possible way of reaching majority or the // min_vote_threshold_weight for another option. This tipping is always // strict, there's no equivalent to "early" tipping for deny votes. if *vote_tipping != VoteTipping::Disabled && (deny_vote_weight > (max_voter_weight.saturating_sub(min_vote_threshold_weight)) || deny_vote_weight >= (max_voter_weight.saturating_sub(deny_vote_weight))) { yes_option.vote_result = OptionVoteResult::Defeated; return Some(ProposalState::Defeated); } None } /// Checks if vote can be tipped and transitioned to Vetoed state /// If yes then Some(ProposalState::Vetoed) is returned and None otherwise fn try_get_tipped_veto_vote_state( &mut self, min_vote_threshold_weight: u64, ) -> Option { // Veto vote tips as soon as the required threshold is reached // It's irrespectively of vote_tipping config because the outcome of the Proposal can't change any longer after being vetoed if self.veto_vote_weight >= min_vote_threshold_weight { // Note: Since we don't tip multi option votes all options vote_result would remain as None Some(ProposalState::Vetoed) } else { None } } /// Checks if Proposal can be canceled in the given state pub fn assert_can_cancel( &self, config: &GovernanceConfig, current_unix_timestamp: UnixTimestamp, ) -> Result<(), ProgramError> { match self.state { ProposalState::Draft | ProposalState::SigningOff => Ok(()), ProposalState::Voting => { // Note: If there is no tipping point the proposal can be still in Voting state but already past the configured max_voting_time // In that case we treat the proposal as finalized and it's no longer allowed to be canceled if self.has_voting_max_time_ended(config, current_unix_timestamp) { return Err(GovernanceError::ProposalVotingTimeExpired.into()); } Ok(()) } ProposalState::Executing | ProposalState::ExecutingWithErrors | ProposalState::Completed | ProposalState::Cancelled | ProposalState::Succeeded | ProposalState::Defeated | ProposalState::Vetoed => { Err(GovernanceError::InvalidStateCannotCancelProposal.into()) } } } /// Checks if Instructions can be edited (inserted or removed) for the Proposal in the given state /// It also asserts whether the Proposal is executable (has the reject option) pub fn assert_can_edit_instructions(&self) -> Result<(), ProgramError> { if self.assert_is_draft_state().is_err() { return Err(GovernanceError::InvalidStateCannotEditTransactions.into()); } // For security purposes only proposals with the reject option can have executable instructions if self.deny_vote_weight.is_none() { return Err(GovernanceError::ProposalIsNotExecutable.into()); } Ok(()) } /// Checks if Instructions can be executed for the Proposal in the given state pub fn assert_can_execute_transaction( &self, proposal_transaction_data: &ProposalTransactionV2, current_unix_timestamp: UnixTimestamp, ) -> Result<(), ProgramError> { match self.state { ProposalState::Succeeded | ProposalState::Executing | ProposalState::ExecutingWithErrors => {} ProposalState::Draft | ProposalState::SigningOff | ProposalState::Completed | ProposalState::Voting | ProposalState::Cancelled | ProposalState::Defeated | ProposalState::Vetoed => { return Err(GovernanceError::InvalidStateCannotExecuteTransaction.into()) } } if self.options[proposal_transaction_data.option_index as usize].vote_result != OptionVoteResult::Succeeded { return Err(GovernanceError::CannotExecuteDefeatedOption.into()); } if self .voting_completed_at .unwrap() .checked_add(proposal_transaction_data.hold_up_time as i64) .unwrap() >= current_unix_timestamp { return Err(GovernanceError::CannotExecuteTransactionWithinHoldUpTime.into()); } if proposal_transaction_data.executed_at.is_some() { return Err(GovernanceError::TransactionAlreadyExecuted.into()); } Ok(()) } /// Checks if the instruction can be flagged with error for the Proposal in the given state pub fn assert_can_flag_transaction_error( &self, proposal_transaction_data: &ProposalTransactionV2, current_unix_timestamp: UnixTimestamp, ) -> Result<(), ProgramError> { // Instruction can be flagged for error only when it's eligible for execution self.assert_can_execute_transaction(proposal_transaction_data, current_unix_timestamp)?; if proposal_transaction_data.execution_status == TransactionExecutionStatus::Error { return Err(GovernanceError::TransactionAlreadyFlaggedWithError.into()); } Ok(()) } /// Asserts the given vote is valid for the proposal pub fn assert_valid_vote(&self, vote: &Vote) -> Result<(), ProgramError> { match vote { Vote::Approve(choices) => { if self.options.len() != choices.len() { return Err(GovernanceError::InvalidVote.into()); } let mut choice_count = 0u16; for choice in choices { if choice.rank > 0 { return Err(GovernanceError::InvalidVote.into()); } if choice.weight_percentage == 100 { choice_count = choice_count.checked_add(1).unwrap(); } else if choice.weight_percentage != 0 { return Err(GovernanceError::InvalidVote.into()); } } match self.vote_type { VoteType::SingleChoice => { if choice_count != 1 { return Err(GovernanceError::InvalidVote.into()); } } VoteType::MultiChoice { max_voter_options: _n, max_winning_options: _m, } => { if choice_count == 0 { return Err(GovernanceError::InvalidVote.into()); } } } } Vote::Deny => { if self.deny_vote_weight.is_none() { return Err(GovernanceError::InvalidVote.into()); } } Vote::Abstain => { return Err(GovernanceError::NotSupportedVoteType.into()); } Vote::Veto => {} } Ok(()) } /// Serializes account into the target buffer pub fn serialize(self, writer: &mut W) -> Result<(), ProgramError> { if self.account_type == GovernanceAccountType::ProposalV2 { BorshSerialize::serialize(&self, writer)? } else if self.account_type == GovernanceAccountType::ProposalV1 { // V1 account can't be resized and we have to translate it back to the original format if self.abstain_vote_weight.is_some() { panic!("ProposalV1 doesn't support Abstain vote") } if self.veto_vote_weight > 0 { panic!("ProposalV1 doesn't support Veto vote") } if self.start_voting_at.is_some() { panic!("ProposalV1 doesn't support start time") } if self.max_voting_time.is_some() { panic!("ProposalV1 doesn't support max voting time") } if self.options.len() != 1 { panic!("ProposalV1 doesn't support multiple options") } let proposal_data_v1 = ProposalV1 { account_type: self.account_type, governance: self.governance, governing_token_mint: self.governing_token_mint, state: self.state, token_owner_record: self.token_owner_record, signatories_count: self.signatories_count, signatories_signed_off_count: self.signatories_signed_off_count, yes_votes_count: self.options[0].vote_weight, no_votes_count: self.deny_vote_weight.unwrap(), instructions_executed_count: self.options[0].transactions_executed_count, instructions_count: self.options[0].transactions_count, instructions_next_index: self.options[0].transactions_next_index, draft_at: self.draft_at, signing_off_at: self.signing_off_at, voting_at: self.voting_at, voting_at_slot: self.voting_at_slot, voting_completed_at: self.voting_completed_at, executing_at: self.executing_at, closed_at: self.closed_at, execution_flags: self.execution_flags, max_vote_weight: self.max_vote_weight, vote_threshold: self.vote_threshold, name: self.name, description_link: self.description_link, }; BorshSerialize::serialize(&proposal_data_v1, writer)?; } Ok(()) } } /// Converts given vote threshold (ex. in percentages) to absolute vote weight /// and returns the min weight required for a proposal option to pass fn get_min_vote_threshold_weight( vote_threshold: &VoteThreshold, max_voter_weight: u64, ) -> Result { let yes_vote_threshold_percentage = match vote_threshold { VoteThreshold::YesVotePercentage(yes_vote_threshold_percentage) => { *yes_vote_threshold_percentage } _ => { return Err(GovernanceError::VoteThresholdTypeNotSupported.into()); } }; let numerator = (yes_vote_threshold_percentage as u128) .checked_mul(max_voter_weight as u128) .unwrap(); let mut yes_vote_threshold = numerator.checked_div(100).unwrap(); if yes_vote_threshold.checked_mul(100).unwrap() < numerator { yes_vote_threshold = yes_vote_threshold.checked_add(1).unwrap(); } Ok(yes_vote_threshold as u64) } /// Deserializes Proposal account and checks owner program pub fn get_proposal_data( program_id: &Pubkey, proposal_info: &AccountInfo, ) -> Result { let account_type: GovernanceAccountType = get_account_type(program_id, proposal_info)?; // If the account is V1 version then translate to V2 if account_type == GovernanceAccountType::ProposalV1 { let proposal_data_v1 = get_account_data::(program_id, proposal_info)?; let vote_result = match proposal_data_v1.state { ProposalState::Draft | ProposalState::SigningOff | ProposalState::Voting | ProposalState::Cancelled => OptionVoteResult::None, ProposalState::Succeeded | ProposalState::Executing | ProposalState::ExecutingWithErrors | ProposalState::Completed => OptionVoteResult::Succeeded, ProposalState::Vetoed | ProposalState::Defeated => OptionVoteResult::None, }; return Ok(ProposalV2 { account_type, governance: proposal_data_v1.governance, governing_token_mint: proposal_data_v1.governing_token_mint, state: proposal_data_v1.state, token_owner_record: proposal_data_v1.token_owner_record, signatories_count: proposal_data_v1.signatories_count, signatories_signed_off_count: proposal_data_v1.signatories_signed_off_count, vote_type: VoteType::SingleChoice, options: vec![ProposalOption { label: "Yes".to_string(), vote_weight: proposal_data_v1.yes_votes_count, vote_result, transactions_executed_count: proposal_data_v1.instructions_executed_count, transactions_count: proposal_data_v1.instructions_count, transactions_next_index: proposal_data_v1.instructions_next_index, }], deny_vote_weight: Some(proposal_data_v1.no_votes_count), veto_vote_weight: 0, abstain_vote_weight: None, start_voting_at: None, draft_at: proposal_data_v1.draft_at, signing_off_at: proposal_data_v1.signing_off_at, voting_at: proposal_data_v1.voting_at, voting_at_slot: proposal_data_v1.voting_at_slot, voting_completed_at: proposal_data_v1.voting_completed_at, executing_at: proposal_data_v1.executing_at, closed_at: proposal_data_v1.closed_at, execution_flags: proposal_data_v1.execution_flags, max_vote_weight: proposal_data_v1.max_vote_weight, max_voting_time: None, vote_threshold: proposal_data_v1.vote_threshold, name: proposal_data_v1.name, description_link: proposal_data_v1.description_link, reserved: [0; 64], reserved1: 0, }); } get_account_data::(program_id, proposal_info) } /// Deserializes Proposal and validates it belongs to the given Governance and governing_token_mint pub fn get_proposal_data_for_governance_and_governing_mint( program_id: &Pubkey, proposal_info: &AccountInfo, governance: &Pubkey, governing_token_mint: &Pubkey, ) -> Result { let proposal_data = get_proposal_data_for_governance(program_id, proposal_info, governance)?; if proposal_data.governing_token_mint != *governing_token_mint { return Err(GovernanceError::InvalidGoverningMintForProposal.into()); } Ok(proposal_data) } /// Deserializes Proposal and validates it belongs to the given Governance pub fn get_proposal_data_for_governance( program_id: &Pubkey, proposal_info: &AccountInfo, governance: &Pubkey, ) -> Result { let proposal_data = get_proposal_data(program_id, proposal_info)?; if proposal_data.governance != *governance { return Err(GovernanceError::InvalidGovernanceForProposal.into()); } Ok(proposal_data) } /// Returns Proposal PDA seeds pub fn get_proposal_address_seeds<'a>( governance: &'a Pubkey, governing_token_mint: &'a Pubkey, proposal_seed: &'a Pubkey, ) -> [&'a [u8]; 4] { [ PROGRAM_AUTHORITY_SEED, governance.as_ref(), governing_token_mint.as_ref(), proposal_seed.as_ref(), ] } /// Returns Proposal PDA address pub fn get_proposal_address<'a>( program_id: &Pubkey, governance: &'a Pubkey, governing_token_mint: &'a Pubkey, proposal_seed: &'a Pubkey, ) -> Pubkey { Pubkey::find_program_address( &get_proposal_address_seeds(governance, governing_token_mint, proposal_seed), program_id, ) .0 } /// Assert options to create proposal are valid for the Proposal vote_type pub fn assert_valid_proposal_options( options: &[String], vote_type: &VoteType, ) -> Result<(), ProgramError> { if options.is_empty() || options.len() > 10 { return Err(GovernanceError::InvalidProposalOptions.into()); } if let VoteType::MultiChoice { max_voter_options, max_winning_options, } = *vote_type { if options.len() == 1 || max_voter_options as usize != options.len() || max_winning_options as usize != options.len() { return Err(GovernanceError::InvalidProposalOptions.into()); } } // TODO: Check for duplicated option labels // The options are identified by index so it's ok for now if options.iter().any(|o| o.is_empty()) { return Err(GovernanceError::InvalidProposalOptions.into()); } Ok(()) } #[cfg(test)] mod test { use super::*; use solana_program::clock::Epoch; use crate::state::{ enums::{MintMaxVoterWeightSource, VoteThreshold}, legacy::ProposalV1, realm::RealmConfig, vote_record::VoteChoice, }; use proptest::prelude::*; fn create_test_proposal() -> ProposalV2 { ProposalV2 { account_type: GovernanceAccountType::TokenOwnerRecordV2, governance: Pubkey::new_unique(), governing_token_mint: Pubkey::new_unique(), max_vote_weight: Some(10), state: ProposalState::Draft, token_owner_record: Pubkey::new_unique(), signatories_count: 10, signatories_signed_off_count: 5, description_link: "This is my description".to_string(), name: "This is my name".to_string(), start_voting_at: Some(0), draft_at: 10, signing_off_at: Some(10), voting_at: Some(10), voting_at_slot: Some(500), voting_completed_at: Some(10), executing_at: Some(10), closed_at: Some(10), vote_type: VoteType::SingleChoice, options: vec![ProposalOption { label: "yes".to_string(), vote_weight: 0, vote_result: OptionVoteResult::None, transactions_executed_count: 10, transactions_count: 10, transactions_next_index: 10, }], deny_vote_weight: Some(0), abstain_vote_weight: Some(0), veto_vote_weight: 0, execution_flags: InstructionExecutionFlags::Ordered, max_voting_time: Some(0), vote_threshold: Some(VoteThreshold::YesVotePercentage(100)), reserved: [0; 64], reserved1: 0, } } fn create_test_multi_option_proposal() -> ProposalV2 { let mut proposal = create_test_proposal(); proposal.options = vec![ ProposalOption { label: "option 1".to_string(), vote_weight: 0, vote_result: OptionVoteResult::None, transactions_executed_count: 10, transactions_count: 10, transactions_next_index: 10, }, ProposalOption { label: "option 2".to_string(), vote_weight: 0, vote_result: OptionVoteResult::None, transactions_executed_count: 10, transactions_count: 10, transactions_next_index: 10, }, ProposalOption { label: "option 3".to_string(), vote_weight: 0, vote_result: OptionVoteResult::None, transactions_executed_count: 10, transactions_count: 10, transactions_next_index: 10, }, ]; proposal } fn create_test_realm() -> RealmV2 { RealmV2 { account_type: GovernanceAccountType::RealmV2, community_mint: Pubkey::new_unique(), reserved: [0; 6], authority: Some(Pubkey::new_unique()), name: "test-realm".to_string(), config: RealmConfig { council_mint: Some(Pubkey::new_unique()), reserved: [0; 6], legacy1: 0, legacy2: 0, community_mint_max_voter_weight_source: MintMaxVoterWeightSource::FULL_SUPPLY_FRACTION, min_community_weight_to_create_governance: 10, }, legacy1: 0, reserved_v2: [0; 128], } } fn create_test_governance_config() -> GovernanceConfig { GovernanceConfig { community_vote_threshold: VoteThreshold::YesVotePercentage(60), min_community_weight_to_create_proposal: 5, min_transaction_hold_up_time: 10, voting_base_time: 5, community_vote_tipping: VoteTipping::Strict, council_vote_threshold: VoteThreshold::YesVotePercentage(60), council_veto_vote_threshold: VoteThreshold::YesVotePercentage(50), min_council_weight_to_create_proposal: 1, council_vote_tipping: VoteTipping::Strict, community_veto_vote_threshold: VoteThreshold::YesVotePercentage(40), voting_cool_off_time: 0, deposit_exempt_proposal_count: 0, } } #[test] fn test_max_size() { let mut proposal = create_test_proposal(); proposal.vote_type = VoteType::MultiChoice { max_voter_options: 1, max_winning_options: 1, }; let size = proposal.try_to_vec().unwrap().len(); assert_eq!(proposal.get_max_size(), Some(size)); } #[test] fn test_multi_option_proposal_max_size() { let mut proposal = create_test_multi_option_proposal(); proposal.vote_type = VoteType::MultiChoice { max_voter_options: 3, max_winning_options: 3, }; let size = proposal.try_to_vec().unwrap().len(); assert_eq!(proposal.get_max_size(), Some(size)); } prop_compose! { fn vote_results()(governing_token_supply in 1..=u64::MAX)( governing_token_supply in Just(governing_token_supply), vote_count in 0..=governing_token_supply, ) -> (u64, u64) { (vote_count as u64, governing_token_supply as u64) } } fn editable_signatory_states() -> impl Strategy { prop_oneof![Just(ProposalState::Draft)] } proptest! { #[test] fn test_assert_can_edit_signatories(state in editable_signatory_states()) { let mut proposal = create_test_proposal(); proposal.state = state; proposal.assert_can_edit_signatories().unwrap(); } } fn none_editable_signatory_states() -> impl Strategy { prop_oneof![ Just(ProposalState::Voting), Just(ProposalState::Succeeded), Just(ProposalState::Executing), Just(ProposalState::ExecutingWithErrors), Just(ProposalState::Completed), Just(ProposalState::Cancelled), Just(ProposalState::Defeated), Just(ProposalState::Vetoed), Just(ProposalState::SigningOff), ] } proptest! { #[test] fn test_assert_can_edit_signatories_with_invalid_state_error(state in none_editable_signatory_states()) { // Arrange let mut proposal = create_test_proposal(); proposal.state = state; // Act let err = proposal.assert_can_edit_signatories().err().unwrap(); // Assert assert_eq!(err, GovernanceError::InvalidStateCannotEditSignatories.into()); } } fn sign_off_states() -> impl Strategy { prop_oneof![Just(ProposalState::SigningOff), Just(ProposalState::Draft),] } proptest! { #[test] fn test_assert_can_sign_off(state in sign_off_states()) { let mut proposal = create_test_proposal(); proposal.state = state; proposal.assert_can_sign_off().unwrap(); } } fn none_sign_off_states() -> impl Strategy { prop_oneof![ Just(ProposalState::Voting), Just(ProposalState::Succeeded), Just(ProposalState::Executing), Just(ProposalState::ExecutingWithErrors), Just(ProposalState::Completed), Just(ProposalState::Cancelled), Just(ProposalState::Defeated), Just(ProposalState::Vetoed), ] } proptest! { #[test] fn test_assert_can_sign_off_with_state_error(state in none_sign_off_states()) { // Arrange let mut proposal = create_test_proposal(); proposal.state = state; // Act let err = proposal.assert_can_sign_off().err().unwrap(); // Assert assert_eq!(err, GovernanceError::InvalidStateCannotSignOff.into()); } } fn cancellable_states() -> impl Strategy { prop_oneof![ Just(ProposalState::Draft), Just(ProposalState::SigningOff), Just(ProposalState::Voting), ] } proptest! { #[test] fn test_assert_can_cancel(state in cancellable_states()) { // Arrange let mut proposal = create_test_proposal(); let governance_config = create_test_governance_config(); // Act proposal.state = state; // Assert proposal.assert_can_cancel(&governance_config,1).unwrap(); } } fn none_cancellable_states() -> impl Strategy { prop_oneof![ Just(ProposalState::Succeeded), Just(ProposalState::Executing), Just(ProposalState::ExecutingWithErrors), Just(ProposalState::Completed), Just(ProposalState::Cancelled), Just(ProposalState::Defeated), Just(ProposalState::Vetoed), ] } proptest! { #[test] fn test_assert_can_cancel_with_invalid_state_error(state in none_cancellable_states()) { // Arrange let mut proposal = create_test_proposal(); proposal.state = state; let governance_config = create_test_governance_config(); // Act let err = proposal.assert_can_cancel(&governance_config,1).err().unwrap(); // Assert assert_eq!(err, GovernanceError::InvalidStateCannotCancelProposal.into()); } } #[derive(Clone, Debug)] pub struct VoteCastTestCase { #[allow(dead_code)] name: &'static str, governing_token_supply: u64, yes_vote_threshold_percentage: u8, yes_votes_count: u64, no_votes_count: u64, expected_tipped_state: ProposalState, expected_finalized_state: ProposalState, } fn vote_casting_test_cases() -> impl Strategy { prop_oneof![ // threshold < 50% Just(VoteCastTestCase { name: "45:10 @40 -- Nays can still outvote Yeahs", governing_token_supply: 100, yes_vote_threshold_percentage: 40, yes_votes_count: 45, no_votes_count: 10, expected_tipped_state: ProposalState::Voting, expected_finalized_state: ProposalState::Succeeded, }), Just(VoteCastTestCase { name: "49:50 @40 -- In best case scenario it can be 50:50 tie and hence Defeated", governing_token_supply: 100, yes_vote_threshold_percentage: 40, yes_votes_count: 49, no_votes_count: 50, expected_tipped_state: ProposalState::Defeated, expected_finalized_state: ProposalState::Defeated, }), Just(VoteCastTestCase { name: "40:40 @40 -- Still can go either way", governing_token_supply: 100, yes_vote_threshold_percentage: 40, yes_votes_count: 40, no_votes_count: 40, expected_tipped_state: ProposalState::Voting, expected_finalized_state: ProposalState::Defeated, }), Just(VoteCastTestCase { name: "45:45 @40 -- Still can go either way", governing_token_supply: 100, yes_vote_threshold_percentage: 40, yes_votes_count: 45, no_votes_count: 45, expected_tipped_state: ProposalState::Voting, expected_finalized_state: ProposalState::Defeated, }), Just(VoteCastTestCase { name: "50:10 @40 -- Nay sayers can still tie up", governing_token_supply: 100, yes_vote_threshold_percentage: 40, yes_votes_count: 50, no_votes_count: 10, expected_tipped_state: ProposalState::Voting, expected_finalized_state: ProposalState::Succeeded, }), Just(VoteCastTestCase { name: "50:50 @40 -- It's a tie and hence Defeated", governing_token_supply: 100, yes_vote_threshold_percentage: 40, yes_votes_count: 50, no_votes_count: 50, expected_tipped_state: ProposalState::Defeated, expected_finalized_state: ProposalState::Defeated, }), Just(VoteCastTestCase { name: "45:51 @ 40 -- Nays won", governing_token_supply: 100, yes_vote_threshold_percentage: 40, yes_votes_count: 45, no_votes_count: 51, expected_tipped_state: ProposalState::Defeated, expected_finalized_state: ProposalState::Defeated, }), Just(VoteCastTestCase { name: "40:55 @ 40 -- Nays won", governing_token_supply: 100, yes_vote_threshold_percentage: 40, yes_votes_count: 40, no_votes_count: 55, expected_tipped_state: ProposalState::Defeated, expected_finalized_state: ProposalState::Defeated, }), // threshold == 50% Just(VoteCastTestCase { name: "50:10 @50 -- +1 tie breaker required to tip", governing_token_supply: 100, yes_vote_threshold_percentage: 50, yes_votes_count: 50, no_votes_count: 10, expected_tipped_state: ProposalState::Voting, expected_finalized_state: ProposalState::Succeeded, }), Just(VoteCastTestCase { name: "10:50 @50 -- +1 tie breaker vote not possible any longer", governing_token_supply: 100, yes_vote_threshold_percentage: 50, yes_votes_count: 10, no_votes_count: 50, expected_tipped_state: ProposalState::Defeated, expected_finalized_state: ProposalState::Defeated, }), Just(VoteCastTestCase { name: "50:50 @50 -- +1 tie breaker vote not possible any longer", governing_token_supply: 100, yes_vote_threshold_percentage: 50, yes_votes_count: 50, no_votes_count: 50, expected_tipped_state: ProposalState::Defeated, expected_finalized_state: ProposalState::Defeated, }), Just(VoteCastTestCase { name: "51:10 @ 50 -- Nay sayers can't outvote any longer", governing_token_supply: 100, yes_vote_threshold_percentage: 50, yes_votes_count: 51, no_votes_count: 10, expected_tipped_state: ProposalState::Succeeded, expected_finalized_state: ProposalState::Succeeded, }), Just(VoteCastTestCase { name: "10:51 @ 50 -- Nays won", governing_token_supply: 100, yes_vote_threshold_percentage: 50, yes_votes_count: 10, no_votes_count: 51, expected_tipped_state: ProposalState::Defeated, expected_finalized_state: ProposalState::Defeated, }), // threshold > 50% Just(VoteCastTestCase { name: "10:10 @ 60 -- Can still go either way", governing_token_supply: 100, yes_vote_threshold_percentage: 60, yes_votes_count: 10, no_votes_count: 10, expected_tipped_state: ProposalState::Voting, expected_finalized_state: ProposalState::Defeated, }), Just(VoteCastTestCase { name: "55:10 @ 60 -- Can still go either way", governing_token_supply: 100, yes_vote_threshold_percentage: 60, yes_votes_count: 55, no_votes_count: 10, expected_tipped_state: ProposalState::Voting, expected_finalized_state: ProposalState::Defeated, }), Just(VoteCastTestCase { name: "60:10 @ 60 -- Yeah reached the required threshold", governing_token_supply: 100, yes_vote_threshold_percentage: 60, yes_votes_count: 60, no_votes_count: 10, expected_tipped_state: ProposalState::Succeeded, expected_finalized_state: ProposalState::Succeeded, }), Just(VoteCastTestCase { name: "61:10 @ 60 -- Yeah won", governing_token_supply: 100, yes_vote_threshold_percentage: 60, yes_votes_count: 61, no_votes_count: 10, expected_tipped_state: ProposalState::Succeeded, expected_finalized_state: ProposalState::Succeeded, }), Just(VoteCastTestCase { name: "10:40 @ 60 -- Yeah can still outvote Nay", governing_token_supply: 100, yes_vote_threshold_percentage: 60, yes_votes_count: 10, no_votes_count: 40, expected_tipped_state: ProposalState::Voting, expected_finalized_state: ProposalState::Defeated, }), Just(VoteCastTestCase { name: "60:40 @ 60 -- Yeah won", governing_token_supply: 100, yes_vote_threshold_percentage: 60, yes_votes_count: 60, no_votes_count: 40, expected_tipped_state: ProposalState::Succeeded, expected_finalized_state: ProposalState::Succeeded, }), Just(VoteCastTestCase { name: "10:41 @ 60 -- Aye can't outvote Nay any longer", governing_token_supply: 100, yes_vote_threshold_percentage: 60, yes_votes_count: 10, no_votes_count: 41, expected_tipped_state: ProposalState::Defeated, expected_finalized_state: ProposalState::Defeated, }), Just(VoteCastTestCase { name: "100:0", governing_token_supply: 100, yes_vote_threshold_percentage: 100, yes_votes_count: 100, no_votes_count: 0, expected_tipped_state: ProposalState::Succeeded, expected_finalized_state: ProposalState::Succeeded, }), Just(VoteCastTestCase { name: "0:100", governing_token_supply: 100, yes_vote_threshold_percentage: 100, yes_votes_count: 0, no_votes_count: 100, expected_tipped_state: ProposalState::Defeated, expected_finalized_state: ProposalState::Defeated, }), ] } proptest! { #[test] fn test_try_tip_vote(test_case in vote_casting_test_cases()) { // Arrange let mut proposal = create_test_proposal(); proposal.options[0].vote_weight = test_case.yes_votes_count; proposal.deny_vote_weight = Some(test_case.no_votes_count); proposal.state = ProposalState::Voting; let current_timestamp = 15_i64; let realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; let vote_tipping = VoteTipping::Strict; let max_voter_weight = proposal.get_max_voter_weight_from_mint_supply(&realm,&governing_token_mint, test_case.governing_token_supply,&vote_kind).unwrap(); let vote_threshold = VoteThreshold::YesVotePercentage(test_case.yes_vote_threshold_percentage); // Act proposal.try_tip_vote(max_voter_weight, &vote_tipping,current_timestamp,&vote_threshold,&vote_kind).unwrap(); // Assert assert_eq!(proposal.state,test_case.expected_tipped_state,"CASE: {:?}",test_case); if test_case.expected_tipped_state != ProposalState::Voting { assert_eq!(Some(current_timestamp),proposal.voting_completed_at); } match proposal.options[0].vote_result { OptionVoteResult::Succeeded => { assert_eq!(ProposalState::Succeeded,test_case.expected_tipped_state) }, OptionVoteResult::Defeated => { assert_eq!(ProposalState::Defeated,test_case.expected_tipped_state) }, OptionVoteResult::None => { assert_eq!(ProposalState::Voting,test_case.expected_tipped_state) }, }; } #[test] fn test_finalize_vote(test_case in vote_casting_test_cases()) { // Arrange let mut proposal = create_test_proposal(); proposal.options[0].vote_weight = test_case.yes_votes_count; proposal.deny_vote_weight = Some(test_case.no_votes_count); proposal.state = ProposalState::Voting; let governance_config = create_test_governance_config(); let current_timestamp = 16_i64; let realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; let max_voter_weight = proposal.get_max_voter_weight_from_mint_supply(&realm,&governing_token_mint,test_case.governing_token_supply,&vote_kind).unwrap(); let vote_threshold = VoteThreshold::YesVotePercentage(test_case.yes_vote_threshold_percentage); // Act proposal.finalize_vote(max_voter_weight, &governance_config,current_timestamp,&vote_threshold).unwrap(); // Assert assert_eq!(proposal.state,test_case.expected_finalized_state,"CASE: {:?}",test_case); assert_eq!( Some(proposal.voting_max_time_end(&governance_config)), proposal.voting_completed_at ); match proposal.options[0].vote_result { OptionVoteResult::Succeeded => { assert_eq!(ProposalState::Succeeded,test_case.expected_finalized_state) }, OptionVoteResult::Defeated => { assert_eq!(ProposalState::Defeated,test_case.expected_finalized_state) }, OptionVoteResult::None => { panic!("Option result must be resolved for finalized vote") }, }; } } prop_compose! { fn full_vote_results()(governing_token_supply in 1..=u64::MAX, yes_vote_threshold in 1..100)( governing_token_supply in Just(governing_token_supply), yes_vote_threshold in Just(yes_vote_threshold), yes_votes_count in 0..=governing_token_supply, no_votes_count in 0..=governing_token_supply, ) -> (u64, u64, u64, u8) { (yes_votes_count as u64, no_votes_count as u64, governing_token_supply as u64,yes_vote_threshold as u8) } } proptest! { #[test] fn test_try_tip_vote_with_full_vote_results( (yes_votes_count, no_votes_count, governing_token_supply, yes_vote_threshold_percentage) in full_vote_results(), ) { // Arrange let mut proposal = create_test_proposal(); proposal.options[0].vote_weight = yes_votes_count; proposal.deny_vote_weight = Some(no_votes_count.min(governing_token_supply-yes_votes_count)); proposal.state = ProposalState::Voting; let yes_vote_threshold_percentage = VoteThreshold::YesVotePercentage(yes_vote_threshold_percentage); let current_timestamp = 15_i64; let realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; let vote_tipping = VoteTipping::Strict; let max_voter_weight = proposal.get_max_voter_weight_from_mint_supply(&realm,&governing_token_mint,governing_token_supply,&vote_kind).unwrap(); let vote_threshold = yes_vote_threshold_percentage.clone(); // Act proposal.try_tip_vote(max_voter_weight, &vote_tipping, current_timestamp,&vote_threshold,&vote_kind).unwrap(); // Assert let yes_vote_threshold_count = get_min_vote_threshold_weight(&yes_vote_threshold_percentage,governing_token_supply).unwrap(); let no_vote_weight = proposal.deny_vote_weight.unwrap(); if yes_votes_count >= yes_vote_threshold_count && yes_votes_count > (governing_token_supply - yes_votes_count) { assert_eq!(proposal.state,ProposalState::Succeeded); } else if no_vote_weight > (governing_token_supply - yes_vote_threshold_count) || no_vote_weight >= (governing_token_supply - no_vote_weight ) { assert_eq!(proposal.state,ProposalState::Defeated); } else { assert_eq!(proposal.state,ProposalState::Voting); } } } proptest! { #[test] fn test_finalize_vote_with_full_vote_results( (yes_votes_count, no_votes_count, governing_token_supply, yes_vote_threshold_percentage) in full_vote_results(), ) { // Arrange let mut proposal = create_test_proposal(); proposal.options[0].vote_weight = yes_votes_count; proposal.deny_vote_weight = Some(no_votes_count.min(governing_token_supply-yes_votes_count)); proposal.state = ProposalState::Voting; let governance_config = create_test_governance_config(); let yes_vote_threshold_percentage = VoteThreshold::YesVotePercentage(yes_vote_threshold_percentage); let current_timestamp = 16_i64; let realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; let max_voter_weight = proposal.get_max_voter_weight_from_mint_supply(&realm,&governing_token_mint,governing_token_supply,&vote_kind).unwrap(); let vote_threshold = yes_vote_threshold_percentage.clone(); // Act proposal.finalize_vote(max_voter_weight, &governance_config,current_timestamp,&vote_threshold).unwrap(); // Assert let no_vote_weight = proposal.deny_vote_weight.unwrap(); let yes_vote_threshold_count = get_min_vote_threshold_weight(&yes_vote_threshold_percentage,governing_token_supply).unwrap(); if yes_votes_count >= yes_vote_threshold_count && yes_votes_count > no_vote_weight { assert_eq!(proposal.state,ProposalState::Succeeded); } else { assert_eq!(proposal.state,ProposalState::Defeated); } } } #[test] fn test_try_tip_vote_with_reduced_community_mint_max_vote_weight() { // Arrange let mut proposal = create_test_proposal(); proposal.options[0].vote_weight = 60; proposal.deny_vote_weight = Some(10); proposal.state = ProposalState::Voting; let current_timestamp = 15_i64; let community_token_supply = 200; let mut realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; let vote_tipping = VoteTipping::Strict; // reduce max vote weight to 100 realm.config.community_mint_max_voter_weight_source = MintMaxVoterWeightSource::SupplyFraction( MintMaxVoterWeightSource::SUPPLY_FRACTION_BASE / 2, ); let max_voter_weight = proposal .get_max_voter_weight_from_mint_supply( &realm, &governing_token_mint, community_token_supply, &vote_kind, ) .unwrap(); let vote_threshold = &VoteThreshold::YesVotePercentage(60); let vote_kind = VoteKind::Electorate; // Act proposal .try_tip_vote( max_voter_weight, &vote_tipping, current_timestamp, vote_threshold, &vote_kind, ) .unwrap(); // Assert assert_eq!(proposal.state, ProposalState::Succeeded); assert_eq!(proposal.max_vote_weight, Some(100)); } #[test] fn test_try_tip_vote_with_reduced_absolute_community_mint_max_vote_weight() { // Arrange let mut proposal = create_test_proposal(); proposal.options[0].vote_weight = 60; proposal.deny_vote_weight = Some(10); proposal.state = ProposalState::Voting; let current_timestamp = 15_i64; let community_token_supply = 200; let mut realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; let vote_tipping = VoteTipping::Strict; // set max vote weight to 100 realm.config.community_mint_max_voter_weight_source = MintMaxVoterWeightSource::Absolute(community_token_supply / 2); let max_voter_weight = proposal .get_max_voter_weight_from_mint_supply( &realm, &governing_token_mint, community_token_supply, &vote_kind, ) .unwrap(); let vote_threshold = &VoteThreshold::YesVotePercentage(60); let vote_kind = VoteKind::Electorate; // Act proposal .try_tip_vote( max_voter_weight, &vote_tipping, current_timestamp, vote_threshold, &vote_kind, ) .unwrap(); // Assert assert_eq!(proposal.state, ProposalState::Succeeded); assert_eq!(proposal.max_vote_weight, Some(100)); } #[test] fn test_try_tip_vote_with_reduced_community_mint_max_vote_weight_and_vote_overflow() { // Arrange let mut proposal = create_test_proposal(); // no vote weight proposal.deny_vote_weight = Some(10); proposal.state = ProposalState::Voting; let current_timestamp = 15_i64; let community_token_supply = 200; let mut realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; let vote_tipping = VoteTipping::Strict; // reduce max vote weight to 100 realm.config.community_mint_max_voter_weight_source = MintMaxVoterWeightSource::SupplyFraction( MintMaxVoterWeightSource::SUPPLY_FRACTION_BASE / 2, ); // vote above reduced supply // Yes vote weight proposal.options[0].vote_weight = 120; let max_voter_weight = proposal .get_max_voter_weight_from_mint_supply( &realm, &governing_token_mint, community_token_supply, &vote_kind, ) .unwrap(); let vote_threshold = VoteThreshold::YesVotePercentage(60); // Act proposal .try_tip_vote( max_voter_weight, &vote_tipping, current_timestamp, &vote_threshold, &vote_kind, ) .unwrap(); // Assert assert_eq!(proposal.state, ProposalState::Succeeded); assert_eq!(proposal.max_vote_weight, Some(130)); } #[test] fn test_try_tip_vote_with_reduced_absolute_mint_max_vote_weight_and_vote_overflow() { // Arrange let mut proposal = create_test_proposal(); // no vote weight proposal.deny_vote_weight = Some(10); proposal.state = ProposalState::Voting; let current_timestamp = 15_i64; let community_token_supply = 200; let mut realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; let vote_tipping = VoteTipping::Strict; // reduce max vote weight to 100 realm.config.community_mint_max_voter_weight_source = MintMaxVoterWeightSource::Absolute(community_token_supply / 2); // vote above reduced supply // Yes vote weight proposal.options[0].vote_weight = 120; let max_voter_weight = proposal .get_max_voter_weight_from_mint_supply( &realm, &governing_token_mint, community_token_supply, &vote_kind, ) .unwrap(); let vote_threshold = VoteThreshold::YesVotePercentage(60); // Act proposal .try_tip_vote( max_voter_weight, &vote_tipping, current_timestamp, &vote_threshold, &vote_kind, ) .unwrap(); // Assert assert_eq!(proposal.state, ProposalState::Succeeded); assert_eq!(proposal.max_vote_weight, Some(130)); // Deny Vote 10 + Approve Vote 120 } #[test] fn test_try_tip_vote_for_council_vote_with_reduced_community_mint_max_vote_weight() { // Arrange let mut proposal = create_test_proposal(); proposal.options[0].vote_weight = 60; proposal.deny_vote_weight = Some(10); proposal.state = ProposalState::Voting; let current_timestamp = 15_i64; let community_token_supply = 200; let mut realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; let vote_tipping = VoteTipping::Strict; realm.config.community_mint_max_voter_weight_source = MintMaxVoterWeightSource::SupplyFraction( MintMaxVoterWeightSource::SUPPLY_FRACTION_BASE / 2, ); realm.config.council_mint = Some(proposal.governing_token_mint); let max_voter_weight = proposal .get_max_voter_weight_from_mint_supply( &realm, &governing_token_mint, community_token_supply, &vote_kind, ) .unwrap(); let vote_threshold = VoteThreshold::YesVotePercentage(60); // Act proposal .try_tip_vote( max_voter_weight, &vote_tipping, current_timestamp, &vote_threshold, &vote_kind, ) .unwrap(); // Assert assert_eq!(proposal.state, ProposalState::Voting); } #[test] fn test_finalize_vote_with_reduced_community_mint_max_vote_weight() { // Arrange let mut proposal = create_test_proposal(); proposal.options[0].vote_weight = 60; proposal.deny_vote_weight = Some(10); proposal.state = ProposalState::Voting; let governance_config = create_test_governance_config(); let current_timestamp = 16_i64; let community_token_supply = 200; let mut realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; // reduce max vote weight to 100 realm.config.community_mint_max_voter_weight_source = MintMaxVoterWeightSource::SupplyFraction( MintMaxVoterWeightSource::SUPPLY_FRACTION_BASE / 2, ); let max_voter_weight = proposal .get_max_voter_weight_from_mint_supply( &realm, &governing_token_mint, community_token_supply, &vote_kind, ) .unwrap(); let vote_threshold = VoteThreshold::YesVotePercentage(60); // Act proposal .finalize_vote( max_voter_weight, &governance_config, current_timestamp, &vote_threshold, ) .unwrap(); // Assert assert_eq!(proposal.state, ProposalState::Succeeded); assert_eq!(proposal.max_vote_weight, Some(100)); } #[test] fn test_finalize_vote_with_reduced_community_mint_max_vote_weight_and_vote_overflow() { // Arrange let mut proposal = create_test_proposal(); proposal.options[0].vote_weight = 60; proposal.deny_vote_weight = Some(10); proposal.state = ProposalState::Voting; let governance_config = create_test_governance_config(); let current_timestamp = 16_i64; let community_token_supply = 200; let mut realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; // reduce max vote weight to 100 realm.config.community_mint_max_voter_weight_source = MintMaxVoterWeightSource::SupplyFraction( MintMaxVoterWeightSource::SUPPLY_FRACTION_BASE / 2, ); // vote above reduced supply proposal.options[0].vote_weight = 120; let max_voter_weight = proposal .get_max_voter_weight_from_mint_supply( &realm, &governing_token_mint, community_token_supply, &vote_kind, ) .unwrap(); let vote_threshold = VoteThreshold::YesVotePercentage(60); // Act proposal .finalize_vote( max_voter_weight, &governance_config, current_timestamp, &vote_threshold, ) .unwrap(); // Assert assert_eq!(proposal.state, ProposalState::Succeeded); assert_eq!(proposal.max_vote_weight, Some(130)); } #[test] pub fn test_finalize_vote_with_expired_voting_time_error() { // Arrange let mut proposal = create_test_proposal(); proposal.state = ProposalState::Voting; let governance_config = create_test_governance_config(); let current_timestamp = proposal.voting_at.unwrap() + governance_config.voting_base_time as i64; let realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; let max_voter_weight = proposal .get_max_voter_weight_from_mint_supply(&realm, &governing_token_mint, 100, &vote_kind) .unwrap(); let vote_threshold = &governance_config.community_vote_threshold; // Act let err = proposal .finalize_vote( max_voter_weight, &governance_config, current_timestamp, vote_threshold, ) .err() .unwrap(); // Assert assert_eq!(err, GovernanceError::CannotFinalizeVotingInProgress.into()); } #[test] pub fn test_finalize_vote_after_voting_time() { // Arrange let mut proposal = create_test_proposal(); proposal.state = ProposalState::Voting; let governance_config = create_test_governance_config(); let current_timestamp = proposal.voting_at.unwrap() + governance_config.voting_base_time as i64 + 1; let realm = create_test_realm(); let governing_token_mint = proposal.governing_token_mint; let vote_kind = VoteKind::Electorate; let max_voter_weight = proposal .get_max_voter_weight_from_mint_supply(&realm, &governing_token_mint, 100, &vote_kind) .unwrap(); let vote_threshold = &governance_config.community_vote_threshold; // Act let result = proposal.finalize_vote( max_voter_weight, &governance_config, current_timestamp, vote_threshold, ); // Assert assert_eq!(result, Ok(())); } #[test] pub fn test_assert_can_vote_with_expired_voting_time_error() { // Arrange let mut proposal = create_test_proposal(); proposal.state = ProposalState::Voting; let governance_config = create_test_governance_config(); let current_timestamp = proposal.voting_at.unwrap() + governance_config.voting_base_time as i64 + 1; let vote = Vote::Approve(vec![]); // Act let err = proposal .assert_can_cast_vote(&governance_config, &vote, current_timestamp) .err() .unwrap(); // Assert assert_eq!(err, GovernanceError::ProposalVotingTimeExpired.into()); } #[test] pub fn test_assert_can_vote_within_voting_time() { // Arrange let mut proposal = create_test_proposal(); proposal.state = ProposalState::Voting; let governance_config = create_test_governance_config(); let current_timestamp = proposal.voting_at.unwrap() + governance_config.voting_base_time as i64; let vote = Vote::Approve(vec![]); // Act let result = proposal.assert_can_cast_vote(&governance_config, &vote, current_timestamp); // Assert assert_eq!(result, Ok(())); } #[test] pub fn test_assert_can_vote_approve_before_voting_cool_off_time() { // Arrange let mut proposal = create_test_proposal(); proposal.state = ProposalState::Voting; let mut governance_config = create_test_governance_config(); governance_config.voting_cool_off_time = 2; let current_timestamp = proposal.voting_at.unwrap() + governance_config.voting_base_time as i64 - 1; let vote = Vote::Approve(vec![]); // Act let result = proposal.assert_can_cast_vote(&governance_config, &vote, current_timestamp); // Assert assert_eq!(result, Ok(())); } #[test] pub fn test_assert_cannot_vote_approve_within_voting_cool_off_time() { // Arrange let mut proposal = create_test_proposal(); proposal.state = ProposalState::Voting; let mut governance_config = create_test_governance_config(); governance_config.voting_cool_off_time = 2; let current_timestamp = proposal.voting_at.unwrap() + governance_config.voting_base_time as i64 + 1; let vote = Vote::Approve(vec![]); // Act let err = proposal .assert_can_cast_vote(&governance_config, &vote, current_timestamp) .err() .unwrap(); // Assert assert_eq!(err, GovernanceError::VoteNotAllowedInCoolOffTime.into()); } #[test] pub fn test_assert_can_vote_veto_within_voting_cool_off_time() { // Arrange let mut proposal = create_test_proposal(); proposal.state = ProposalState::Voting; let mut governance_config = create_test_governance_config(); governance_config.voting_cool_off_time = 2; let current_timestamp = proposal.voting_at.unwrap() + governance_config.voting_base_time as i64 + 1; let vote = Vote::Veto; // Act let result = proposal.assert_can_cast_vote(&governance_config, &vote, current_timestamp); // Assert assert_eq!(result, Ok(())); } #[test] pub fn test_assert_can_vote_deny_within_voting_cool_off_time() { // Arrange let mut proposal = create_test_proposal(); proposal.state = ProposalState::Voting; let mut governance_config = create_test_governance_config(); governance_config.voting_cool_off_time = 1; let current_timestamp = proposal.voting_at.unwrap() + governance_config.voting_base_time as i64 + 1; let vote = Vote::Deny; // Act let result = proposal.assert_can_cast_vote(&governance_config, &vote, current_timestamp); // Assert assert_eq!(result, Ok(())); } #[test] pub fn test_assert_valid_vote_with_deny_vote_for_survey_only_proposal_error() { // Arrange let mut proposal = create_test_proposal(); proposal.deny_vote_weight = None; // Survey only proposal can't be denied let vote = Vote::Deny; // Act let result = proposal.assert_valid_vote(&vote); // Assert assert_eq!(result, Err(GovernanceError::InvalidVote.into())); } #[test] pub fn test_assert_valid_vote_with_too_many_options_error() { // Arrange let proposal = create_test_proposal(); let choices = vec![ VoteChoice { rank: 0, weight_percentage: 100, }, VoteChoice { rank: 0, weight_percentage: 100, }, ]; let vote = Vote::Approve(choices.clone()); // Ensure assert!(proposal.options.len() != choices.len()); // Act let result = proposal.assert_valid_vote(&vote); // Assert assert_eq!(result, Err(GovernanceError::InvalidVote.into())); } #[test] pub fn test_assert_valid_vote_with_no_choice_for_single_choice_error() { // Arrange let proposal = create_test_proposal(); let choices = vec![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::InvalidVote.into())); } #[test] pub fn test_assert_valid_vote_with_to_many_choices_for_single_choice_error() { // Arrange let proposal = create_test_multi_option_proposal(); let choices = vec![ VoteChoice { rank: 0, weight_percentage: 100, }, 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, Err(GovernanceError::InvalidVote.into())); } #[test] pub fn test_assert_valid_vote_with_no_choices_for_multi_choice_error() { // Arrange let mut proposal = create_test_multi_option_proposal(); proposal.vote_type = VoteType::MultiChoice { max_voter_options: 3, max_winning_options: 3, }; let choices = vec![ VoteChoice { rank: 0, weight_percentage: 0, }, VoteChoice { rank: 0, weight_percentage: 0, }, 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::InvalidVote.into())); } #[test] pub fn test_assert_valid_proposal_options_with_invalid_choice_number_for_multi_choice_vote_error( ) { // Arrange let vote_type = VoteType::MultiChoice { 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::InvalidProposalOptions.into())); } #[test] pub fn test_assert_valid_proposal_options_with_no_options_for_multi_choice_vote_error() { // Arrange let vote_type = VoteType::MultiChoice { 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_with_no_options_for_single_choice_vote_error() { // Arrange let vote_type = VoteType::SingleChoice; 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_choice_vote() { // Arrange let vote_type = VoteType::MultiChoice { 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_choice_vote_with_empty_option_error() { // Arrange let vote_type = VoteType::MultiChoice { 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] fn test_proposal_v1_to_v2_serialisation_roundtrip() { // Arrange let proposal_v1_source = ProposalV1 { account_type: GovernanceAccountType::ProposalV1, governance: Pubkey::new_unique(), governing_token_mint: Pubkey::new_unique(), state: ProposalState::Executing, token_owner_record: Pubkey::new_unique(), signatories_count: 5, signatories_signed_off_count: 4, yes_votes_count: 100, no_votes_count: 80, instructions_executed_count: 7, instructions_count: 8, instructions_next_index: 9, draft_at: 200, signing_off_at: Some(201), voting_at: Some(202), voting_at_slot: Some(203), voting_completed_at: Some(204), executing_at: Some(205), closed_at: Some(206), execution_flags: InstructionExecutionFlags::None, max_vote_weight: Some(250), vote_threshold: Some(VoteThreshold::YesVotePercentage(65)), name: "proposal".to_string(), description_link: "proposal-description".to_string(), }; let mut account_data = vec![]; proposal_v1_source.serialize(&mut account_data).unwrap(); let program_id = Pubkey::new_unique(); let info_key = Pubkey::new_unique(); let mut lamports = 10u64; let account_info = AccountInfo::new( &info_key, false, false, &mut lamports, &mut account_data[..], &program_id, false, Epoch::default(), ); // Act let proposal_v2 = get_proposal_data(&program_id, &account_info).unwrap(); proposal_v2 .serialize(&mut &mut **account_info.data.borrow_mut()) .unwrap(); // Assert let proposal_v1_target = get_account_data::(&program_id, &account_info).unwrap(); assert_eq!(proposal_v1_source, proposal_v1_target) } }