diff --git a/book/src/data-model/contested-documents.md b/book/src/data-model/contested-documents.md index 11b29c27a69..d9f10347eba 100644 --- a/book/src/data-model/contested-documents.md +++ b/book/src/data-model/contested-documents.md @@ -116,13 +116,17 @@ which holds its contenders, stored result and tallies, is a count tree, so a joi contenders there are in one fetch; a contest started before 14 keeps its plain tree, and a join counts its contenders by reading their keys. The masternodes' vote references live under `votes / contested_resource / identity_votes`, and the end dates under `votes / end_date_queries`, -one tree per end date holding an entry for each contest ending then. +one tree per end date holding an entry for each vote poll ending then. From protocol version 15 +that index also lists yes/no vote polls (the masternode polls that ask "should this happen?", +stored under `votes / decisions`), so an end date can hold contests and yes/no polls side by side. Once the contest ends, the winning document is awarded, the losers are removed, and the stored result stays for the `getContestedResourceVoteState` query. -A block ends at most `maximum_vote_polls_to_process` contests (a drive-abci `event_constants` -value, two at protocol version 14), the earliest end date first, so contests due together may end -over several blocks. The cleanup removes each ended contest's -end-date entry, and removes an end date only once none of its contests remain under it; the rest -end in the next blocks. Before that read, a block removes any end date among the first due that -holds no contest, since each would take a place in the read and end nothing. +A block ends at most `maximum_vote_polls_to_process` vote polls (a drive-abci `event_constants` +value, two at protocol version 14), the earliest end date first, so polls due together may end +over several blocks; from protocol version 15 yes/no polls count toward the same limit, so a +yes/no poll due earlier can push a contest to the next block. The cleanup removes each ended +poll's end-date entry, and removes an end date only once none of its polls, of either kind, +remain under it; the rest end in the next blocks. Before that read, a block removes any end date +among the first due that holds no vote poll, since each would take a place in the read and end +nothing. diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 124a8df7c28..39bbeb647d0 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -109,7 +109,7 @@ The fee category currently has a single code. The 30000 range is reserved for fu | 40000-40009 | Data Contract | `DataContractAlreadyPresentError` (40000), `DataContractIsReadonlyError` (40001), `DataContractNotFoundError` (40008) | | 40100-40149 | Documents | `DocumentAlreadyPresentError` (40100), `DocumentNotFoundError` (40101), `DuplicateUniqueIndexError` (40105), `DocumentActionFeeAgreementNotSetError` (40132), `DocumentActionFeeAgreementMismatchError` (40133), `DocumentActionFeeMultiplierNotToleratedError` (40134), `DocumentActionFeeModeratorsShareMismatchError` (40139), `DocumentExpiredError` (40140), `DocumentContestMaximumContendersReachedError` (40141), `ReferencedDocumentRequirementNotMetError` (40142), `ReferencedDocumentTypeNotModeratedError` (40143), `ReferencedDocumentTypeModeratedError` (40144), `ReferencedDocumentRemovedError` (40145), `ReferencedDocumentTypeIndexOnlyError` (40146), `DocumentDeleteConstraintViolatedError` (40147), `ReferencedDocumentRuleReadInvalidError` (40148), `ReferencedDocumentPrecededError` (40149) | | 40200-40217 | Identity | `IdentityAlreadyExistsError` (40200), `InvalidIdentityRevisionError` (40203), `IdentityInsufficientBalanceError` (40210) | -| 40300-40307 | Voting | `MasternodeNotFoundError` (40300), `MasternodeVoteAlreadyPresentError` (40304), `VoteChoiceNotAllowedForVotePollError` (40307) | +| 40300-40308 | Voting | `MasternodeNotFoundError` (40300), `MasternodeVoteAlreadyPresentError` (40304), `VoteChoiceNotAllowedForVotePollError` (40307), `YesNoVotePollNotAvailableForVotingError` (40308) | | 40400-40401 | Prefunded Balances | `PrefundedSpecializedBalanceInsufficientError` (40400) | | 40500-40502 | Data Triggers | `DataTriggerConditionError` (40500), `DataTriggerExecutionError` (40501) | | 40600-40603 | Addresses | `AddressDoesNotExistError` (40600), `AddressNotEnoughFundsError` (40601) | diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index 66f9ea9084b..470361285da 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -30,7 +30,7 @@ use crate::data_contract::document_type::DocumentPropertyType; #[cfg(feature = "validation")] use crate::data_contract::document_type::StringPropertySizes; use crate::fee::Credits; -use crate::voting::vote_polls::VotePoll; +use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; use platform_value::btreemap_extensions::{ BTreeValueMapInsertionPathHelper, BTreeValueMapPathHelper, }; @@ -1046,7 +1046,7 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe &self, document: &Document, platform_version: &PlatformVersion, - ) -> Result, ProtocolError> { + ) -> Result, ProtocolError> { match platform_version .dpp .contract_versions @@ -1067,7 +1067,7 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe &self, document_properties: &BTreeMap, platform_version: &PlatformVersion, - ) -> Result, ProtocolError> { + ) -> Result, ProtocolError> { match platform_version .dpp .contract_versions diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs index 00d288ad68c..72c518aafce 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs @@ -29,7 +29,6 @@ use crate::validation::SimpleConsensusValidationResult; use crate::voting::vote_polls::contested_document_resource_vote_poll::{ required_vote_resolution_fund, ContestedDocumentResourceVotePoll, }; -use crate::voting::vote_polls::VotePoll; use crate::ProtocolError; use chrono::Utc; use itertools::Itertools; @@ -373,19 +372,24 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa } /// Figures out the prefunded voting balance (v0) for a document in a document type + // In place, type only: this v0 (every protocol version) returned the same poll wrapped in + // `VotePoll::ContestedDocumentResourceVotePoll`, the only variant it ever built. The poll + // and every check made on it are unchanged. fn contested_vote_poll_for_document_v0( &self, document: &Document, platform_version: &PlatformVersion, - ) -> Result, ProtocolError> { + ) -> Result, ProtocolError> { self.contested_vote_poll_for_document_properties_v0(document.properties(), platform_version) } + // In place, type only, as for `contested_vote_poll_for_document_v0`: the poll built below is + // the one this v0 always built, no longer wrapped in `VotePoll`. fn contested_vote_poll_for_document_properties_v0( &self, document_properties: &BTreeMap, platform_version: &PlatformVersion, - ) -> Result, ProtocolError> { + ) -> Result, ProtocolError> { self.indexes() .values() .find(|index| { @@ -417,14 +421,12 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa self.flattened_properties(), platform_version, )?; - Ok(VotePoll::ContestedDocumentResourceVotePoll( - ContestedDocumentResourceVotePoll { - contract_id: self.data_contract_id(), - document_type_name: self.name().clone(), - index_name: index.name.clone(), - index_values, - }, - )) + Ok(ContestedDocumentResourceVotePoll { + contract_id: self.data_contract_id(), + document_type_name: self.name().clone(), + index_name: index.name.clone(), + index_values, + }) }) .transpose() } diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 51a88b762d2..cdae5e712b1 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -423,6 +423,7 @@ impl ErrorWithCode for StateError { Self::MasternodeIncorrectVotingAddressError(_) => 40305, Self::MasternodeIncorrectVoterIdentityIdError(_) => 40306, Self::VoteChoiceNotAllowedForVotePollError(_) => 40307, + Self::YesNoVotePollNotAvailableForVotingError(_) => 40308, // Prefunded specialized balances Errors: 40400-40499 Self::PrefundedSpecializedBalanceInsufficientError(_) => 40400, diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 7b60b11c177..e6db59653ca 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -123,6 +123,7 @@ use crate::consensus::state::voting::masternode_voted_too_many_times::Masternode use crate::consensus::state::voting::vote_poll_not_available_for_voting_error::VotePollNotAvailableForVotingError; use crate::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; use crate::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; +use crate::consensus::state::voting::yes_no_vote_poll_not_available_for_voting_error::YesNoVotePollNotAvailableForVotingError; use super::document::document_timestamps_are_equal_error::DocumentTimestampsAreEqualError; @@ -745,6 +746,10 @@ pub enum StateError { // `chooseFirst` groups by (protocol version 14). #[error(transparent)] ReferencedDocumentPrecededError(ReferencedDocumentPrecededError), + + // Yes/no vote polls (protocol version 15). + #[error(transparent)] + YesNoVotePollNotAvailableForVotingError(YesNoVotePollNotAvailableForVotingError), } impl From for ConsensusError { @@ -756,6 +761,7 @@ impl From for ConsensusError { #[cfg(test)] mod tests { use super::*; + use crate::block::block_info::BlockInfo; use crate::consensus::basic::document::PropertyConstraintViolation; use crate::consensus::state::contract_moderation::ContractModerationCounterpartyRole; use crate::consensus::state::identity::identity_public_key_limit_not_set_error::KeyLimit; @@ -772,9 +778,11 @@ mod tests { }; use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; use crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use crate::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStatus; use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + use crate::voting::vote_polls::yes_no_vote_poll::{YesNoMinimumVotingPower, YesNoVotePoll}; use crate::voting::vote_polls::VotePoll; - use platform_value::Identifier; + use platform_value::{BinaryData, Identifier}; use std::collections::BTreeMap; /// `StateError` is encoded by variant position, so inserting a variant @@ -1674,5 +1682,22 @@ mod tests { )), 175 ); + // Yes/no vote polls (protocol version 15): the tail of the enum. + assert_eq!( + discriminant_of(StateError::YesNoVotePollNotAvailableForVotingError( + YesNoVotePollNotAvailableForVotingError::new( + YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![1; 32])], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute { + voting_power: 400 + }, + }, + YesNoVotePollStatus::Started(BlockInfo::default()) + ) + )), + 176 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs b/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs index 0414bd7656a..35488d89dc3 100644 --- a/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs @@ -6,3 +6,4 @@ pub mod masternode_voted_too_many_times; pub mod vote_choice_not_allowed_for_vote_poll_error; pub mod vote_poll_not_available_for_voting_error; pub mod vote_poll_not_found_error; +pub mod yes_no_vote_poll_not_available_for_voting_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/voting/yes_no_vote_poll_not_available_for_voting_error.rs b/packages/rs-dpp/src/errors/consensus/state/voting/yes_no_vote_poll_not_available_for_voting_error.rs new file mode 100644 index 00000000000..be53422911d --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/voting/yes_no_vote_poll_not_available_for_voting_error.rs @@ -0,0 +1,53 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStatus; +use crate::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A vote on a yes/no poll that exists but is no longer taking votes (protocol version 15). +#[derive( + Error, + Debug, + Clone, + PartialEq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("yes/no vote poll {vote_poll} is not available for voting: {status}")] +#[platform_serialize(unversioned)] +pub struct YesNoVotePollNotAvailableForVotingError { + /* + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + */ + vote_poll: YesNoVotePoll, + status: YesNoVotePollStatus, +} + +impl YesNoVotePollNotAvailableForVotingError { + pub fn new(vote_poll: YesNoVotePoll, status: YesNoVotePollStatus) -> Self { + Self { vote_poll, status } + } + + pub fn vote_poll(&self) -> &YesNoVotePoll { + &self.vote_poll + } + + pub fn status(&self) -> &YesNoVotePollStatus { + &self.status + } +} + +impl From for ConsensusError { + fn from(err: YesNoVotePollNotAvailableForVotingError) -> Self { + Self::StateError(StateError::YesNoVotePollNotAvailableForVotingError(err)) + } +} diff --git a/packages/rs-dpp/src/serialization/json/safe_fields.rs b/packages/rs-dpp/src/serialization/json/safe_fields.rs index 50d14da7120..76045bd4df6 100644 --- a/packages/rs-dpp/src/serialization/json/safe_fields.rs +++ b/packages/rs-dpp/src/serialization/json/safe_fields.rs @@ -150,6 +150,11 @@ impl JsonSafeFields for TokenTransition {} #[cfg(feature = "state-transitions")] impl JsonSafeFields for BatchedTransition {} impl JsonSafeFields for crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice {} +impl JsonSafeFields + for crate::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice +{ +} +impl JsonSafeFields for crate::voting::vote_polls::yes_no_vote_poll::YesNoMinimumVotingPower {} impl JsonSafeFields for crate::group::action_event::GroupActionEvent {} // TokenEvent contains u64 aliases (TokenAmount, Credits) in tuple variants that // `#[json_safe_fields]` can't auto-annotate. Developer takes responsibility for diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index a47c0d07eb8..78ad1a003f4 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -19,6 +19,7 @@ use dashcore::signer::double_sha; use platform_serialization_derive::{ PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, PlatformSignable, }; +use platform_version::version::feature_initial_protocol_versions::YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION; use platform_version::version::{PlatformVersion, ProtocolVersion, ALL_VERSIONS, LATEST_VERSION}; #[cfg(any( @@ -158,6 +159,7 @@ use crate::state_transition::identity_update_transition::accessors::IdentityUpda use crate::state_transition::identity_update_transition::{ IdentityUpdateTransition, IdentityUpdateTransitionSignable, }; +use crate::state_transition::masternode_vote_transition::accessors::MasternodeVoteTransitionAccessorsV0; use crate::state_transition::masternode_vote_transition::MasternodeVoteTransition; use crate::state_transition::masternode_vote_transition::MasternodeVoteTransitionSignable; use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; @@ -189,6 +191,9 @@ use crate::state_transition::token_unshield_with_shielded_fee_transition::{ use crate::state_transition::unshield_transition::{ UnshieldTransition, UnshieldTransitionSignable, }; +use crate::voting::vote_polls::VotePoll; +use crate::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; +use crate::voting::votes::Vote; use state_transitions::document::batch_transition::batched_transition::token_transition::TokenTransition; pub use state_transitions::*; @@ -1215,8 +1220,20 @@ impl StateTransition { } StateTransition::IdentityTopUp(_) | StateTransition::IdentityCreditWithdrawal(_) - | StateTransition::IdentityCreditTransfer(_) - | StateTransition::MasternodeVote(_) => ALL_VERSIONS, + | StateTransition::IdentityCreditTransfer(_) => ALL_VERSIONS, + // A vote that names a yes/no poll, as a yes/no vote or as a resource vote, exists + // from protocol version 15. Binaries from before it cannot decode one, so an earlier + // version rejects it without charging, exactly as they do, and no masternode vote + // validator selected before 15 ever sees a yes/no poll. + StateTransition::MasternodeVote(masternode_vote) => match masternode_vote.vote() { + Vote::ResourceVote(resource_vote) => match resource_vote.vote_poll() { + VotePoll::ContestedDocumentResourceVotePoll(_) => ALL_VERSIONS, + VotePoll::YesNoVotePoll(_) => { + YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION..=LATEST_VERSION + } + }, + Vote::YesNoVote(_) => YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION..=LATEST_VERSION, + }, StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::IdentityTopUpFromAddresses(_) | StateTransition::AddressFundsTransfer(_) @@ -2738,6 +2755,72 @@ mod tests { assert_eq!(sample_withdrawal_st().active_version_range(), ALL_VERSIONS); } + /// Both vote kinds can name a yes/no poll: a yes/no vote, and a resource vote (which + /// protocol version 15 refuses as naming no contested poll). Before 15 neither decodes, so + /// the masternode vote validators selected there never meet a yes/no poll. + #[test] + fn should_make_a_masternode_vote_on_a_yes_no_poll_active_from_protocol_version_15() { + use crate::serialization::PlatformSerializable; + use crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use crate::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; + use crate::voting::vote_polls::yes_no_vote_poll::{YesNoMinimumVotingPower, YesNoVotePoll}; + use crate::voting::votes::resource_vote::v0::ResourceVoteV0; + use crate::voting::votes::resource_vote::ResourceVote; + use crate::voting::votes::yes_no_vote::v0::YesNoVoteV0; + use crate::voting::votes::yes_no_vote::YesNoVote; + + let vote_poll = YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![7u8; 32])], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 400 }, + }; + let votes = [ + Vote::YesNoVote(YesNoVote::V0(YesNoVoteV0 { + vote_poll: vote_poll.clone(), + vote_choice: YesNoAbstainVoteChoice::Yes, + })), + Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::YesNoVotePoll(vote_poll), + resource_vote_choice: ResourceVoteChoice::Abstain, + })), + ]; + let version_14 = PlatformVersion::get(14).expect("protocol version 14"); + let version_15 = PlatformVersion::get(15).expect("protocol version 15"); + + for vote in votes { + let v0 = MasternodeVoteTransitionV0 { + pro_tx_hash: Identifier::from([3u8; 32]), + voter_identity_id: Identifier::from([4u8; 32]), + vote, + nonce: 2, + signature_public_key_id: 5, + signature: BinaryData::new(vec![9u8; 10]), + }; + let state_transition = + StateTransition::MasternodeVote(MasternodeVoteTransition::V0(v0)); + assert_eq!( + state_transition.active_version_range(), + YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION..=LATEST_VERSION + ); + + let bytes = state_transition + .serialize_to_bytes() + .expect("expected to serialize the vote"); + assert!(matches!( + StateTransition::deserialize_from_bytes_untrusted_in_version(&bytes, version_14), + Err(ProtocolError::StateTransitionError( + StateTransitionIsNotActiveError { .. } + )) + )); + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted_in_version(&bytes, version_15) + .expect("expected the vote to be active at 15"), + state_transition + ); + } + } + #[test] fn test_unique_identifiers_non_empty() { let ids = sample_transfer_st().unique_identifiers(); diff --git a/packages/rs-dpp/src/voting/vote_choices/yes_no_abstain_vote_choice/mod.rs b/packages/rs-dpp/src/voting/vote_choices/yes_no_abstain_vote_choice/mod.rs index a3e16b456a5..b129e3c5b50 100644 --- a/packages/rs-dpp/src/voting/vote_choices/yes_no_abstain_vote_choice/mod.rs +++ b/packages/rs-dpp/src/voting/vote_choices/yes_no_abstain_vote_choice/mod.rs @@ -5,18 +5,53 @@ use crate::serialization::ValueConvertible; use bincode::{Decode, DecodeUntrusted, Encode}; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; +use std::fmt; -#[derive(Debug, Clone, Copy, Encode, Decode, PartialEq, Default, DecodeUntrusted)] +/// The answer a masternode gives a yes/no vote poll. Abstaining counts towards nothing: the +/// poll's supermajority and minimum are measured over yes plus no only. +#[derive( + Debug, + Clone, + Copy, + Encode, + Decode, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Default, + DecodeUntrusted, +)] #[cfg_attr( feature = "serde-conversion", derive(Serialize, Deserialize), serde(rename_all = "camelCase") )] pub enum YesNoAbstainVoteChoice { - YES, - NO, + Yes, + No, #[default] - ABSTAIN, + Abstain, +} + +impl YesNoAbstainVoteChoice { + /// Every choice, in tree key order. + pub const ALL: [YesNoAbstainVoteChoice; 3] = [ + YesNoAbstainVoteChoice::Yes, + YesNoAbstainVoteChoice::No, + YesNoAbstainVoteChoice::Abstain, + ]; +} + +impl fmt::Display for YesNoAbstainVoteChoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + YesNoAbstainVoteChoice::Yes => write!(f, "Yes"), + YesNoAbstainVoteChoice::No => write!(f, "No"), + YesNoAbstainVoteChoice::Abstain => write!(f, "Abstain"), + } + } } // --- canonical conversion trait impls (unification pass 1) --- @@ -37,23 +72,15 @@ mod json_convertible_tests_yesnoabstainvotechoice { use platform_value::platform_value; use serde_json::json; - // `YesNoAbstainVoteChoice` is a unit-only enum with `rename_all = "camelCase"`, - // so each variant serializes as a plain string: `"yes"` / `"no"` / `"abstain"`. - - // Surprise wire shape: the variants are SCREAMING_CASE in source - // (`YES`/`NO`/`ABSTAIN`) and the type carries `rename_all = "camelCase"`. - // serde's camelCase rule lowercases the FIRST letter only, leaving the - // rest as-is — so the wire emits `"yES"` / `"nO"` / `"aBSTAIN"` rather - // than the lowercase-clean strings a casual reader would expect. These - // tests pin that behaviour so a future "looks-like-a-typo" rename to - // lowercase doesn't silently change the on-the-wire format. + // A unit-only enum with `rename_all = "camelCase"`: each variant is a plain lowercase + // string on the wire. #[test] fn json_round_trip_yes() { use crate::serialization::JsonConvertible; - let original = YesNoAbstainVoteChoice::YES; + let original = YesNoAbstainVoteChoice::Yes; let json = original.to_json().expect("to_json"); - assert_eq!(json, json!("yES")); + assert_eq!(json, json!("yes")); let recovered = YesNoAbstainVoteChoice::from_json(json).expect("from_json"); assert_eq!(original, recovered); } @@ -61,9 +88,9 @@ mod json_convertible_tests_yesnoabstainvotechoice { #[test] fn json_round_trip_no() { use crate::serialization::JsonConvertible; - let original = YesNoAbstainVoteChoice::NO; + let original = YesNoAbstainVoteChoice::No; let json = original.to_json().expect("to_json"); - assert_eq!(json, json!("nO")); + assert_eq!(json, json!("no")); let recovered = YesNoAbstainVoteChoice::from_json(json).expect("from_json"); assert_eq!(original, recovered); } @@ -71,9 +98,9 @@ mod json_convertible_tests_yesnoabstainvotechoice { #[test] fn json_round_trip_abstain() { use crate::serialization::JsonConvertible; - let original = YesNoAbstainVoteChoice::ABSTAIN; + let original = YesNoAbstainVoteChoice::Abstain; let json = original.to_json().expect("to_json"); - assert_eq!(json, json!("aBSTAIN")); + assert_eq!(json, json!("abstain")); let recovered = YesNoAbstainVoteChoice::from_json(json).expect("from_json"); assert_eq!(original, recovered); } @@ -81,9 +108,9 @@ mod json_convertible_tests_yesnoabstainvotechoice { #[test] fn value_round_trip_yes() { use crate::serialization::ValueConvertible; - let original = YesNoAbstainVoteChoice::YES; + let original = YesNoAbstainVoteChoice::Yes; let value = original.to_object().expect("to_object"); - assert_eq!(value, platform_value!("yES")); + assert_eq!(value, platform_value!("yes")); let recovered = YesNoAbstainVoteChoice::from_object(value).expect("from_object"); assert_eq!(original, recovered); } @@ -91,9 +118,9 @@ mod json_convertible_tests_yesnoabstainvotechoice { #[test] fn value_round_trip_no() { use crate::serialization::ValueConvertible; - let original = YesNoAbstainVoteChoice::NO; + let original = YesNoAbstainVoteChoice::No; let value = original.to_object().expect("to_object"); - assert_eq!(value, platform_value!("nO")); + assert_eq!(value, platform_value!("no")); let recovered = YesNoAbstainVoteChoice::from_object(value).expect("from_object"); assert_eq!(original, recovered); } @@ -101,9 +128,9 @@ mod json_convertible_tests_yesnoabstainvotechoice { #[test] fn value_round_trip_abstain() { use crate::serialization::ValueConvertible; - let original = YesNoAbstainVoteChoice::ABSTAIN; + let original = YesNoAbstainVoteChoice::Abstain; let value = original.to_object().expect("to_object"); - assert_eq!(value, platform_value!("aBSTAIN")); + assert_eq!(value, platform_value!("abstain")); let recovered = YesNoAbstainVoteChoice::from_object(value).expect("from_object"); assert_eq!(original, recovered); } diff --git a/packages/rs-dpp/src/voting/vote_info_storage/mod.rs b/packages/rs-dpp/src/voting/vote_info_storage/mod.rs index a69f0aee63a..740f6cddc3f 100644 --- a/packages/rs-dpp/src/voting/vote_info_storage/mod.rs +++ b/packages/rs-dpp/src/voting/vote_info_storage/mod.rs @@ -1,2 +1,3 @@ pub mod contested_document_vote_poll_stored_info; pub mod contested_document_vote_poll_winner_info; +pub mod yes_no_vote_poll_stored_info; diff --git a/packages/rs-dpp/src/voting/vote_info_storage/yes_no_vote_poll_stored_info/mod.rs b/packages/rs-dpp/src/voting/vote_info_storage/yes_no_vote_poll_stored_info/mod.rs new file mode 100644 index 00000000000..3c7fb1f8d94 --- /dev/null +++ b/packages/rs-dpp/src/voting/vote_info_storage/yes_no_vote_poll_stored_info/mod.rs @@ -0,0 +1,223 @@ +pub mod v0; + +use crate::block::block_info::BlockInfo; +use crate::voting::vote_info_storage::yes_no_vote_poll_stored_info::v0::YesNoVotePollStoredInfoV0; +use crate::voting::vote_polls::yes_no_vote_poll::VotingPower; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::From; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_version::version::PlatformVersion; +use std::fmt; + +/// How a yes/no vote poll ended: the final tallies and whether they passed the poll's rule. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default, Encode, Decode, DecodeUntrusted)] +pub struct YesNoVotePollResult { + /// Whether the poll passed. + pub passed: bool, + /// The yes voting power at the end. + pub yes_voting_power: VotingPower, + /// The no voting power at the end. + pub no_voting_power: VotingPower, + /// The abstaining voting power at the end. Recorded, but it plays no part in the result. + pub abstain_voting_power: VotingPower, + /// The yes plus no voting power the poll needed, resolved in the block that closed it (a + /// share of the total is measured then). + pub required_voting_power: VotingPower, + /// The block the poll opened in. + pub start_block: BlockInfo, + /// The block the poll was closed in. + pub finalization_block: BlockInfo, +} + +impl fmt::Display for YesNoVotePollResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "YesNoVotePollResult {{ passed: {}, yes: {}, no: {}, abstain: {}, required: {}, start_block: {}, finalization_block: {} }}", + self.passed, + self.yes_voting_power, + self.no_voting_power, + self.abstain_voting_power, + self.required_voting_power, + self.start_block, + self.finalization_block + ) + } +} + +/// Where a yes/no vote poll is in its life. A poll runs once: finished, it stays finished, and +/// its result is the record of the decision. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Encode, Decode, DecodeUntrusted)] +pub enum YesNoVotePollStatus { + /// Votes are being taken since this block. + Started(BlockInfo), + /// The poll was closed with this result. + Finished(YesNoVotePollResult), +} + +impl fmt::Display for YesNoVotePollStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + YesNoVotePollStatus::Started(block_info) => write!(f, "Started({})", block_info), + YesNoVotePollStatus::Finished(result) => write!(f, "Finished({})", result), + } + } +} + +/// What Drive stores about a yes/no vote poll beside its votes. +#[derive( + Debug, + PartialEq, + Eq, + Clone, + From, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[platform_serialize(unversioned)] +pub enum YesNoVotePollStoredInfo { + V0(YesNoVotePollStoredInfoV0), +} + +impl fmt::Display for YesNoVotePollStoredInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + YesNoVotePollStoredInfo::V0(info) => write!(f, "V0({})", info), + } + } +} + +impl YesNoVotePollStoredInfo { + /// The stored info of a poll that opens in `start_block`. + pub fn new( + start_block: BlockInfo, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .voting_versions + .yes_no_vote_poll_stored_info_version + { + Some(0) => Ok(YesNoVotePollStoredInfoV0::new(start_block).into()), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "YesNoVotePollStoredInfo::new".to_string(), + known_versions: vec![0], + received: version, + }), + None => Err(ProtocolError::UnknownVersionError( + "YesNoVotePollStoredInfo::new is not active before protocol version 15".to_string(), + )), + } + } + + /// Closes a started poll with its final tallies. `passed` is the poll's own verdict on + /// them; the stored info records it without judging. + pub fn finalize_vote_poll( + &mut self, + yes_voting_power: VotingPower, + no_voting_power: VotingPower, + abstain_voting_power: VotingPower, + required_voting_power: VotingPower, + passed: bool, + finalization_block: BlockInfo, + ) -> Result { + match self { + YesNoVotePollStoredInfo::V0(v0) => v0.finalize_vote_poll( + yes_voting_power, + no_voting_power, + abstain_voting_power, + required_voting_power, + passed, + finalization_block, + ), + } + } + + /// Where the poll is in its life. + pub fn status(&self) -> &YesNoVotePollStatus { + match self { + YesNoVotePollStoredInfo::V0(v0) => &v0.status, + } + } + + /// The result, once the poll has finished. + pub fn result(&self) -> Option<&YesNoVotePollResult> { + match self.status() { + YesNoVotePollStatus::Started(_) => None, + YesNoVotePollStatus::Finished(result) => Some(result), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::serialization::{PlatformDeserializableTrusted, PlatformSerializable}; + + #[test] + fn should_report_the_stored_info_not_active_before_protocol_version_15() { + let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); + assert!(matches!( + YesNoVotePollStoredInfo::new(BlockInfo::default(), platform_version), + Err(ProtocolError::UnknownVersionError(_)) + )); + } + + #[test] + fn should_finalize_a_started_poll_once() { + let platform_version = PlatformVersion::latest(); + let start_block = BlockInfo::default_with_time(10); + let mut stored_info = + YesNoVotePollStoredInfo::new(start_block, platform_version).expect("stored info"); + assert_eq!( + stored_info.status(), + &YesNoVotePollStatus::Started(start_block) + ); + assert!(stored_info.result().is_none()); + + let finalization_block = BlockInfo::default_with_time(20); + let result = stored_info + .finalize_vote_poll(300, 100, 7, 400, true, finalization_block) + .expect("finalize"); + assert_eq!( + result, + YesNoVotePollResult { + passed: true, + yes_voting_power: 300, + no_voting_power: 100, + abstain_voting_power: 7, + required_voting_power: 400, + start_block, + finalization_block, + } + ); + assert_eq!(stored_info.result(), Some(&result)); + + // A finished poll is not finalized twice. + assert!(stored_info + .finalize_vote_poll(1, 1, 1, 400, false, finalization_block) + .is_err()); + } + + #[test] + fn should_round_trip_through_platform_serialization() { + let platform_version = PlatformVersion::latest(); + let mut stored_info = + YesNoVotePollStoredInfo::new(BlockInfo::default_with_time(10), platform_version) + .expect("stored info"); + stored_info + .finalize_vote_poll(5, 4, 3, 400, false, BlockInfo::default_with_time(20)) + .expect("finalize"); + let bytes = stored_info.serialize_to_bytes().expect("serialize"); + let recovered = + YesNoVotePollStoredInfo::deserialize_from_bytes_trusted(&bytes).expect("deserialize"); + assert_eq!(recovered, stored_info); + } +} diff --git a/packages/rs-dpp/src/voting/vote_info_storage/yes_no_vote_poll_stored_info/v0/mod.rs b/packages/rs-dpp/src/voting/vote_info_storage/yes_no_vote_poll_stored_info/v0/mod.rs new file mode 100644 index 00000000000..d89954e853d --- /dev/null +++ b/packages/rs-dpp/src/voting/vote_info_storage/yes_no_vote_poll_stored_info/v0/mod.rs @@ -0,0 +1,56 @@ +use crate::block::block_info::BlockInfo; +use crate::voting::vote_info_storage::yes_no_vote_poll_stored_info::{ + YesNoVotePollResult, YesNoVotePollStatus, +}; +use crate::voting::vote_polls::yes_no_vote_poll::VotingPower; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use std::fmt; + +/// Version 0 of the stored info of a yes/no vote poll: only its status. +#[derive(Debug, PartialEq, Eq, Clone, Encode, Decode, DecodeUntrusted)] +pub struct YesNoVotePollStoredInfoV0 { + /// Where the poll is in its life. + pub status: YesNoVotePollStatus, +} + +impl fmt::Display for YesNoVotePollStoredInfoV0 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "YesNoVotePollStoredInfoV0 {{ status: {} }}", self.status) + } +} + +impl YesNoVotePollStoredInfoV0 { + pub fn new(start_block: BlockInfo) -> YesNoVotePollStoredInfoV0 { + YesNoVotePollStoredInfoV0 { + status: YesNoVotePollStatus::Started(start_block), + } + } + + pub fn finalize_vote_poll( + &mut self, + yes_voting_power: VotingPower, + no_voting_power: VotingPower, + abstain_voting_power: VotingPower, + required_voting_power: VotingPower, + passed: bool, + finalization_block: BlockInfo, + ) -> Result { + let YesNoVotePollStatus::Started(start_block) = self.status else { + return Err(ProtocolError::CorruptedCodeExecution( + "trying to finalize a yes/no vote poll that is not started".to_string(), + )); + }; + let result = YesNoVotePollResult { + passed, + yes_voting_power, + no_voting_power, + abstain_voting_power, + required_voting_power, + start_block, + finalization_block, + }; + self.status = YesNoVotePollStatus::Finished(result); + Ok(result) + } +} diff --git a/packages/rs-dpp/src/voting/vote_polls/mod.rs b/packages/rs-dpp/src/voting/vote_polls/mod.rs index 8664016e4e5..f6bc473cd36 100644 --- a/packages/rs-dpp/src/voting/vote_polls/mod.rs +++ b/packages/rs-dpp/src/voting/vote_polls/mod.rs @@ -3,6 +3,7 @@ use crate::serialization::JsonConvertible; #[cfg(feature = "value-conversion")] use crate::serialization::ValueConvertible; use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +use crate::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::From; @@ -10,11 +11,13 @@ use platform_serialization_derive::{ PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, }; use platform_value::Identifier; +use platform_version::version::PlatformVersion; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; use std::fmt; pub mod contested_document_resource_vote_poll; +pub mod yes_no_vote_poll; #[cfg_attr( all(feature = "json-conversion", feature = "serde-conversion"), @@ -44,6 +47,10 @@ pub mod contested_document_resource_vote_poll; #[platform_serialize(limit = 100000)] pub enum VotePoll { ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll), + /// A yes/no poll keyed by a resource path, resolved by a supermajority once enough voting + /// power has been cast (protocol version 15). Appended: the bincode discriminant is the + /// variant position. + YesNoVotePoll(YesNoVotePoll), } impl fmt::Display for VotePoll { @@ -52,6 +59,7 @@ impl fmt::Display for VotePoll { VotePoll::ContestedDocumentResourceVotePoll(poll) => { write!(f, "ContestedDocumentResourceVotePoll({})", poll) } + VotePoll::YesNoVotePoll(poll) => write!(f, "YesNoVotePoll({})", poll), } } } @@ -63,21 +71,37 @@ impl Default for VotePoll { } impl VotePoll { - pub fn specialized_balance_id(&self) -> Result, ProtocolError> { + /// The prefunded balance the votes on this poll are paid from. A yes/no poll's id is + /// derived as the platform version selects; a contested poll's does not depend on it. + pub fn specialized_balance_id( + &self, + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { match self { VotePoll::ContestedDocumentResourceVotePoll(contested_document_resource_vote_poll) => { Ok(Some( contested_document_resource_vote_poll.specialized_balance_id()?, )) } + VotePoll::YesNoVotePoll(yes_no_vote_poll) => Ok(Some( + yes_no_vote_poll.specialized_balance_id(platform_version)?, + )), } } - pub fn unique_id(&self) -> Result { + /// The id the poll is stored and voted on under. A yes/no poll's id is derived as the + /// platform version selects; a contested poll's does not depend on it. + pub fn unique_id( + &self, + platform_version: &PlatformVersion, + ) -> Result { match self { VotePoll::ContestedDocumentResourceVotePoll(contested_document_resource_vote_poll) => { contested_document_resource_vote_poll.unique_id() } + VotePoll::YesNoVotePoll(yes_no_vote_poll) => { + yes_no_vote_poll.unique_id(platform_version) + } } } } diff --git a/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs b/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs new file mode 100644 index 00000000000..f18803f05f8 --- /dev/null +++ b/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs @@ -0,0 +1,877 @@ +#[cfg(feature = "json-conversion")] +use crate::serialization::json_safe_fields; +#[cfg(feature = "json-conversion")] +use crate::serialization::JsonConvertible; +use crate::serialization::PlatformSerializable; +#[cfg(feature = "value-conversion")] +use crate::serialization::ValueConvertible; +use crate::util::hash::hash_double; +use crate::voting::vote_polls::VotePoll; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::{BinaryData, Identifier}; +use platform_version::version::PlatformVersion; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Voting power: a masternode vote weighs 1, an evonode vote weighs 4. The sum over the whole +/// masternode list fits a `u32`. +pub type VotingPower = u32; + +/// Prefixed to the serialized poll before its id is hashed. A contested poll's id hashes its +/// bare encoding, which starts with a 32-byte contract id; matching this 33-byte tag would take +/// a contract id equal to its first 32 bytes, so the two kinds never share an id (and with it a +/// prefunded balance and an end date index entry). +const YES_NO_VOTE_POLL_ID_DOMAIN: &[u8] = b"dash platform yes/no vote poll id"; + +/// Which way a fraction of the total voting power rounds to a whole voting power. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub enum VotingPowerRounding { + /// Toward zero: 1/3 of 10 is 3, 1/3 of 9 is 3. + Down, + /// Away from zero: 1/3 of 10 is 4, 1/3 of 9 is 3. + Up, + /// Rounded down, plus one: 1/3 of 10 is 4, 1/3 of 9 is 4. Half the total this way is a + /// strict majority of it. + DownPlusOne, + /// Rounded up, minus one, never below zero: 1/3 of 10 is 3, 1/3 of 9 is 2. + UpMinusOne, +} + +/// The least yes plus no voting power a yes/no poll needs to pass. +/// +/// Its JSON and Value shape is internally tagged, as every sum type's is: +/// `{ "$type": "absolute", "votingPower": 400 }` or +/// `{ "$type": "fractionOfTotal", "numerator": 1, "denominator": 3, "rounding": "up" }`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$type", rename_all = "camelCase") +)] +pub enum YesNoMinimumVotingPower { + /// A fixed voting power, at most `max_yes_no_vote_poll_minimum_voting_power`. + #[cfg_attr(feature = "serde-conversion", serde(rename_all = "camelCase"))] + Absolute { voting_power: VotingPower }, + /// A share of the total voting power of the masternode list (masternode 1, evonode 4) in + /// the block that closes the poll, rounded as asked, at most + /// `max_yes_no_vote_poll_minimum_voting_power_percent_of_total` percent of it. + FractionOfTotal { + numerator: u8, + denominator: u8, + rounding: VotingPowerRounding, + }, +} + +impl YesNoMinimumVotingPower { + /// The minimum as a voting power, given the total voting power of the masternode list when + /// the poll closes, as `YesNoVotePoll::required_voting_power` version 0 resolves it. A + /// fraction with a zero denominator (refused when the poll opens) needs more than any poll + /// can gather. + fn resolve_v0(&self, total_voting_power: VotingPower) -> VotingPower { + match *self { + YesNoMinimumVotingPower::Absolute { voting_power } => voting_power, + YesNoMinimumVotingPower::FractionOfTotal { + numerator, + denominator, + rounding, + } => { + if denominator == 0 { + return VotingPower::MAX; + } + let share = total_voting_power as u64 * numerator as u64; + let denominator = denominator as u64; + let resolved = match rounding { + VotingPowerRounding::Down => share / denominator, + VotingPowerRounding::Up => share.div_ceil(denominator), + VotingPowerRounding::DownPlusOne => share / denominator + 1, + VotingPowerRounding::UpMinusOne => { + share.div_ceil(denominator).saturating_sub(1) + } + }; + VotingPower::try_from(resolved).unwrap_or(VotingPower::MAX) + } + } + } +} + +impl fmt::Display for YesNoMinimumVotingPower { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + YesNoMinimumVotingPower::Absolute { voting_power } => write!(f, "{}", voting_power), + YesNoMinimumVotingPower::FractionOfTotal { + numerator, + denominator, + rounding, + } => write!( + f, + "{}/{} of the total, rounded {}", + numerator, + denominator, + match rounding { + VotingPowerRounding::Down => "down", + VotingPowerRounding::Up => "up", + VotingPowerRounding::DownPlusOne => "down plus one", + VotingPowerRounding::UpMinusOne => "up minus one", + } + ), + } + } +} + +/// A poll the masternodes answer with yes, no or abstain. +/// +/// The poll is keyed by a resource path the feature that opens it chooses (a contract id and a +/// purpose, for example). Two polls with the same resource path and the same parameters are the +/// same poll, so a feature that may run the same question again includes something that +/// distinguishes the rounds in the path. +/// +/// The poll passes when the yes voting power is strictly more than +/// `supermajority_numerator / supermajority_denominator` of the yes plus no voting power, the +/// abstaining power left out of both sides, and the yes plus no voting power is at least +/// `minimum_voting_power`. Otherwise it fails. The minimum is a fixed voting power or a share of +/// the masternode list's total voting power when the poll closes. The whole rule is carried by +/// the poll so every feature sets its own. Two thirds with a floor of 400 is numerator 2, +/// denominator 3 and an absolute minimum of 400. +/// +/// The parameters are part of the poll's identity: [`Self::unique_id`] version 0 is the double +/// SHA-256 of the domain tag `dash platform yes/no vote poll id` followed by the serialized +/// [`VotePoll`] that carries the poll, so a vote names the exact poll it answers. The prefunded +/// balance that pays for the votes has the same id. +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[cfg_attr( + all(feature = "json-conversion", feature = "serde-conversion"), + derive(JsonConvertible) +)] +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + PartialEq, + Eq, + DecodeUntrusted, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] +// One attribute: the derives read only the first `platform_serialize` attribute. +#[platform_serialize(limit = 100000, unversioned)] +pub struct YesNoVotePoll { + /// What the poll decides on, as opaque segments chosen by the feature that opened it. + pub resource_path: Vec, + /// The numerator of the share of yes plus no voting power that yes must exceed. + pub supermajority_numerator: u8, + /// The denominator of the share of yes plus no voting power that yes must exceed. + pub supermajority_denominator: u8, + /// The least yes plus no voting power for the poll to pass. + pub minimum_voting_power: YesNoMinimumVotingPower, +} + +impl fmt::Display for YesNoVotePoll { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let resource_path: Vec = self + .resource_path + .iter() + .map(|segment| hex::encode(segment.as_slice())) + .collect(); + write!( + f, + "YesNoVotePoll {{ resource_path: [{}], supermajority: {}/{}, minimum_voting_power: {} }}", + resource_path.join("/"), + self.supermajority_numerator, + self.supermajority_denominator, + self.minimum_voting_power + ) + } +} + +impl YesNoVotePoll { + /// Refuses parameters no poll can be opened with, by the rules the platform version selects + /// (`dpp.voting_versions.yes_no_vote_poll_parameters`). + /// + /// # Parameters + /// - `platform_version`: the platform version, whose system limits bound the parameters. + /// + /// # Returns + /// `Ok(())` for parameters a poll can run with, or the error refusing them. + pub fn validate_parameters( + &self, + platform_version: &PlatformVersion, + ) -> Result<(), ProtocolError> { + match platform_version + .dpp + .voting_versions + .yes_no_vote_poll_parameters + { + Some(0) => self.validate_parameters_v0(platform_version), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "YesNoVotePoll::validate_parameters".to_string(), + known_versions: vec![0], + received: version, + }), + None => Err(ProtocolError::UnknownVersionError( + "YesNoVotePoll::validate_parameters is not active before protocol version 15" + .to_string(), + )), + } + } + + /// Version 0 refuses an empty or oversized resource path (the poll rides in every vote, + /// whose wire size is bounded), a zero denominator, a zero numerator (any single yes would + /// pass against every no), a numerator that is not below the denominator (yes could never + /// exceed the whole of yes plus no), and a minimum above its cap (a voting power, or a share + /// of the total with a zero denominator or above the allowed percentage). + fn validate_parameters_v0( + &self, + platform_version: &PlatformVersion, + ) -> Result<(), ProtocolError> { + if self.resource_path.is_empty() { + return Err(ProtocolError::VoteError( + "a yes/no vote poll needs a resource path".to_string(), + )); + } + let max_segments = platform_version + .system_limits + .max_yes_no_vote_poll_resource_path_segments; + if self.resource_path.len() > max_segments as usize { + return Err(ProtocolError::VoteError(format!( + "a yes/no vote poll resource path has at most {} segments, got {}", + max_segments, + self.resource_path.len() + ))); + } + let max_bytes = platform_version + .system_limits + .max_yes_no_vote_poll_resource_path_bytes; + let bytes: usize = self.resource_path.iter().map(|segment| segment.len()).sum(); + if bytes > max_bytes as usize { + return Err(ProtocolError::VoteError(format!( + "a yes/no vote poll resource path holds at most {} bytes, got {}", + max_bytes, bytes + ))); + } + if self.supermajority_denominator == 0 { + return Err(ProtocolError::VoteError( + "a yes/no vote poll supermajority denominator must not be zero".to_string(), + )); + } + if self.supermajority_numerator == 0 { + return Err(ProtocolError::VoteError( + "a yes/no vote poll supermajority numerator must not be zero".to_string(), + )); + } + if self.supermajority_numerator >= self.supermajority_denominator { + return Err(ProtocolError::VoteError(format!( + "a yes/no vote poll supermajority numerator ({}) must be below its denominator ({})", + self.supermajority_numerator, self.supermajority_denominator + ))); + } + match self.minimum_voting_power { + YesNoMinimumVotingPower::Absolute { voting_power } => { + let max_voting_power = platform_version + .system_limits + .max_yes_no_vote_poll_minimum_voting_power; + if voting_power > max_voting_power { + return Err(ProtocolError::VoteError(format!( + "a yes/no vote poll minimum voting power is at most {}, got {}", + max_voting_power, voting_power + ))); + } + } + YesNoMinimumVotingPower::FractionOfTotal { + numerator, + denominator, + .. + } => { + if denominator == 0 { + return Err(ProtocolError::VoteError( + "a yes/no vote poll minimum share denominator must not be zero".to_string(), + )); + } + let max_percent = platform_version + .system_limits + .max_yes_no_vote_poll_minimum_voting_power_percent_of_total; + if numerator as u32 * 100 > denominator as u32 * max_percent as u32 { + return Err(ProtocolError::VoteError(format!( + "a yes/no vote poll minimum share is at most {}% of the total voting power, got {}/{}", + max_percent, numerator, denominator + ))); + } + } + } + Ok(()) + } + + /// The least yes plus no voting power the poll needs, given the total voting power of the + /// masternode list in the block that closes it, by the rule the platform version selects + /// (`dpp.voting_versions.yes_no_vote_poll_decision`). + /// + /// # Parameters + /// - `total_voting_power`: the masternode list's total voting power in the closing block. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// The required voting power. + pub fn required_voting_power( + &self, + total_voting_power: VotingPower, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .voting_versions + .yes_no_vote_poll_decision + { + Some(0) => Ok(self.required_voting_power_v0(total_voting_power)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "YesNoVotePoll::required_voting_power".to_string(), + known_versions: vec![0], + received: version, + }), + None => Err(ProtocolError::UnknownVersionError( + "YesNoVotePoll::required_voting_power is not active before protocol version 15" + .to_string(), + )), + } + } + + /// Whether the poll passes with these final tallies, given the total voting power of the + /// masternode list in the block that closes it, by the rule the platform version selects + /// (`dpp.voting_versions.yes_no_vote_poll_decision`). Abstaining power plays no part. + /// + /// # Parameters + /// - `yes_voting_power`: the yes voting power cast. + /// - `no_voting_power`: the no voting power cast. + /// - `total_voting_power`: the masternode list's total voting power in the closing block. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// Whether the poll passes. + pub fn passes( + &self, + yes_voting_power: VotingPower, + no_voting_power: VotingPower, + total_voting_power: VotingPower, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .voting_versions + .yes_no_vote_poll_decision + { + Some(0) => Ok(self.passes_v0(yes_voting_power, no_voting_power, total_voting_power)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "YesNoVotePoll::passes".to_string(), + known_versions: vec![0], + received: version, + }), + None => Err(ProtocolError::UnknownVersionError( + "YesNoVotePoll::passes is not active before protocol version 15".to_string(), + )), + } + } + + /// Version 0: the minimum resolved against the total, rounded as the poll asks. + fn required_voting_power_v0(&self, total_voting_power: VotingPower) -> VotingPower { + self.minimum_voting_power.resolve_v0(total_voting_power) + } + + /// Version 0: yes plus no must reach the required voting power, and yes must be strictly + /// more than the supermajority share of yes plus no. + fn passes_v0( + &self, + yes_voting_power: VotingPower, + no_voting_power: VotingPower, + total_voting_power: VotingPower, + ) -> bool { + let yes = yes_voting_power as u64; + let cast = yes + no_voting_power as u64; + if cast < self.required_voting_power_v0(total_voting_power) as u64 { + return false; + } + yes * self.supermajority_denominator as u64 > cast * self.supermajority_numerator as u64 + } + + /// The id the poll is stored and voted on under, which also keys its end date index entry + /// and names its prefunded balance, derived as the platform version selects + /// (`dpp.voting_versions.yes_no_vote_poll_id`). + /// + /// # Parameters + /// - `platform_version`: the platform version. + /// + /// # Returns + /// The poll's id. + pub fn unique_id( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version.dpp.voting_versions.yes_no_vote_poll_id { + Some(0) => self.unique_id_v0(), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "YesNoVotePoll::unique_id".to_string(), + known_versions: vec![0], + received: version, + }), + None => Err(ProtocolError::UnknownVersionError( + "YesNoVotePoll::unique_id is not active before protocol version 15".to_string(), + )), + } + } + + /// The prefunded balance the votes on this poll are paid from: the poll's unique id. + /// + /// # Parameters + /// - `platform_version`: the platform version, which selects how the id is derived. + /// + /// # Returns + /// The balance's id. + pub fn specialized_balance_id( + &self, + platform_version: &PlatformVersion, + ) -> Result { + self.unique_id(platform_version) + } + + /// Version 0: the double SHA-256 of the yes/no poll id domain tag followed by the serialized + /// `VotePoll` carrying this poll. + fn unique_id_v0(&self) -> Result { + let mut preimage = YES_NO_VOTE_POLL_ID_DOMAIN.to_vec(); + preimage.extend(VotePoll::YesNoVotePoll(self.clone()).serialize_to_bytes()?); + Ok(Identifier::new(hash_double(preimage))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn poll() -> YesNoVotePoll { + YesNoVotePoll { + resource_path: vec![ + BinaryData::new(vec![0xc1; 32]), + BinaryData::new(b"challenge".to_vec()), + ], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 400 }, + } + } + + #[test] + fn should_pass_strictly_above_two_thirds_and_fail_at_or_below_it() { + // The minimum is met by every tally below; only the share decides. + let poll = YesNoVotePoll { + minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 300 }, + ..poll() + }; + // 300 cast: two thirds is 200, so 201 passes and 200 does not. + assert!(poll.passes_v0(201, 99, 0)); + assert!(!poll.passes_v0(200, 100, 0)); + // 301 cast: two thirds is 200.67, so 201 passes and 200 does not. + assert!(poll.passes_v0(201, 100, 0)); + assert!(!poll.passes_v0(200, 101, 0)); + } + + #[test] + fn should_fail_below_the_minimum_voting_power_even_when_unanimous() { + let poll = poll(); + assert!(!poll.passes_v0(399, 0, 0)); + assert!(poll.passes_v0(400, 0, 0)); + } + + #[test] + fn should_resolve_a_share_of_the_total_rounded_as_asked() { + let third = |rounding| YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 3, + rounding, + }; + assert_eq!(third(VotingPowerRounding::Down).resolve_v0(10), 3); + assert_eq!(third(VotingPowerRounding::Up).resolve_v0(10), 4); + // An exact share is the same either way. + assert_eq!(third(VotingPowerRounding::Down).resolve_v0(9), 3); + assert_eq!(third(VotingPowerRounding::Up).resolve_v0(9), 3); + assert_eq!(third(VotingPowerRounding::Up).resolve_v0(0), 0); + // Rounded down plus one, and rounded up minus one (never below zero). + assert_eq!(third(VotingPowerRounding::DownPlusOne).resolve_v0(10), 4); + assert_eq!(third(VotingPowerRounding::DownPlusOne).resolve_v0(9), 4); + assert_eq!(third(VotingPowerRounding::DownPlusOne).resolve_v0(0), 1); + assert_eq!(third(VotingPowerRounding::UpMinusOne).resolve_v0(10), 3); + assert_eq!(third(VotingPowerRounding::UpMinusOne).resolve_v0(9), 2); + assert_eq!(third(VotingPowerRounding::UpMinusOne).resolve_v0(0), 0); + // Half rounded down plus one is a strict majority. + let majority = YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 2, + rounding: VotingPowerRounding::DownPlusOne, + }; + assert_eq!(majority.resolve_v0(10), 6); + assert_eq!(majority.resolve_v0(9), 5); + assert_eq!(majority.resolve_v0(u32::MAX), u32::MAX / 2 + 1); + assert_eq!( + YesNoMinimumVotingPower::Absolute { voting_power: 400 }.resolve_v0(10), + 400 + ); + // No overflow on the largest total. + let half = YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 2, + rounding: VotingPowerRounding::Up, + }; + assert_eq!(half.resolve_v0(u32::MAX), u32::MAX / 2 + 1); + } + + #[test] + fn should_judge_a_share_minimum_against_the_total_at_close() { + // Two thirds with at least 1/10 of the total cast, rounded up. + let poll = YesNoVotePoll { + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 10, + rounding: VotingPowerRounding::Up, + }, + ..poll() + }; + // A total of 3,801 needs 381 cast. + assert_eq!(poll.required_voting_power_v0(3_801), 381); + assert!(!poll.passes_v0(380, 0, 3_801)); + assert!(poll.passes_v0(381, 0, 3_801)); + // The same tally fails once the list has grown. + assert!(!poll.passes_v0(381, 0, 3_811)); + } + + #[test] + fn should_bound_the_minimum() { + let platform_version = PlatformVersion::latest(); + let max_voting_power = platform_version + .system_limits + .max_yes_no_vote_poll_minimum_voting_power; + let with_minimum = |minimum_voting_power| YesNoVotePoll { + minimum_voting_power, + ..poll() + }; + let share = |numerator, denominator| YesNoMinimumVotingPower::FractionOfTotal { + numerator, + denominator, + rounding: VotingPowerRounding::Up, + }; + for accepted in [ + YesNoMinimumVotingPower::Absolute { voting_power: 0 }, + YesNoMinimumVotingPower::Absolute { + voting_power: max_voting_power, + }, + share(0, 1), + share(1, 3), + share(1, 2), + share(50, 100), + ] { + assert!( + with_minimum(accepted) + .validate_parameters(platform_version) + .is_ok(), + "{accepted} should be accepted" + ); + } + for refused in [ + YesNoMinimumVotingPower::Absolute { + voting_power: max_voting_power + 1, + }, + share(1, 0), + share(51, 100), + share(2, 3), + ] { + assert!( + with_minimum(refused) + .validate_parameters(platform_version) + .is_err(), + "{refused} should be refused" + ); + } + } + + #[test] + fn should_not_overflow_on_the_largest_tallies() { + let poll = YesNoVotePoll { + supermajority_numerator: 254, + supermajority_denominator: 255, + ..poll() + }; + assert!(poll.passes_v0(u32::MAX, 0, 0)); + assert!(!poll.passes_v0(u32::MAX, u32::MAX, 0)); + } + + #[test] + fn should_refuse_parameters_no_poll_can_run_with() { + let platform_version = PlatformVersion::latest(); + assert!(poll().validate_parameters(platform_version).is_ok()); + assert!(YesNoVotePoll { + resource_path: vec![], + ..poll() + } + .validate_parameters(platform_version) + .is_err()); + assert!(YesNoVotePoll { + supermajority_denominator: 0, + ..poll() + } + .validate_parameters(platform_version) + .is_err()); + assert!(YesNoVotePoll { + supermajority_numerator: 3, + supermajority_denominator: 3, + ..poll() + } + .validate_parameters(platform_version) + .is_err()); + // A zero numerator would let one yes pass against any amount of no. + assert!(YesNoVotePoll { + supermajority_numerator: 0, + ..poll() + } + .validate_parameters(platform_version) + .is_err()); + // A simple majority is a valid rule. + assert!(YesNoVotePoll { + supermajority_numerator: 1, + supermajority_denominator: 2, + ..poll() + } + .validate_parameters(platform_version) + .is_ok()); + } + + #[test] + fn should_bound_the_resource_path() { + let platform_version = PlatformVersion::latest(); + let max_segments = platform_version + .system_limits + .max_yes_no_vote_poll_resource_path_segments as usize; + let max_bytes = platform_version + .system_limits + .max_yes_no_vote_poll_resource_path_bytes as usize; + let at_most_segments = YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![1]); max_segments], + ..poll() + }; + assert!(at_most_segments + .validate_parameters(platform_version) + .is_ok()); + let too_many_segments = YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![1]); max_segments + 1], + ..poll() + }; + assert!(too_many_segments + .validate_parameters(platform_version) + .is_err()); + let at_most_bytes = YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![1; max_bytes])], + ..poll() + }; + assert!(at_most_bytes.validate_parameters(platform_version).is_ok()); + let too_many_bytes = YesNoVotePoll { + resource_path: vec![ + BinaryData::new(vec![1; max_bytes]), + BinaryData::new(vec![1]), + ], + ..poll() + }; + assert!(too_many_bytes + .validate_parameters(platform_version) + .is_err()); + } + + #[test] + fn should_change_the_id_with_every_parameter() { + let platform_version = PlatformVersion::latest(); + let base = poll().unique_id(platform_version).expect("id"); + let other_path = YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![0xc1; 32])], + ..poll() + }; + let other_rule = YesNoVotePoll { + supermajority_numerator: 1, + supermajority_denominator: 2, + ..poll() + }; + let other_minimum = YesNoVotePoll { + minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 401 }, + ..poll() + }; + for other in [other_path, other_rule, other_minimum] { + assert_ne!(other.unique_id(platform_version).expect("id"), base); + } + assert_eq!( + poll() + .specialized_balance_id(platform_version) + .expect("balance id"), + base + ); + } + + #[test] + fn should_hash_the_id_under_the_yes_no_domain_tag() { + let encoded = VotePoll::YesNoVotePoll(poll()) + .serialize_to_bytes() + .expect("encoded poll"); + let untagged = Identifier::new(hash_double(&encoded)); + let tagged = Identifier::new(hash_double( + [YES_NO_VOTE_POLL_ID_DOMAIN, encoded.as_slice()].concat(), + )); + let id = poll().unique_id(PlatformVersion::latest()).expect("id"); + assert_eq!(id, tagged); + assert_ne!(id, untagged); + } + + #[test] + fn should_report_the_poll_rules_not_active_before_protocol_version_15() { + let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); + let not_active = |result: Result<(), ProtocolError>, method: &str| { + assert!( + matches!(result, Err(ProtocolError::UnknownVersionError(_))), + "expected {method} to be not active at protocol version 14, got {result:?}" + ); + }; + not_active( + poll().validate_parameters(platform_version), + "validate_parameters", + ); + not_active( + poll() + .required_voting_power(100, platform_version) + .map(|_| ()), + "required_voting_power", + ); + not_active( + poll().passes(10, 0, 100, platform_version).map(|_| ()), + "passes", + ); + not_active(poll().unique_id(platform_version).map(|_| ()), "unique_id"); + not_active( + poll().specialized_balance_id(platform_version).map(|_| ()), + "specialized_balance_id", + ); + } +} + +#[cfg(all( + test, + feature = "json-conversion", + feature = "value-conversion", + feature = "serde-conversion" +))] +mod json_convertible_tests { + use super::*; + use platform_value::platform_value; + use serde_json::json; + + fn fixture() -> YesNoVotePoll { + YesNoVotePoll { + resource_path: vec![ + BinaryData::new(vec![0xc1; 4]), + BinaryData::new(b"challenge".to_vec()), + ], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 400 }, + } + } + + #[test] + fn json_round_trip_with_full_wire_shape() { + use crate::serialization::JsonConvertible; + let original = fixture(); + let json = original.to_json().expect("to_json"); + // Path segments are `BinaryData`, base64 strings in JSON. + assert_eq!( + json, + json!({ + "resourcePath": ["wcHBwQ==", "Y2hhbGxlbmdl"], + "supermajorityNumerator": 2, + "supermajorityDenominator": 3, + "minimumVotingPower": { "$type": "absolute", "votingPower": 400 }, + }) + ); + let recovered = YesNoVotePoll::from_json(json).expect("from_json"); + assert_eq!(original, recovered); + } + + #[test] + fn should_round_trip_a_share_minimum_through_json() { + use crate::serialization::JsonConvertible; + let original = YesNoVotePoll { + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 3, + rounding: VotingPowerRounding::Up, + }, + ..fixture() + }; + let json = original.to_json().expect("to_json"); + assert_eq!( + json["minimumVotingPower"], + json!({ "$type": "fractionOfTotal", "numerator": 1, "denominator": 3, "rounding": "up" }) + ); + let recovered = YesNoVotePoll::from_json(json).expect("from_json"); + assert_eq!(original, recovered); + } + + #[test] + fn should_name_every_rounding_in_camel_case() { + use crate::serialization::JsonConvertible; + for (rounding, name) in [ + (VotingPowerRounding::Down, "down"), + (VotingPowerRounding::Up, "up"), + (VotingPowerRounding::DownPlusOne, "downPlusOne"), + (VotingPowerRounding::UpMinusOne, "upMinusOne"), + ] { + let original = YesNoVotePoll { + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 2, + rounding, + }, + ..fixture() + }; + let json = original.to_json().expect("to_json"); + assert_eq!(json["minimumVotingPower"]["rounding"], json!(name)); + assert_eq!(YesNoVotePoll::from_json(json).expect("from_json"), original); + } + } + + #[test] + fn value_round_trip_with_full_wire_shape() { + use crate::serialization::ValueConvertible; + let original = fixture(); + let value = original.to_object().expect("to_object"); + assert_eq!( + value, + platform_value!({ + "resourcePath": [ + platform_value::Value::Bytes(vec![0xc1; 4]), + platform_value::Value::Bytes(b"challenge".to_vec()), + ], + "supermajorityNumerator": 2u8, + "supermajorityDenominator": 3u8, + "minimumVotingPower": { "$type": "absolute", "votingPower": 400u32 }, + }) + ); + let recovered = YesNoVotePoll::from_object(value).expect("from_object"); + assert_eq!(original, recovered); + } +} diff --git a/packages/rs-dpp/src/voting/votes/mod.rs b/packages/rs-dpp/src/voting/votes/mod.rs index 4b19cd9757b..a59db1f5c65 100644 --- a/packages/rs-dpp/src/voting/votes/mod.rs +++ b/packages/rs-dpp/src/voting/votes/mod.rs @@ -1,4 +1,5 @@ pub mod resource_vote; +pub mod yes_no_vote; #[cfg(feature = "json-conversion")] use crate::serialization::JsonConvertible; @@ -6,6 +7,8 @@ use crate::serialization::JsonConvertible; use crate::serialization::ValueConvertible; use crate::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; use crate::voting::votes::resource_vote::ResourceVote; +use crate::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; +use crate::voting::votes::yes_no_vote::YesNoVote; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::From; @@ -13,6 +16,7 @@ use platform_serialization_derive::{ PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, }; use platform_value::Identifier; +use platform_version::version::PlatformVersion; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; @@ -40,6 +44,9 @@ use serde::{Deserialize, Serialize}; #[platform_serialize(limit = 15000, unversioned)] pub enum Vote { ResourceVote(ResourceVote), + /// A vote on a yes/no poll (protocol version 15). Appended: the bincode discriminant is + /// the variant position. + YesNoVote(YesNoVote), } // Manual impl because Vote is a flat enum (not versioned V0/V1). @@ -53,14 +60,35 @@ impl Default for Vote { } impl Vote { - pub fn specialized_balance_id(&self) -> Result, ProtocolError> { + /// The prefunded balance the votes on this vote's poll are paid from. A yes/no poll's id is + /// derived as the platform version selects. + pub fn specialized_balance_id( + &self, + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { match self { - Vote::ResourceVote(resource_vote) => resource_vote.vote_poll().specialized_balance_id(), + Vote::ResourceVote(resource_vote) => resource_vote + .vote_poll() + .specialized_balance_id(platform_version), + Vote::YesNoVote(yes_no_vote) => Ok(Some( + yes_no_vote + .vote_poll() + .specialized_balance_id(platform_version)?, + )), } } - pub fn vote_poll_unique_id(&self) -> Result { + + /// The id of the poll this vote answers. A yes/no poll's id is derived as the platform + /// version selects. + pub fn vote_poll_unique_id( + &self, + platform_version: &PlatformVersion, + ) -> Result { match self { - Vote::ResourceVote(resource_vote) => resource_vote.vote_poll().unique_id(), + Vote::ResourceVote(resource_vote) => { + resource_vote.vote_poll().unique_id(platform_version) + } + Vote::YesNoVote(yes_no_vote) => yes_no_vote.vote_poll().unique_id(platform_version), } } } diff --git a/packages/rs-dpp/src/voting/votes/resource_vote/mod.rs b/packages/rs-dpp/src/voting/votes/resource_vote/mod.rs index 6084c04e5ba..a74e6dfa0b9 100644 --- a/packages/rs-dpp/src/voting/votes/resource_vote/mod.rs +++ b/packages/rs-dpp/src/voting/votes/resource_vote/mod.rs @@ -139,4 +139,28 @@ mod json_convertible_tests_resource_vote { let recovered = ResourceVote::from_object(value).expect("from_object"); assert_eq!(original, recovered); } + + #[test] + fn should_refuse_a_resource_vote_naming_a_yes_no_poll_from_json() { + use crate::serialization::JsonConvertible; + let yes_no_poll = json!({ + "$type": "yesNoVotePoll", + "resourcePath": ["Y2hhbGxlbmdl"], + "supermajorityNumerator": 2, + "supermajorityDenominator": 3, + "minimumVotingPower": { "$type": "absolute", "votingPower": 0 }, + }); + VotePoll::from_json(yes_no_poll.clone()).expect("expected the yes/no poll to decode"); + let json = json!({ + "$formatVersion": "0", + "votePoll": yes_no_poll, + "resourceVoteChoice": { "$type": "abstain" }, + }); + let error = ResourceVote::from_json(json) + .expect_err("expected a resource vote on a yes/no poll to be refused"); + assert!( + error.to_string().contains("a yes/no vote"), + "unexpected error: {error}" + ); + } } diff --git a/packages/rs-dpp/src/voting/votes/resource_vote/v0/mod.rs b/packages/rs-dpp/src/voting/votes/resource_vote/v0/mod.rs index d1253100944..f2258b7c74c 100644 --- a/packages/rs-dpp/src/voting/votes/resource_vote/v0/mod.rs +++ b/packages/rs-dpp/src/voting/votes/resource_vote/v0/mod.rs @@ -8,6 +8,7 @@ use platform_serialization_derive::{ PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, }; use platform_value::Identifier; +use platform_version::version::PlatformVersion; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; @@ -26,7 +27,8 @@ use serde::{Deserialize, Serialize}; #[cfg_attr( feature = "serde-conversion", derive(Serialize, Deserialize), - serde(rename_all = "camelCase") + serde(rename_all = "camelCase"), + serde(try_from = "ResourceVoteV0Inner") )] #[platform_serialize(unversioned)] pub struct ResourceVoteV0 { @@ -34,6 +36,25 @@ pub struct ResourceVoteV0 { pub resource_vote_choice: ResourceVoteChoice, } +/// The fields of a resource vote read from a plain object or JSON, built into one through +/// [`ResourceVoteV0::try_new`] so those conversions refuse a yes/no poll as `try_new` does. +#[cfg(feature = "serde-conversion")] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResourceVoteV0Inner { + vote_poll: VotePoll, + resource_vote_choice: ResourceVoteChoice, +} + +#[cfg(feature = "serde-conversion")] +impl TryFrom for ResourceVoteV0 { + type Error = ProtocolError; + + fn try_from(value: ResourceVoteV0Inner) -> Result { + ResourceVoteV0::try_new(value.vote_poll, value.resource_vote_choice) + } +} + impl Default for ResourceVoteV0 { fn default() -> Self { ResourceVoteV0 { @@ -44,7 +65,58 @@ impl Default for ResourceVoteV0 { } impl ResourceVoteV0 { - pub fn vote_poll_unique_id(&self) -> Result { - self.vote_poll.unique_id() + /// A resource vote on `vote_poll`, refused when the poll is a yes/no poll, which a yes/no + /// vote answers. The fields stay public for decoding; consensus refuses a resource vote + /// naming a yes/no poll however it was built. + pub fn try_new( + vote_poll: VotePoll, + resource_vote_choice: ResourceVoteChoice, + ) -> Result { + match vote_poll { + VotePoll::ContestedDocumentResourceVotePoll(_) => Ok(ResourceVoteV0 { + vote_poll, + resource_vote_choice, + }), + VotePoll::YesNoVotePoll(_) => Err(ProtocolError::VoteError( + "a resource vote answers a contested document resource vote poll; a yes/no vote \ + poll is answered by a yes/no vote" + .to_string(), + )), + } + } + + /// The id of the poll this vote answers. + pub fn vote_poll_unique_id( + &self, + platform_version: &PlatformVersion, + ) -> Result { + self.vote_poll.unique_id(platform_version) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + use crate::voting::vote_polls::yes_no_vote_poll::{YesNoMinimumVotingPower, YesNoVotePoll}; + use platform_value::BinaryData; + + #[test] + fn should_build_a_resource_vote_only_on_a_contested_poll() { + let contested_poll = VotePoll::ContestedDocumentResourceVotePoll( + ContestedDocumentResourceVotePoll::default(), + ); + let vote = ResourceVoteV0::try_new(contested_poll.clone(), ResourceVoteChoice::Abstain) + .expect("expected a resource vote on a contested poll"); + assert_eq!(vote.vote_poll, contested_poll); + + let yes_no_poll = VotePoll::YesNoVotePoll(YesNoVotePoll { + resource_path: vec![BinaryData::new(b"challenge".to_vec())], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 0 }, + }); + ResourceVoteV0::try_new(yes_no_poll, ResourceVoteChoice::Abstain) + .expect_err("expected a resource vote on a yes/no poll to be refused"); } } diff --git a/packages/rs-dpp/src/voting/votes/yes_no_vote/accessors/mod.rs b/packages/rs-dpp/src/voting/votes/yes_no_vote/accessors/mod.rs new file mode 100644 index 00000000000..87d327779c1 --- /dev/null +++ b/packages/rs-dpp/src/voting/votes/yes_no_vote/accessors/mod.rs @@ -0,0 +1,20 @@ +use crate::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use crate::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use crate::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; +use crate::voting::votes::yes_no_vote::YesNoVote; + +pub mod v0; + +impl YesNoVoteGettersV0 for YesNoVote { + fn vote_poll(&self) -> &YesNoVotePoll { + match self { + YesNoVote::V0(v0) => &v0.vote_poll, + } + } + + fn vote_choice(&self) -> YesNoAbstainVoteChoice { + match self { + YesNoVote::V0(v0) => v0.vote_choice, + } + } +} diff --git a/packages/rs-dpp/src/voting/votes/yes_no_vote/accessors/v0/mod.rs b/packages/rs-dpp/src/voting/votes/yes_no_vote/accessors/v0/mod.rs new file mode 100644 index 00000000000..74079ff46b4 --- /dev/null +++ b/packages/rs-dpp/src/voting/votes/yes_no_vote/accessors/v0/mod.rs @@ -0,0 +1,11 @@ +use crate::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use crate::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; + +/// Getters of a yes/no vote. +pub trait YesNoVoteGettersV0 { + /// The poll answered. + fn vote_poll(&self) -> &YesNoVotePoll; + + /// The answer. + fn vote_choice(&self) -> YesNoAbstainVoteChoice; +} diff --git a/packages/rs-dpp/src/voting/votes/yes_no_vote/mod.rs b/packages/rs-dpp/src/voting/votes/yes_no_vote/mod.rs new file mode 100644 index 00000000000..57eb1073a6d --- /dev/null +++ b/packages/rs-dpp/src/voting/votes/yes_no_vote/mod.rs @@ -0,0 +1,114 @@ +#[cfg(feature = "json-conversion")] +use crate::serialization::JsonConvertible; +#[cfg(feature = "value-conversion")] +use crate::serialization::ValueConvertible; +use crate::voting::votes::yes_no_vote::v0::YesNoVoteV0; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +pub mod accessors; +pub mod v0; + +/// A masternode's answer to a yes/no vote poll (protocol version 15). +#[cfg_attr( + all(feature = "json-conversion", feature = "serde-conversion"), + derive(JsonConvertible) +)] +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + PartialEq, + Eq, + DecodeUntrusted, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$formatVersion") +)] +#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] +#[platform_serialize(limit = 15000, unversioned)] +pub enum YesNoVote { + #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] + V0(YesNoVoteV0), +} + +#[cfg(all( + test, + feature = "json-conversion", + feature = "value-conversion", + feature = "serde-conversion" +))] +mod json_convertible_tests_yes_no_vote { + use super::*; + use crate::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; + use crate::voting::vote_polls::yes_no_vote_poll::{YesNoMinimumVotingPower, YesNoVotePoll}; + use platform_value::{platform_value, BinaryData}; + use serde_json::json; + + fn fixture() -> YesNoVote { + YesNoVote::V0(YesNoVoteV0 { + vote_poll: YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![0xc1; 4])], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 400 }, + }, + vote_choice: YesNoAbstainVoteChoice::Yes, + }) + } + + #[test] + fn json_round_trip_with_full_wire_shape() { + use crate::serialization::JsonConvertible; + let original = fixture(); + let json = original.to_json().expect("to_json"); + assert_eq!( + json, + json!({ + "$formatVersion": "0", + "votePoll": { + "resourcePath": ["wcHBwQ=="], + "supermajorityNumerator": 2, + "supermajorityDenominator": 3, + "minimumVotingPower": { "$type": "absolute", "votingPower": 400 }, + }, + "voteChoice": "yes", + }) + ); + let recovered = YesNoVote::from_json(json).expect("from_json"); + assert_eq!(original, recovered); + } + + #[test] + fn value_round_trip_with_full_wire_shape() { + use crate::serialization::ValueConvertible; + let original = fixture(); + let value = original.to_object().expect("to_object"); + assert_eq!( + value, + platform_value!({ + "$formatVersion": "0", + "votePoll": { + "resourcePath": [platform_value::Value::Bytes(vec![0xc1; 4])], + "supermajorityNumerator": 2u8, + "supermajorityDenominator": 3u8, + "minimumVotingPower": { "$type": "absolute", "votingPower": 400u32 }, + }, + "voteChoice": "yes", + }) + ); + let recovered = YesNoVote::from_object(value).expect("from_object"); + assert_eq!(original, recovered); + } +} diff --git a/packages/rs-dpp/src/voting/votes/yes_no_vote/v0/mod.rs b/packages/rs-dpp/src/voting/votes/yes_no_vote/v0/mod.rs new file mode 100644 index 00000000000..bfc9d564b12 --- /dev/null +++ b/packages/rs-dpp/src/voting/votes/yes_no_vote/v0/mod.rs @@ -0,0 +1,38 @@ +#[cfg(feature = "json-conversion")] +use crate::serialization::json_safe_fields; +use crate::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use crate::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +/// Version 0 of a yes/no vote: the poll answered and the answer. +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + PlatformSerialize, + PartialEq, + Eq, + DecodeUntrusted, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +#[platform_serialize(unversioned)] +pub struct YesNoVoteV0 { + /// The poll answered. + pub vote_poll: YesNoVotePoll, + /// The answer. + pub vote_choice: YesNoAbstainVoteChoice, +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 3820f30b922..4b8870fa3f1 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -327,6 +327,11 @@ impl Platform { VotePoll::ContestedDocumentResourceVotePoll(contested) => { contested.specialized_balance_id().map_err(Error::Protocol) } + // Unreachable: this runs once, on the block entering protocol version 8, + // and yes/no polls exist from 15. The arm only keeps the match exhaustive. + VotePoll::YesNoVotePoll(yes_no) => yes_no + .specialized_balance_id(platform_version) + .map_err(Error::Protocol), } }) .collect::, Error>>()?; @@ -910,7 +915,7 @@ impl Platform { /// When transitioning to version 15 we re-store the DPNS contract whose v3 /// schema checks a domain create with keywords instead of most of its data - /// trigger. + /// trigger, and create the vote decisions trees that yes/no polls live in. fn transition_to_version_15( &self, block_info: &BlockInfo, @@ -930,6 +935,14 @@ impl Platform { platform_version, )?; + // Vote decisions trees: the active yes/no polls and the identity votes index of the + // decisions branch. Fresh chains call the same helper from + // `create_initial_state_structure` v4, behind a `>= 15` check on the chain's initial + // protocol version, so both node populations build the decisions Merk from the same + // sequence of inserts. + self.drive + .insert_vote_decisions_trees(Some(transaction), platform_version)?; + Ok(()) } } @@ -2511,6 +2524,74 @@ mod tests { ); } + /// The only way a running network gets the vote decisions trees is this upgrade hook. + /// They are absent at 14 and present after the transition, and the votes subtree is then + /// byte-identical to the one of a chain born at 15, so both node populations build the + /// same Merk when the first yes/no poll opens. + #[test] + fn should_create_the_vote_decisions_trees_on_transition_to_version_15() { + use drive::drive::votes::paths::{ + vote_decisions_tree_path, ACTIVE_POLLS_TREE_KEY, IDENTITY_VOTES_TREE_KEY, + }; + use drive::util::grove_operations::DirectQueryType; + + let platform_version = PlatformVersion::latest(); + let born_at_15 = TestPlatformBuilder::new() + .with_initial_protocol_version(15) + .build_with_mock_rpc() + .set_genesis_state(); + let upgraded = TestPlatformBuilder::new() + .with_initial_protocol_version(14) + .build_with_mock_rpc() + .set_genesis_state(); + + let transaction = upgraded.drive.grove.start_transaction(); + + let decisions_tree_exists = |key: char, transaction: &Transaction| { + upgraded + .drive + .grove_has_raw( + (&vote_decisions_tree_path()).into(), + &[key as u8], + DirectQueryType::StatefulDirectQuery, + Some(transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("expected to query the decisions tree") + }; + for key in [ACTIVE_POLLS_TREE_KEY, IDENTITY_VOTES_TREE_KEY] { + assert!( + !decisions_tree_exists(key, &transaction), + "protocol version 14 has no decisions {key} tree" + ); + } + + upgraded + .transition_to_version_15(&BlockInfo::default(), &transaction, platform_version) + .expect("expected version 15 transition to succeed"); + + for key in [ACTIVE_POLLS_TREE_KEY, IDENTITY_VOTES_TREE_KEY] { + assert!( + decisions_tree_exists(key, &transaction), + "the decisions {key} tree must exist after the transition" + ); + } + + let diffs = collect_subtree_diffs( + &born_at_15, + &upgraded, + &transaction, + vec![vec![RootTree::Votes as u8]], + ); + assert!( + diffs.is_empty(), + "the votes subtree differs between a chain born at version 15 and one upgraded to \ + it:\n{}", + diffs.join("\n") + ); + } + #[test] fn should_create_the_document_ttl_trees_on_transition_to_version_14() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs index 0eb6ba217aa..4968ccb3e05 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs @@ -1,18 +1,28 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; +use crate::execution::platform_events::voting::clean_up_after_yes_no_vote_polls_end::FinishedYesNoVotePoll; +use crate::execution::platform_events::voting::masternode_vote_strength; use crate::platform_types::platform::Platform; -use crate::platform_types::platform_state::PlatformState; +use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; use dpp::data_contract::document_type::ContestedIndexResolution; use dpp::document::DocumentV0Getters; +use dpp::identifier::Identifier; use dpp::prelude::TimestampMillis; use dpp::version::PlatformVersion; use dpp::voting::contender_structs::FinalizedContender; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::TowardsIdentity; use dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::{ + VotingPower, YesNoMinimumVotingPower, YesNoVotePoll, +}; +use dpp::voting::vote_polls::VotePoll; use drive::drive::votes::paths::vote_end_date_queries_tree_path_vec; use drive::drive::votes::resolved::vote_polls::resolve::VotePollResolver; use drive::drive::votes::resolved::vote_polls::{ResolvedVotePoll, ResolvedVotePollWithVotes}; +use drive::error::drive::DriveError; +use drive::error::Error as DriveCrateError; use drive::grovedb::TransactionArg; use drive::query::VotePollsByEndDateDriveQuery; use drive::util::common::encode::encode_u64; @@ -24,7 +34,9 @@ where C: CoreRPCLike, { /// Checks for ended vote polls, awarding a tie to the earliest contender, after removing - /// the end dates left with no vote poll among the first due + /// the end dates left with no vote poll among the first due. From protocol version 15 it + /// also closes yes/no polls: it records their result, hands it to the feature that opened + /// them and cleans their votes up. #[inline(always)] pub(super) fn check_for_ended_vote_polls_v1( &self, @@ -72,8 +84,48 @@ where platform_version, )?; + // Yes/no polls need no resolution: they close here, before the contested polls, which + // keep the loop below as it was. Reached from protocol version 15 only: at 14, which + // also selects this generation, no yes/no poll is in the end date index, since opening + // one (`Drive::open_yes_no_vote_poll`) is not active before 15, and the contested polls + // are the polls the index returned, in the same order. Every due yes/no entry goes from + // the index; the polls this block closes also keep their voters for the clean-up, and a + // poll an earlier entry already closed has nothing more to clean. + let mut yes_no_end_date_entries: Vec<(Identifier, TimestampMillis)> = vec![]; + let mut finished_yes_no_vote_polls: Vec = vec![]; + let mut contested_vote_polls_by_timestamp: BTreeMap> = + BTreeMap::new(); + for (end_date, vote_polls) in vote_polls_by_timestamp { + for vote_poll in vote_polls { + match vote_poll { + VotePoll::YesNoVotePoll(yes_no_vote_poll) => { + let poll_id = yes_no_vote_poll.unique_id(platform_version)?; + yes_no_end_date_entries.push((poll_id, end_date)); + if let Some(voters) = self.finish_yes_no_vote_poll( + block_platform_state, + block_info, + &yes_no_vote_poll, + transaction, + platform_version, + )? { + finished_yes_no_vote_polls.push(FinishedYesNoVotePoll { + poll_id, + prefunded_balance_id: yes_no_vote_poll + .specialized_balance_id(platform_version)?, + voters, + }); + } + } + contested_vote_poll => contested_vote_polls_by_timestamp + .entry(end_date) + .or_default() + .push(contested_vote_poll), + } + } + } + // Check if awarding is disabled - let vote_polls_with_info = vote_polls_by_timestamp.into_iter().map(|(end_date, vote_polls)| { + let vote_polls_with_info = contested_vote_polls_by_timestamp.into_iter().map(|(end_date, vote_polls)| { let vote_polls_with_votes = vote_polls.into_iter().map(|vote_poll| { let resolved_vote_poll = vote_poll.resolve(&self.drive, transaction, platform_version)?; @@ -224,6 +276,88 @@ where )?; } + // Then the yes/no polls, in a batch of their own: its read of a shared end date sees + // what the contested clean-up left there. + if !yes_no_end_date_entries.is_empty() { + self.clean_up_after_yes_no_vote_polls_end( + block_info, + &yes_no_end_date_entries, + &finished_yes_no_vote_polls, + transaction, + platform_version, + )?; + } + Ok(()) } + + /// Closes one yes/no poll whose time has come: tallies it, records the result in its + /// stored info, hands the result to the feature that opened the poll, and returns the + /// voters so the clean-up can remove their votes. + /// + /// Returns `None` for a poll that is already finished: one listed under two end dates + /// (two openings that could not see each other) closes at the first, and its later entry + /// is only removed. A due poll with no stored info is corrupted state: an opening writes it + /// with the end date entry. + fn finish_yes_no_vote_poll( + &self, + block_platform_state: &PlatformState, + block_info: &BlockInfo, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + let mut tally = + self.tally_votes_for_yes_no_vote_poll(vote_poll, transaction, platform_version)?; + let stored_info = match tally.stored_info.take() { + Some(stored_info) if stored_info.result().is_some() => return Ok(None), + Some(stored_info) => stored_info, + None => { + return Err(Error::Drive(DriveCrateError::Drive( + DriveError::CorruptedDriveState( + "an ended yes/no vote poll must have a record in the state".to_string(), + ), + ))) + } + }; + // A minimum given as a share of the total is measured against the masternode list of + // the closing block, each masternode weighed as its vote is. A fixed minimum reads no + // total. + let total_voting_power = match vote_poll.minimum_voting_power { + YesNoMinimumVotingPower::Absolute { .. } => 0, + YesNoMinimumVotingPower::FractionOfTotal { .. } => block_platform_state + .full_masternode_list() + .values() + .try_fold(0 as VotingPower, |total, masternode| { + total.checked_add( + masternode_vote_strength(&masternode.node_type, platform_version).into(), + ) + }) + .ok_or(Error::Execution(ExecutionError::Overflow( + "the masternode list's total voting power overflows", + )))?, + }; + let result = self.keep_record_of_finished_yes_no_vote_poll( + block_info, + vote_poll, + stored_info, + &tally, + total_voting_power, + transaction, + platform_version, + )?; + self.on_yes_no_vote_poll_finished( + block_info, + vote_poll, + &result, + transaction, + platform_version, + )?; + let voters = self.drive.fetch_identities_voting_in_yes_no_vote_poll( + vote_poll, + transaction, + platform_version, + )?; + Ok(Some(voters)) + } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/v1/mod.rs index f4c4112147c..9c38054b1c3 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/v1/mod.rs @@ -8,7 +8,9 @@ use dpp::identifier::Identifier; use dpp::prelude::TimestampMillis; use dpp::version::PlatformVersion; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::ProtocolError; use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use drive::fees::op::LowLevelDriveOperation; use drive::grovedb::TransactionArg; use std::collections::BTreeMap; @@ -40,13 +42,45 @@ where )?; // We also need to clean out the specialized balances + self.empty_vote_poll_prefunded_balances_operations_v1( + vote_polls + .iter() + .map(|(vote_poll, _, _)| vote_poll.specialized_balance_id()), + block_info, + &mut operations, + transaction, + platform_version, + )?; + + if !operations.is_empty() { + self.drive.apply_batch_low_level_drive_operations( + None, + transaction, + operations, + &mut vec![], + &platform_version.drive, + )?; + } + + Ok(()) + } + /// Empties the prefunded balances of ended vote polls, in the given order, and credits what + /// they held to the epoch's processing pool, appending the operations. Moved here unchanged + /// from the body above, which every protocol version from 8 selects (mainnet included), so + /// its operations must not change; the yes/no clean-up v0 (protocol version 15) drains its + /// polls' balances through it too. + pub(in crate::execution::platform_events::voting) fn empty_vote_poll_prefunded_balances_operations_v1( + &self, + specialized_balance_ids: impl IntoIterator>, + block_info: &BlockInfo, + operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { let mut total_credits_to_add_to_processing: Credits = 0; - for specialized_balance_id in vote_polls - .iter() - .map(|(vote_poll, _, _)| vote_poll.specialized_balance_id()) - { + for specialized_balance_id in specialized_balance_ids { let (credits, mut empty_specialized_balance_operation) = self.drive.empty_prefunded_specialized_balance_operations( specialized_balance_id?, @@ -75,16 +109,6 @@ where ); } - if !operations.is_empty() { - self.drive.apply_batch_low_level_drive_operations( - None, - transaction, - operations, - &mut vec![], - &platform_version.drive, - )?; - } - Ok(()) } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_yes_no_vote_polls_end/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_yes_no_vote_polls_end/mod.rs new file mode 100644 index 00000000000..ad26f40b78a --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_yes_no_vote_polls_end/mod.rs @@ -0,0 +1,78 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; + +mod v0; + +/// A yes/no poll the block closed, as its clean-up needs it. +pub(in crate::execution) struct FinishedYesNoVotePoll { + /// The poll's id, which keys its vote trees and its voters' entries in the identity votes + /// index. + pub poll_id: Identifier, + /// The id of the prefunded balance its votes were paid from + /// (`YesNoVotePoll::specialized_balance_id`). + pub prefunded_balance_id: Identifier, + /// The masternodes that voted in it. + pub voters: Vec, +} + +impl Platform +where + C: CoreRPCLike, +{ + /// Cleans up after yes/no polls that ended: their entries in the end date index, the vote + /// trees of the polls this block closed, their voters' entries in the identity votes index, + /// and what is left of the prefunded balances that paid for the votes, which goes to the + /// processing pool. The stored info stays as the record of the decision. + /// + /// `end_date_entries` lists every due yes/no entry, including those of polls an earlier + /// entry already closed, of which only the entry goes. + /// + /// # Parameters + /// - `block_info`: the block that closes the polls, whose epoch gets what is left of their funds. + /// - `end_date_entries`: the due yes/no entries of the end date index, by poll id. + /// - `finished_vote_polls`: the polls this block closed. + /// - `transaction`: the transaction to read and write in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// `Ok(())` once the clean-up is applied. + pub(in crate::execution) fn clean_up_after_yes_no_vote_polls_end( + &self, + block_info: &BlockInfo, + end_date_entries: &[(Identifier, TimestampMillis)], + finished_vote_polls: &[FinishedYesNoVotePoll], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive_abci + .methods + .voting + .clean_up_after_yes_no_vote_polls_end + { + Some(0) => self.clean_up_after_yes_no_vote_polls_end_v0( + block_info, + end_date_entries, + finished_vote_polls, + transaction, + platform_version, + ), + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "clean_up_after_yes_no_vote_polls_end".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "clean_up_after_yes_no_vote_polls_end".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_yes_no_vote_polls_end/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_yes_no_vote_polls_end/v0/mod.rs new file mode 100644 index 00000000000..ae93c6d760f --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_yes_no_vote_polls_end/v0/mod.rs @@ -0,0 +1,82 @@ +use super::FinishedYesNoVotePoll; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; + +impl Platform +where + C: CoreRPCLike, +{ + #[inline(always)] + pub(super) fn clean_up_after_yes_no_vote_polls_end_v0( + &self, + block_info: &BlockInfo, + end_date_entries: &[(Identifier, TimestampMillis)], + finished_vote_polls: &[FinishedYesNoVotePoll], + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let mut operations = vec![]; + + // The polls' entries in the end date index, which contested polls share: an end date's + // tree goes only once nothing of either kind is left under it. + self.drive.remove_vote_poll_end_date_query_operations( + end_date_entries, + &mut operations, + transaction, + platform_version, + )?; + + // The three vote trees of every poll this block closed. + let vote_poll_ids: Vec = finished_vote_polls + .iter() + .map(|vote_poll| vote_poll.poll_id) + .collect(); + self.drive.remove_yes_no_vote_poll_votes_operations( + vote_poll_ids.as_slice(), + &mut operations, + platform_version, + )?; + + // Each voter's entry in the identity votes index. + for vote_poll in finished_vote_polls { + for voter in &vote_poll.voters { + self.drive + .remove_yes_no_vote_references_given_by_identity_operations( + voter, + &[&vote_poll.poll_id], + &mut operations, + transaction, + platform_version, + )?; + } + } + + // What is left of the prefunded balances goes to processing, as for contested polls. + self.empty_vote_poll_prefunded_balances_operations_v1( + finished_vote_polls + .iter() + .map(|vote_poll| Ok(vote_poll.prefunded_balance_id)), + block_info, + &mut operations, + transaction, + platform_version, + )?; + + if !operations.is_empty() { + self.drive.apply_batch_low_level_drive_operations( + None, + transaction, + operations, + &mut vec![], + &platform_version.drive, + )?; + } + Ok(()) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/keep_record_of_finished_yes_no_vote_poll/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/keep_record_of_finished_yes_no_vote_poll/mod.rs new file mode 100644 index 00000000000..4e2e7514f20 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/keep_record_of_finished_yes_no_vote_poll/mod.rs @@ -0,0 +1,72 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::{ + YesNoVotePollResult, YesNoVotePollStoredInfo, +}; +use dpp::voting::vote_polls::yes_no_vote_poll::{VotingPower, YesNoVotePoll}; +use drive::grovedb::TransactionArg; +use drive::query::yes_no_vote_poll_state_query::YesNoVotePollState; + +mod v0; + +impl Platform +where + C: CoreRPCLike, +{ + /// Decides a finished yes/no poll from its tallies and the total voting power of the + /// masternode list in the closing block, and writes the result into its stored info, which + /// stays as the record of the decision after the votes are cleaned up. + /// + /// # Parameters + /// - `block_info`: the block that closes the poll. + /// - `vote_poll`: the poll that ended. + /// - `stored_info`: its stored info, started and not yet finished. + /// - `tally`: its tallies. + /// - `total_voting_power`: the masternode list's total voting power in the closing block, 0 for a fixed minimum. + /// - `transaction`: the transaction to read and write in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// The poll's result, as recorded. + #[allow(clippy::too_many_arguments)] + pub(in crate::execution) fn keep_record_of_finished_yes_no_vote_poll( + &self, + block_info: &BlockInfo, + vote_poll: &YesNoVotePoll, + stored_info: YesNoVotePollStoredInfo, + tally: &YesNoVotePollState, + total_voting_power: VotingPower, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .methods + .voting + .keep_record_of_finished_yes_no_vote_poll + { + Some(0) => self.keep_record_of_finished_yes_no_vote_poll_v0( + block_info, + vote_poll, + stored_info, + tally, + total_voting_power, + transaction, + platform_version, + ), + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "keep_record_of_finished_yes_no_vote_poll".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "keep_record_of_finished_yes_no_vote_poll".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/keep_record_of_finished_yes_no_vote_poll/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/keep_record_of_finished_yes_no_vote_poll/v0/mod.rs new file mode 100644 index 00000000000..4d7de36438b --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/keep_record_of_finished_yes_no_vote_poll/v0/mod.rs @@ -0,0 +1,53 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::{ + YesNoVotePollResult, YesNoVotePollStoredInfo, +}; +use dpp::voting::vote_polls::yes_no_vote_poll::{VotingPower, YesNoVotePoll}; +use drive::grovedb::TransactionArg; +use drive::query::yes_no_vote_poll_state_query::YesNoVotePollState; + +impl Platform +where + C: CoreRPCLike, +{ + #[allow(clippy::too_many_arguments)] + #[inline(always)] + pub(super) fn keep_record_of_finished_yes_no_vote_poll_v0( + &self, + block_info: &BlockInfo, + vote_poll: &YesNoVotePoll, + mut stored_info: YesNoVotePollStoredInfo, + tally: &YesNoVotePollState, + total_voting_power: VotingPower, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let required_voting_power = + vote_poll.required_voting_power(total_voting_power, platform_version)?; + let passed = vote_poll.passes( + tally.yes_voting_power, + tally.no_voting_power, + total_voting_power, + platform_version, + )?; + let result = stored_info.finalize_vote_poll( + tally.yes_voting_power, + tally.no_voting_power, + tally.abstain_voting_power, + required_voting_power, + passed, + *block_info, + )?; + self.drive.insert_stored_info_for_yes_no_vote_poll( + vote_poll, + stored_info, + transaction, + platform_version, + )?; + Ok(result) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/mod.rs index 326867bbcb7..4d559da2d40 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/mod.rs @@ -1,8 +1,35 @@ +use dpp::dashcore_rpc::dashcore_rpc_json::MasternodeType; +use dpp::version::PlatformVersion; + mod award_document_to_winner; mod check_for_ended_vote_polls; mod clean_up_after_contested_resources_vote_polls_end; mod clean_up_after_vote_polls_end; +mod clean_up_after_yes_no_vote_polls_end; +mod keep_record_of_finished_yes_no_vote_poll; mod keep_record_of_vote_poll; +mod on_yes_no_vote_poll_finished; mod remove_votes_for_removed_masternodes; mod run_dao_platform_events; mod tally_votes_for_contested_document_resource_vote_poll; +mod tally_votes_for_yes_no_vote_poll; + +/// The voting power a masternode's yes/no vote carries, read from the protocol version's +/// `SystemLimits` (`yes_no_vote_poll_masternode_vote_strength` and +/// `yes_no_vote_poll_evonode_vote_strength`: 1 for a masternode, 4 for an evonode, as for +/// contested polls). The strength of each yes/no vote (`transform_into_action` v1) and the +/// masternode list total a share minimum is measured against (`check_for_ended_vote_polls` v1) +/// both come from here, so they always use the same weights; a later protocol version changes +/// them in its own `SystemLimits` table. The contested vote strength of masternode vote +/// `transform_into_action` v0 (protocol versions 1 to 14, and every contested vote at 15) keeps +/// its own copy of these weights. +pub(in crate::execution) fn masternode_vote_strength( + node_type: &MasternodeType, + platform_version: &PlatformVersion, +) -> u8 { + let limits = &platform_version.system_limits; + match node_type { + MasternodeType::Regular => limits.yes_no_vote_poll_masternode_vote_strength, + MasternodeType::Evo => limits.yes_no_vote_poll_evonode_vote_strength, + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/on_yes_no_vote_poll_finished/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/on_yes_no_vote_poll_finished/mod.rs new file mode 100644 index 00000000000..efcb9eec4c0 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/on_yes_no_vote_poll_finished/mod.rs @@ -0,0 +1,62 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollResult; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use drive::grovedb::TransactionArg; + +mod v0; + +impl Platform +where + C: CoreRPCLike, +{ + /// Acts on the result of a yes/no poll that just finished, in the same block, before its + /// votes are cleaned up. The feature that opened the poll recognises it by its resource + /// path. Nothing opens a yes/no poll yet, so version 0 acts on nothing. + /// + /// # Parameters + /// - `block_info`: the block that closes the poll. + /// - `vote_poll`: the poll that finished. + /// - `result`: its result. + /// - `transaction`: the transaction to read and write in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// `Ok(())` once the feature that opened the poll acted on its result. + pub(in crate::execution) fn on_yes_no_vote_poll_finished( + &self, + block_info: &BlockInfo, + vote_poll: &YesNoVotePoll, + result: &YesNoVotePollResult, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive_abci + .methods + .voting + .on_yes_no_vote_poll_finished + { + Some(0) => self.on_yes_no_vote_poll_finished_v0( + block_info, + vote_poll, + result, + transaction, + platform_version, + ), + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "on_yes_no_vote_poll_finished".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "on_yes_no_vote_poll_finished".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/on_yes_no_vote_poll_finished/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/on_yes_no_vote_poll_finished/v0/mod.rs new file mode 100644 index 00000000000..f33ba6278a4 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/on_yes_no_vote_poll_finished/v0/mod.rs @@ -0,0 +1,28 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollResult; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use drive::grovedb::TransactionArg; + +impl Platform +where + C: CoreRPCLike, +{ + /// No feature opens a yes/no poll yet, so no feature acts on one. The moderation team + /// challenge and charter amendment features add their arms here, matched on the poll's + /// resource path. + #[inline(always)] + pub(super) fn on_yes_no_vote_poll_finished_v0( + &self, + _block_info: &BlockInfo, + _vote_poll: &YesNoVotePoll, + _result: &YesNoVotePollResult, + _transaction: TransactionArg, + _platform_version: &PlatformVersion, + ) -> Result<(), Error> { + Ok(()) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_yes_no_vote_poll/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_yes_no_vote_poll/mod.rs new file mode 100644 index 00000000000..2be5e948cb2 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_yes_no_vote_poll/mod.rs @@ -0,0 +1,51 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use drive::grovedb::TransactionArg; +use drive::query::yes_no_vote_poll_state_query::YesNoVotePollState; + +mod v0; + +impl Platform +where + C: CoreRPCLike, +{ + /// The tallies of a yes/no poll, read from its vote sum trees, with its stored info. + /// + /// # Parameters + /// - `vote_poll`: the poll to tally. + /// - `transaction`: the transaction to read in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// The poll's state: its stored info and the voting power cast for each answer. + pub(in crate::execution) fn tally_votes_for_yes_no_vote_poll( + &self, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .methods + .voting + .tally_votes_for_yes_no_vote_poll + { + Some(0) => { + self.tally_votes_for_yes_no_vote_poll_v0(vote_poll, transaction, platform_version) + } + Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "tally_votes_for_yes_no_vote_poll".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Execution(ExecutionError::VersionNotActive { + method: "tally_votes_for_yes_no_vote_poll".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_yes_no_vote_poll/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_yes_no_vote_poll/v0/mod.rs new file mode 100644 index 00000000000..9f249f2fb9f --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_yes_no_vote_poll/v0/mod.rs @@ -0,0 +1,27 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use drive::grovedb::TransactionArg; +use drive::query::yes_no_vote_poll_state_query::{ + YesNoVotePollState, YesNoVotePollStateDriveQuery, +}; + +impl Platform +where + C: CoreRPCLike, +{ + #[inline(always)] + pub(super) fn tally_votes_for_yes_no_vote_poll_v0( + &self, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let query = YesNoVotePollStateDriveQuery { + vote_poll: vote_poll.clone(), + }; + Ok(query.execute_no_proof(&self.drive, transaction, &mut vec![], platform_version)?) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs index e60341912b8..849f66cf2b3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs @@ -16,7 +16,6 @@ use dpp::data_contract::validate_document::DataContractDocumentValidationMethods use dpp::identifier::Identifier; use dpp::validation::{SimpleConsensusValidationResult}; use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; -use dpp::voting::vote_polls::VotePoll; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0}; use dpp::version::PlatformVersion; @@ -60,11 +59,11 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans let expected_vote_poll = document_type .contested_vote_poll_for_document_properties(self.data(), platform_version)?; + // In place, type only (protocol versions 14 and 15): the expected poll was the same + // contested poll wrapped in `VotePoll`, the only variant the helper built, so these + // arms match the same cases as before. match (expected_vote_poll, self.prefunded_voting_balance()) { - ( - Some(VotePoll::ContestedDocumentResourceVotePoll(expected)), - Some((provided, _)), - ) => { + (Some(expected), Some((provided, _))) => { // -->> Changed in V1 <<-- The amount is the most the contender pays, and // what it has to pay depends on how many contenders the contest holds, so // state validation, which counts them, judges it and refuses a contender @@ -91,7 +90,7 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans } // -->> End Introduced in V1 <<-- } - (Some(VotePoll::ContestedDocumentResourceVotePoll(expected)), None) => { + (Some(expected), None) => { // A contested document stating no fund at all is refused with the contest's // fund, the least a contest takes: this step does not count contenders let expected_amount = expected.required_vote_resolution_fund(platform_version); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index c3f0d25aa7e..5afe657671d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -734,7 +734,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "0ed48425e10d0aa37b68f4f1e52c773a667304f9b510a4151b1dbc323f350076" + "24e77c57edbf5294e7b43df5af21fcd055697120124f3912021c2e41c954f4e0" ); assert_eq!( pool.platform @@ -2158,39 +2158,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "6a823c2db94612652926e6b9c1ae7feec9c2547dfd4cfc4e474318da1c16d59c", - "5ec2378c4d9015a2b5ad8552797497e7c53f3a80b31b6491db88e43e27972060", + "a80509bb285ede04d5c921e432a6d38021bfa2b38e2855cf8f0e735fd20285d9", ), "valid-payment" => ( "a1308c6fe05b0a60cfee4fa0d43f7bda5856b02f7d11f3499fd1acd866328aa3", - "90439b1b17e8d5a003958e2cb256f4bc47c7d08afaf3d18f0e36b668c00ac9df", + "f6694678f1b835c476af2012fd217c67524f4f8d3ca5e4f9facbcec0b205081b", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "cbccba78463e46aaab22351f696d704651d76095d8d09cb820d7d3d901d0b0da", + "4a1e0a2664ef5d529920510c003340cf5ecdc443d7478b08974a821076a7c8cc", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "fa77338960e55ad5ce67a338127a0ae77fcae262f1a23bc237b1d9a15dcc61fa", + "a3cbb2d69941b402cb5fc9c564da81d4716a168355fe4ce4d3ae530d99e40247", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "575b104c33a9a4425b3fbb58a6a6d5b3d5790ba04c84ac11f4bb432e8c58f3fd", + "20b47800939b417e433c695208924af0582df67c33edbf085f1fba297c9e6a06", ), "open-mint-group" => ( "5c68381297c9bf68c5e85ebe6bb9e0f1289e2c07718eecfb5e6773d6009455bb", - "8f312783271abade3cd47d38981225a72477cb7b1cd3812028d514119eca1517", + "5d22e1accc47d79db4b31a93e35a88b70e04412884239c4e04a05a0e2dde7f95", ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "5dac51fdee8bb00560d035496286d8ca5703bdffbf444d3fb82af5f2d050803c", + "bc346f8d036f51e82f5f7af69a9ece71f34501e21c848f1d9df7e17c76dd847f", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", - "45d313dd0b30e5875b4c207c272d08b23241cd8d22df976ef3abe5fab00a04d5", + "2b6f1d4e5b67d252c1aa76c466e716106107a98ec96d64eda488c37216611cd9", ), "closing-burn-group" => ( "93e1732286f168c4a91d2765931e181c5721bfca1bfd5f93b8baacca03e00b2b", - "a89f568a0068341804b030000219994a544a48ef5f2591085ee98820c75923cb", + "764422c7f8a87ad8165a7d38c795dcb57ea67d092fa751e7f0c50c06d52fbba9", ), _ => unreachable!("enumerated characterization case"), }; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs index bdabab16e3d..35151e3788f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs @@ -45,11 +45,13 @@ impl MasternodeVoteTransitionBalanceValidationV0 for MasternodeVoteTransition { let vote = self.vote(); - let balance_id = vote.specialized_balance_id()?.ok_or(Error::Execution( - ExecutionError::CorruptedCodeExecution( + // Only a yes/no poll's id reads the platform version, and no vote naming one decodes + // before protocol version 15. + let balance_id = vote + .specialized_balance_id(platform_version)? + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( "In this version there should always be a specialized balance id", - ), - ))?; + )))?; let maybe_balance = drive.fetch_prefunded_specialized_balance( balance_id.to_buffer(), tx, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs index b9f0bbdfa03..94c6eb752ad 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs @@ -5,15 +5,14 @@ //! included, keeps the generic windows and fund. use crate::execution::validation::state_transition::state_transitions::tests::{ - create_dpns_identity_name_contest, fill_contest_with_bare_contenders, setup_identity, - setup_masternode_voting_identity, + create_dpns_identity_name_contest, fill_contest_with_bare_contenders, processing_credits, + setup_identity, setup_masternode_voting_identity, }; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; use dpp::block::block_info::BlockInfo; -use dpp::block::epoch::Epoch; use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0; use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; @@ -53,8 +52,6 @@ use dpp::voting::votes::Vote; use drive::drive::contract::paths::contract_root_path; use drive::drive::document::ContestWindows; use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::resolve::ContestedDocumentResourceVotePollResolver; -use drive::error::Error as DriveError; -use drive::grovedb::Error as GroveError; use drive::query::VotePollsByEndDateDriveQuery; use drive::util::test_helpers::setup_contract; use platform_version::version::PlatformVersion; @@ -613,25 +610,6 @@ fn balance_of( .expect("expected the identity") } -/// The processing credits epoch 0 will distribute; the item is written by the first credit, so -/// before any it is missing, which is none. -fn processing_credits( - platform: &TempPlatform, - platform_version: &PlatformVersion, -) -> Credits { - match platform - .drive - .get_epoch_processing_credits_for_distribution( - &Epoch::new(0).expect("epoch"), - None, - platform_version, - ) { - Ok(credits) => credits, - Err(DriveError::GroveDB(error)) if matches!(*error, GroveError::PathKeyNotFound(_)) => 0, - Err(error) => panic!("expected the epoch's processing credits: {error:?}"), - } -} - #[tokio::test] async fn should_award_a_single_applicant_when_the_target_join_window_closes() { let (mut platform, platform_version, charters, mut rng) = setup(); @@ -1148,7 +1126,10 @@ async fn should_keep_the_generic_windows_and_fund_for_a_dpns_contest() { let [(end_date, VotePoll::ContestedDocumentResourceVotePoll(poll))]: [_; 1] = end_dates(&platform, platform_version) .try_into() - .expect("expected one contest"); + .expect("expected one contest") + else { + panic!("expected a contested document resource vote poll"); + }; assert_eq!(poll.contract_id, dpns_contract.id()); let ContestedDocumentVotePollStatus::Started(start_block) = diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/end_date_cleanup_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/end_date_cleanup_tests.rs index 18f5fe91e04..e2ed7bd3a0a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/end_date_cleanup_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/end_date_cleanup_tests.rs @@ -14,13 +14,12 @@ use dpp::block::block_info::BlockInfo; use dpp::data_contract::DataContract; use dpp::prelude::{Identifier, TimestampMillis}; use dpp::version::PlatformVersion; -use dpp::voting::vote_polls::VotePoll; use drive::util::test_helpers::vote_poll_end_dates; use std::collections::{BTreeMap, BTreeSet}; /// The unique id of the DPNS name contest on `name` fn contest_id(dpns_contract: &DataContract, name: &str) -> Identifier { - VotePoll::ContestedDocumentResourceVotePoll(dpns_name_vote_poll(dpns_contract, name)) + dpns_name_vote_poll(dpns_contract, name) .unique_id() .expect("expected a vote poll id") } @@ -78,11 +77,30 @@ fn end_due_vote_polls( .expect("expected to commit the block"); } +/// The protocol versions the end of block tests run at, 14 first. Protocol version 14 runs the +/// same end of block generations on these paths as 15 today (`check_for_ended_vote_polls` v1, +/// `clean_up_after_vote_polls_end` v0); the run at 14 pins them there, so a later change to 15's +/// tables cannot leave 14 untested. +pub(super) fn end_of_block_test_versions() -> [&'static PlatformVersion; 2] { + [ + PlatformVersion::get(14).expect("expected protocol version 14"), + PlatformVersion::latest(), + ] +} + #[tokio::test] async fn should_end_vote_polls_across_end_dates_and_keep_the_end_date_of_the_one_left() { - let platform_version = PlatformVersion::latest(); + for platform_version in end_of_block_test_versions() { + end_vote_polls_across_end_dates_and_keep_the_end_date_of_the_one_left(platform_version) + .await; + } +} + +async fn end_vote_polls_across_end_dates_and_keep_the_end_date_of_the_one_left( + platform_version: &'static PlatformVersion, +) { let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() + .with_initial_protocol_version(platform_version.protocol_version) .build_with_mock_rpc() .set_genesis_state(); @@ -180,9 +198,16 @@ async fn should_end_vote_polls_across_end_dates_and_keep_the_end_date_of_the_one #[tokio::test] async fn should_end_every_vote_poll_of_an_end_date_at_the_limit_in_one_block() { - let platform_version = PlatformVersion::latest(); + for platform_version in end_of_block_test_versions() { + end_every_vote_poll_of_an_end_date_at_the_limit_in_one_block(platform_version).await; + } +} + +async fn end_every_vote_poll_of_an_end_date_at_the_limit_in_one_block( + platform_version: &'static PlatformVersion, +) { let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() + .with_initial_protocol_version(platform_version.protocol_version) .build_with_mock_rpc() .set_genesis_state(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs index 6150b629f0e..a897304e72d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs @@ -3,6 +3,8 @@ mod balance; mod nonce; mod state; mod transform_into_action; +#[cfg(test)] +mod yes_no_tests; use dpp::address_funds::PlatformAddress; use dpp::block::block_info::BlockInfo; @@ -24,6 +26,7 @@ use crate::rpc::core::CoreRPCLike; use crate::execution::validation::state_transition::masternode_vote::state::v0::MasternodeVoteStateTransitionStateValidationV0; use crate::execution::validation::state_transition::masternode_vote::state::v1::MasternodeVoteStateTransitionStateValidationV1; use crate::execution::validation::state_transition::masternode_vote::transform_into_action::v0::MasternodeVoteStateTransitionTransformIntoActionValidationV0; +use crate::execution::validation::state_transition::masternode_vote::transform_into_action::v1::MasternodeVoteStateTransitionTransformIntoActionValidationV1; use crate::execution::validation::state_transition::processor::state::StateTransitionStateValidation; use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; use crate::execution::validation::state_transition::ValidationMode; @@ -52,9 +55,12 @@ impl StateTransitionActionTransformer for MasternodeVoteTransition { 0 => self .transform_into_action_v0(platform, validation_mode, tx, platform_version) .map(|result| result.map(|action| action.into())), + 1 => self + .transform_into_action_v1(platform, validation_mode, tx, platform_version) + .map(|result| result.map(|action| action.into())), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "masternode votes state transition: transform_into_action".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -4321,7 +4327,9 @@ mod tests { }, ); ( - vote_poll.unique_id().expect("expected a vote poll id"), + vote_poll + .unique_id(platform_version) + .expect("expected a vote poll id"), ( ResourceVote::V0(ResourceVoteV0 { vote_poll, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs index d9083e260b4..04f5277151d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs @@ -9,6 +9,7 @@ use crate::execution::validation::state_transition::state_transitions::tests::{ get_vote_states, perform_vote, perform_votes_multi, serialized_dpns_name_vote, setup_identity, setup_masternode_voting_identity, }; +use super::end_date_cleanup_tests::end_of_block_test_versions; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; @@ -80,9 +81,20 @@ fn setup() -> ( DataContract, StdRng, ) { - let platform_version = PlatformVersion::latest(); + setup_at(PlatformVersion::latest()) +} + +/// [`setup`] on a chain born at `platform_version`. +fn setup_at( + platform_version: &'static PlatformVersion, +) -> ( + TempPlatform, + &'static PlatformVersion, + DataContract, + StdRng, +) { let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() + .with_initial_protocol_version(platform_version.protocol_version) .build_with_mock_rpc() .set_genesis_state(); let mut rng = StdRng::seed_from_u64(0x9010_C41A); @@ -676,7 +688,7 @@ async fn should_open_the_vote_window_when_a_second_contender_joins() { BTreeMap::from([( start + poll_duration, BTreeSet::from([vote_poll(&contract) - .unique_id() + .unique_id(platform_version) .expect("expected a vote poll id")]), )]), "no end date is left at the end of the join window" @@ -734,7 +746,15 @@ async fn should_open_the_vote_window_when_a_second_contender_joins() { /// Both then end in the one block at the end of their vote window. #[tokio::test] async fn should_keep_a_shared_join_window_end_date_until_its_last_contest_moves() { - let (mut platform, platform_version, contract, mut rng) = setup(); + for platform_version in end_of_block_test_versions() { + keep_a_shared_join_window_end_date_until_its_last_contest_moves(platform_version).await; + } +} + +async fn keep_a_shared_join_window_end_date_until_its_last_contest_moves( + platform_version: &'static PlatformVersion, +) { + let (mut platform, platform_version, contract, mut rng) = setup_at(platform_version); let (join_window, poll_duration) = windows(&platform, platform_version); let alice = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); let bob = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); @@ -742,7 +762,7 @@ async fn should_keep_a_shared_join_window_end_date_until_its_last_contest_moves( let dave = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); let unique_id = |name| { vote_poll_for(&contract, name) - .unique_id() + .unique_id(platform_version) .expect("expected a vote poll id") }; @@ -830,7 +850,13 @@ async fn should_keep_a_shared_join_window_end_date_until_its_last_contest_moves( /// block; the block removes them first and ends the contest behind them. #[tokio::test] async fn should_end_a_contest_behind_end_dates_left_empty() { - let (mut platform, platform_version, contract, mut rng) = setup(); + for platform_version in end_of_block_test_versions() { + end_a_contest_behind_end_dates_left_empty(platform_version).await; + } +} + +async fn end_a_contest_behind_end_dates_left_empty(platform_version: &'static PlatformVersion) { + let (mut platform, platform_version, contract, mut rng) = setup_at(platform_version); let (join_window, _) = windows(&platform, platform_version); let alice = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); let start = join( @@ -868,7 +894,7 @@ async fn should_end_a_contest_behind_end_dates_left_empty() { end_dates_before.insert( start + join_window, BTreeSet::from([vote_poll(&contract) - .unique_id() + .unique_id(platform_version) .expect("expected a vote poll id")]), ); assert_eq!( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v0/mod.rs index cde77903a56..5be8ad8f026 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v0/mod.rs @@ -94,6 +94,13 @@ impl MasternodeVoteStateTransitionStateValidationV0 for MasternodeVoteTransition } } } + // Unreachable: this v0 is selected up to protocol version 13, where a yes/no vote + // does not decode (`StateTransition::active_version_range`). + ResolvedVote::YesNoVote(_) => { + Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "yes/no votes need validate_state version 1", + ))) + } } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs index 284f7e00f93..a13c4c2c9da 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs @@ -4,6 +4,7 @@ use dpp::consensus::state::state_error::StateError; use dpp::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; use dpp::consensus::state::voting::vote_poll_not_available_for_voting_error::VotePollNotAvailableForVotingError; use dpp::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; +use dpp::consensus::state::voting::yes_no_vote_poll_not_available_for_voting_error::YesNoVotePollNotAvailableForVotingError; use dpp::consensus::ConsensusError; use dpp::prelude::ConsensusValidationResult; @@ -17,6 +18,8 @@ use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ ContestedDocumentVotePollStatus, ContestedDocumentVotePollStoredInfoV0Getters, }; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStatus; +use dpp::voting::vote_polls::VotePoll; use drive::drive::votes::paths::{ RESOURCE_ABSTAIN_VOTE_TREE_KEY_U8_32, RESOURCE_LOCK_VOTE_TREE_KEY_U8_32, RESOURCE_STORED_INFO_KEY_U8_32, @@ -145,6 +148,39 @@ impl MasternodeVoteStateTransitionStateValidationV1 for MasternodeVoteTransition } } } + // Reached from protocol version 15 only: at 14, which also selects this generation, + // a yes/no vote is not active (`StateTransition::active_version_range`) and never + // decodes. + ResolvedVote::YesNoVote(yes_no_vote) => { + let vote_poll = &yes_no_vote.vote_poll; + let Some(stored_info) = platform.drive.fetch_yes_no_vote_poll_stored_info( + vote_poll, + tx, + platform_version, + )? + else { + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::VotePollNotFoundError( + VotePollNotFoundError::new(VotePoll::YesNoVotePoll(vote_poll.clone())), + )), + )); + }; + match stored_info.status() { + YesNoVotePollStatus::Started(_) => Ok( + ConsensusValidationResult::new_with_data(masternode_vote_action.into()), + ), + status @ YesNoVotePollStatus::Finished(_) => Ok( + ConsensusValidationResult::new_with_error(ConsensusError::StateError( + StateError::YesNoVotePollNotAvailableForVotingError( + YesNoVotePollNotAvailableForVotingError::new( + vote_poll.clone(), + *status, + ), + ), + )), + ), + } + } } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/mod.rs index 9a1925de7fc..008be12cc67 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/mod.rs @@ -1 +1,2 @@ pub(crate) mod v0; +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v0/mod.rs index e76fd0eb8b6..81c4878e860 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v0/mod.rs @@ -1,3 +1,4 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::platform::PlatformRef; use dpp::consensus::state::state_error::StateError; @@ -7,11 +8,13 @@ use dpp::consensus::state::voting::masternode_voted_too_many_times::MasternodeVo use dpp::consensus::ConsensusError; use dpp::dashcore::hashes::Hash; use dpp::dashcore::ProTxHash; -use dpp::dashcore_rpc::dashcore_rpc_json::MasternodeType; +use dpp::dashcore_rpc::dashcore_rpc_json::{MasternodeListItem, MasternodeType}; +use dpp::identifier::Identifier; use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::masternode_vote_transition::accessors::MasternodeVoteTransitionAccessorsV0; use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; +use drive::drive::votes::PreviousVoteCount; use drive::state_transition_action::identity::masternode_vote::MasternodeVoteTransitionAction; use crate::execution::validation::state_transition::ValidationMode; @@ -57,65 +60,43 @@ impl MasternodeVoteStateTransitionTransformIntoActionValidationV0 for Masternode &mut vec![], platform_version, )?; - if let Some((existing_resource_vote_choice, previous_vote_count)) = - maybe_existing_resource_vote_choice - { - if existing_resource_vote_choice - == resource_vote.resource_vote_choice() - { - // We are submitting a vote for something we already have - return Ok(ConsensusValidationResult::new_with_error( - ConsensusError::StateError( - StateError::MasternodeVoteAlreadyPresentError( - MasternodeVoteAlreadyPresentError::new( - self.pro_tx_hash(), - resource_vote.vote_poll().clone(), - ), - ), - ), - )); - } else if previous_vote_count - >= platform_version - .dpp - .validation - .voting - .votes_allowed_per_masternode - { - // We are submitting a vote for something we already have - return Ok(ConsensusValidationResult::new_with_error( - ConsensusError::StateError( - StateError::MasternodeVotedTooManyTimesError( - MasternodeVotedTooManyTimesError::new( - self.pro_tx_hash(), - previous_vote_count, - platform_version - .dpp - .validation - .voting - .votes_allowed_per_masternode, - ), - ), - ), - )); - } else { - previous_resource_vote_choice_to_remove = - Some((existing_resource_vote_choice, previous_vote_count)); + match previous_vote_to_replace_v0( + self.pro_tx_hash(), + maybe_existing_resource_vote_choice, + &resource_vote.resource_vote_choice(), + || resource_vote.vote_poll().clone(), + platform_version, + ) { + Ok(previous_vote) => { + previous_resource_vote_choice_to_remove = previous_vote + } + Err(error) => { + return Ok(ConsensusValidationResult::new_with_error(error)) } } } + // Unreachable: this v0 is selected up to protocol version 14, where a + // masternode vote that names a yes/no poll does not decode + // (`StateTransition::active_version_range`). + VotePoll::YesNoVotePoll(_) => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "yes/no vote polls need transform_into_action version 1", + ))) + } } } + // Unreachable for the same reason: no yes/no vote decodes before 15. + Vote::YesNoVote(_) => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "yes/no votes need transform_into_action version 1", + ))) + } } } - let Some(masternode) = platform - .state - .full_masternode_list() - .get(&ProTxHash::from_byte_array(self.pro_tx_hash().to_buffer())) - else { - return Ok(ConsensusValidationResult::new_with_error( - MasternodeNotFoundError::new(self.pro_tx_hash()).into(), - )); + let masternode = match voting_masternode_v0(platform, self.pro_tx_hash()) { + Ok(masternode) => masternode, + Err(error) => return Ok(ConsensusValidationResult::new_with_error(error)), }; let strength = match masternode.node_type { @@ -136,3 +117,70 @@ impl MasternodeVoteStateTransitionTransformIntoActionValidationV0 for Masternode )) } } + +/// What a masternode's earlier vote on a poll means for its new one, the same for both poll +/// kinds: the same answer again is refused, a change once the masternode has voted +/// `votes_allowed_per_masternode` times is refused, and any other change carries the earlier +/// answer for registration to remove. `None` for a first vote. +/// +/// Moved here unchanged from `transform_into_action_v0` (in place: the same checks, in the same +/// order, with the same errors at every protocol version that selects it), so version 1 applies +/// the rule to yes/no votes too. Shipped with protocol version 1 (version 0 runs it for every +/// contested vote up to 14), so it must not change: a different rule for a later version is a +/// new helper. +pub(super) fn previous_vote_to_replace_v0( + pro_tx_hash: Identifier, + existing_vote: Option<(Choice, PreviousVoteCount)>, + new_vote_choice: &Choice, + vote_poll: impl FnOnce() -> VotePoll, + platform_version: &PlatformVersion, +) -> Result, ConsensusError> { + let Some((existing_vote_choice, previous_vote_count)) = existing_vote else { + return Ok(None); + }; + if &existing_vote_choice == new_vote_choice { + // We are submitting a vote for something we already have + Err(ConsensusError::StateError( + StateError::MasternodeVoteAlreadyPresentError(MasternodeVoteAlreadyPresentError::new( + pro_tx_hash, + vote_poll(), + )), + )) + } else if previous_vote_count + >= platform_version + .dpp + .validation + .voting + .votes_allowed_per_masternode + { + // We are submitting a vote for something we already have + Err(ConsensusError::StateError( + StateError::MasternodeVotedTooManyTimesError(MasternodeVotedTooManyTimesError::new( + pro_tx_hash, + previous_vote_count, + platform_version + .dpp + .validation + .voting + .votes_allowed_per_masternode, + )), + )) + } else { + Ok(Some((existing_vote_choice, previous_vote_count))) + } +} + +/// The voting masternode in the block's masternode list, or the error refusing a vote from one +/// that is not in it. Moved here unchanged from `transform_into_action_v0` (in place, as +/// [`previous_vote_to_replace_v0`]) for version 1. Shipped with protocol version 1, so it must +/// not change. +pub(super) fn voting_masternode_v0<'a, C>( + platform: &PlatformRef<'a, C>, + pro_tx_hash: Identifier, +) -> Result<&'a MasternodeListItem, ConsensusError> { + platform + .state + .full_masternode_list() + .get(&ProTxHash::from_byte_array(pro_tx_hash.to_buffer())) + .ok_or_else(|| MasternodeNotFoundError::new(pro_tx_hash).into()) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v1/mod.rs new file mode 100644 index 00000000000..e21ce0320f1 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v1/mod.rs @@ -0,0 +1,122 @@ +use crate::error::Error; +use crate::execution::platform_events::voting::masternode_vote_strength; +use crate::execution::validation::state_transition::state_transitions::masternode_vote::transform_into_action::v0::{ + previous_vote_to_replace_v0, voting_masternode_v0, + MasternodeVoteStateTransitionTransformIntoActionValidationV0, +}; +use crate::platform_types::platform::PlatformRef; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; +use dpp::consensus::ConsensusError; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::masternode_vote_transition::accessors::MasternodeVoteTransitionAccessorsV0; +use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; +use drive::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; +use drive::drive::votes::resolved::votes::ResolvedVote; +use drive::state_transition_action::identity::masternode_vote::v0::MasternodeVoteTransitionActionV0; +use drive::state_transition_action::identity::masternode_vote::MasternodeVoteTransitionAction; + +use crate::execution::validation::state_transition::ValidationMode; +use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::VotePoll; +use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; +use dpp::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; +use dpp::voting::votes::Vote; +use drive::grovedb::TransactionArg; + +pub(in crate::execution::validation::state_transition::state_transitions::masternode_vote) trait MasternodeVoteStateTransitionTransformIntoActionValidationV1 +{ + fn transform_into_action_v1( + &self, + platform: &PlatformRef, + validation_mode: ValidationMode, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl MasternodeVoteStateTransitionTransformIntoActionValidationV1 for MasternodeVoteTransition { + /// Version 1 (protocol version 15) also transforms a vote on a yes/no poll, with the rule + /// of a resource vote ([`previous_vote_to_replace_v0`]): the same answer cannot be given twice, + /// and a masternode may vote on a poll at most `votes_allowed_per_masternode` times, its + /// first vote included. A resource vote on a contested poll transforms through version 0, + /// unchanged. + fn transform_into_action_v1( + &self, + platform: &PlatformRef, + validation_mode: ValidationMode, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let yes_no_vote = match self.vote() { + Vote::YesNoVote(yes_no_vote) => yes_no_vote, + Vote::ResourceVote(resource_vote) => { + // A resource vote choice (towards an identity, abstain, lock) answers a + // contested document resource poll. No such poll is named by a resource vote + // that carries a yes/no poll, whatever validation mode we are in. + if let VotePoll::YesNoVotePoll(_) = resource_vote.vote_poll() { + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::VotePollNotFoundError( + VotePollNotFoundError::new(resource_vote.vote_poll().clone()), + )), + )); + } + return self.transform_into_action_v0( + platform, + validation_mode, + tx, + platform_version, + ); + } + }; + + let mut previous_vote_choice_to_remove = None; + if validation_mode != ValidationMode::NoValidation { + // Before we transform into action we want to make sure that we have not yet voted + let vote_poll = yes_no_vote.vote_poll(); + let vote_id = vote_poll.unique_id(platform_version)?; + let maybe_existing_vote_choice = platform.drive.fetch_identity_yes_no_vote( + self.pro_tx_hash(), + vote_id, + tx, + &mut vec![], + platform_version, + )?; + match previous_vote_to_replace_v0( + self.pro_tx_hash(), + maybe_existing_vote_choice, + &yes_no_vote.vote_choice(), + || VotePoll::YesNoVotePoll(vote_poll.clone()), + platform_version, + ) { + Ok(previous_vote) => previous_vote_choice_to_remove = previous_vote, + Err(error) => return Ok(ConsensusValidationResult::new_with_error(error)), + } + } + + let masternode = match voting_masternode_v0(platform, self.pro_tx_hash()) { + Ok(masternode) => masternode, + Err(error) => return Ok(ConsensusValidationResult::new_with_error(error)), + }; + + // A yes/no vote needs nothing from the state to be resolved; the previous answer found + // above rides with it. + let resolved_vote = ResolvedYesNoVote { + vote_poll: yes_no_vote.vote_poll().clone(), + vote_choice: yes_no_vote.vote_choice(), + previous_vote_choice_to_remove, + }; + let action = MasternodeVoteTransitionActionV0 { + pro_tx_hash: self.pro_tx_hash(), + voter_identity_id: self.voter_identity_id(), + voting_address: masternode.state.voting_address, + vote_strength: masternode_vote_strength(&masternode.node_type, platform_version), + vote: ResolvedVote::YesNoVote(resolved_vote), + previous_resource_vote_choice_to_remove: None, + nonce: self.nonce(), + } + .into(); + + Ok(ConsensusValidationResult::new_with_data(action)) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests.rs new file mode 100644 index 00000000000..b30c972fa3e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests.rs @@ -0,0 +1,1462 @@ +//! Votes on yes/no vote polls: the poll's life from opening to the record of its decision. + +use crate::execution::check_tx::CheckTxLevel; +use crate::execution::platform_events::voting::masternode_vote_strength; +use crate::execution::validation::state_transition::state_transitions::tests::{ + process_test_state_transition, processing_credits, setup_masternode_voting_identity, + take_down_masternode_identities, +}; +use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; +use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; +use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::{ + InternalError, SuccessfulExecution, UnpaidConsensusError, +}; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::fast_forward_to_block::fast_forward_to_block; +use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use crate::test::helpers::state_mutation_guard::check_tx_at_all_levels; +use assert_matches::assert_matches; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::BasicError; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::ConsensusError; +use dpp::dashcore::hashes::Hash; +use dpp::dashcore::ProTxHash; +use dpp::dashcore_rpc::dashcore_rpc_json::MasternodeType; +use dpp::identifier::Identifier; +use dpp::identity::IdentityPublicKey; +use dpp::platform_value::BinaryData; +use dpp::prelude::IdentityNonce; +use dpp::serialization::PlatformSerializable; +use dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; +use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::{ + YesNoVotePollResult, YesNoVotePollStatus, +}; +use dpp::voting::vote_polls::yes_no_vote_poll::{ + VotingPowerRounding, YesNoMinimumVotingPower, YesNoVotePoll, +}; +use dpp::voting::vote_polls::VotePoll; +use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; +use dpp::voting::votes::resource_vote::ResourceVote; +use dpp::voting::votes::yes_no_vote::v0::YesNoVoteV0; +use dpp::voting::votes::yes_no_vote::YesNoVote; +use dpp::voting::votes::Vote; +use drive::query::yes_no_vote_poll_state_query::{ + YesNoVotePollState, YesNoVotePollStateDriveQuery, +}; +use drive::util::test_helpers::vote_poll_end_dates; +use simple_signer::signer::SimpleSigner; +use std::ops::Deref; +use std::sync::Arc; + +/// Two weeks after genesis: the polls in these tests end here. +const END_DATE: u64 = 1_209_600_000; +/// Enough for many votes at the single vote cost. +const FUND: u64 = 100_000_000_000; + +fn two_thirds_poll(purpose: &[u8], minimum_voting_power: u32) -> YesNoVotePoll { + YesNoVotePoll { + resource_path: vec![ + BinaryData::new(vec![0xc1; 32]), + BinaryData::new(purpose.to_vec()), + ], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute { + voting_power: minimum_voting_power, + }, + } +} + +fn open_poll( + platform: &TempPlatform, + vote_poll: &YesNoVotePoll, + fund: u64, + platform_version: &PlatformVersion, +) { + open_poll_ending_at(platform, vote_poll, END_DATE, fund, platform_version) +} + +fn open_poll_ending_at( + platform: &TempPlatform, + vote_poll: &YesNoVotePoll, + end_date: u64, + fund: u64, + platform_version: &PlatformVersion, +) { + platform + .drive + .open_yes_no_vote_poll( + vote_poll, + end_date, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected to open the yes/no vote poll"); + if fund > 0 { + platform + .drive + .add_prefunded_specialized_balance( + vote_poll + .specialized_balance_id(platform_version) + .expect("expected a specialized balance id"), + fund, + None, + platform_version, + ) + .expect("expected to fund the poll"); + } +} + +/// A voting masternode, promoted to an evonode when asked, so its vote weighs 4. +fn setup_voter( + platform: &mut TempPlatform, + seed: u64, + evonode: bool, + platform_version: &PlatformVersion, +) -> (Identifier, SimpleSigner, IdentityPublicKey) { + let (pro_tx_hash, _identity, signer, voting_key) = + setup_masternode_voting_identity(platform, seed, platform_version); + if evonode { + let mut platform_state = platform.state.load().clone().deref().clone(); + platform_state + .full_masternode_list_mut() + .get_mut(&ProTxHash::from_byte_array(pro_tx_hash.to_buffer())) + .expect("expected the masternode") + .node_type = MasternodeType::Evo; + platform.state.store(Arc::new(platform_state)); + } + (pro_tx_hash, signer, voting_key) +} + +#[allow(clippy::too_many_arguments)] +async fn perform_yes_no_vote( + platform: &mut TempPlatform, + vote_poll: &YesNoVotePoll, + vote_choice: YesNoAbstainVoteChoice, + signer: &SimpleSigner, + pro_tx_hash: Identifier, + voting_key: &IdentityPublicKey, + nonce: IdentityNonce, + platform_version: &PlatformVersion, +) -> Result<(), StateTransitionExecutionResult> { + let vote = Vote::YesNoVote(YesNoVote::V0(YesNoVoteV0 { + vote_poll: vote_poll.clone(), + vote_choice, + })); + perform_vote_transition( + platform, + vote, + signer, + pro_tx_hash, + voting_key, + nonce, + platform_version, + ) + .await +} + +/// CheckTx's verdicts on the serialized transition at both levels, taken before a block runs it: +/// whether it admits the transition, or the error it failed with. +fn check_tx_verdicts( + platform: &TempPlatform, + serialized_transition: &[u8], +) -> Vec<(CheckTxLevel, Result)> { + check_tx_at_all_levels(platform, serialized_transition) + .into_iter() + .map(|(level, result)| { + let verdict = result + .map(|result| result.is_valid()) + .map_err(|error| error.to_string()); + (level, verdict) + }) + .collect() +} + +/// Processes one masternode vote transition in a block and reports how it went. +/// +/// Every vote first goes through CheckTx, which asserts under cfg(test) that it leaves committed +/// state untouched (the devnet paloma height 788 class). As the shared masternode vote helper +/// requires, a vote the block accepts must pass CheckTx at both levels, and a vote the block +/// refuses must be refused by CheckTx's first check, or its voter never learns why. +async fn perform_vote_transition( + platform: &mut TempPlatform, + vote: Vote, + signer: &SimpleSigner, + pro_tx_hash: Identifier, + voting_key: &IdentityPublicKey, + nonce: IdentityNonce, + platform_version: &PlatformVersion, +) -> Result<(), StateTransitionExecutionResult> { + let masternode_vote_transition = MasternodeVoteTransition::try_from_vote_with_signer( + vote, + signer, + pro_tx_hash, + voting_key, + nonce, + platform_version, + None, + ) + .await + .expect("expected to make transition vote"); + let serialized_transition = masternode_vote_transition + .serialize_to_bytes() + .expect("expected to serialize the vote"); + let check_tx_verdicts = check_tx_verdicts(platform, &serialized_transition); + let platform_state = platform.state.load(); + let processing_result = process_test_state_transition( + platform, + masternode_vote_transition, + &platform_state, + platform_version, + ); + match processing_result.into_execution_results().remove(0) { + SuccessfulExecution { .. } => { + for (level, verdict) in check_tx_verdicts { + assert_eq!( + verdict, + Ok(true), + "check_tx {level:?} must admit a yes/no vote a block accepts" + ); + } + Ok(()) + } + other => { + // CheckTx does not run the prefunded balance pre-check, for votes on contested polls + // either, so a vote on a poll without a fund passes it. + let refused_for_its_fund = matches!( + &other, + UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceNotFoundError(_) + | StateError::PrefundedSpecializedBalanceInsufficientError(_) + )) + ); + // CheckTx fails with an error only where the block reports an internal error too + // (a transition protocol version 13 cannot decode); any other refusal must be a + // consensus refusal. + let (level, verdict) = &check_tx_verdicts[0]; + let refused = match verdict { + Ok(admitted) => !admitted, + Err(_) => matches!(other, InternalError(_)), + }; + assert!( + refused_for_its_fund || refused, + "check_tx {level:?} must refuse a yes/no vote a block refuses ({other:?}), got \ + {verdict:?}" + ); + Err(other) + } + } +} + +/// Casts one vote per masternode: `count` voters, each with the choice, at consecutive seeds. +async fn cast_votes( + platform: &mut TempPlatform, + vote_poll: &YesNoVotePoll, + vote_choice: YesNoAbstainVoteChoice, + count: u64, + start_seed: u64, + platform_version: &PlatformVersion, +) -> Vec { + let mut voters = vec![]; + for seed in start_seed..start_seed + count { + let (pro_tx_hash, signer, voting_key) = + setup_voter(platform, seed, false, platform_version); + perform_yes_no_vote( + platform, + vote_poll, + vote_choice, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect("expected the vote to be accepted"); + voters.push(pro_tx_hash); + } + voters +} + +/// Moves the chain past the polls' end date and closes the polls that ended. +fn close_ended_polls( + platform: &mut TempPlatform, + platform_version: &PlatformVersion, +) { + close_ended_polls_at(platform, END_DATE + 300_000, platform_version) +} + +/// Moves the chain to `time_ms`, one block after the last, and closes the polls that ended by +/// then. +fn close_ended_polls_at( + platform: &mut TempPlatform, + time_ms: u64, + platform_version: &PlatformVersion, +) { + close_ended_polls_in_block(platform, time_ms, None, platform_version) +} + +/// Like [`close_ended_polls_at`], with the closing block's own platform state when given: the +/// last committed one stays the chain's. +fn close_ended_polls_in_block( + platform: &mut TempPlatform, + time_ms: u64, + block_platform_state: Option, + platform_version: &PlatformVersion, +) { + let height = platform.state.load().last_committed_block_height() + 1; + fast_forward_to_block(platform, time_ms, height, 42, 0, false); + let block_info = BlockInfo { + time_ms, + height, + core_height: 42, + epoch: Default::default(), + }; + let last_committed_platform_state = platform.state.load(); + let block_platform_state = block_platform_state + .unwrap_or_else(|| PlatformState::clone(&last_committed_platform_state)); + let transaction = platform.drive.grove.start_transaction(); + platform + .check_for_ended_vote_polls( + &last_committed_platform_state, + &block_platform_state, + &block_info, + Some(&transaction), + platform_version, + ) + .expect("expected to check for ended vote polls"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); +} + +fn poll_state( + platform: &TempPlatform, + vote_poll: &YesNoVotePoll, + platform_version: &PlatformVersion, +) -> YesNoVotePollState { + YesNoVotePollStateDriveQuery { + vote_poll: vote_poll.clone(), + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("expected the poll state") +} + +/// The recorded result of a poll that ended. +fn finished_result( + platform: &TempPlatform, + vote_poll: &YesNoVotePoll, + platform_version: &PlatformVersion, +) -> YesNoVotePollResult { + *poll_state(platform, vote_poll, platform_version) + .stored_info + .expect("stored info") + .result() + .expect("expected the poll to be finished") +} + +/// A masternode's answer to a poll and how many times it voted on it, from the identity votes +/// index. +fn identity_vote( + platform: &TempPlatform, + voter: Identifier, + vote_poll_id: Identifier, + platform_version: &PlatformVersion, +) -> Option<(YesNoAbstainVoteChoice, u16)> { + platform + .drive + .fetch_identity_yes_no_vote(voter, vote_poll_id, None, &mut vec![], platform_version) + .expect("vote") +} + +fn new_platform() -> TempPlatform { + TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state() +} + +#[tokio::test] +async fn should_pass_at_exactly_the_threshold_and_fail_one_power_below_it_with_abstains() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + // Six cast votes: two thirds is four, so five yes passes and four yes does not. + let passing = two_thirds_poll(b"passing", 6); + let failing = two_thirds_poll(b"failing", 6); + open_poll(&platform, &passing, FUND, platform_version); + open_poll(&platform, &failing, FUND, platform_version); + + cast_votes( + &mut platform, + &passing, + YesNoAbstainVoteChoice::Yes, + 5, + 100, + platform_version, + ) + .await; + cast_votes( + &mut platform, + &passing, + YesNoAbstainVoteChoice::No, + 1, + 200, + platform_version, + ) + .await; + cast_votes( + &mut platform, + &passing, + YesNoAbstainVoteChoice::Abstain, + 2, + 300, + platform_version, + ) + .await; + + cast_votes( + &mut platform, + &failing, + YesNoAbstainVoteChoice::Yes, + 4, + 400, + platform_version, + ) + .await; + cast_votes( + &mut platform, + &failing, + YesNoAbstainVoteChoice::No, + 2, + 500, + platform_version, + ) + .await; + cast_votes( + &mut platform, + &failing, + YesNoAbstainVoteChoice::Abstain, + 3, + 600, + platform_version, + ) + .await; + + let state = poll_state(&platform, &passing, platform_version); + assert_eq!( + ( + state.yes_voting_power, + state.no_voting_power, + state.abstain_voting_power + ), + (5, 1, 2) + ); + assert_matches!( + state.stored_info.expect("stored info").status(), + YesNoVotePollStatus::Started(_) + ); + + close_ended_polls(&mut platform, platform_version); + + let passing_result = finished_result(&platform, &passing, platform_version); + assert!(passing_result.passed); + assert_eq!(passing_result.yes_voting_power, 5); + assert_eq!(passing_result.no_voting_power, 1); + assert_eq!(passing_result.abstain_voting_power, 2); + assert_eq!( + passing_result.finalization_block.time_ms, + END_DATE + 300_000 + ); + + let failing_result = finished_result(&platform, &failing, platform_version); + assert!(!failing_result.passed); + assert_eq!(failing_result.yes_voting_power, 4); + assert_eq!(failing_result.no_voting_power, 2); + assert_eq!(failing_result.abstain_voting_power, 3); +} + +#[tokio::test] +async fn should_fail_below_the_minimum_voting_power_even_when_every_vote_is_yes() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"unanimous", 6); + open_poll(&platform, &vote_poll, FUND, platform_version); + cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + 5, + 100, + platform_version, + ) + .await; + // Abstaining power does not count towards the minimum. + cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Abstain, + 3, + 200, + platform_version, + ) + .await; + + close_ended_polls(&mut platform, platform_version); + + let result = finished_result(&platform, &vote_poll, platform_version); + assert!(!result.passed); + assert_eq!(result.yes_voting_power, 5); + assert_eq!(result.no_voting_power, 0); + assert_eq!(result.abstain_voting_power, 3); +} + +/// A minimum given as a share of the total is measured against the masternode list of the +/// closing block, evonodes weighing 4, and the bar it resolved to is kept in the result. +#[tokio::test] +async fn should_judge_a_share_minimum_against_the_masternode_list_at_close() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let half_rounded_up = |purpose: &[u8]| YesNoVotePoll { + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 2, + rounding: VotingPowerRounding::Up, + }, + ..two_thirds_poll(purpose, 0) + }; + let evonode_alone = half_rounded_up(b"evonode alone"); + let evonode_and_three = half_rounded_up(b"evonode and three masternodes"); + open_poll(&platform, &evonode_alone, FUND, platform_version); + open_poll(&platform, &evonode_and_three, FUND, platform_version); + + // One evonode and five masternodes: a total of 9 while the polls run. + let evonode = setup_voter(&mut platform, 100, true, platform_version); + let masternodes: Vec<_> = (101..106) + .map(|seed| setup_voter(&mut platform, seed, false, platform_version)) + .collect(); + let total_voting_power = |platform_state: &PlatformState| -> u32 { + platform_state + .full_masternode_list() + .values() + .map(|masternode| { + u32::from(masternode_vote_strength( + &masternode.node_type, + platform_version, + )) + }) + .sum() + }; + assert_eq!( + total_voting_power(&platform.state.load()), + 9, + "the list holds only the voters set up here" + ); + assert_eq!(masternodes.len(), 5); + + let (pro_tx_hash, signer, voting_key) = &evonode; + for (nonce, vote_poll) in [(1, &evonode_alone), (2, &evonode_and_three)] { + perform_yes_no_vote( + &mut platform, + vote_poll, + YesNoAbstainVoteChoice::Yes, + signer, + *pro_tx_hash, + voting_key, + nonce, + platform_version, + ) + .await + .expect("expected the evonode's vote to be accepted"); + } + for (pro_tx_hash, signer, voting_key) in &masternodes[..3] { + perform_yes_no_vote( + &mut platform, + &evonode_and_three, + YesNoAbstainVoteChoice::Yes, + signer, + *pro_tx_hash, + voting_key, + 1, + platform_version, + ) + .await + .expect("expected the masternode's vote to be accepted"); + } + + // The closing block's list gains four masternodes the last committed one lacks: a total of + // 13, so half rounded up is 7. Judged against the last committed list (9), the bar would be + // 5 instead. + let mut closing_block_state = PlatformState::clone(&platform.state.load()); + let template = closing_block_state + .full_masternode_list() + .values() + .find(|masternode| masternode.node_type == MasternodeType::Regular) + .expect("expected a masternode") + .clone(); + for seed in 0..4u8 { + let mut masternode = template.clone(); + masternode.pro_tx_hash = ProTxHash::from_byte_array([0xa0 + seed; 32]); + closing_block_state + .full_masternode_list_mut() + .insert(masternode.pro_tx_hash, masternode); + } + assert_eq!(total_voting_power(&closing_block_state), 13); + close_ended_polls_in_block( + &mut platform, + END_DATE + 300_000, + Some(closing_block_state), + platform_version, + ); + + let alone = finished_result(&platform, &evonode_alone, platform_version); + assert_eq!(alone.yes_voting_power, 4); + assert_eq!(alone.required_voting_power, 7); + assert!(!alone.passed); + let with_three = finished_result(&platform, &evonode_and_three, platform_version); + assert_eq!(with_three.yes_voting_power, 7); + assert_eq!(with_three.required_voting_power, 7); + assert!(with_three.passed); +} + +#[tokio::test] +async fn should_count_an_evonode_vote_as_four() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"evonode", 5); + open_poll(&platform, &vote_poll, FUND, platform_version); + + let (evonode, signer, voting_key) = setup_voter(&mut platform, 100, true, platform_version); + perform_yes_no_vote( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + evonode, + &voting_key, + 1, + platform_version, + ) + .await + .expect("expected the evonode vote to be accepted"); + cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::No, + 1, + 200, + platform_version, + ) + .await; + + let state = poll_state(&platform, &vote_poll, platform_version); + assert_eq!((state.yes_voting_power, state.no_voting_power), (4, 1)); + + close_ended_polls(&mut platform, platform_version); + + // Five cast, minimum five: four is strictly more than two thirds of five. + let result = finished_result(&platform, &vote_poll, platform_version); + assert!(result.passed); + assert_eq!(result.yes_voting_power, 4); +} + +#[tokio::test] +async fn should_leave_only_the_record_behind_after_the_poll_ends() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"cleanup", 1); + open_poll(&platform, &vote_poll, FUND, platform_version); + let yes_voters = cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + 3, + 100, + platform_version, + ) + .await; + let no_voters = cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::No, + 1, + 200, + platform_version, + ) + .await; + let vote_poll_id = vote_poll.unique_id(platform_version).expect("id"); + let vote_cost = platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_single_vote_cost; + + // Before the end: votes, index entries, end date entry and the fund minus four votes. + let voters = platform + .drive + .fetch_identities_voting_in_yes_no_vote_poll(&vote_poll, None, platform_version) + .expect("voters"); + let mut sorted_voters = voters.clone(); + sorted_voters.sort(); + let mut expected_voters: Vec = + yes_voters.iter().chain(no_voters.iter()).copied().collect(); + expected_voters.sort(); + assert_eq!(sorted_voters, expected_voters); + assert_eq!( + identity_vote(&platform, yes_voters[0], vote_poll_id, platform_version), + Some((YesNoAbstainVoteChoice::Yes, 1)) + ); + let balance = platform + .drive + .fetch_prefunded_specialized_balance(vote_poll_id.to_buffer(), None, platform_version) + .expect("balance"); + assert_eq!(balance, Some(FUND - 4 * vote_cost)); + let end_dates = vote_poll_end_dates(&platform.drive, platform_version); + assert_eq!(end_dates.len(), 1); + let processing_credits_before = processing_credits(&platform, platform_version); + + close_ended_polls(&mut platform, platform_version); + + // After the end: the record with the result, and nothing else. + let state = poll_state(&platform, &vote_poll, platform_version); + let result = finished_result(&platform, &vote_poll, platform_version); + assert!(result.passed); + assert_eq!((result.yes_voting_power, result.no_voting_power), (3, 1)); + assert_eq!( + ( + state.yes_voting_power, + state.no_voting_power, + state.abstain_voting_power + ), + (0, 0, 0) + ); + let voters = platform + .drive + .fetch_identities_voting_in_yes_no_vote_poll(&vote_poll, None, platform_version) + .expect("voters"); + assert!(voters.is_empty()); + for voter in yes_voters.iter().chain(no_voters.iter()) { + assert_eq!( + identity_vote(&platform, *voter, vote_poll_id, platform_version), + None + ); + } + // What the votes left of the fund goes to the processing pool, and the balance goes. + let balance = platform + .drive + .fetch_prefunded_specialized_balance(vote_poll_id.to_buffer(), None, platform_version) + .expect("balance"); + assert_eq!(balance, None); + assert_eq!( + processing_credits(&platform, platform_version) - processing_credits_before, + FUND - 4 * vote_cost + ); + let end_dates = vote_poll_end_dates(&platform.drive, platform_version); + assert!(end_dates.is_empty()); + + // A finished poll takes no more votes, and cannot be opened again under the same id. + let (pro_tx_hash, signer, voting_key) = + setup_voter(&mut platform, 300, false, platform_version); + let error = perform_yes_no_vote( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect_err("expected the vote to be refused"); + // Closing the poll emptied its fund, and the fund pre-check runs before state validation, + // so the missing fund is what refuses the vote. + assert_matches!( + &error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceNotFoundError(_) + )) + ); + // With a fund at its id again, the finished status refuses the vote. + platform + .drive + .add_prefunded_specialized_balance(vote_poll_id, FUND, None, platform_version) + .expect("expected to fund the finished poll"); + let error = perform_yes_no_vote( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect_err("expected the vote to be refused"); + assert_matches!( + &error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::YesNoVotePollNotAvailableForVotingError(_) + )) + ); + assert!(platform + .drive + .open_yes_no_vote_poll( + &vote_poll, + END_DATE, + &BlockInfo::default(), + None, + platform_version + ) + .is_err()); +} + +#[tokio::test] +async fn should_refuse_a_vote_on_an_unknown_poll_and_the_same_answer_twice() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"rules", 1); + let unknown_poll = two_thirds_poll(b"unknown", 1); + open_poll(&platform, &vote_poll, FUND, platform_version); + // The unknown poll's fund exists, the poll does not. + platform + .drive + .add_prefunded_specialized_balance( + unknown_poll + .specialized_balance_id(platform_version) + .expect("id"), + FUND, + None, + platform_version, + ) + .expect("fund"); + let (pro_tx_hash, signer, voting_key) = + setup_voter(&mut platform, 100, false, platform_version); + + let error = perform_yes_no_vote( + &mut platform, + &unknown_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect_err("expected the vote on an unknown poll to be refused"); + assert_matches!( + error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::VotePollNotFoundError(_) + )) + ); + + perform_yes_no_vote( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect("expected the first vote to be accepted"); + let error = perform_yes_no_vote( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + pro_tx_hash, + &voting_key, + 2, + platform_version, + ) + .await + .expect_err("expected the same answer to be refused"); + assert_matches!( + error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::MasternodeVoteAlreadyPresentError(_) + )) + ); +} + +#[tokio::test] +async fn should_refuse_a_resource_vote_that_names_a_yes_no_poll() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"cross kind", 1); + open_poll(&platform, &vote_poll, FUND, platform_version); + let (pro_tx_hash, signer, voting_key) = + setup_voter(&mut platform, 100, false, platform_version); + // A resource vote choice answers a contested document resource poll, not a yes/no one. + let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::YesNoVotePoll(vote_poll.clone()), + resource_vote_choice: ResourceVoteChoice::Abstain, + })); + let error = perform_vote_transition( + &mut platform, + vote, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect_err("expected the vote to be refused"); + assert_matches!( + error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::VotePollNotFoundError(_) + )) + ); + let state = poll_state(&platform, &vote_poll, platform_version); + assert_eq!(state.abstain_voting_power, 0); +} + +#[tokio::test] +async fn should_move_a_changed_vote_and_cap_the_changes() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"changes", 1); + open_poll(&platform, &vote_poll, FUND, platform_version); + let (pro_tx_hash, signer, voting_key) = setup_voter(&mut platform, 100, true, platform_version); + let vote_poll_id = vote_poll.unique_id(platform_version).expect("id"); + let allowed = platform_version + .dpp + .validation + .voting + .votes_allowed_per_masternode; + + let choices = [ + YesNoAbstainVoteChoice::Yes, + YesNoAbstainVoteChoice::No, + YesNoAbstainVoteChoice::Abstain, + ]; + let mut nonce = 1; + for (vote_number, vote_choice) in choices.iter().cycle().take(allowed as usize).enumerate() { + perform_yes_no_vote( + &mut platform, + &vote_poll, + *vote_choice, + &signer, + pro_tx_hash, + &voting_key, + nonce, + platform_version, + ) + .await + .expect("expected the vote change to be accepted"); + nonce += 1; + // Only the latest answer holds the evonode's four. + let state = poll_state(&platform, &vote_poll, platform_version); + for choice in choices { + let expected = if choice == *vote_choice { 4 } else { 0 }; + let voting_power = match choice { + YesNoAbstainVoteChoice::Yes => state.yes_voting_power, + YesNoAbstainVoteChoice::No => state.no_voting_power, + YesNoAbstainVoteChoice::Abstain => state.abstain_voting_power, + }; + assert_eq!(voting_power, expected, "vote {}", vote_number); + } + assert_eq!( + identity_vote(&platform, pro_tx_hash, vote_poll_id, platform_version), + Some((*vote_choice, vote_number as u16 + 1)) + ); + } + + let next_choice = choices[allowed as usize % choices.len()]; + let error = perform_yes_no_vote( + &mut platform, + &vote_poll, + next_choice, + &signer, + pro_tx_hash, + &voting_key, + nonce, + platform_version, + ) + .await + .expect_err("expected one change too many to be refused"); + assert_matches!( + error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::MasternodeVotedTooManyTimesError(_) + )) + ); +} + +#[tokio::test] +async fn should_refuse_a_vote_when_the_poll_has_no_fund() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"unfunded", 1); + open_poll(&platform, &vote_poll, 0, platform_version); + let (pro_tx_hash, signer, voting_key) = + setup_voter(&mut platform, 100, false, platform_version); + // The prefunded balance pre-check runs before the vote is transformed, so the missing fund + // refuses it unpaid and nothing of the vote is recorded. + let error = perform_yes_no_vote( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect_err("expected the vote to be refused"); + assert_matches!( + &error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceNotFoundError(_) + )) + ); + assert_eq!( + identity_vote( + &platform, + pro_tx_hash, + vote_poll.unique_id(platform_version).expect("id"), + platform_version + ), + None + ); + let state = poll_state(&platform, &vote_poll, platform_version); + assert_eq!(state.yes_voting_power, 0); + + // The poll still closes when its time comes: the clean-up finds no fund to drain and moves + // on, rather than failing the block. + let processing_credits_before = processing_credits(&platform, platform_version); + close_ended_polls(&mut platform, platform_version); + let result = finished_result(&platform, &vote_poll, platform_version); + assert!(!result.passed); + assert_eq!( + processing_credits(&platform, platform_version), + processing_credits_before + ); + assert!(vote_poll_end_dates(&platform.drive, platform_version).is_empty()); +} + +#[tokio::test] +async fn should_remove_the_votes_of_masternodes_that_left_the_list() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"removals", 1); + open_poll(&platform, &vote_poll, FUND, platform_version); + let yes_voters = cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + 3, + 100, + platform_version, + ) + .await; + let no_voters = cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::No, + 2, + 200, + platform_version, + ) + .await; + let vote_poll_id = vote_poll.unique_id(platform_version).expect("id"); + + let platform_state_before_removals = platform.state.load().deref().clone(); + let removed = vec![yes_voters[0], yes_voters[1], no_voters[0]]; + take_down_masternode_identities(&mut platform, &removed); + let block_platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + platform + .remove_votes_for_removed_masternodes( + &BlockInfo::default(), + &platform_state_before_removals, + &block_platform_state, + Some(&transaction), + platform_version, + ) + .expect("expected to remove votes for removed masternodes"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let state = poll_state(&platform, &vote_poll, platform_version); + assert_eq!((state.yes_voting_power, state.no_voting_power), (1, 1)); + let voters = platform + .drive + .fetch_identities_voting_in_yes_no_vote_poll(&vote_poll, None, platform_version) + .expect("voters"); + // The yes voters, then the no voters. + assert_eq!(voters, vec![yes_voters[2], no_voters[1]]); + for voter in &removed { + assert_eq!( + identity_vote(&platform, *voter, vote_poll_id, platform_version), + None + ); + } + assert_eq!( + identity_vote(&platform, yes_voters[2], vote_poll_id, platform_version), + Some((YesNoAbstainVoteChoice::Yes, 1)) + ); + + // The poll still closes cleanly with what is left. + close_ended_polls(&mut platform, platform_version); + let result = finished_result(&platform, &vote_poll, platform_version); + assert_eq!((result.yes_voting_power, result.no_voting_power), (1, 1)); + assert!(!result.passed); +} + +#[tokio::test] +async fn should_close_a_contested_poll_and_a_yes_no_poll_that_end_at_the_same_time() { + use crate::execution::validation::state_transition::state_transitions::tests::{ + create_dpns_identity_name_contest, dpns_name_vote_poll, get_vote_states, + }; + use dapi_grpc::platform::v0::get_contested_resource_vote_state_request::get_contested_resource_vote_state_request_v0::ResultType; + + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let platform_state = platform.state.load(); + // The DPNS contest ends two weeks after its creation at the genesis block time. + let (_contender_1, _contender_2, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version, + ) + .await; + // The yes/no poll ends at the very millisecond the contest does. + let contest_end_date = *vote_poll_end_dates(&platform.drive, platform_version) + .keys() + .next() + .expect("expected the contest's end date"); + let vote_poll = two_thirds_poll(b"shared end date", 1); + open_poll_ending_at( + &platform, + &vote_poll, + contest_end_date, + FUND, + platform_version, + ); + cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + 1, + 100, + platform_version, + ) + .await; + + let end_dates = vote_poll_end_dates(&platform.drive, platform_version); + assert_eq!(end_dates.len(), 1, "both polls end at the same time"); + assert_eq!(end_dates.values().next().expect("polls").len(), 2); + + close_ended_polls_at(&mut platform, contest_end_date + 300_000, platform_version); + + let end_dates = vote_poll_end_dates(&platform.drive, platform_version); + assert!(end_dates.is_empty()); + assert!(finished_result(&platform, &vote_poll, platform_version).passed); + + // The contest closed in the same block: it has a result, and its clean-up drained its + // fund. + let platform_state = platform.state.load(); + let (_contenders, _abstaining, _locking, finished_vote_info) = get_vote_states( + &platform, + &platform_state, + &dpns_contract, + "quantum", + None, + true, + None, + ResultType::DocumentsAndVoteTally, + platform_version, + ); + assert!(finished_vote_info.is_some(), "the contest must be finished"); + let contest_fund_id = dpns_name_vote_poll(&dpns_contract, "quantum") + .specialized_balance_id() + .expect("expected the contest's fund id"); + assert_eq!( + platform + .drive + .fetch_prefunded_specialized_balance( + contest_fund_id.to_buffer(), + None, + platform_version + ) + .expect("balance"), + None + ); +} + +/// The block closes at most `maximum_vote_polls_to_process` polls, counted across every due end +/// date. When that cuts the last end date short, its tree must stay for the polls still under it: +/// deleting it in the same batch as the fetched poll's entry fails the whole block. +#[tokio::test] +async fn should_keep_an_end_date_the_block_only_partly_closed() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let earlier = two_thirds_poll(b"earlier end date", 1); + let later_polls = [ + two_thirds_poll(b"later end date one", 1), + two_thirds_poll(b"later end date two", 1), + ]; + open_poll_ending_at(&platform, &earlier, END_DATE, FUND, platform_version); + for vote_poll in &later_polls { + open_poll_ending_at( + &platform, + vote_poll, + END_DATE + 1_000, + FUND, + platform_version, + ); + } + let end_dates = |platform: &TempPlatform| { + vote_poll_end_dates(&platform.drive, platform_version) + }; + let finished = |platform: &TempPlatform, vote_poll: &YesNoVotePoll| { + poll_state(platform, vote_poll, platform_version) + .stored_info + .expect("stored info") + .result() + .is_some() + }; + + // One block past both end dates closes the earlier poll and one of the later two. + close_ended_polls_at(&mut platform, END_DATE + 300_000, platform_version); + assert!(finished(&platform, &earlier)); + let finished_later = later_polls + .iter() + .filter(|vote_poll| finished(&platform, vote_poll)) + .count(); + assert_eq!(finished_later, 1); + let remaining = end_dates(&platform); + assert_eq!(remaining.len(), 1); + assert_eq!( + remaining.get(&(END_DATE + 1_000)).map(|polls| polls.len()), + Some(1) + ); + + // The next block closes the last one and the end date goes with it. + close_ended_polls_at(&mut platform, END_DATE + 301_000, platform_version); + assert!(later_polls + .iter() + .all(|vote_poll| finished(&platform, vote_poll))); + assert!(end_dates(&platform).is_empty()); +} + +/// Two openings of one poll that cannot see each other (each built with no previous +/// operations, as drive operations are converted one at a time) list it under two end dates. +/// The poll closes at the first; when the second comes due, only its entry goes, rather than +/// the block failing on a poll that is already finished. +#[tokio::test] +async fn should_only_remove_the_later_end_date_entry_of_a_poll_listed_twice() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"listed twice", 1); + let later_end_date = END_DATE + 1_000_000; + let mut operations = vec![]; + for end_date in [END_DATE, later_end_date] { + let mut opening_operations = vec![]; + platform + .drive + .open_yes_no_vote_poll_operations( + &vote_poll, + end_date, + &BlockInfo::default(), + &mut None, + &mut opening_operations, + None, + platform_version, + ) + .expect("expected to open the poll"); + operations.append(&mut opening_operations); + } + platform + .drive + .apply_batch_low_level_drive_operations( + None, + None, + operations, + &mut vec![], + &platform_version.drive, + ) + .expect("expected the openings to apply"); + platform + .drive + .add_prefunded_specialized_balance( + vote_poll + .specialized_balance_id(platform_version) + .expect("id"), + FUND, + None, + platform_version, + ) + .expect("expected to fund the poll"); + assert_eq!( + vote_poll_end_dates(&platform.drive, platform_version).len(), + 2 + ); + + close_ended_polls(&mut platform, platform_version); + let result = finished_result(&platform, &vote_poll, platform_version); + assert!(!result.passed); + assert_eq!( + vote_poll_end_dates(&platform.drive, platform_version) + .keys() + .copied() + .collect::>(), + vec![later_end_date] + ); + + close_ended_polls_at(&mut platform, later_end_date + 300_000, platform_version); + assert!(vote_poll_end_dates(&platform.drive, platform_version).is_empty()); + assert_eq!( + finished_result(&platform, &vote_poll, platform_version), + result + ); +} + +/// Protocol version 13 selects the shipped (v0) masternode vote transform and state +/// validation and vote registration, which this change gave yes/no arms. A contest there runs +/// as it did before: the vote counts, the poll ends, the +/// contender is awarded and the end date index empties. A masternode vote naming a yes/no +/// poll, of either kind, does not decode there, so it is stripped before any of them runs and +/// consumes nothing. +#[tokio::test] +async fn should_run_a_contest_unchanged_and_strip_yes_no_polls_at_protocol_version_13() { + run_a_contest_and_strip_yes_no_polls(13).await; +} + +/// Protocol version 14 selects the masternode vote transform v0 and state validation v1, vote +/// registration v0 and end of poll check v1, which this change gave yes/no arms that only 15 +/// reaches. A contest there runs as it did before, and a masternode vote naming a +/// yes/no poll is stripped as at 13. +#[tokio::test] +async fn should_run_a_contest_unchanged_and_strip_yes_no_polls_at_protocol_version_14() { + run_a_contest_and_strip_yes_no_polls(14).await; +} + +async fn run_a_contest_and_strip_yes_no_polls(protocol_version: u32) { + use crate::execution::validation::state_transition::state_transitions::tests::{ + create_dpns_identity_name_contest, dpns_name_vote_poll, get_vote_states, + }; + use dapi_grpc::platform::v0::get_contested_resource_vote_state_request::get_contested_resource_vote_state_request_v0::ResultType; + use dapi_grpc::platform::v0::get_contested_resource_vote_state_response::get_contested_resource_vote_state_response_v0::finished_vote_info::FinishedVoteOutcome; + use dpp::identity::accessors::IdentityGettersV0; + + let platform_version = PlatformVersion::get(protocol_version).expect("protocol version"); + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let (contender_1, _contender_2, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version, + ) + .await; + let (pro_tx_hash, signer, voting_key) = + setup_voter(&mut platform, 100, false, platform_version); + + let yes_no_poll = two_thirds_poll(b"before 15", 1); + let votes_naming_a_yes_no_poll = [ + Vote::YesNoVote(YesNoVote::V0(YesNoVoteV0 { + vote_poll: yes_no_poll.clone(), + vote_choice: YesNoAbstainVoteChoice::Yes, + })), + Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::YesNoVotePoll(yes_no_poll), + resource_vote_choice: ResourceVoteChoice::Abstain, + })), + ]; + for vote in votes_naming_a_yes_no_poll { + let result = perform_vote_transition( + &mut platform, + vote, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect_err("expected the vote to be stripped"); + // Both decoding generations (`decode_raw_state_transitions` 0 at 13, 1 at 14) report a + // transition not active yet as an unpaid consensus error, which strips the vote without + // charging. + assert_matches!( + result, + UnpaidConsensusError(ConsensusError::BasicError( + BasicError::StateTransitionNotActiveError(_) + )) + ); + } + + // Nonce 1 is still free: the stripped votes consumed nothing. + let contested_vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::ContestedDocumentResourceVotePoll(dpns_name_vote_poll( + &dpns_contract, + "quantum", + )), + resource_vote_choice: ResourceVoteChoice::TowardsIdentity(contender_1.id()), + })); + perform_vote_transition( + &mut platform, + contested_vote, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect("expected the contested vote to be accepted"); + + close_ended_polls(&mut platform, platform_version); + + let platform_state = platform.state.load(); + let (_contenders, _abstaining, _locking, finished_vote_info) = get_vote_states( + &platform, + &platform_state, + &dpns_contract, + "quantum", + None, + true, + None, + ResultType::DocumentsAndVoteTally, + platform_version, + ); + let finished_vote_info = finished_vote_info.expect("expected the contest to be finished"); + assert_eq!( + finished_vote_info.finished_vote_outcome, + FinishedVoteOutcome::TowardsIdentity as i32 + ); + assert_eq!( + finished_vote_info.won_by_identity_id, + Some(contender_1.id().to_vec()) + ); + let end_dates = vote_poll_end_dates(&platform.drive, platform_version); + assert!(end_dates.is_empty()); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 61e02898e96..0d8c5b3d6eb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -186,7 +186,7 @@ pub(in crate::execution) mod tests { use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; use dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; use dpp::state_transition::StateTransition; - use dpp::block::epoch::EpochIndex; + use dpp::block::epoch::{Epoch, EpochIndex}; use dpp::tokens::calculate_token_id; use dpp::util::hash::hash_double; use dpp::util::strings::convert_to_homograph_safe_chars; @@ -206,7 +206,9 @@ pub(in crate::execution) mod tests { use drive::drive::votes::paths::VotePollPaths; use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::resolve::ContestedDocumentResourceVotePollResolver; use drive::fees::op::LowLevelDriveOperation; + use drive::error::Error as DriveError; use drive::grovedb::Element; + use drive::grovedb::Error as GroveError; use crate::execution::types::block_execution_context::BlockExecutionContext; use crate::execution::types::block_execution_context::v0::BlockExecutionContextV0; use crate::expect_match; @@ -901,6 +903,27 @@ pub(in crate::execution) mod tests { platform.state.store(Arc::new(platform_state)); } + /// The processing credits epoch 0 will distribute; the item is written by the first credit, + /// so before any it is missing, which is none. + pub(in crate::execution) fn processing_credits( + platform: &TempPlatform, + platform_version: &PlatformVersion, + ) -> Credits { + match platform + .drive + .get_epoch_processing_credits_for_distribution( + &Epoch::new(0).expect("epoch"), + None, + platform_version, + ) { + Ok(credits) => credits, + Err(DriveError::GroveDB(error)) if matches!(*error, GroveError::PathKeyNotFound(_)) => { + 0 + } + Err(error) => panic!("expected the epoch's processing credits: {error:?}"), + } + } + #[allow(dead_code)] pub(in crate::execution) enum IdentityTestInfo<'a> { Given { diff --git a/packages/rs-drive-abci/src/test/helpers/state_mutation_guard.rs b/packages/rs-drive-abci/src/test/helpers/state_mutation_guard.rs index a588390ef11..c017ffed93a 100644 --- a/packages/rs-drive-abci/src/test/helpers/state_mutation_guard.rs +++ b/packages/rs-drive-abci/src/test/helpers/state_mutation_guard.rs @@ -15,11 +15,14 @@ //! during CheckTx fee estimation). #3823 made the converter pure; the helpers here guard the //! CLASS by asserting the committed root hash is byte-identical around CheckTx-path entry points. -use crate::execution::check_tx::CheckTxLevel; +use crate::error::Error; +use crate::execution::check_tx::{CheckTxLevel, CheckTxResult}; use crate::platform_types::platform::PlatformRef; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TempPlatform; +use dpp::consensus::ConsensusError; +use dpp::validation::ValidationResult; use dpp::version::PlatformVersion; use drive::drive::Drive; @@ -56,22 +59,15 @@ pub fn assert_committed_root_hash_unchanged( result } -/// Runs `check_tx` at BOTH levels (`FirstTimeCheck`, then `Recheck`) on a serialized state -/// transition and asserts each returns a VALID result. -/// -/// Call this from every state-transition type's canonical valid-fixture test, BEFORE the fixture -/// is processed: it proves the type's full CheckTx pipeline (decode -> validation -> fee -/// estimation) runs against committed state without errors, and — because `Platform::check_tx` -/// itself asserts committed-root-hash invariance under `cfg(test)` — that nothing on the type's -/// CheckTx path mutates committed GroveDB state (the devnet paloma height-788 halt class). -/// -/// The validity assertions matter for the guard's strength: an early consensus rejection would -/// skip fee estimation, leaving the type's drive-op converters unexercised in estimation mode. -pub fn assert_check_tx_valid_at_all_levels( +/// Runs `check_tx` at both levels (`FirstTimeCheck`, then `Recheck`) on a serialized state +/// transition against committed state, and returns each level's result. +pub fn check_tx_at_all_levels( platform: &TempPlatform, serialized_transition: &[u8], - context: &str, -) { +) -> Vec<( + CheckTxLevel, + Result, Error>, +)> { let platform_state = platform.state.load(); let platform_version = platform_state .current_platform_version() @@ -83,17 +79,40 @@ pub fn assert_check_tx_valid_at_all_levels( core_rpc: &platform.core_rpc, }; - for level in [CheckTxLevel::FirstTimeCheck, CheckTxLevel::Recheck] { - let result = platform - .check_tx( + [CheckTxLevel::FirstTimeCheck, CheckTxLevel::Recheck] + .into_iter() + .map(|level| { + let result = platform.check_tx( serialized_transition, level, &platform_ref, platform_version, - ) - .unwrap_or_else(|error| { - panic!("{context}: check_tx {level:?} must not error, got {error}") - }); + ); + (level, result) + }) + .collect() +} + +/// Runs `check_tx` at BOTH levels (`FirstTimeCheck`, then `Recheck`) on a serialized state +/// transition and asserts each returns a VALID result. +/// +/// Call this from every state-transition type's canonical valid-fixture test, BEFORE the fixture +/// is processed: it proves the type's full CheckTx pipeline (decode -> validation -> fee +/// estimation) runs against committed state without errors, and — because `Platform::check_tx` +/// itself asserts committed-root-hash invariance under `cfg(test)` — that nothing on the type's +/// CheckTx path mutates committed GroveDB state (the devnet paloma height-788 halt class). +/// +/// The validity assertions matter for the guard's strength: an early consensus rejection would +/// skip fee estimation, leaving the type's drive-op converters unexercised in estimation mode. +pub fn assert_check_tx_valid_at_all_levels( + platform: &TempPlatform, + serialized_transition: &[u8], + context: &str, +) { + for (level, result) in check_tx_at_all_levels(platform, serialized_transition) { + let result = result.unwrap_or_else(|error| { + panic!("{context}: check_tx {level:?} must not error, got {error}") + }); assert!( result.is_valid(), "{context}: check_tx {level:?} must pass for the canonical valid fixture so fee \ diff --git a/packages/rs-drive-abci/tests/query_request_errors/offset_controls.rs b/packages/rs-drive-abci/tests/query_request_errors/offset_controls.rs index 0fb73b30783..a4c9a430652 100644 --- a/packages/rs-drive-abci/tests/query_request_errors/offset_controls.rs +++ b/packages/rs-drive-abci/tests/query_request_errors/offset_controls.rs @@ -207,7 +207,7 @@ async fn should_preserve_supported_offset_pages_and_original_proof_roots() { .expect("register identity vote"); let poll: dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll = poll.into(); let poll = VotePoll::ContestedDocumentResourceVotePoll(poll); - let id = poll.unique_id().expect("poll ID"); + let id = poll.unique_id(version).expect("poll ID"); expected_votes.insert( id, ResourceVote::V0(ResourceVoteV0 { diff --git a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs index ab37b75ee34..6fdb9ebb1af 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs @@ -970,28 +970,38 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( } } StateTransitionAction::MasternodeVoteAction(masternode_vote_action) => { - let data_contract = match masternode_vote_action.vote_ref() { - ResolvedVote::ResolvedResourceVote(resource_vote) => match resource_vote - .vote_poll() - { - ResolvedVotePoll::ContestedDocumentResourceVotePollWithContractInfo( - contested_document_resource_vote_poll, - ) => contested_document_resource_vote_poll.contract.as_ref(), - }, - }; - let vote: Vote = masternode_vote_action.vote_ref().clone().into(); // we expect to get a vote that matches the state transition - let (root_hash_vote, maybe_vote) = Drive::verify_masternode_vote( - &response_proof.grovedb_proof, - masternode_vote_action.pro_tx_hash().into_buffer(), - &vote, - data_contract, - false, // we are not in a subset, we have just one vote - platform_version, - ) - .expect("expected to verify balance identity"); + let (root_hash_vote, maybe_vote) = match masternode_vote_action.vote_ref() { + ResolvedVote::ResolvedResourceVote(resource_vote) => { + let data_contract = match resource_vote.vote_poll() { + ResolvedVotePoll::ContestedDocumentResourceVotePollWithContractInfo( + contested_document_resource_vote_poll, + ) => contested_document_resource_vote_poll.contract.as_ref(), + }; + Drive::verify_masternode_vote( + &response_proof.grovedb_proof, + masternode_vote_action.pro_tx_hash().into_buffer(), + &vote, + data_contract, + false, // we are not in a subset, we have just one vote + platform_version, + ) + .expect("expected to verify the masternode vote") + } + ResolvedVote::YesNoVote(yes_no_vote) => { + let (root_hash, maybe_vote) = Drive::verify_masternode_yes_no_vote( + &response_proof.grovedb_proof, + masternode_vote_action.pro_tx_hash().into_buffer(), + &yes_no_vote.clone().into(), + false, + platform_version, + ) + .expect("expected to verify the masternode yes/no vote"); + (root_hash, maybe_vote.map(Vote::YesNoVote)) + } + }; assert_eq!( &root_hash_vote, diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 6d99f41c026..5615ae6ea1d 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -4587,7 +4587,7 @@ "since": 1, "presence": "always", "source": "packages/rs-drive/src/drive/mod.rs", - "description": "Masternode votes, today on contested resources such as premium DPNS names.", + "description": "Masternode votes: on contested resources such as premium DPNS names, and on yes/no decisions.", "children": [ { "id": "votes.contested_resource", @@ -5116,8 +5116,251 @@ "since": 1, "presence": "always", "source": "packages/rs-drive/src/drive/votes/paths.rs", - "description": "Reserved for polls that decide something for the network. Nothing writes to it yet.", - "children": [] + "description": "Yes/no polls: decisions taken by a supermajority of the masternodes once enough voting power was cast.", + "children": [ + { + "id": "votes.decisions.identity_votes", + "key": { + "type": "fixed", + "hex": "69", + "label": "DecisionIdentityVotes", + "constant": "IDENTITY_VOTES_TREE_KEY", + "ascii": true + }, + "kinds": [ + "Tree" + ], + "since": 15, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The yes/no votes each masternode cast, so they can be changed and removed with the masternode.", + "children": [ + { + "id": "votes.decisions.identity_votes.voter", + "key": { + "type": "dynamic", + "name": "pro_tx_hash", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The voting masternode's pro tx hash" + }, + "kinds": [ + "Tree" + ], + "since": 15, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One masternode's yes/no votes. Stays when its polls close, and is dropped when the masternode leaves the list.", + "children": [ + { + "id": "votes.decisions.identity_votes.voter.vote", + "key": { + "type": "dynamic", + "name": "vote_poll_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "hash32", + "description": "The double sha256 of the domain tag `dash platform yes/no vote poll id` followed by the serialized vote poll" + }, + "kinds": [ + "Item" + ], + "value": "bincode YesNoVoteReferenceStorageForm: the choice and how many times the masternode voted on the poll", + "since": 15, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The masternode's current answer to one poll.", + "children": [] + } + ] + } + ] + }, + { + "id": "votes.decisions.active_polls", + "key": { + "type": "fixed", + "hex": "70", + "label": "DecisionActivePolls", + "constant": "ACTIVE_POLLS_TREE_KEY", + "ascii": true + }, + "kinds": [ + "Tree" + ], + "since": 15, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The yes/no polls, by their unique id.", + "children": [ + { + "id": "votes.decisions.active_polls.poll", + "key": { + "type": "dynamic", + "name": "vote_poll_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "hash32", + "description": "The double sha256 of the domain tag `dash platform yes/no vote poll id` followed by the serialized vote poll" + }, + "kinds": [ + "Tree" + ], + "since": 15, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One yes/no poll: its stored info and its three vote sum trees. The vote trees go when the poll ends; the stored info stays as the record of the decision.", + "children": [ + { + "id": "votes.decisions.active_polls.poll.stored_info", + "key": { + "type": "fixed", + "hex": "00", + "label": "YesNoStoredInfo", + "constant": "YES_NO_VOTE_POLL_STORED_INFO_KEY" + }, + "kinds": [ + "Item" + ], + "value": "serialized YesNoVotePollStoredInfo", + "since": 15, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The poll's status and, once it ends, its result.", + "children": [] + }, + { + "id": "votes.decisions.active_polls.poll.yes", + "key": { + "type": "fixed", + "hex": "01", + "label": "YesVotes", + "constant": "YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY" + }, + "kinds": [ + "SumTree" + ], + "since": 15, + "presence": "until_deleted", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "Votes for yes.", + "children": [ + { + "id": "votes.decisions.active_polls.poll.yes.voter", + "key": { + "type": "dynamic", + "name": "pro_tx_hash", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The voting masternode's pro tx hash" + }, + "kinds": [ + "SumItem" + ], + "value": "vote strength: 1 for a masternode, 4 for an evonode", + "since": 15, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One masternode's vote; the tree's sum is the tally.", + "children": [] + } + ] + }, + { + "id": "votes.decisions.active_polls.poll.no", + "key": { + "type": "fixed", + "hex": "02", + "label": "NoVotes", + "constant": "YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY" + }, + "kinds": [ + "SumTree" + ], + "since": 15, + "presence": "until_deleted", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "Votes for no.", + "children": [ + { + "id": "votes.decisions.active_polls.poll.no.voter", + "key": { + "type": "dynamic", + "name": "pro_tx_hash", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The voting masternode's pro tx hash" + }, + "kinds": [ + "SumItem" + ], + "value": "vote strength: 1 for a masternode, 4 for an evonode", + "since": 15, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One masternode's vote; the tree's sum is the tally.", + "children": [] + } + ] + }, + { + "id": "votes.decisions.active_polls.poll.abstain", + "key": { + "type": "fixed", + "hex": "03", + "label": "AbstainVotes", + "constant": "YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY" + }, + "kinds": [ + "SumTree" + ], + "since": 15, + "presence": "until_deleted", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "Votes to abstain, which count towards nothing.", + "children": [ + { + "id": "votes.decisions.active_polls.poll.abstain.voter", + "key": { + "type": "dynamic", + "name": "pro_tx_hash", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The voting masternode's pro tx hash" + }, + "kinds": [ + "SumItem" + ], + "value": "vote strength: 1 for a masternode, 4 for an evonode", + "since": 15, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One masternode's vote; the tree's sum is the tally.", + "children": [] + } + ] + } + ] + } + ] + } + ] }, { "id": "votes.end_date_queries", @@ -5152,9 +5395,10 @@ "Tree" ], "flags": [ - "EpochOwned" + "EpochOwned", + "None" ], - "flags_note": "The owner is the identity whose contested document started the poll.", + "flags_note": "The owner is the identity whose contested document started the poll. A yes/no poll is opened by the system and carries none.", "since": 1, "presence": "always", "source": "packages/rs-drive/src/drive/votes/paths.rs", @@ -5170,15 +5414,16 @@ "len": 32 }, "encoding": "hash32", - "description": "The double sha256 of the serialized vote poll" + "description": "The poll's unique id: for a contest the double sha256 of the serialized poll, for a yes/no poll that of the yes/no domain tag followed by the serialized vote poll" }, "kinds": [ "Item" ], "flags": [ - "EpochOwned" + "EpochOwned", + "None" ], - "flags_note": "The owner is the identity whose contested document started the poll.", + "flags_note": "The owner is the identity whose contested document started the poll. A yes/no poll is opened by the system and carries none.", "value": "serialized VotePoll", "since": 1, "presence": "always", @@ -6266,6 +6511,30 @@ } } }, + "votes.decisions": { + "origin": "genesis@15", + "tree": { + "hex": "70", + "left": { + "hex": "69" + } + } + }, + "votes.decisions.active_polls.poll": { + "origin": "fixture yes_no_vote_polls@15", + "tree": { + "hex": "02", + "left": { + "hex": "01", + "left": { + "hex": "00" + } + }, + "right": { + "hex": "03" + } + } + }, "withdrawals": { "origin": "genesis@15", "tree": { diff --git a/packages/rs-drive/src/drive/contract/migration/rekey_unsigned_integer_index_values.rs b/packages/rs-drive/src/drive/contract/migration/rekey_unsigned_integer_index_values.rs index c9cb93e10aa..17e38d50150 100644 --- a/packages/rs-drive/src/drive/contract/migration/rekey_unsigned_integer_index_values.rs +++ b/packages/rs-drive/src/drive/contract/migration/rekey_unsigned_integer_index_values.rs @@ -1637,7 +1637,9 @@ mod tests { panic!("expected the one vote, got {votes:?}"); }; assert_eq!(*vote_count, 1); - let VotePoll::ContestedDocumentResourceVotePoll(poll) = vote.vote_poll(); + let VotePoll::ContestedDocumentResourceVotePoll(poll) = vote.vote_poll() else { + panic!("expected a contested document resource vote poll"); + }; assert_eq!(poll.index_values, vec![Value::U8(200)]); assert_eq!(vote.resource_vote_choice(), towards_first); } @@ -1709,7 +1711,10 @@ mod tests { .try_into() .expect("expected one end date"); let [VotePoll::ContestedDocumentResourceVotePoll(poll)]: [VotePoll; 1] = - polls.try_into().expect("expected one poll"); + polls.try_into().expect("expected one poll") + else { + panic!("expected a contested document resource vote poll"); + }; assert_eq!(poll.index_values, vec![Value::U8(200)]); let tally: FinalizedContestedDocumentVotePollDriveQueryExecutionResult = diff --git a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/v1/mod.rs index 7a1bccb0a2d..6ffa3e973a6 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/v1/mod.rs @@ -200,7 +200,8 @@ impl Drive { contested_document_resource_vote_poll.into(), ); - let unique_id = vote_poll.unique_id()?; + // A contested poll's id does not read the platform version. + let unique_id = vote_poll.unique_id(platform_version)?; let join_end_path = vote_contested_resource_end_date_queries_at_time_tree_path_vec(join_end); // The windows are read again rather than remembered, so the entry the contest diff --git a/packages/rs-drive/src/drive/initialization/v4/mod.rs b/packages/rs-drive/src/drive/initialization/v4/mod.rs index fb4cf1b4f39..6f4998761b2 100644 --- a/packages/rs-drive/src/drive/initialization/v4/mod.rs +++ b/packages/rs-drive/src/drive/initialization/v4/mod.rs @@ -4,6 +4,7 @@ use crate::drive::{Drive, RootTree}; use crate::error::Error; +use dpp::version::feature_initial_protocol_versions::YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION; use dpp::version::PlatformVersion; use grovedb::{TransactionArg, TreeType}; use grovedb_path::SubtreePath; @@ -126,6 +127,16 @@ impl Drive { // withdrawals Merk is built by the same sequence of inserts on both node populations. self.insert_withdrawal_limit_trees(transaction, platform_version)?; + // Vote decisions trees (protocol version 15): the active yes/no polls and the identity + // votes index under the votes tree's decisions branch, which the batch above creates. + // Chain-creation content, so it compares the chain's initial protocol version: a chain + // born at 14 replays its genesis without them and gets them from its upgrade rung. The + // upgrade path (`Platform::transition_to_version_15`) calls the same helper, so both + // node populations build the decisions Merk from the same sequence of inserts. + if platform_version.protocol_version >= YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION { + self.insert_vote_decisions_trees(transaction, platform_version)?; + } + Ok(()) } } diff --git a/packages/rs-drive/src/drive/votes/cleanup/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/mod.rs index e425c9a6c4e..488a517a2c0 100644 --- a/packages/rs-drive/src/drive/votes/cleanup/mod.rs +++ b/packages/rs-drive/src/drive/votes/cleanup/mod.rs @@ -6,3 +6,6 @@ mod remove_contested_resource_vote_poll_info_operations; mod remove_contested_resource_vote_poll_top_level_index_operations; mod remove_contested_resource_vote_poll_votes_operations; mod remove_specific_votes_given_by_identity; +mod remove_vote_poll_end_date_query_operations; +mod remove_yes_no_vote_poll_votes_operations; +mod remove_yes_no_vote_references_given_by_identity; diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/mod.rs index f93d92a424d..f5a2ebe5127 100644 --- a/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/mod.rs +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; @@ -22,12 +23,15 @@ impl Drive { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result<(), Error> { + // In place: this dispatcher read `cleanup.remove_specific_vote_references_given_by_identity`, + // which, like its own slot, is 0 in every table up to protocol version 14, so v0 still + // runs there. match platform_version .drive .methods .vote .cleanup - .remove_specific_vote_references_given_by_identity + .remove_all_votes_given_by_identities { 0 => self.remove_all_votes_given_by_identities_v0( identity_ids_as_byte_arrays, @@ -37,9 +41,17 @@ impl Drive { transaction, platform_version, ), + 1 => self.remove_all_votes_given_by_identities_v1( + identity_ids_as_byte_arrays, + block_height, + network, + chain_id, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "remove_all_votes_given_by_identities".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/v0/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/v0/mod.rs index 11d83100c88..3af376a7604 100644 --- a/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/v0/mod.rs +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/v0/mod.rs @@ -16,7 +16,8 @@ use dpp::platform_serialization::PLATFORM_BINCODE_CONFIG; use dpp::prelude::{BlockHeight, Identifier}; use dpp::version::PlatformVersion; use grovedb::query_result_type::QueryResultType::QueryPathKeyElementTrioResultType; -use grovedb::{MaybeTree, PathQuery, Query, SizedQuery, TransactionArg}; +use grovedb::{Element, MaybeTree, PathQuery, Query, SizedQuery, TransactionArg}; +use std::collections::BTreeMap; impl Drive { /// We remove votes for an identity when that identity is somehow disabled. Currently there is @@ -34,26 +35,12 @@ impl Drive { let vote_path = vote_contested_resource_identity_votes_tree_path_vec(); - let mut query = Query::new_with_direction(true); - - query.insert_keys(identity_ids_as_byte_arrays); - - let subquery = Query::new_single_query_item(QueryItem::RangeFull(RangeFull)); - - query.set_subquery(subquery); - - let path_query = PathQuery::new(vote_path.clone(), SizedQuery::new(query, None, None)); - - let votes_to_remove_by_identity_id = self - .grove_get_raw_path_query( - &path_query, - transaction, - QueryPathKeyElementTrioResultType, - &mut vec![], - &platform_version.drive, - )? - .0 - .to_last_path_to_key_elements_btree_map(); + let votes_to_remove_by_identity_id = self.identity_vote_entries_of_identities_v0( + vote_path, + identity_ids_as_byte_arrays, + transaction, + platform_version, + )?; // Then we take each votes and go looking for it (to remove it) @@ -143,6 +130,40 @@ impl Drive { Ok(()) } + + /// The entries of these identities in the identity votes index at `vote_path`, by identity + /// then vote poll id. The query of `remove_all_votes_given_by_identities_v0`, moved here + /// unchanged (in place: the same read at every protocol version that selects it) so v1 + /// reads the yes/no identity votes index, which differs only in its path, the same way. + #[allow(clippy::type_complexity)] + pub(super) fn identity_vote_entries_of_identities_v0( + &self, + vote_path: Vec>, + identity_ids_as_byte_arrays: Vec>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, BTreeMap, Element>>, Error> { + let mut query = Query::new_with_direction(true); + + query.insert_keys(identity_ids_as_byte_arrays); + + let subquery = Query::new_single_query_item(QueryItem::RangeFull(RangeFull)); + + query.set_subquery(subquery); + + let path_query = PathQuery::new(vote_path, SizedQuery::new(query, None, None)); + + Ok(self + .grove_get_raw_path_query( + &path_query, + transaction, + QueryPathKeyElementTrioResultType, + &mut vec![], + &platform_version.drive, + )? + .0 + .to_last_path_to_key_elements_btree_map()) + } } #[cfg(test)] @@ -291,8 +312,9 @@ mod tests { #[test] fn mainnet_evo1_below_32329_commits_vote_deletion_outside_block_transaction() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); + // v0 is the generation protocol version 14 and earlier select; 15 selects v1. + let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); seed_vote(&drive, platform_version); let transaction = drive.grove.start_transaction(); @@ -317,8 +339,9 @@ mod tests { #[test] fn mainnet_evo1_at_32329_keeps_vote_deletion_inside_block_transaction() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); + // v0 is the generation protocol version 14 and earlier select; 15 selects v1. + let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); seed_vote(&drive, platform_version); let transaction = drive.grove.start_transaction(); diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/v1/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/v1/mod.rs new file mode 100644 index 00000000000..831e07d98d7 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/v1/mod.rs @@ -0,0 +1,126 @@ +use crate::drive::Drive; + +use crate::error::Error; + +use crate::drive::votes::paths::{ + vote_decisions_identity_votes_tree_path_vec, vote_decisions_poll_choice_votes_tree_path_vec, + vote_decisions_tree_path, IDENTITY_VOTES_TREE_KEY, +}; +use crate::drive::votes::storage_form::yes_no_vote_reference_storage_form::YesNoVoteReferenceStorageForm; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::{BatchDeleteApplyType, DirectQueryType}; +use dpp::dashcore::Network; +use dpp::prelude::{BlockHeight, Identifier}; +use dpp::version::PlatformVersion; +use grovedb::{MaybeTree, TransactionArg, TreeType}; +use std::collections::BTreeSet; + +impl Drive { + /// Version 1 (protocol version 15) removes the contested resource votes of the removed + /// masternodes through version 0, unchanged, then their yes/no votes in a batch of its own: + /// the vote under the poll's choice sum tree, and each removed masternode's tree in the + /// decisions identity votes index, entries included. The two batches touch different + /// subtrees, so applying them one after the other leaves the same state as one batch would. + pub(super) fn remove_all_votes_given_by_identities_v1( + &self, + identity_ids_as_byte_arrays: Vec>, + block_height: BlockHeight, + network: Network, + chain_id: &str, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + self.remove_all_votes_given_by_identities_v0( + identity_ids_as_byte_arrays.clone(), + block_height, + network, + chain_id, + transaction, + platform_version, + )?; + + // Yes/no votes: the index item names the poll and the answer, so the vote sits under + // the poll's tree at the answer's sum tree. A chain that activated protocol version 15 + // before the decisions trees existed has no identity votes index, and no yes/no votes. + let has_decisions_index = self.grove_has_raw( + (&vote_decisions_tree_path()).into(), + &[IDENTITY_VOTES_TREE_KEY as u8], + DirectQueryType::StatefulDirectQuery, + transaction, + &mut vec![], + &platform_version.drive, + )?; + if !has_decisions_index { + return Ok(()); + } + + let mut deletion_batch = vec![]; + let identity_vote_entries = self.identity_vote_entries_of_identities_v0( + vote_decisions_identity_votes_tree_path_vec(), + identity_ids_as_byte_arrays.clone(), + transaction, + platform_version, + )?; + let identities_with_entries: BTreeSet> = + identity_vote_entries.keys().cloned().collect(); + for (identifier_bytes, votes_to_remove) in identity_vote_entries { + for (vote_poll_id, vote_to_remove) in votes_to_remove { + let storage_form = + YesNoVoteReferenceStorageForm::deserialize(&vote_to_remove.into_item_bytes()?)?; + let vote_poll_id = Identifier::from_vec(vote_poll_id)?; + let votes_path = vote_decisions_poll_choice_votes_tree_path_vec( + vote_poll_id.as_bytes(), + storage_form.vote_choice, + ); + self.batch_delete( + votes_path.as_slice().into(), + identifier_bytes.as_slice(), + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &mut deletion_batch, + &platform_version.drive, + )?; + } + } + + // Then each removed masternode's tree in the identity votes index, emptied or not: the + // close of a poll removes the poll's entries but keeps the tree, which no one else + // removes. It holds only items, so it is dropped flat with them. A masternode with + // entries has its tree; one without is read, since the close of its last poll leaves + // its tree empty. + let identity_votes_path = vote_decisions_identity_votes_tree_path_vec(); + for identifier_bytes in identity_ids_as_byte_arrays { + let has_identity_votes_tree = identities_with_entries.contains(&identifier_bytes) + || self.grove_has_raw( + identity_votes_path.as_slice().into(), + identifier_bytes.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + &mut vec![], + &platform_version.drive, + )?; + if has_identity_votes_tree { + deletion_batch.push(LowLevelDriveOperation::for_known_path_key_drop_flat_tree( + identity_votes_path.clone(), + identifier_bytes, + TreeType::NormalTree, + )); + } + } + + if !deletion_batch.is_empty() { + self.apply_batch_low_level_drive_operations( + None, + transaction, + deletion_batch, + &mut vec![], + &platform_version.drive, + ) + .inspect_err(|err| tracing::error!(?err, "yes/no vote deletion batch failed"))?; + } + + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_contested_resource_vote_poll_end_date_query_operations/v2/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_contested_resource_vote_poll_end_date_query_operations/v2/mod.rs index dbbeb4ddcb0..629e0b1af78 100644 --- a/packages/rs-drive/src/drive/votes/cleanup/remove_contested_resource_vote_poll_end_date_query_operations/v2/mod.rs +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_contested_resource_vote_poll_end_date_query_operations/v2/mod.rs @@ -32,6 +32,34 @@ impl Drive { batch_operations: &mut Vec, transaction: TransactionArg, platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let mut by_end_date: BTreeMap> = BTreeMap::new(); + + for (vote_poll, end_date, _) in vote_polls { + by_end_date + .entry(**end_date) + .or_default() + .insert(vote_poll.unique_id()?); + } + + self.remove_vote_poll_ids_from_end_dates_operations_v2( + by_end_date, + batch_operations, + transaction, + platform_version, + ) + } + + /// The body of version 2 once the vote polls are grouped into their unique ids by end date, + /// whatever their kind. Shipped with protocol version 14, so its operations must not change; + /// `remove_vote_poll_end_date_query_operations` v0 (protocol version 15) removes yes/no polls + /// from the shared end date index through it too. + pub(in crate::drive::votes) fn remove_vote_poll_ids_from_end_dates_operations_v2( + &self, + by_end_date: BTreeMap>, + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, ) -> Result<(), Error> { // This is a GroveDB Tree (Not Sub Tree Merk representation) // End Date queries @@ -44,15 +72,6 @@ impl Drive { is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), }; - let mut by_end_date: BTreeMap> = BTreeMap::new(); - - for (vote_poll, end_date, _) in vote_polls { - by_end_date - .entry(**end_date) - .or_default() - .insert(vote_poll.unique_id()?); - } - for (end_date, unique_ids) in by_end_date { let time_path = vote_contested_resource_end_date_queries_at_time_tree_path_vec(end_date); diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_specific_votes_given_by_identity/v0/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_specific_votes_given_by_identity/v0/mod.rs index 1802e9ab242..fc479ff1fbe 100644 --- a/packages/rs-drive/src/drive/votes/cleanup/remove_specific_votes_given_by_identity/v0/mod.rs +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_specific_votes_given_by_identity/v0/mod.rs @@ -24,6 +24,27 @@ impl Drive { let vote_path_ref = vote_contested_resource_identity_votes_tree_path_for_identity(identity_id.as_bytes()); + self.remove_identity_vote_references_operations_v0( + &vote_path_ref, + votes, + batch_operations, + transaction, + platform_version, + ) + } + + /// The loop of `remove_specific_vote_references_given_by_identity_v0`, moved here unchanged + /// (in place: the same deletes in the same order at every protocol version that selects + /// it) so the yes/no identity votes index, which differs only in its path, removes its + /// entries the same way. + pub(in crate::drive::votes::cleanup) fn remove_identity_vote_references_operations_v0( + &self, + vote_path_ref: &[&[u8]; 4], + votes: &[&Identifier], + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { for vote_identifier_to_remove in votes { self.batch_delete( vote_path_ref.as_slice().into(), diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_vote_poll_end_date_query_operations/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_vote_poll_end_date_query_operations/mod.rs new file mode 100644 index 00000000000..b32d4043ab3 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_vote_poll_end_date_query_operations/mod.rs @@ -0,0 +1,57 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::identifier::Identifier; +use dpp::identity::TimestampMillis; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Removes finished polls, given by unique id with their end date, from the end date index, + /// and each end date's tree once nothing is left under it. Whether an end date is left empty + /// is read from the tree, since it can still hold a poll of either kind that this batch does + /// not remove. The yes/no clean-up removes its polls with it. + /// + /// # Parameters + /// - `vote_polls`: the finished polls' unique ids, each with its end date. + /// - `batch_operations`: where the removals are added. + /// - `transaction`: the transaction to read the end dates in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// `Ok(())` once the removals are added. + pub fn remove_vote_poll_end_date_query_operations( + &self, + vote_polls: &[(Identifier, TimestampMillis)], + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .cleanup + .remove_vote_poll_end_date_query_operations + { + Some(0) => self.remove_vote_poll_end_date_query_operations_v0( + vote_polls, + batch_operations, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "remove_vote_poll_end_date_query_operations".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "remove_vote_poll_end_date_query_operations".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_vote_poll_end_date_query_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_vote_poll_end_date_query_operations/v0/mod.rs new file mode 100644 index 00000000000..c5b894acdd9 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_vote_poll_end_date_query_operations/v0/mod.rs @@ -0,0 +1,33 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::identifier::Identifier; +use dpp::identity::TimestampMillis; +use grovedb::TransactionArg; +use platform_version::version::PlatformVersion; +use std::collections::{BTreeMap, BTreeSet}; + +impl Drive { + /// Removes the given vote polls, of any kind, from the end date index with the rule of + /// `remove_contested_resource_vote_poll_end_date_query_operations` v2, whose body it reuses: + /// an end date goes once none of its vote polls remain. + pub(super) fn remove_vote_poll_end_date_query_operations_v0( + &self, + vote_polls: &[(Identifier, TimestampMillis)], + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let mut by_end_date: BTreeMap> = BTreeMap::new(); + for (unique_id, end_date) in vote_polls { + by_end_date.entry(*end_date).or_default().insert(*unique_id); + } + + self.remove_vote_poll_ids_from_end_dates_operations_v2( + by_end_date, + batch_operations, + transaction, + platform_version, + ) + } +} diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_poll_votes_operations/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_poll_votes_operations/mod.rs new file mode 100644 index 00000000000..458ec6c5a8f --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_poll_votes_operations/mod.rs @@ -0,0 +1,50 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; + +impl Drive { + /// Removes the votes of finished yes/no polls with the three sum trees that hold them. The + /// polls' stored info stays as the record of the decision. + /// + /// # Parameters + /// - `vote_poll_ids`: the unique ids of the finished polls. + /// - `batch_operations`: where the removals are added. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// `Ok(())` once the removals are added. + pub fn remove_yes_no_vote_poll_votes_operations( + &self, + vote_poll_ids: &[Identifier], + batch_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .remove_yes_no_vote_poll_votes_operations + { + Some(0) => self.remove_yes_no_vote_poll_votes_operations_v0( + vote_poll_ids, + batch_operations, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "remove_yes_no_vote_poll_votes_operations".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "remove_yes_no_vote_poll_votes_operations".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_poll_votes_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_poll_votes_operations/v0/mod.rs new file mode 100644 index 00000000000..d636fa375fb --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_poll_votes_operations/v0/mod.rs @@ -0,0 +1,33 @@ +use crate::drive::votes::paths::vote_decisions_active_polls_poll_tree_path_vec; +use crate::drive::votes::YesNoAbstainVoteChoiceToKeyTrait; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use grovedb::TreeType; + +impl Drive { + pub(super) fn remove_yes_no_vote_poll_votes_operations_v0( + &self, + vote_poll_ids: &[Identifier], + batch_operations: &mut Vec, + _platform_version: &PlatformVersion, + ) -> Result<(), Error> { + for vote_poll_id in vote_poll_ids { + let poll_path = vote_decisions_active_polls_poll_tree_path_vec(vote_poll_id.as_bytes()); + for vote_choice in YesNoAbstainVoteChoice::ALL { + // Each choice's sum tree holds only the votes, one sum item per voter, and a + // finished poll is never opened again, so the tree is dropped whole without + // reading or deleting its votes one by one. + batch_operations.push(LowLevelDriveOperation::for_known_path_key_drop_flat_tree( + poll_path.clone(), + vec![vote_choice.to_tree_key()], + TreeType::SumTree, + )); + } + } + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_references_given_by_identity/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_references_given_by_identity/mod.rs new file mode 100644 index 00000000000..89d2f4419e8 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_references_given_by_identity/mod.rs @@ -0,0 +1,57 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Removes a masternode's entries for these yes/no polls from the identity votes index of + /// the decisions branch. + /// + /// # Parameters + /// - `masternode_pro_tx_hash`: the masternode. + /// - `vote_poll_ids`: the polls whose entries go. + /// - `batch_operations`: where the removals are added. + /// - `transaction`: the transaction to read in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// `Ok(())` once the removals are added. + pub fn remove_yes_no_vote_references_given_by_identity_operations( + &self, + masternode_pro_tx_hash: &Identifier, + vote_poll_ids: &[&Identifier], + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .remove_yes_no_vote_references_given_by_identity + { + Some(0) => self.remove_yes_no_vote_references_given_by_identity_operations_v0( + masternode_pro_tx_hash, + vote_poll_ids, + batch_operations, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "remove_yes_no_vote_references_given_by_identity_operations".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "remove_yes_no_vote_references_given_by_identity_operations".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_references_given_by_identity/v0/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_references_given_by_identity/v0/mod.rs new file mode 100644 index 00000000000..a30bea1d24b --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_references_given_by_identity/v0/mod.rs @@ -0,0 +1,28 @@ +use crate::drive::votes::paths::vote_decisions_identity_votes_tree_path_for_identity; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + pub(super) fn remove_yes_no_vote_references_given_by_identity_operations_v0( + &self, + masternode_pro_tx_hash: &Identifier, + vote_poll_ids: &[&Identifier], + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let path = + vote_decisions_identity_votes_tree_path_for_identity(masternode_pro_tx_hash.as_bytes()); + self.remove_identity_vote_references_operations_v0( + &path, + vote_poll_ids, + batch_operations, + transaction, + platform_version, + ) + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_identities_voting_in_yes_no_vote_poll/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_identities_voting_in_yes_no_vote_poll/mod.rs new file mode 100644 index 00000000000..48cd957db44 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_identities_voting_in_yes_no_vote_poll/mod.rs @@ -0,0 +1,51 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::TransactionArg; + +impl Drive { + /// The masternodes that voted on a yes/no poll: the yes voters, then the no voters, then + /// the abstaining ones. + /// + /// # Parameters + /// - `vote_poll`: the poll. + /// - `transaction`: the transaction to read in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// The voters' pro tx hashes. + pub fn fetch_identities_voting_in_yes_no_vote_poll( + &self, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .fetch_identities_voting_in_yes_no_vote_poll + { + Some(0) => self.fetch_identities_voting_in_yes_no_vote_poll_v0( + vote_poll, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_identities_voting_in_yes_no_vote_poll".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_identities_voting_in_yes_no_vote_poll".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_identities_voting_in_yes_no_vote_poll/v0/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_identities_voting_in_yes_no_vote_poll/v0/mod.rs new file mode 100644 index 00000000000..441f5876ad4 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_identities_voting_in_yes_no_vote_poll/v0/mod.rs @@ -0,0 +1,55 @@ +use crate::drive::votes::paths::YesNoVotePollPaths; +use crate::drive::votes::YesNoAbstainVoteChoiceToKeyTrait; +use crate::drive::Drive; +use crate::error::Error; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::query_result_type::QueryResultType; +use grovedb::{PathQuery, Query, QueryItem, SizedQuery, TransactionArg}; +use std::ops::RangeFull; + +impl Drive { + pub(super) fn fetch_identities_voting_in_yes_no_vote_poll_v0( + &self, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path = vote_poll.poll_path_vec(platform_version)?; + let mut query = Query::new_with_direction(true); + query.insert_keys( + YesNoAbstainVoteChoice::ALL + .iter() + .map(|vote_choice| vec![vote_choice.to_tree_key()]) + .collect(), + ); + query.set_subquery(Query::new_single_query_item(QueryItem::RangeFull( + RangeFull, + ))); + let path_query = PathQuery { + path, + query: SizedQuery { + query, + limit: None, + offset: None, + }, + }; + // The subquery's keys are the voters' pro tx hashes, under the three choice trees. + let voters = self + .grove_get_path_query( + &path_query, + transaction, + QueryResultType::QueryKeyElementPairResultType, + &mut vec![], + &platform_version.drive, + )? + .0 + .to_keys() + .into_iter() + .map(|voter| voter.try_into()) + .collect::, _>>()?; + Ok(voters) + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_identity_yes_no_vote/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_identity_yes_no_vote/mod.rs new file mode 100644 index 00000000000..7cc056f767d --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_identity_yes_no_vote/mod.rs @@ -0,0 +1,59 @@ +mod v0; + +use crate::drive::votes::PreviousVoteCount; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use grovedb::TransactionArg; + +impl Drive { + /// A masternode's current answer to a yes/no poll and how many times it voted on it, or + /// `None` when it never did. + /// + /// # Parameters + /// - `masternode_pro_tx_hash`: the masternode. + /// - `vote_poll_id`: the poll's unique id. + /// - `transaction`: the transaction to read in. + /// - `drive_operations`: where the read's cost is added. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// The answer and how many times the masternode voted on the poll, or `None`. + pub fn fetch_identity_yes_no_vote( + &self, + masternode_pro_tx_hash: Identifier, + vote_poll_id: Identifier, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .fetch_identity_yes_no_vote + { + Some(0) => self.fetch_identity_yes_no_vote_v0( + masternode_pro_tx_hash, + vote_poll_id, + transaction, + drive_operations, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_identity_yes_no_vote".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_identity_yes_no_vote".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_identity_yes_no_vote/v0/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_identity_yes_no_vote/v0/mod.rs new file mode 100644 index 00000000000..9ac6fbea7ff --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_identity_yes_no_vote/v0/mod.rs @@ -0,0 +1,38 @@ +use crate::drive::votes::paths::vote_decisions_identity_votes_tree_path_for_identity; +use crate::drive::votes::storage_form::yes_no_vote_reference_storage_form::YesNoVoteReferenceStorageForm; +use crate::drive::votes::PreviousVoteCount; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::DirectQueryType; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use grovedb::TransactionArg; + +impl Drive { + pub(super) fn fetch_identity_yes_no_vote_v0( + &self, + masternode_pro_tx_hash: Identifier, + vote_poll_id: Identifier, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path = + vote_decisions_identity_votes_tree_path_for_identity(masternode_pro_tx_hash.as_bytes()); + self.grove_get_raw_optional_item( + (&path).into(), + vote_poll_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + )? + .map(|bytes| { + let storage_form = YesNoVoteReferenceStorageForm::deserialize(&bytes)?; + Ok((storage_form.vote_choice, storage_form.identity_vote_times)) + }) + .transpose() + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_yes_no_vote_poll_stored_info/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_yes_no_vote_poll_stored_info/mod.rs new file mode 100644 index 00000000000..7a3bc753f24 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_yes_no_vote_poll_stored_info/mod.rs @@ -0,0 +1,48 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::TransactionArg; + +impl Drive { + /// The stored info of a yes/no vote poll, `None` when the poll was never opened. + /// + /// # Parameters + /// - `vote_poll`: the poll. + /// - `transaction`: the transaction to read in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// The stored info, or `None`. + pub fn fetch_yes_no_vote_poll_stored_info( + &self, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .fetch_yes_no_vote_poll_stored_info + { + Some(0) => { + self.fetch_yes_no_vote_poll_stored_info_v0(vote_poll, transaction, platform_version) + } + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_yes_no_vote_poll_stored_info".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_yes_no_vote_poll_stored_info".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_yes_no_vote_poll_stored_info/v0/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_yes_no_vote_poll_stored_info/v0/mod.rs new file mode 100644 index 00000000000..abe44bce794 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_yes_no_vote_poll_stored_info/v0/mod.rs @@ -0,0 +1,31 @@ +use crate::drive::votes::paths::{YesNoVotePollPaths, YES_NO_VOTE_POLL_STORED_INFO_KEY}; +use crate::drive::Drive; +use crate::error::Error; +use crate::util::grove_operations::DirectQueryType; +use dpp::serialization::PlatformDeserializableTrusted; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::TransactionArg; + +impl Drive { + pub(super) fn fetch_yes_no_vote_poll_stored_info_v0( + &self, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path = vote_poll.poll_path_vec(platform_version)?; + self.grove_get_raw_optional_item( + path.as_slice().into(), + &[YES_NO_VOTE_POLL_STORED_INFO_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + &mut vec![], + &platform_version.drive, + )? + .map(|bytes| YesNoVotePollStoredInfo::deserialize_from_bytes_trusted(&bytes)) + .transpose() + .map_err(Error::from) + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/mod.rs b/packages/rs-drive/src/drive/votes/fetch/mod.rs index d4393ec6b95..8288d67c834 100644 --- a/packages/rs-drive/src/drive/votes/fetch/mod.rs +++ b/packages/rs-drive/src/drive/votes/fetch/mod.rs @@ -1,4 +1,7 @@ mod fetch_contested_document_vote_poll_contender_count; mod fetch_contested_document_vote_poll_stored_info; mod fetch_identities_voting_for_contenders; +mod fetch_identities_voting_in_yes_no_vote_poll; mod fetch_identity_contested_resource_vote; +mod fetch_identity_yes_no_vote; +mod fetch_yes_no_vote_poll_stored_info; diff --git a/packages/rs-drive/src/drive/votes/insert/mod.rs b/packages/rs-drive/src/drive/votes/insert/mod.rs index 67ea229fe2a..02c9ff21d61 100644 --- a/packages/rs-drive/src/drive/votes/insert/mod.rs +++ b/packages/rs-drive/src/drive/votes/insert/mod.rs @@ -1,3 +1,4 @@ mod contested_resource; mod register_identity_vote; mod vote_poll; +mod yes_no; diff --git a/packages/rs-drive/src/drive/votes/insert/register_identity_vote/mod.rs b/packages/rs-drive/src/drive/votes/insert/register_identity_vote/mod.rs index 1060ea88dc1..12c897dcc49 100644 --- a/packages/rs-drive/src/drive/votes/insert/register_identity_vote/mod.rs +++ b/packages/rs-drive/src/drive/votes/insert/register_identity_vote/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; @@ -50,11 +51,14 @@ impl Drive { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result { + // In place: this dispatcher read `contested_resource_insert.register_identity_vote`, + // which, like `insert.register_identity_vote`, is 0 in every table up to protocol + // version 14, so v0 still runs there. match platform_version .drive .methods .vote - .contested_resource_insert + .insert .register_identity_vote { 0 => self.register_identity_vote_v0( @@ -66,9 +70,18 @@ impl Drive { transaction, platform_version, ), + 1 => self.register_identity_vote_v1( + voter_pro_tx_hash, + strength, + vote, + previous_resource_vote_choice_to_remove, + block_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "register_identity_vote".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -108,11 +121,12 @@ impl Drive { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { + // In place, as in `register_identity_vote`: both slots are 0 up to protocol version 14. match platform_version .drive .methods .vote - .contested_resource_insert + .insert .register_identity_vote { 0 => self.register_identity_vote_operations_v0( @@ -123,9 +137,17 @@ impl Drive { transaction, platform_version, ), + 1 => self.register_identity_vote_operations_v1( + voter_pro_tx_hash, + strength, + vote, + previous_resource_vote_choice_to_remove, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "register_identity_vote_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v0/mod.rs b/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v0/mod.rs index e5cd5a52fd0..36c3e9cff93 100644 --- a/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v0/mod.rs +++ b/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v0/mod.rs @@ -2,6 +2,7 @@ use crate::drive::votes::resolved::vote_polls::ResolvedVotePoll; use crate::drive::votes::resolved::votes::resolved_resource_vote::accessors::v0::ResolvedResourceVoteGettersV0; use crate::drive::votes::resolved::votes::ResolvedVote; use crate::drive::Drive; +use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::state_transition_action::identity::masternode_vote::v0::PreviousVoteCount; @@ -41,6 +42,12 @@ impl Drive { ), } } + // Unreachable: this v0 is selected up to protocol version 14, its vote comes from a + // masternode vote action, and a yes/no vote does not decode there + // (`StateTransition::active_version_range`). + ResolvedVote::YesNoVote(_) => Err(Error::Drive(DriveError::CorruptedCodeExecution( + "yes/no votes need version 1", + ))), } } @@ -70,6 +77,12 @@ impl Drive { ), } } + // Unreachable: this v0 is selected up to protocol version 14, its vote comes from a + // masternode vote action, and a yes/no vote does not decode there + // (`StateTransition::active_version_range`). + ResolvedVote::YesNoVote(_) => Err(Error::Drive(DriveError::CorruptedCodeExecution( + "yes/no votes need version 1", + ))), } } } diff --git a/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v1/mod.rs b/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v1/mod.rs new file mode 100644 index 00000000000..468706df0bc --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v1/mod.rs @@ -0,0 +1,74 @@ +use crate::drive::votes::resolved::votes::ResolvedVote; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::state_transition_action::identity::masternode_vote::v0::PreviousVoteCount; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use grovedb::TransactionArg; +use platform_version::version::PlatformVersion; + +impl Drive { + /// Version 1 (protocol version 15) registers a vote of either kind: a yes/no vote under its + /// poll in the decisions branch, and a resource vote through version 0, unchanged. + #[allow(clippy::too_many_arguments)] + pub(super) fn register_identity_vote_v1( + &self, + voter_pro_tx_hash: [u8; 32], + strength: u8, + vote: ResolvedVote, + previous_resource_vote_choice_to_remove: Option<(ResourceVoteChoice, PreviousVoteCount)>, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match vote { + ResolvedVote::YesNoVote(yes_no_vote) => self.register_yes_no_identity_vote( + voter_pro_tx_hash, + strength, + yes_no_vote, + block_info, + transaction, + platform_version, + ), + resource_vote => self.register_identity_vote_v0( + voter_pro_tx_hash, + strength, + resource_vote, + previous_resource_vote_choice_to_remove, + block_info, + transaction, + platform_version, + ), + } + } + + pub(super) fn register_identity_vote_operations_v1( + &self, + voter_pro_tx_hash: [u8; 32], + strength: u8, + vote: ResolvedVote, + previous_resource_vote_choice_to_remove: Option<(ResourceVoteChoice, PreviousVoteCount)>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match vote { + ResolvedVote::YesNoVote(yes_no_vote) => self.register_yes_no_identity_vote_operations( + voter_pro_tx_hash, + strength, + yes_no_vote, + transaction, + platform_version, + ), + resource_vote => self.register_identity_vote_operations_v0( + voter_pro_tx_hash, + strength, + resource_vote, + previous_resource_vote_choice_to_remove, + transaction, + platform_version, + ), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/insert/vote_poll/add_vote_poll_end_date_query_operations/mod.rs b/packages/rs-drive/src/drive/votes/insert/vote_poll/add_vote_poll_end_date_query_operations/mod.rs index de9882409d2..bc487f0db04 100644 --- a/packages/rs-drive/src/drive/votes/insert/vote_poll/add_vote_poll_end_date_query_operations/mod.rs +++ b/packages/rs-drive/src/drive/votes/insert/vote_poll/add_vote_poll_end_date_query_operations/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use grovedb::batch::KeyInfoPath; @@ -50,9 +51,20 @@ impl Drive { transaction, platform_version, ), + 1 => self.add_vote_poll_end_date_query_operations_v1( + creator_identity_id, + vote_poll, + end_date, + block_info, + estimated_costs_only_with_layer_info, + previous_batch_operations, + batch_operations, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_vote_poll_end_date_query_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/votes/insert/vote_poll/add_vote_poll_end_date_query_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/insert/vote_poll/add_vote_poll_end_date_query_operations/v0/mod.rs index 5df377420f5..37653d5b676 100644 --- a/packages/rs-drive/src/drive/votes/insert/vote_poll/add_vote_poll_end_date_query_operations/v0/mod.rs +++ b/packages/rs-drive/src/drive/votes/insert/vote_poll/add_vote_poll_end_date_query_operations/v0/mod.rs @@ -142,7 +142,9 @@ impl Drive { } }; - let unique_id = vote_poll.unique_id()?; + // Only a yes/no poll's id reads the platform version, and no yes/no poll is opened + // before protocol version 15. + let unique_id = vote_poll.unique_id(platform_version)?; let path_key_element_info: PathKeyElementInfo<'_, 0> = if estimated_costs_only_with_layer_info.is_none() { diff --git a/packages/rs-drive/src/drive/votes/insert/vote_poll/add_vote_poll_end_date_query_operations/v1/mod.rs b/packages/rs-drive/src/drive/votes/insert/vote_poll/add_vote_poll_end_date_query_operations/v1/mod.rs new file mode 100644 index 00000000000..9992ca5a1ac --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/vote_poll/add_vote_poll_end_date_query_operations/v1/mod.rs @@ -0,0 +1,107 @@ +use crate::drive::votes::paths::{ + vote_contested_resource_end_date_queries_at_time_tree_path_vec, + vote_end_date_queries_tree_path_vec, +}; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::common::encode::encode_u64; +use crate::util::grove_operations::pending_grove_operations::pending_grove_operations; +use crate::util::grove_operations::BatchInsertApplyType; +use crate::util::object_size_info::PathKeyElementInfo::PathKeyRefElement; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::identity::TimestampMillis; +use dpp::serialization::PlatformSerializable; +use dpp::voting::vote_polls::VotePoll; +use grovedb::batch::key_info::KeyInfo; +use grovedb::batch::{GroveOp, KeyInfoPath}; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; +use platform_version::version::PlatformVersion; +use std::collections::HashMap; + +impl Drive { + /// Version 1 (protocol version 15): when the previous operations already queue an insert of + /// the end date's tree, whatever its storage flags, only the poll's entry is added and the + /// tree keeps the flags it was queued with. Otherwise it is version 0, whose tree insert skips + /// only an identical insert (flags included) and cancels a queued deletion of the tree. A + /// contest and a yes/no poll opening that share an end date in one batch queue the tree with + /// different flags, so version 0 queued it twice when the contest came second. + #[allow(clippy::too_many_arguments)] + pub(in crate::drive::votes::insert) fn add_vote_poll_end_date_query_operations_v1( + &self, + creator_identity_id: Option<[u8; 32]>, + vote_poll: VotePoll, + end_date: TimestampMillis, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + previous_batch_operations: &mut Option<&mut Vec>, + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let end_date_tree_insert_queued = estimated_costs_only_with_layer_info.is_none() + && previous_batch_operations + .as_deref() + .is_some_and(|previous| end_date_tree_insert_queued(previous, end_date)); + if !end_date_tree_insert_queued { + return self.add_vote_poll_end_date_query_operations_v0( + creator_identity_id, + vote_poll, + end_date, + block_info, + estimated_costs_only_with_layer_info, + previous_batch_operations, + batch_operations, + transaction, + platform_version, + ); + } + + // The entry version 0 writes after the tree. + let storage_flags = creator_identity_id.map(|creator_identity_id| { + StorageFlags::new_single_epoch(block_info.epoch.index, Some(creator_identity_id)) + }); + let unique_id = vote_poll.unique_id(platform_version)?; + let item = Element::Item( + vote_poll.serialize_to_bytes()?, + StorageFlags::map_to_some_element_flags(storage_flags.as_ref()), + ); + self.batch_insert_if_not_exists( + PathKeyRefElement::<0>(( + vote_contested_resource_end_date_queries_at_time_tree_path_vec(end_date), + unique_id.as_bytes(), + item, + )), + BatchInsertApplyType::StatefulBatchInsert, + transaction, + batch_operations, + &platform_version.drive, + )?; + Ok(()) + } +} + +/// Whether `previous_batch_operations` queue an insert of the tree of `end_date` in the end date +/// index. +fn end_date_tree_insert_queued( + previous_batch_operations: &[LowLevelDriveOperation], + end_date: TimestampMillis, +) -> bool { + let path = KeyInfoPath::from_known_owned_path(vote_end_date_queries_tree_path_vec()); + let key = KeyInfo::KnownKey(encode_u64(end_date)); + pending_grove_operations(previous_batch_operations).any(|operation| { + operation.path == path + && operation.key.as_ref() == Some(&key) + && matches!( + &operation.op, + GroveOp::InsertOrReplace { element } + | GroveOp::InsertOrReplaceDontCheckForBackwardsReferences { element } + | GroveOp::InsertWithKnownToNotAlreadyExist { element } + | GroveOp::InsertIfNotExists { element, .. } + if element.is_any_tree() + ) + }) +} diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/insert_stored_info_for_yes_no_vote_poll/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/insert_stored_info_for_yes_no_vote_poll/mod.rs new file mode 100644 index 00000000000..d9c4fb68ff5 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/insert_stored_info_for_yes_no_vote_poll/mod.rs @@ -0,0 +1,93 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::TransactionArg; + +impl Drive { + /// Writes the stored info of a yes/no vote poll, replacing what was there. + /// + /// # Parameters + /// - `vote_poll`: the poll. + /// - `stored_info`: its stored info. + /// - `transaction`: the transaction to write in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// `Ok(())` once the stored info is written. + pub fn insert_stored_info_for_yes_no_vote_poll( + &self, + vote_poll: &YesNoVotePoll, + stored_info: YesNoVotePollStoredInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .insert_stored_info_for_yes_no_vote_poll + { + Some(0) => self.insert_stored_info_for_yes_no_vote_poll_v0( + vote_poll, + stored_info, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "insert_stored_info_for_yes_no_vote_poll".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "insert_stored_info_for_yes_no_vote_poll".to_string(), + known_versions: vec![0], + })), + } + } + + /// The operations of [`Self::insert_stored_info_for_yes_no_vote_poll`]. + /// + /// # Parameters + /// - `vote_poll`: the poll. + /// - `stored_info`: its stored info. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// The operations that write it. + pub fn insert_stored_info_for_yes_no_vote_poll_operations( + &self, + vote_poll: &YesNoVotePoll, + stored_info: YesNoVotePollStoredInfo, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .insert_stored_info_for_yes_no_vote_poll + { + Some(0) => self.insert_stored_info_for_yes_no_vote_poll_operations_v0( + vote_poll, + stored_info, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "insert_stored_info_for_yes_no_vote_poll_operations".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "insert_stored_info_for_yes_no_vote_poll_operations".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/insert_stored_info_for_yes_no_vote_poll/v0/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/insert_stored_info_for_yes_no_vote_poll/v0/mod.rs new file mode 100644 index 00000000000..cf57cfc19f6 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/insert_stored_info_for_yes_no_vote_poll/v0/mod.rs @@ -0,0 +1,53 @@ +use crate::drive::votes::paths::{YesNoVotePollPaths, YES_NO_VOTE_POLL_STORED_INFO_KEY}; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::object_size_info::PathKeyElementInfo::PathKeyElement; +use dpp::serialization::PlatformSerializable; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::{Element, TransactionArg}; + +impl Drive { + pub(super) fn insert_stored_info_for_yes_no_vote_poll_v0( + &self, + vote_poll: &YesNoVotePoll, + stored_info: YesNoVotePollStoredInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let batch_operations = self.insert_stored_info_for_yes_no_vote_poll_operations_v0( + vote_poll, + stored_info, + platform_version, + )?; + self.apply_batch_low_level_drive_operations( + None, + transaction, + batch_operations, + &mut vec![], + &platform_version.drive, + ) + } + + pub(super) fn insert_stored_info_for_yes_no_vote_poll_operations_v0( + &self, + vote_poll: &YesNoVotePoll, + stored_info: YesNoVotePollStoredInfo, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut drive_operations: Vec = vec![]; + let serialization = stored_info.serialize_consume_to_bytes()?; + self.batch_insert::<0>( + PathKeyElement(( + vote_poll.poll_path_vec(platform_version)?, + vec![YES_NO_VOTE_POLL_STORED_INFO_KEY], + Element::new_item(serialization), + )), + &mut drive_operations, + &platform_version.drive, + )?; + Ok(drive_operations) + } +} diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/mod.rs new file mode 100644 index 00000000000..54327dd002c --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/mod.rs @@ -0,0 +1,3 @@ +mod insert_stored_info_for_yes_no_vote_poll; +mod open_yes_no_vote_poll; +mod register_yes_no_identity_vote; diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/mod.rs new file mode 100644 index 00000000000..a23cb9b8445 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/mod.rs @@ -0,0 +1,692 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::TransactionArg; + +impl Drive { + /// Opens a yes/no vote poll that takes votes until `end_date`: its tree with the stored + /// info and the three vote sum trees, and its entry in the end date index. The prefunded + /// balance the votes are paid from is the caller's to fund, under the poll's + /// `specialized_balance_id`. + /// + /// Refuses parameters no poll can run with, an end date that is not after the opening block + /// or is further from it than `max_yes_no_vote_poll_duration_ms`, and a poll that already + /// exists. Not active before protocol version 15. + /// + /// # Parameters + /// - `vote_poll`: the poll to open. + /// - `end_date`: when the poll closes, in milliseconds. + /// - `block_info`: the block that opens it. + /// - `transaction`: the transaction to read and write in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// `Ok(())` once the poll is open, or the error refusing it. + pub fn open_yes_no_vote_poll( + &self, + vote_poll: &YesNoVotePoll, + end_date: TimestampMillis, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .open_yes_no_vote_poll + { + Some(0) => self.open_yes_no_vote_poll_v0( + vote_poll, + end_date, + block_info, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "open_yes_no_vote_poll".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "open_yes_no_vote_poll".to_string(), + known_versions: vec![0], + })), + } + } + + /// The operations of [`Self::open_yes_no_vote_poll`], added to `batch_operations`. + /// + /// Operations already queued in the same batch, earlier openings and contests included, + /// go in `previous_batch_operations`, with `batch_operations` fresh, as for document + /// operations: that is how an opening refuses a poll the batch already opens, queues a tree + /// the batch already holds once (an end date's tree a contest queued keeps its contender's + /// storage flags) and cancels a queued deletion of its end date's tree (a contest leaving its + /// join-window end date). Converted without them, an opening sees none of this: a deletion + /// of its end date's tree queued earlier in the batch then fails the batch, as it would for + /// a contest, and two openings with no view of each other can list one poll under two end + /// dates; it then closes at the first and its later entry is only removed. + /// + /// They are stateful and have no estimated-costs path: a poll opens as a system action, + /// outside any fee estimate. A caller that runs them inside a fee estimate must first give + /// them one. + /// + /// # Parameters + /// - `vote_poll`: the poll to open. + /// - `end_date`: when the poll closes, in milliseconds. + /// - `block_info`: the block that opens it. + /// - `previous_batch_operations`: the operations the batch already holds. + /// - `batch_operations`: where the opening's operations are added. + /// - `transaction`: the transaction to read in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// `Ok(())` once the operations are added, or the error refusing the poll. + #[allow(clippy::too_many_arguments)] + pub fn open_yes_no_vote_poll_operations( + &self, + vote_poll: &YesNoVotePoll, + end_date: TimestampMillis, + block_info: &BlockInfo, + previous_batch_operations: &mut Option<&mut Vec>, + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .open_yes_no_vote_poll + { + Some(0) => self.open_yes_no_vote_poll_operations_v0( + vote_poll, + end_date, + block_info, + previous_batch_operations, + batch_operations, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "open_yes_no_vote_poll_operations".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "open_yes_no_vote_poll_operations".to_string(), + known_versions: vec![0], + })), + } + } +} + +#[cfg(test)] +mod tests { + use crate::drive::votes::paths::{ + vote_contested_resource_end_date_queries_at_time_tree_path_vec, vote_decisions_tree_path, + vote_end_date_queries_tree_path, vote_root_path, ACTIVE_POLLS_TREE_KEY, + IDENTITY_VOTES_TREE_KEY, + }; + use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; + use crate::drive::Drive; + use crate::error::drive::DriveError; + use crate::error::Error; + use crate::query::yes_no_vote_poll_state_query::YesNoVotePollStateDriveQuery; + use crate::util::common::encode::encode_u64; + use crate::util::grove_operations::{BatchDeleteUpTreeApplyType, DirectQueryType}; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use crate::util::test_helpers::test_yes_no_vote_poll as poll; + use crate::util::test_helpers::vote_poll_end_dates; + use dpp::block::block_info::BlockInfo; + use dpp::identifier::Identifier; + use dpp::platform_value::BinaryData; + use dpp::version::PlatformVersion; + use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; + use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; + use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + use dpp::voting::vote_polls::VotePoll; + use dpp::voting::votes::yes_no_vote::v0::YesNoVoteV0; + use dpp::voting::votes::yes_no_vote::YesNoVote; + use grovedb::batch::KeyInfoPath; + use grovedb::MaybeTree; + + #[test] + fn should_refuse_to_open_a_poll_a_batch_already_opens() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let vote_poll = poll(); + let mut batch_operations = vec![]; + drive + .open_yes_no_vote_poll_operations( + &vote_poll, + 1_000, + &BlockInfo::default(), + &mut None, + &mut batch_operations, + None, + platform_version, + ) + .expect("expected to open the poll"); + // The same poll with another end date (the end date is not part of its id), with the + // batch so far as the previous operations. + drive + .open_yes_no_vote_poll_operations( + &vote_poll, + 2_000, + &BlockInfo::default(), + &mut Some(&mut batch_operations), + &mut vec![], + None, + platform_version, + ) + .expect_err("expected the second opening in the batch to be refused"); + } + + #[test] + fn should_create_the_decisions_trees_a_chain_lacks_when_opening_a_poll() { + // A chain born at protocol version 14 has no decisions trees until it upgrades. + let drive = setup_drive_with_initial_state_structure(Some( + PlatformVersion::get(14).expect("expected protocol version 14"), + )); + let platform_version = PlatformVersion::latest(); + let has_decisions_tree = |key: char| { + drive + .grove_has_raw( + (&vote_decisions_tree_path()).into(), + &[key as u8], + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to read the decisions tree") + }; + assert!(!has_decisions_tree(ACTIVE_POLLS_TREE_KEY)); + + let vote_poll = poll(); + drive + .open_yes_no_vote_poll( + &vote_poll, + 1_000, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected to open the poll"); + + assert!(has_decisions_tree(ACTIVE_POLLS_TREE_KEY)); + assert!(has_decisions_tree(IDENTITY_VOTES_TREE_KEY)); + assert!(drive + .fetch_yes_no_vote_poll_stored_info(&vote_poll, None, platform_version) + .expect("expected to fetch the stored info") + .is_some()); + } + + #[test] + fn should_refuse_to_open_a_poll_before_protocol_version_15() { + let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + drive + .open_yes_no_vote_poll( + &poll(), + 1_000, + &BlockInfo::default(), + None, + platform_version, + ) + .expect_err("expected protocol version 14 to refuse opening a yes/no poll"); + } + + #[test] + fn should_report_every_yes_no_method_not_active_before_protocol_version_15() { + let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let vote_poll = poll(); + // The id as protocol version 15 derives it: version 14 derives none. + let poll_id = vote_poll.unique_id(PlatformVersion::latest()).expect("id"); + let not_active = |result: Result<(), Error>, method: &str| { + assert!( + matches!( + result, + Err(Error::Drive(DriveError::VersionNotActive { .. })) + ), + "expected {method} to be not active at protocol version 14, got {result:?}" + ); + }; + not_active( + drive + .register_yes_no_identity_vote_operations( + [1; 32], + 1, + ResolvedYesNoVote { + vote_poll: vote_poll.clone(), + vote_choice: YesNoAbstainVoteChoice::Yes, + previous_vote_choice_to_remove: None, + }, + None, + platform_version, + ) + .map(|_| ()), + "register_yes_no_identity_vote", + ); + not_active( + YesNoVotePollStoredInfo::new(BlockInfo::default(), PlatformVersion::latest()) + .map_err(|error| Error::Protocol(Box::new(error))) + .and_then(|stored_info| { + drive.insert_stored_info_for_yes_no_vote_poll_operations( + &vote_poll, + stored_info, + platform_version, + ) + }) + .map(|_| ()), + "insert_stored_info_for_yes_no_vote_poll", + ); + not_active( + drive + .fetch_yes_no_vote_poll_stored_info(&vote_poll, None, platform_version) + .map(|_| ()), + "fetch_yes_no_vote_poll_stored_info", + ); + not_active( + drive + .fetch_identity_yes_no_vote( + Identifier::new([1; 32]), + poll_id, + None, + &mut vec![], + platform_version, + ) + .map(|_| ()), + "fetch_identity_yes_no_vote", + ); + not_active( + drive + .fetch_identities_voting_in_yes_no_vote_poll(&vote_poll, None, platform_version) + .map(|_| ()), + "fetch_identities_voting_in_yes_no_vote_poll", + ); + not_active( + drive.remove_yes_no_vote_poll_votes_operations( + &[poll_id], + &mut vec![], + platform_version, + ), + "remove_yes_no_vote_poll_votes_operations", + ); + not_active( + drive.remove_yes_no_vote_references_given_by_identity_operations( + &Identifier::new([1; 32]), + &[&poll_id], + &mut vec![], + None, + platform_version, + ), + "remove_yes_no_vote_references_given_by_identity", + ); + not_active( + drive.remove_vote_poll_end_date_query_operations( + &[(poll_id, 1_000)], + &mut vec![], + None, + platform_version, + ), + "remove_vote_poll_end_date_query_operations", + ); + not_active( + Drive::verify_masternode_yes_no_vote( + &[], + [1; 32], + &YesNoVote::V0(YesNoVoteV0 { + vote_poll: vote_poll.clone(), + vote_choice: YesNoAbstainVoteChoice::Yes, + }), + false, + platform_version, + ) + .map(|_| ()), + "verify_masternode_yes_no_vote", + ); + not_active( + YesNoVotePollStateDriveQuery { + vote_poll: vote_poll.clone(), + } + .verify_yes_no_vote_poll_state_proof(&[], platform_version) + .map(|_| ()), + "verify_yes_no_vote_poll_state_proof", + ); + let state_query = YesNoVotePollStateDriveQuery { + vote_poll: vote_poll.clone(), + }; + not_active( + state_query + .execute_no_proof(&drive, None, &mut vec![], platform_version) + .map(|_| ()), + "fetch_yes_no_vote_poll_state (no proof)", + ); + not_active( + state_query + .execute_with_proof(&drive, None, &mut vec![], platform_version) + .map(|_| ()), + "fetch_yes_no_vote_poll_state (proof)", + ); + } + + #[test] + fn should_refuse_a_poll_that_ends_by_the_block_that_opens_it() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let block_info = BlockInfo { + time_ms: 1_000, + ..Default::default() + }; + let max_duration = platform_version + .system_limits + .max_yes_no_vote_poll_duration_ms; + // One that runs too long is refused too; the last two would also sort before every + // block time in the end date index. + for end_date in [ + 999, + 1_000, + 1_000 + max_duration + 1, + i64::MAX as u64 + 1, + u64::MAX, + ] { + drive + .open_yes_no_vote_poll(&poll(), end_date, &block_info, None, platform_version) + .expect_err("expected a poll that ends at once or too late to be refused"); + } + drive + .open_yes_no_vote_poll(&poll(), 1_001, &block_info, None, platform_version) + .expect("expected a poll ending after the opening block to open"); + let mut longest_poll = poll(); + longest_poll + .resource_path + .push(BinaryData::new(b"longest".to_vec())); + drive + .open_yes_no_vote_poll( + &longest_poll, + 1_000 + max_duration, + &block_info, + None, + platform_version, + ) + .expect("expected a poll running exactly the longest duration to open"); + } + + #[test] + fn should_queue_the_trees_two_openings_in_a_batch_share_once() { + // A chain born at 14 lacks the decisions trees, which both openings need, and both polls + // end at the same time. The test drive verifies batch consistency, which refuses a tree + // queued twice. + let drive = setup_drive_with_initial_state_structure(Some( + PlatformVersion::get(14).expect("expected protocol version 14"), + )); + let platform_version = PlatformVersion::latest(); + let mut other_poll = poll(); + other_poll + .resource_path + .push(BinaryData::new(b"other".to_vec())); + let mut batch_operations = vec![]; + for vote_poll in [poll(), other_poll.clone()] { + let mut opening_operations = vec![]; + drive + .open_yes_no_vote_poll_operations( + &vote_poll, + 1_000, + &BlockInfo::default(), + &mut Some(&mut batch_operations), + &mut opening_operations, + None, + platform_version, + ) + .expect("expected to open the poll"); + batch_operations.append(&mut opening_operations); + } + drive + .apply_batch_low_level_drive_operations( + None, + None, + batch_operations, + &mut vec![], + &platform_version.drive, + ) + .expect("expected the batch to apply"); + let end_dates = vote_poll_end_dates(&drive, platform_version); + assert_eq!(end_dates.len(), 1); + assert_eq!( + end_dates[&1_000], + [ + poll().unique_id(platform_version).expect("id"), + other_poll.unique_id(platform_version).expect("id") + ] + .into_iter() + .collect() + ); + } + + #[test] + fn should_keep_the_flags_of_a_contest_end_date_tree_the_batch_already_queues() { + // A contest created earlier in the batch queues the end date's tree with its + // contender's storage flags; the opening adds only its entry, and the tree keeps them. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let contested_poll = VotePoll::ContestedDocumentResourceVotePoll( + ContestedDocumentResourceVotePoll::default(), + ); + let mut batch_operations = vec![]; + drive + .add_vote_poll_end_date_query_operations( + Some([7; 32]), + contested_poll.clone(), + 1_000, + &BlockInfo::default(), + &mut None, + &mut None, + &mut batch_operations, + None, + platform_version, + ) + .expect("expected the contest's end date entry"); + let mut opening_operations = vec![]; + drive + .open_yes_no_vote_poll_operations( + &poll(), + 1_000, + &BlockInfo::default(), + &mut Some(&mut batch_operations), + &mut opening_operations, + None, + platform_version, + ) + .expect("expected to open the poll"); + batch_operations.append(&mut opening_operations); + drive + .apply_batch_low_level_drive_operations( + None, + None, + batch_operations, + &mut vec![], + &platform_version.drive, + ) + .expect("expected the batch to apply"); + + let end_dates = vote_poll_end_dates(&drive, platform_version); + assert_eq!( + end_dates[&1_000], + [ + contested_poll.unique_id(platform_version).expect("id"), + poll().unique_id(platform_version).expect("id") + ] + .into_iter() + .collect() + ); + let end_date_tree = drive + .grove + .get( + &vote_end_date_queries_tree_path(), + encode_u64(1_000).as_slice(), + None, + &platform_version.drive.grove_version, + ) + .unwrap() + .expect("expected the end date's tree"); + assert!(end_date_tree.get_flags().is_some()); + } + + #[test] + fn should_cancel_a_queued_deletion_of_the_end_date_tree_when_opening_a_poll() { + // A contest whose join-window entry is the only one at its end date, in state. A second + // contender moving the contest off that end date queues the entry's deletion and, with + // it, the deletion of the emptied end date tree; a poll opened later in the batch at that + // end date must keep the tree. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let contested_poll = VotePoll::ContestedDocumentResourceVotePoll( + ContestedDocumentResourceVotePoll::default(), + ); + let mut contest_operations = vec![]; + drive + .add_vote_poll_end_date_query_operations( + Some([7; 32]), + contested_poll.clone(), + 1_000, + &BlockInfo::default(), + &mut None, + &mut None, + &mut contest_operations, + None, + platform_version, + ) + .expect("expected the contest's end date entry"); + drive + .apply_batch_low_level_drive_operations( + None, + None, + contest_operations, + &mut vec![], + &platform_version.drive, + ) + .expect("expected the contest's end date entry to apply"); + + let mut batch_operations = vec![]; + drive + .batch_delete_up_tree_while_empty( + KeyInfoPath::from_known_owned_path( + vote_contested_resource_end_date_queries_at_time_tree_path_vec(1_000), + ), + contested_poll + .unique_id(platform_version) + .expect("id") + .as_slice(), + Some(vote_root_path().len() as u16), + BatchDeleteUpTreeApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + None, + &None, + &mut batch_operations, + &platform_version.drive, + ) + .expect("expected the contest's entry and end date tree to be queued for deletion"); + let mut opening_operations = vec![]; + drive + .open_yes_no_vote_poll_operations( + &poll(), + 1_000, + &BlockInfo::default(), + &mut Some(&mut batch_operations), + &mut opening_operations, + None, + platform_version, + ) + .expect("expected to open the poll"); + batch_operations.append(&mut opening_operations); + drive + .apply_batch_low_level_drive_operations( + None, + None, + batch_operations, + &mut vec![], + &platform_version.drive, + ) + .expect("expected the batch to apply"); + + let end_dates = vote_poll_end_dates(&drive, platform_version); + assert_eq!( + end_dates[&1_000], + [poll().unique_id(platform_version).expect("id")] + .into_iter() + .collect() + ); + } + + #[test] + fn should_queue_the_end_date_tree_once_when_a_contest_follows_an_opening() { + // The opening queues the end date's tree without storage flags, the contest after it + // would queue it with its contender's: the contest adds only its entry. The test drive + // verifies batch consistency, which refuses a tree queued twice. + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let contested_poll = VotePoll::ContestedDocumentResourceVotePoll( + ContestedDocumentResourceVotePoll::default(), + ); + let mut batch_operations = vec![]; + drive + .open_yes_no_vote_poll_operations( + &poll(), + 1_000, + &BlockInfo::default(), + &mut None, + &mut batch_operations, + None, + platform_version, + ) + .expect("expected to open the poll"); + let mut contest_operations = vec![]; + drive + .add_vote_poll_end_date_query_operations( + Some([7; 32]), + contested_poll.clone(), + 1_000, + &BlockInfo::default(), + &mut None, + &mut Some(&mut batch_operations), + &mut contest_operations, + None, + platform_version, + ) + .expect("expected the contest's end date entry"); + batch_operations.append(&mut contest_operations); + drive + .apply_batch_low_level_drive_operations( + None, + None, + batch_operations, + &mut vec![], + &platform_version.drive, + ) + .expect("expected the batch to apply"); + + let end_dates = vote_poll_end_dates(&drive, platform_version); + assert_eq!( + end_dates[&1_000], + [ + contested_poll.unique_id(platform_version).expect("id"), + poll().unique_id(platform_version).expect("id") + ] + .into_iter() + .collect() + ); + } +} diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/v0/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/v0/mod.rs new file mode 100644 index 00000000000..6f5bc526656 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/v0/mod.rs @@ -0,0 +1,170 @@ +use crate::drive::votes::paths::{ + vote_decisions_active_polls_tree_path_vec, vote_decisions_tree_path_vec, YesNoVotePollPaths, + VOTE_DECISIONS_SUBTREE_KEYS, +}; +use crate::drive::votes::YesNoAbstainVoteChoiceToKeyTrait; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchInsertTreeApplyType; +use crate::util::object_size_info::{DriveKeyInfo, PathKeyInfo}; +use dpp::block::block_info::BlockInfo; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use dpp::voting::vote_polls::VotePoll; +use dpp::ProtocolError; +use grovedb::{TransactionArg, TreeType}; + +impl Drive { + pub(super) fn open_yes_no_vote_poll_v0( + &self, + vote_poll: &YesNoVotePoll, + end_date: TimestampMillis, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let mut batch_operations = vec![]; + self.open_yes_no_vote_poll_operations_v0( + vote_poll, + end_date, + block_info, + &mut None, + &mut batch_operations, + transaction, + platform_version, + )?; + self.apply_batch_low_level_drive_operations( + None, + transaction, + batch_operations, + &mut vec![], + &platform_version.drive, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn open_yes_no_vote_poll_operations_v0( + &self, + vote_poll: &YesNoVotePoll, + end_date: TimestampMillis, + block_info: &BlockInfo, + previous_batch_operations: &mut Option<&mut Vec>, + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + vote_poll.validate_parameters(platform_version)?; + // A poll that ends in the block that opens it, or earlier, would close at the end of + // this block with no vote cast. One that runs longer than the system limit would hold + // its fund and trees for as long. The limit also keeps the end date below `i64::MAX`, + // past which the end date index (which flips its top bit, `encode_u64`) would sort it + // before every block time. + let max_duration = platform_version + .system_limits + .max_yes_no_vote_poll_duration_ms; + if end_date <= block_info.time_ms + || end_date - block_info.time_ms > max_duration + || end_date > i64::MAX as TimestampMillis + { + return Err(Error::Protocol(Box::new(ProtocolError::VoteError( + format!( + "yes/no vote poll {} must end after the block that opens it (at {}), at \ + most {} ms later and at most at {}, not at {}", + vote_poll, + block_info.time_ms, + max_duration, + i64::MAX, + end_date + ), + )))); + } + let poll_id = vote_poll.unique_id(platform_version)?; + + // The decisions trees, when the chain lacks them: genesis and the upgrade to protocol + // version 15 create them, but a chain that reached 15 before they existed did not. An + // earlier opening in the batch, among the previous operations, may already queue them. + for subtree_key in VOTE_DECISIONS_SUBTREE_KEYS { + self.batch_insert_empty_tree_if_not_exists( + PathKeyInfo::PathKey::<0>(( + vote_decisions_tree_path_vec(), + vec![subtree_key as u8], + )), + TreeType::NormalTree, + None, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + batch_operations, + &platform_version.drive, + )?; + } + + // The poll's own tree. A poll that already has one, in state or among the previous + // operations, was opened before: opened twice, it would be listed under two end dates. + let inserted = self.batch_insert_empty_tree_if_not_exists( + PathKeyInfo::PathKey::<0>(( + vote_decisions_active_polls_tree_path_vec(), + poll_id.to_vec(), + )), + TreeType::NormalTree, + None, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + batch_operations, + &platform_version.drive, + )?; + if !inserted { + return Err(Error::Protocol(Box::new(ProtocolError::VoteError( + format!( + "yes/no vote poll {} is already open or was already decided", + vote_poll + ), + )))); + } + + // Its stored info: started in this block. + let stored_info = YesNoVotePollStoredInfo::new(*block_info, platform_version)?; + let mut stored_info_operations = self.insert_stored_info_for_yes_no_vote_poll_operations( + vote_poll, + stored_info, + platform_version, + )?; + batch_operations.append(&mut stored_info_operations); + + // One sum tree per choice; the sums are the tallies. + let poll_path = vote_poll.poll_path_vec(platform_version)?; + for vote_choice in YesNoAbstainVoteChoice::ALL { + self.batch_insert_empty_sum_tree( + poll_path.iter().map(|segment| segment.as_slice()), + DriveKeyInfo::Key(vec![vote_choice.to_tree_key()]), + None, + batch_operations, + &platform_version.drive, + )?; + } + + // The end date index closes the poll when its time comes: the entry a contest gets, under + // the end date's tree. Nobody is refunded for what an opening adds: the poll is a system + // action, so it carries no storage flags. A contest among the previous operations may + // already queue the end date's tree with its contender's flags, which the tree keeps, or + // queue its deletion (a contest leaving its join-window end date), which the insert + // cancels. + self.add_vote_poll_end_date_query_operations( + None, + VotePoll::YesNoVotePoll(vote_poll.clone()), + end_date, + block_info, + &mut None, + previous_batch_operations, + batch_operations, + transaction, + platform_version, + )?; + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/register_yes_no_identity_vote/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/register_yes_no_identity_vote/mod.rs new file mode 100644 index 00000000000..d0b4fb5fa19 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/register_yes_no_identity_vote/mod.rs @@ -0,0 +1,108 @@ +mod v0; + +use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Registers a masternode's vote on a yes/no poll: its strength under the chosen answer's + /// sum tree, the previous answer removed if the vote changed, and the identity votes index + /// updated. + /// + /// # Parameters + /// - `voter_pro_tx_hash`: the voting masternode's pro tx hash. + /// - `strength`: its voting power: 1 for a masternode, 4 for an evonode. + /// - `vote`: the vote, with the previous answer to remove when it changed. + /// - `block_info`: the block that registers it. + /// - `transaction`: the transaction to read and write in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// The fee of the operations applied. + pub fn register_yes_no_identity_vote( + &self, + voter_pro_tx_hash: [u8; 32], + strength: u8, + vote: ResolvedYesNoVote, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .vote + .yes_no + .register_yes_no_identity_vote + { + Some(0) => self.register_yes_no_identity_vote_v0( + voter_pro_tx_hash, + strength, + vote, + block_info, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "register_yes_no_identity_vote".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "register_yes_no_identity_vote".to_string(), + known_versions: vec![0], + })), + } + } + + /// The operations of [`Self::register_yes_no_identity_vote`]. + /// + /// # Parameters + /// - `voter_pro_tx_hash`: the voting masternode's pro tx hash. + /// - `strength`: its voting power: 1 for a masternode, 4 for an evonode. + /// - `vote`: the vote, with the previous answer to remove when it changed. + /// - `transaction`: the transaction to read in. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// The operations that register the vote. + pub fn register_yes_no_identity_vote_operations( + &self, + voter_pro_tx_hash: [u8; 32], + strength: u8, + vote: ResolvedYesNoVote, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .register_yes_no_identity_vote + { + Some(0) => self.register_yes_no_identity_vote_operations_v0( + voter_pro_tx_hash, + strength, + vote, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "register_yes_no_identity_vote_operations".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "register_yes_no_identity_vote_operations".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/register_yes_no_identity_vote/v0/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/register_yes_no_identity_vote/v0/mod.rs new file mode 100644 index 00000000000..6dfcac0432b --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/register_yes_no_identity_vote/v0/mod.rs @@ -0,0 +1,136 @@ +use crate::drive::votes::paths::{ + vote_decisions_identity_votes_tree_path_for_identity_vec, + vote_decisions_identity_votes_tree_path_vec, YesNoVotePollPaths, +}; +use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; +use crate::drive::votes::storage_form::yes_no_vote_reference_storage_form::YesNoVoteReferenceStorageForm; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::{BatchDeleteApplyType, BatchInsertTreeApplyType}; +use crate::util::object_size_info::PathKeyElementInfo::PathKeyElement; +use crate::util::object_size_info::PathKeyInfo; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::version::PlatformVersion; +use grovedb::{Element, MaybeTree, TransactionArg, TreeType}; + +impl Drive { + pub(super) fn register_yes_no_identity_vote_v0( + &self, + voter_pro_tx_hash: [u8; 32], + strength: u8, + vote: ResolvedYesNoVote, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let batch_operations = self.register_yes_no_identity_vote_operations_v0( + voter_pro_tx_hash, + strength, + vote, + transaction, + platform_version, + )?; + let mut drive_operations: Vec = vec![]; + self.apply_batch_low_level_drive_operations( + None, + transaction, + batch_operations, + &mut drive_operations, + &platform_version.drive, + )?; + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + Ok(fees) + } + + pub(super) fn register_yes_no_identity_vote_operations_v0( + &self, + voter_pro_tx_hash: [u8; 32], + strength: u8, + vote: ResolvedYesNoVote, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + // Voting has a fixed cost, so there are no estimated costs to keep. + let mut drive_operations: Vec = vec![]; + let ResolvedYesNoVote { + vote_poll, + vote_choice, + previous_vote_choice_to_remove, + } = vote; + + // The vote itself: the masternode's strength under its answer's sum tree. + let voting_path = vote_poll.choice_votes_path_vec(vote_choice, platform_version)?; + self.batch_insert::<0>( + PathKeyElement(( + voting_path, + voter_pro_tx_hash.to_vec(), + Element::new_sum_item(strength as i64), + )), + &mut drive_operations, + &platform_version.drive, + )?; + + // A changed vote leaves its previous answer. + let mut identity_vote_times = 1; + if let Some((previous_vote_choice, previous_vote_count)) = previous_vote_choice_to_remove { + let previous_voting_path = + vote_poll.choice_votes_path_vec(previous_vote_choice, platform_version)?; + self.batch_delete( + previous_voting_path.as_slice().into(), + voter_pro_tx_hash.as_slice(), + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + identity_vote_times += previous_vote_count; + } + + // The identity votes index: the masternode's tree, then its answer to this poll. + self.batch_insert_empty_tree_if_not_exists( + PathKeyInfo::PathKey::<0>(( + vote_decisions_identity_votes_tree_path_vec(), + voter_pro_tx_hash.to_vec(), + )), + TreeType::NormalTree, + None, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + &mut None, + &mut drive_operations, + &platform_version.drive, + )?; + let storage_form = YesNoVoteReferenceStorageForm { + vote_choice, + identity_vote_times, + }; + let encoded = storage_form.serialize().map_err(|e| { + Error::Drive(DriveError::CorruptedSerialization(format!( + "can not encode yes/no vote reference: {}", + e + ))) + })?; + self.batch_insert::<0>( + PathKeyElement(( + vote_decisions_identity_votes_tree_path_for_identity_vec(&voter_pro_tx_hash), + vote_poll.unique_id(platform_version)?.to_vec(), + Element::new_item(encoded), + )), + &mut drive_operations, + &platform_version.drive, + )?; + Ok(drive_operations) + } +} diff --git a/packages/rs-drive/src/drive/votes/mod.rs b/packages/rs-drive/src/drive/votes/mod.rs index 83078aab11b..6d0991a576a 100644 --- a/packages/rs-drive/src/drive/votes/mod.rs +++ b/packages/rs-drive/src/drive/votes/mod.rs @@ -1,17 +1,23 @@ use crate::drive::document::paths::contract_document_type_path; use crate::drive::votes::paths::{ RESOURCE_ABSTAIN_VOTE_TREE_KEY_U8_32, RESOURCE_LOCK_VOTE_TREE_KEY_U8_32, + YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY, YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY, + YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY, }; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::DataContract; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; use dpp::voting::vote_polls::VotePoll; use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; use dpp::voting::votes::resource_vote::ResourceVote; use dpp::voting::votes::Vote; use dpp::ProtocolError; +/// How many times a masternode already voted on a poll, before the vote being cast. +pub type PreviousVoteCount = u16; + #[cfg(feature = "server")] mod cleanup; #[cfg(all(feature = "server", any(test, feature = "structure")))] @@ -50,6 +56,11 @@ impl TreePath for Vote { fn tree_path<'a>(&'a self, contract: &'a DataContract) -> Result, ProtocolError> { match self { Vote::ResourceVote(resource_vote) => resource_vote.tree_path(contract), + // A yes/no poll lives under the decisions branch by its unique id, not under a + // contract's index. + Vote::YesNoVote(_) => Err(ProtocolError::VoteError( + "a yes/no vote has no tree path under a contract".to_string(), + )), } } } @@ -94,6 +105,9 @@ impl TreePath for ResourceVote { } Ok(path) } + VotePoll::YesNoVotePoll(_) => Err(ProtocolError::VoteError( + "a resource vote cannot answer a yes/no vote poll".to_string(), + )), } } } @@ -114,6 +128,34 @@ impl ResourceVoteChoiceToKeyTrait for ResourceVoteChoice { } } +/// The key of the sum tree holding the votes for a yes/no choice, and back. +pub trait YesNoAbstainVoteChoiceToKeyTrait: Sized { + /// The key of the choice's sum tree inside the poll's tree + fn to_tree_key(&self) -> u8; + + /// The choice a sum tree key stands for, if it is one of the three + fn from_tree_key(key: u8) -> Option; +} + +impl YesNoAbstainVoteChoiceToKeyTrait for YesNoAbstainVoteChoice { + fn to_tree_key(&self) -> u8 { + match self { + YesNoAbstainVoteChoice::Yes => YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY, + YesNoAbstainVoteChoice::No => YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY, + YesNoAbstainVoteChoice::Abstain => YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY, + } + } + + fn from_tree_key(key: u8) -> Option { + match key { + YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY => Some(YesNoAbstainVoteChoice::Yes), + YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY => Some(YesNoAbstainVoteChoice::No), + YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY => Some(YesNoAbstainVoteChoice::Abstain), + _ => None, + } + } +} + #[cfg(test)] #[cfg(feature = "server")] mod tests { diff --git a/packages/rs-drive/src/drive/votes/paths.rs b/packages/rs-drive/src/drive/votes/paths.rs index dd12fb445a7..c8b8ce046e1 100644 --- a/packages/rs-drive/src/drive/votes/paths.rs +++ b/packages/rs-drive/src/drive/votes/paths.rs @@ -5,15 +5,37 @@ /// /// |- End date Queries [key: "e"] /// |- Decisions [key: "d"] +/// |- Active polls [key: "p"] +/// |- Identifier Votes Query [key: "i"] /// |- Contested Resource [key: "c"] /// |- Active polls [key: "p"] /// |- Identifier Votes Query [key: "i"] /// ``` +/// +/// Decisions are yes/no vote polls (protocol version 15). Each active poll is a tree keyed by +/// the poll's unique id under the active polls tree: +/// +/// ```text +/// Active polls [key: "p"] +/// |- +/// |- Stored info [key: 0]: serialized YesNoVotePollStoredInfo +/// |- Yes votes [key: 1]: sum tree of pro_tx_hash -> vote strength +/// |- No votes [key: 2]: sum tree of pro_tx_hash -> vote strength +/// |- Abstain votes [key: 3]: sum tree of pro_tx_hash -> vote strength +/// ``` +/// +/// and each masternode's votes are indexed by its pro tx hash under the identity votes tree: +/// +/// ```text +/// Identifier Votes Query [key: "i"] +/// |- +/// |- : serialized YesNoVoteReferenceStorageForm (choice + times voted) +/// ``` use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::{ ContestedDocumentResourceVotePollWithContractInfo, ContestedDocumentResourceVotePollWithContractInfoAllowBorrowed, }; -use crate::drive::votes::ResourceVoteChoiceToKeyTrait; +use crate::drive::votes::{ResourceVoteChoiceToKeyTrait, YesNoAbstainVoteChoiceToKeyTrait}; use crate::drive::RootTree; use crate::error::query::QuerySyntaxError; use crate::error::Error; @@ -23,6 +45,8 @@ use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::IndexProperty; use dpp::identity::TimestampMillis; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; use platform_version::version::PlatformVersion; /// A subtree made for polls to the network that represent decisions. @@ -40,6 +64,10 @@ pub const ACTIVE_POLLS_TREE_KEY: char = 'p'; /// A subtree made for being able to query votes that an identity has made pub const IDENTITY_VOTES_TREE_KEY: char = 'i'; +/// The subtrees of the decisions branch, in the order they are created: the active yes/no polls +/// and the identity votes index. +pub const VOTE_DECISIONS_SUBTREE_KEYS: [char; 2] = [ACTIVE_POLLS_TREE_KEY, IDENTITY_VOTES_TREE_KEY]; + /// The finished info pub const RESOURCE_STORED_INFO_KEY_U8_32: [u8; 32] = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, @@ -64,6 +92,18 @@ pub const CONTESTED_DOCUMENT_INDEXES_TREE_KEY: u8 = 1; /// The tree key for storage pub const VOTING_STORAGE_TREE_KEY: u8 = 1; +/// In a yes/no vote poll tree, the key of the item holding the poll's stored info +pub const YES_NO_VOTE_POLL_STORED_INFO_KEY: u8 = 0; + +/// In a yes/no vote poll tree, the key of the sum tree of yes votes +pub const YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY: u8 = 1; + +/// In a yes/no vote poll tree, the key of the sum tree of no votes +pub const YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY: u8 = 2; + +/// In a yes/no vote poll tree, the key of the sum tree of abstaining votes +pub const YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY: u8 = 3; + /// Convenience methods to be easily able to get a path when we know the vote poll pub trait VotePollPaths { /// The root path, under this there should be the documents area and the contract itself @@ -310,6 +350,49 @@ impl VotePollPaths for ContestedDocumentResourceVotePollWithContractInfoAllowBor } } +/// The paths of a yes/no vote poll's storage, derived from its unique id, which the platform +/// version selects how to derive. +pub trait YesNoVotePollPaths { + /// The poll's tree, holding its stored info and its three vote sum trees. + fn poll_path_vec(&self, platform_version: &PlatformVersion) -> Result>, Error>; + + /// The sum tree holding the votes for one choice. + fn choice_votes_path_vec( + &self, + vote_choice: YesNoAbstainVoteChoice, + platform_version: &PlatformVersion, + ) -> Result>, Error>; +} + +impl YesNoVotePollPaths for YesNoVotePoll { + fn poll_path_vec(&self, platform_version: &PlatformVersion) -> Result>, Error> { + Ok(vote_decisions_active_polls_poll_tree_path_vec( + self.unique_id(platform_version)?.as_bytes(), + )) + } + + fn choice_votes_path_vec( + &self, + vote_choice: YesNoAbstainVoteChoice, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + Ok(vote_decisions_poll_choice_votes_tree_path_vec( + self.unique_id(platform_version)?.as_bytes(), + vote_choice, + )) + } +} + +/// the sum tree holding one choice's votes on a yes/no vote poll, by the poll's unique id +pub fn vote_decisions_poll_choice_votes_tree_path_vec( + poll_id: &[u8; 32], + vote_choice: YesNoAbstainVoteChoice, +) -> Vec> { + let mut path = vote_decisions_active_polls_poll_tree_path_vec(poll_id); + path.push(vec![vote_choice.to_tree_key()]); + path +} + /// the root path of the voting branch pub fn vote_root_path<'a>() -> [&'a [u8]; 1] { [Into::<&[u8; 1]>::into(RootTree::Votes)] @@ -552,6 +635,74 @@ pub fn vote_contested_resource_identity_votes_tree_path_for_identity_vec( ] } +/// the active yes/no vote polls of the decisions branch +pub fn vote_decisions_active_polls_tree_path<'a>() -> [&'a [u8]; 3] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[VOTE_DECISIONS_TREE_KEY as u8], + &[ACTIVE_POLLS_TREE_KEY as u8], + ] +} + +/// the active yes/no vote polls of the decisions branch as a vec +pub fn vote_decisions_active_polls_tree_path_vec() -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![VOTE_DECISIONS_TREE_KEY as u8], + vec![ACTIVE_POLLS_TREE_KEY as u8], + ] +} + +/// the tree of one yes/no vote poll, keyed by its unique id, as a vec +pub fn vote_decisions_active_polls_poll_tree_path_vec(poll_id: &[u8; 32]) -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![VOTE_DECISIONS_TREE_KEY as u8], + vec![ACTIVE_POLLS_TREE_KEY as u8], + poll_id.to_vec(), + ] +} + +/// the identity votes of the decisions branch +pub fn vote_decisions_identity_votes_tree_path<'a>() -> [&'a [u8]; 3] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[VOTE_DECISIONS_TREE_KEY as u8], + &[IDENTITY_VOTES_TREE_KEY as u8], + ] +} + +/// the identity votes of the decisions branch as a vec +pub fn vote_decisions_identity_votes_tree_path_vec() -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![VOTE_DECISIONS_TREE_KEY as u8], + vec![IDENTITY_VOTES_TREE_KEY as u8], + ] +} + +/// the yes/no votes of one masternode, by its pro tx hash +pub fn vote_decisions_identity_votes_tree_path_for_identity(pro_tx_hash: &[u8; 32]) -> [&[u8]; 4] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[VOTE_DECISIONS_TREE_KEY as u8], + &[IDENTITY_VOTES_TREE_KEY as u8], + pro_tx_hash, + ] +} + +/// the yes/no votes of one masternode, by its pro tx hash, as a vec +pub fn vote_decisions_identity_votes_tree_path_for_identity_vec( + pro_tx_hash: &[u8; 32], +) -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![VOTE_DECISIONS_TREE_KEY as u8], + vec![IDENTITY_VOTES_TREE_KEY as u8], + pro_tx_hash.to_vec(), + ] +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-drive/src/drive/votes/resolved/vote_polls/resolve.rs b/packages/rs-drive/src/drive/votes/resolved/vote_polls/resolve.rs index eecb852f1b1..c8fc17d3358 100644 --- a/packages/rs-drive/src/drive/votes/resolved/vote_polls/resolve.rs +++ b/packages/rs-drive/src/drive/votes/resolved/vote_polls/resolve.rs @@ -1,6 +1,7 @@ use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::resolve::ContestedDocumentResourceVotePollResolver; use crate::drive::votes::resolved::vote_polls::ResolvedVotePoll; use crate::drive::Drive; +use crate::error::drive::DriveError; use crate::error::Error; use dpp::voting::vote_polls::VotePoll; use grovedb::TransactionArg; @@ -44,6 +45,12 @@ pub trait VotePollResolver { ) -> Result; } +/// Resolution looks up a contested poll's contract; a yes/no poll needs nothing from the state. +/// The end of block check closes a yes/no poll before resolving, and a resource vote naming one +/// is refused before resolution (and does not decode before protocol version 15), so a yes/no +/// poll never reaches here. +const YES_NO_VOTE_POLL_NOT_RESOLVED: &str = "a yes/no vote poll is never resolved"; + impl VotePollResolver for VotePoll { fn resolve( &self, @@ -63,6 +70,9 @@ impl VotePollResolver for VotePoll { ), ) } + VotePoll::YesNoVotePoll(_) => Err(Error::Drive(DriveError::CorruptedCodeExecution( + YES_NO_VOTE_POLL_NOT_RESOLVED, + ))), } } @@ -84,6 +94,9 @@ impl VotePollResolver for VotePoll { ), ) } + VotePoll::YesNoVotePoll(_) => Err(Error::Drive(DriveError::CorruptedCodeExecution( + YES_NO_VOTE_POLL_NOT_RESOLVED, + ))), } } } diff --git a/packages/rs-drive/src/drive/votes/resolved/votes/mod.rs b/packages/rs-drive/src/drive/votes/resolved/votes/mod.rs index 99a31f50a69..a5f11c51064 100644 --- a/packages/rs-drive/src/drive/votes/resolved/votes/mod.rs +++ b/packages/rs-drive/src/drive/votes/resolved/votes/mod.rs @@ -2,27 +2,39 @@ pub(crate) mod resolve; /// Resolved resource vote module pub mod resolved_resource_vote; +/// Resolved yes/no vote module +pub mod resolved_yes_no_vote; use crate::drive::votes::resolved::vote_polls::ResolvedVotePoll; use crate::drive::votes::resolved::votes::resolved_resource_vote::accessors::v0::ResolvedResourceVoteGettersV0; use crate::drive::votes::resolved::votes::resolved_resource_vote::ResolvedResourceVote; +use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; use dpp::identifier::Identifier; use dpp::voting::vote_polls::VotePoll; use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; use dpp::voting::votes::resource_vote::ResourceVote; use dpp::voting::votes::Vote; use dpp::ProtocolError; +use platform_version::version::PlatformVersion; /// Represents the different types of resolved votes within the system. +// A resource vote carries the resolved contract of its poll; a yes/no vote is a few bytes. +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)] pub enum ResolvedVote { /// A resolved vote for a specific resource. ResolvedResourceVote(ResolvedResourceVote), + /// A vote on a yes/no poll. + YesNoVote(ResolvedYesNoVote), } impl ResolvedVote { /// Retrieves the specialized balance identifier associated with the resolved vote. /// + /// # Parameters + /// + /// * `platform_version`: selects how a yes/no poll's id is derived. + /// /// # Returns /// /// * `Ok(Some(identifier))` if a specialized balance ID is available. @@ -32,11 +44,19 @@ impl ResolvedVote { /// # Errors /// /// Returns a `ProtocolError` if there is an issue retrieving the specialized balance ID. - pub fn specialized_balance_id(&self) -> Result, ProtocolError> { + pub fn specialized_balance_id( + &self, + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { match self { ResolvedVote::ResolvedResourceVote(resource_vote) => { resource_vote.vote_poll().specialized_balance_id() } + ResolvedVote::YesNoVote(yes_no_vote) => Ok(Some( + yes_no_vote + .vote_poll + .specialized_balance_id(platform_version)?, + )), } } } @@ -61,6 +81,7 @@ impl From for Vote { } } } + ResolvedVote::YesNoVote(yes_no_vote) => Self::YesNoVote(yes_no_vote.into()), } } } diff --git a/packages/rs-drive/src/drive/votes/resolved/votes/resolve.rs b/packages/rs-drive/src/drive/votes/resolved/votes/resolve.rs index 363a29da07f..9f36001257c 100644 --- a/packages/rs-drive/src/drive/votes/resolved/votes/resolve.rs +++ b/packages/rs-drive/src/drive/votes/resolved/votes/resolve.rs @@ -1,6 +1,7 @@ use crate::drive::votes::resolved::votes::resolved_resource_vote::resolve::ResourceVoteResolver; use crate::drive::votes::resolved::votes::ResolvedVote; use crate::drive::Drive; +use crate::error::drive::DriveError; use crate::error::Error; use dpp::voting::votes::Vote; use grovedb::TransactionArg; @@ -42,6 +43,12 @@ pub trait VoteResolver { ) -> Result; } +/// A yes/no vote is not resolved here: transform_into_action v1 builds its action itself, with +/// the masternode's previous answer, which a resolution from the vote alone would lose (and +/// with it the removal of the previous answer's vote and the count of vote changes). +const YES_NO_VOTE_NOT_RESOLVED: &str = + "a yes/no vote is resolved with its previous answer, by transform_into_action"; + impl VoteResolver for Vote { fn resolve( &self, @@ -53,6 +60,9 @@ impl VoteResolver for Vote { Vote::ResourceVote(resource_vote) => Ok(ResolvedVote::ResolvedResourceVote( resource_vote.resolve(drive, transaction, platform_version)?, )), + Vote::YesNoVote(_) => Err(Error::Drive(DriveError::CorruptedCodeExecution( + YES_NO_VOTE_NOT_RESOLVED, + ))), } } @@ -66,6 +76,9 @@ impl VoteResolver for Vote { Vote::ResourceVote(resource_vote) => Ok(ResolvedVote::ResolvedResourceVote( resource_vote.resolve_owned(drive, transaction, platform_version)?, )), + Vote::YesNoVote(_) => Err(Error::Drive(DriveError::CorruptedCodeExecution( + YES_NO_VOTE_NOT_RESOLVED, + ))), } } } diff --git a/packages/rs-drive/src/drive/votes/resolved/votes/resolved_yes_no_vote/mod.rs b/packages/rs-drive/src/drive/votes/resolved/votes/resolved_yes_no_vote/mod.rs new file mode 100644 index 00000000000..48024bd78ae --- /dev/null +++ b/packages/rs-drive/src/drive/votes/resolved/votes/resolved_yes_no_vote/mod.rs @@ -0,0 +1,28 @@ +use crate::drive::votes::PreviousVoteCount; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use dpp::voting::votes::yes_no_vote::v0::YesNoVoteV0; +use dpp::voting::votes::yes_no_vote::YesNoVote; + +/// A yes/no vote as the execution layer handles it. A yes/no poll needs no contract to be +/// resolved, so this is the vote itself plus what validation learned from the state: the +/// masternode's previous answer to the same poll, which registering the vote removes. +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedYesNoVote { + /// The poll answered. + pub vote_poll: YesNoVotePoll, + /// The answer. + pub vote_choice: YesNoAbstainVoteChoice, + /// The masternode's previous answer to this poll and how many times it had voted, when it + /// changes its vote. `None` for a first vote, and whenever validation was skipped. + pub previous_vote_choice_to_remove: Option<(YesNoAbstainVoteChoice, PreviousVoteCount)>, +} + +impl From for YesNoVote { + fn from(value: ResolvedYesNoVote) -> Self { + YesNoVote::V0(YesNoVoteV0 { + vote_poll: value.vote_poll, + vote_choice: value.vote_choice, + }) + } +} diff --git a/packages/rs-drive/src/drive/votes/setup/insert_vote_decisions_trees/mod.rs b/packages/rs-drive/src/drive/votes/setup/insert_vote_decisions_trees/mod.rs new file mode 100644 index 00000000000..fee760af627 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/setup/insert_vote_decisions_trees/mod.rs @@ -0,0 +1,75 @@ +use crate::drive::votes::paths::{vote_decisions_tree_path, VOTE_DECISIONS_SUBTREE_KEYS}; +use crate::drive::Drive; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::{Element, TransactionArg}; + +impl Drive { + /// Creates the active polls and identity votes trees of the decisions branch when they are + /// missing. A chain upgrading to protocol version 15 calls this on its first block and a + /// chain born at 15 or later calls it from `create_initial_state_structure` v4, so both + /// populations build the same votes Merk. + pub fn insert_vote_decisions_trees( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + for subtree_key in VOTE_DECISIONS_SUBTREE_KEYS { + self.grove_insert_if_not_exists( + vote_decisions_tree_path().as_slice().into(), + &[subtree_key as u8], + Element::empty_tree(), + transaction, + None, + &platform_version.drive, + )?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::drive::votes::paths::{ + vote_decisions_active_polls_tree_path, vote_decisions_identity_votes_tree_path, + }; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::version::PlatformVersion; + + #[test] + fn should_create_the_decisions_trees_once_and_keep_them() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + // Genesis at the latest version already made them; a second run changes nothing. + let root_hash_before = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("root hash"); + drive + .insert_vote_decisions_trees(None, platform_version) + .expect("expected to insert the decisions trees"); + let root_hash_after = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("root hash"); + assert_eq!(root_hash_before, root_hash_after); + for path in [ + vote_decisions_active_polls_tree_path(), + vote_decisions_identity_votes_tree_path(), + ] { + let element = drive + .grove + .get( + &path[..2], + path[2], + None, + &platform_version.drive.grove_version, + ) + .unwrap() + .expect("expected the tree"); + assert!(element.is_any_tree()); + } + } +} diff --git a/packages/rs-drive/src/drive/votes/setup/mod.rs b/packages/rs-drive/src/drive/votes/setup/mod.rs index 00eb6e7ec30..b3391f792d9 100644 --- a/packages/rs-drive/src/drive/votes/setup/mod.rs +++ b/packages/rs-drive/src/drive/votes/setup/mod.rs @@ -1 +1,2 @@ +mod insert_vote_decisions_trees; mod setup_initial_vote_tree_main_structure; diff --git a/packages/rs-drive/src/drive/votes/storage_form/mod.rs b/packages/rs-drive/src/drive/votes/storage_form/mod.rs index a797ee8c5ba..c450b46062a 100644 --- a/packages/rs-drive/src/drive/votes/storage_form/mod.rs +++ b/packages/rs-drive/src/drive/votes/storage_form/mod.rs @@ -6,3 +6,6 @@ pub mod vote_storage_form; /// Module for handling the storage form of the reference of the contested document resources. pub mod contested_document_resource_reference_storage_form; + +/// Module for handling the storage form of a masternode's vote on a yes/no poll. +pub mod yes_no_vote_reference_storage_form; diff --git a/packages/rs-drive/src/drive/votes/storage_form/vote_storage_form/mod.rs b/packages/rs-drive/src/drive/votes/storage_form/vote_storage_form/mod.rs index 2d75ebe90ce..ab8453d88db 100644 --- a/packages/rs-drive/src/drive/votes/storage_form/vote_storage_form/mod.rs +++ b/packages/rs-drive/src/drive/votes/storage_form/vote_storage_form/mod.rs @@ -60,8 +60,11 @@ impl TreePathStorageForm for VoteStorageForm { CONTESTED_RESOURCE_TREE_KEY => Ok(VoteStorageForm::ContestedDocumentResource( ContestedDocumentResourceVoteStorageForm::try_from_tree_path(path)?, )), + // A yes/no vote is keyed by the hash of its poll, which a tree path cannot be + // turned back into. The identity votes index of the decisions branch stores the + // choice itself instead of a reference. VOTE_DECISIONS_TREE_KEY => Err(ProtocolError::NotSupported( - "decision votes not supported yet".to_string(), + "yes/no votes are not addressed by tree path".to_string(), )), _ => Err(ProtocolError::VoteError(format!( "path {} second element must be a byte for CONTESTED_RESOURCE_TREE_KEY {}, got {}", @@ -175,7 +178,7 @@ mod tests { }; match err { ProtocolError::NotSupported(msg) => { - assert!(msg.contains("decision votes"), "msg: {msg}"); + assert!(msg.contains("yes/no votes"), "msg: {msg}"); } other => panic!("expected NotSupported, got {other:?}"), } diff --git a/packages/rs-drive/src/drive/votes/storage_form/yes_no_vote_reference_storage_form/mod.rs b/packages/rs-drive/src/drive/votes/storage_form/yes_no_vote_reference_storage_form/mod.rs new file mode 100644 index 00000000000..c3f87bb8a81 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/storage_form/yes_no_vote_reference_storage_form/mod.rs @@ -0,0 +1,73 @@ +use crate::error::drive::DriveError; +use crate::error::Error; +use bincode::{Decode, DecodeUntrusted, Encode}; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; + +/// What the identity votes index of the decisions branch stores for one masternode's vote on +/// one yes/no poll: the answer, and how many times the masternode voted on the poll. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] +pub struct YesNoVoteReferenceStorageForm { + /// The masternode's current answer + pub vote_choice: YesNoAbstainVoteChoice, + /// How many times the masternode voted on the poll, counting this vote + pub identity_vote_times: u16, +} + +impl YesNoVoteReferenceStorageForm { + /// Encodes the form for storage. + pub fn serialize(&self) -> Result, bincode::error::EncodeError> { + bincode::encode_to_vec(self, bincode::config::standard().with_big_endian()) + } + + /// Decodes the form stored in the identity votes index, a stored form that does not + /// decode, or carries bytes past the form, being corrupted state, as the proof decoder + /// treats it. + pub fn deserialize(bytes: &[u8]) -> Result { + let corrupted = |reason: String| { + Error::Drive(DriveError::CorruptedSerialization(format!( + "serialization of yes/no vote reference {} is corrupted: {}", + hex::encode(bytes), + reason + ))) + }; + let (form, consumed) = + bincode::decode_from_slice(bytes, bincode::config::standard().with_big_endian()) + .map_err(|e| corrupted(e.to_string()))?; + if consumed != bytes.len() { + return Err(corrupted("it carries trailing bytes".to_string())); + } + Ok(form) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_round_trip() { + let form = YesNoVoteReferenceStorageForm { + vote_choice: YesNoAbstainVoteChoice::No, + identity_vote_times: 3, + }; + let bytes = form.serialize().expect("serialize"); + assert_eq!( + YesNoVoteReferenceStorageForm::deserialize(&bytes).expect("deserialize"), + form + ); + } + + #[test] + fn should_refuse_trailing_bytes() { + let form = YesNoVoteReferenceStorageForm { + vote_choice: YesNoAbstainVoteChoice::Yes, + identity_vote_times: 1, + }; + let mut bytes = form.serialize().expect("serialize"); + bytes.push(0); + assert!(matches!( + YesNoVoteReferenceStorageForm::deserialize(&bytes), + Err(Error::Drive(DriveError::CorruptedSerialization(_))) + )); + } +} diff --git a/packages/rs-drive/src/drive/votes/structure.rs b/packages/rs-drive/src/drive/votes/structure.rs index 9163214b1bb..3d517c22794 100644 --- a/packages/rs-drive/src/drive/votes/structure.rs +++ b/packages/rs-drive/src/drive/votes/structure.rs @@ -3,7 +3,9 @@ use crate::drive::votes::paths::{ CONTESTED_DOCUMENT_STORAGE_TREE_KEY, CONTESTED_RESOURCE_TREE_KEY, END_DATE_QUERIES_TREE_KEY, IDENTITY_VOTES_TREE_KEY, RESOURCE_ABSTAIN_VOTE_TREE_KEY_U8_32, RESOURCE_LOCK_VOTE_TREE_KEY_U8_32, RESOURCE_STORED_INFO_KEY_U8_32, VOTE_DECISIONS_TREE_KEY, - VOTING_STORAGE_TREE_KEY, + VOTING_STORAGE_TREE_KEY, YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY, + YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY, YES_NO_VOTE_POLL_STORED_INFO_KEY, + YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY, }; use crate::drive::RootTree; use crate::structure::{ElementKind, FlagsKind, KeyEncoding, KeyMatcher, StructureNode}; @@ -12,7 +14,8 @@ const SOURCE: &str = "packages/rs-drive/src/drive/votes/paths.rs"; const CONTENDER_FLAGS: &str = "The owner is the contender whose document created the level, who is \ refunded when the poll is cleaned up. Written without storage flags, it carries none."; -const POLL_FLAGS: &str = "The owner is the identity whose contested document started the poll."; +const POLL_FLAGS: &str = "The owner is the identity whose contested document started the \ + poll. A yes/no poll is opened by the system and carries none."; const OWNED: [FlagsKind; 2] = [FlagsKind::EpochOwned, FlagsKind::None]; const CONTESTED_DOCUMENT: &str = "votes.contested_resource.active_polls.contract.document_type.storage.document"; @@ -61,8 +64,8 @@ pub(crate) fn structure() -> StructureNode { .kind(ElementKind::Tree) .source("packages/rs-drive/src/drive/mod.rs") .describe( - "Masternode votes, today on contested resources \ - such as premium DPNS names.", + "Masternode votes: on contested resources such as \ + premium DPNS names, and on yes/no decisions.", ) .children(vec![ StructureNode::fixed( @@ -89,9 +92,11 @@ pub(crate) fn structure() -> StructureNode { .kind(ElementKind::Tree) .source(SOURCE) .describe( - "Reserved for polls that decide something for the \ - network. Nothing writes to it yet.", - ), + "Yes/no polls: decisions taken by a supermajority \ + of the masternodes once enough voting power was \ + cast.", + ) + .children(vec![decision_identity_votes(), decision_active_polls()]), StructureNode::fixed( "end_date_queries", &[END_DATE_QUERIES_TREE_KEY as u8], @@ -115,7 +120,7 @@ pub(crate) fn structure() -> StructureNode { with the sign bit flipped", ) .kind(ElementKind::Tree) - .flags(&[FlagsKind::EpochOwned], POLL_FLAGS) + .flags(&OWNED, POLL_FLAGS) .describe("The polls ending at this time.") .child( StructureNode::dynamic( @@ -123,10 +128,12 @@ pub(crate) fn structure() -> StructureNode { "vote_poll_id", KeyMatcher::Len(32), KeyEncoding::Hash32, - "The double sha256 of the serialized vote poll", + "The poll's unique id: for a contest the double sha256 of the \ + serialized poll, for a yes/no poll that of the yes/no \ + domain tag followed by the serialized vote poll", ) .kind(ElementKind::Item) - .flags(&[FlagsKind::EpochOwned], POLL_FLAGS) + .flags(&OWNED, POLL_FLAGS) .value("serialized VotePoll") .describe("One poll ending at this time."), ), @@ -328,3 +335,135 @@ fn index_value() -> StructureNode { ), ]) } + +fn decision_vote_choice( + segment: &str, + key: u8, + label: &str, + constant: &str, + what: &str, +) -> StructureNode { + StructureNode::fixed(segment, &[key], label, constant) + .kind(ElementKind::SumTree) + .until_deleted() + .describe(what) + .child( + StructureNode::identifier( + "voter", + "pro_tx_hash", + "The voting masternode's pro tx hash", + ) + .kind(ElementKind::SumItem) + .value("vote strength: 1 for a masternode, 4 for an evonode") + .describe("One masternode's vote; the tree's sum is the tally."), + ) +} + +fn decision_active_polls() -> StructureNode { + StructureNode::fixed( + "active_polls", + &[ACTIVE_POLLS_TREE_KEY as u8], + "DecisionActivePolls", + "ACTIVE_POLLS_TREE_KEY", + ) + .ascii() + .kind(ElementKind::Tree) + .since(15) + .source(SOURCE) + .describe("The yes/no polls, by their unique id.") + .child( + StructureNode::dynamic( + "poll", + "vote_poll_id", + KeyMatcher::Len(32), + KeyEncoding::Hash32, + "The double sha256 of the domain tag `dash platform yes/no vote \ + poll id` followed by the serialized vote poll", + ) + .kind(ElementKind::Tree) + .describe( + "One yes/no poll: its stored info and its three \ + vote sum trees. The vote trees go when the poll \ + ends; the stored info stays as the record of the \ + decision.", + ) + .children(vec![ + StructureNode::fixed( + "stored_info", + &[YES_NO_VOTE_POLL_STORED_INFO_KEY], + "YesNoStoredInfo", + "YES_NO_VOTE_POLL_STORED_INFO_KEY", + ) + .kind(ElementKind::Item) + .value("serialized YesNoVotePollStoredInfo") + .describe("The poll's status and, once it ends, its result."), + decision_vote_choice( + "yes", + YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY, + "YesVotes", + "YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY", + "Votes for yes.", + ), + decision_vote_choice( + "no", + YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY, + "NoVotes", + "YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY", + "Votes for no.", + ), + decision_vote_choice( + "abstain", + YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY, + "AbstainVotes", + "YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY", + "Votes to abstain, which count towards nothing.", + ), + ]), + ) +} + +fn decision_identity_votes() -> StructureNode { + StructureNode::fixed( + "identity_votes", + &[IDENTITY_VOTES_TREE_KEY as u8], + "DecisionIdentityVotes", + "IDENTITY_VOTES_TREE_KEY", + ) + .ascii() + .kind(ElementKind::Tree) + .since(15) + .source(SOURCE) + .describe( + "The yes/no votes each masternode cast, so they can \ + be changed and removed with the masternode.", + ) + .child( + StructureNode::identifier( + "voter", + "pro_tx_hash", + "The voting masternode's pro tx hash", + ) + .kind(ElementKind::Tree) + .describe( + "One masternode's yes/no votes. Stays when its polls \ + close, and is dropped when the masternode leaves the list.", + ) + .child( + StructureNode::dynamic( + "vote", + "vote_poll_id", + KeyMatcher::Len(32), + KeyEncoding::Hash32, + "The double sha256 of the domain tag `dash platform yes/no vote \ + poll id` followed by the serialized vote poll", + ) + .kind(ElementKind::Item) + .value( + "bincode YesNoVoteReferenceStorageForm: the \ + choice and how many times the masternode voted \ + on the poll", + ) + .describe("The masternode's current answer to one poll."), + ), + ) +} diff --git a/packages/rs-drive/src/fees/op.rs b/packages/rs-drive/src/fees/op.rs index 5550da343be..3793a5ede9b 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -9,6 +9,7 @@ use enum_map::Enum; use grovedb::batch::key_info::KeyInfo; use grovedb::batch::GroveOp; use grovedb::batch::KeyInfoPath; +use grovedb::batch::SubelementsDeletionBehavior; use grovedb::element::reference_path::ReferencePathType; use grovedb::element::IndexAxis; use grovedb::element::MaxReferenceHop; @@ -710,6 +711,26 @@ impl LowLevelDriveOperation { .collect() } + /// Sets `GroveOperation` for dropping the tree at the given path and key with everything in + /// it, without reading or deleting its elements one by one: GroveDB's flat drop + /// (`SubelementsDeletionBehavior::DropFlat`), which a batch accepts from `GROVE_V4`. Only for a + /// tree of `tree_type` that holds no subtree and whose elements no reference points at. + pub fn for_known_path_key_drop_flat_tree( + path: Vec>, + key: Vec, + tree_type: TreeType, + ) -> Self { + GroveOperation( + QualifiedGroveDbOp::delete_tree_op( + path, + key, + tree_type, + SubelementsDeletionBehavior::DropFlat, + ) + .dont_check_for_backwards_references(), + ) + } + /// Sets `GroveOperation` for inserting an empty tree at the given path and key pub fn for_known_path_key_empty_tree( path: Vec>, diff --git a/packages/rs-drive/src/prove/prove_multiple_state_transition_results/v0/mod.rs b/packages/rs-drive/src/prove/prove_multiple_state_transition_results/v0/mod.rs index cc18405f0a6..26e90388295 100644 --- a/packages/rs-drive/src/prove/prove_multiple_state_transition_results/v0/mod.rs +++ b/packages/rs-drive/src/prove/prove_multiple_state_transition_results/v0/mod.rs @@ -115,7 +115,7 @@ impl Drive { path_queries.extend(vote_queries.iter().filter_map(|vote_query| { // The path query construction can only fail if the serialization fails. // Because the serialization will pretty much never fail, we can do this. - let mut path_query = vote_query.construct_path_query().ok()?; + let mut path_query = vote_query.construct_path_query(platform_version).ok()?; path_query.query.limit = None; Some(path_query) })); diff --git a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs index 08e77057ecc..9ac03177827 100644 --- a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs +++ b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs @@ -48,7 +48,9 @@ use dpp::state_transition::masternode_vote_transition::accessors::MasternodeVote use dpp::state_transition::StateTransitionIdentityIdFromInputs; use dpp::state_transition::StateTransitionWitnessSigned; use dpp::state_transition::{StateTransition, StateTransitionLike, StateTransitionOwned}; +use dpp::voting::vote_polls::VotePoll; use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; +use dpp::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; use dpp::voting::votes::Vote; use grovedb::{PathQuery, TransactionArg}; use platform_version::version::PlatformVersion; @@ -513,20 +515,25 @@ impl Drive { StateTransition::MasternodeVote(st) => { let pro_tx_hash = st.pro_tx_hash(); - match st.vote() { - Vote::ResourceVote(resource_vote) => { - let query = IdentityBasedVoteDriveQuery { - identity_id: pro_tx_hash, - vote_poll: resource_vote.vote_poll().clone(), - }; - - // The path query construction can only fail if the serialization fails. - // Because the serialization will pretty much never fail, we can do this. - let mut path_query = query.construct_path_query()?; - path_query.query.limit = None; - path_query + // Shared by versions 0 and 1, and off the block execution path (it answers + // proof queries). A resource vote on a contested poll gets the same path query + // as before; a vote naming a yes/no poll decodes only from protocol version 15. + let vote_poll = match st.vote() { + Vote::ResourceVote(resource_vote) => resource_vote.vote_poll().clone(), + Vote::YesNoVote(yes_no_vote) => { + VotePoll::YesNoVotePoll(yes_no_vote.vote_poll().clone()) } - } + }; + let query = IdentityBasedVoteDriveQuery { + identity_id: pro_tx_hash, + vote_poll, + }; + + // The path query construction can only fail if the serialization fails. + // Because the serialization will pretty much never fail, we can do this. + let mut path_query = query.construct_path_query(platform_version)?; + path_query.query.limit = None; + path_query } StateTransition::IdentityCreditTransferToAddresses(st) => { let identity_query = Drive::revision_and_balance_path_query( diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 7352a30a02a..fcfd8c7f9c8 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -215,6 +215,10 @@ pub mod vote_polls_by_end_date_query; /// Vote polls by document type query pub mod vote_polls_by_document_type_query; +/// Yes/no vote poll state query +#[cfg(any(feature = "server", feature = "verify"))] +pub mod yes_no_vote_poll_state_query; + /// Function type for looking up a contract by identifier /// /// This function is used to look up a contract by its identifier. diff --git a/packages/rs-drive/src/query/vote_query.rs b/packages/rs-drive/src/query/vote_query.rs index c49927ae108..6ccf484f345 100644 --- a/packages/rs-drive/src/query/vote_query.rs +++ b/packages/rs-drive/src/query/vote_query.rs @@ -1,10 +1,14 @@ -use crate::drive::votes::paths::vote_contested_resource_identity_votes_tree_path_for_identity_vec; +use crate::drive::votes::paths::{ + vote_contested_resource_identity_votes_tree_path_for_identity_vec, + vote_decisions_identity_votes_tree_path_for_identity_vec, +}; use crate::error::Error; use crate::query::Query; use bincode::{Decode, Encode}; use dpp::identifier::Identifier; use dpp::voting::vote_polls::VotePoll; use grovedb::{PathQuery, SizedQuery}; +use platform_version::version::PlatformVersion; /// Vote Drive Query struct #[derive(Debug, PartialEq, Clone, Encode, Decode)] @@ -16,14 +20,25 @@ pub struct IdentityBasedVoteDriveQuery { } impl IdentityBasedVoteDriveQuery { - /// Operations to construct a path query. - pub fn construct_path_query(&self) -> Result { - // First we should get the overall document_type_path - let path = vote_contested_resource_identity_votes_tree_path_for_identity_vec( - self.identity_id.as_bytes(), - ); + /// Operations to construct a path query. The platform version selects how a yes/no poll's + /// id is derived. + pub fn construct_path_query( + &self, + platform_version: &PlatformVersion, + ) -> Result { + // Each poll kind indexes the votes of a masternode under its own branch + let path = match &self.vote_poll { + VotePoll::ContestedDocumentResourceVotePoll(_) => { + vote_contested_resource_identity_votes_tree_path_for_identity_vec( + self.identity_id.as_bytes(), + ) + } + VotePoll::YesNoVotePoll(_) => vote_decisions_identity_votes_tree_path_for_identity_vec( + self.identity_id.as_bytes(), + ), + }; - let vote_id = self.vote_poll.unique_id()?; + let vote_id = self.vote_poll.unique_id(platform_version)?; let mut query = Query::new(); query.insert_key(vote_id.to_vec()); diff --git a/packages/rs-drive/src/query/yes_no_vote_poll_state_query.rs b/packages/rs-drive/src/query/yes_no_vote_poll_state_query.rs new file mode 100644 index 00000000000..503a396641d --- /dev/null +++ b/packages/rs-drive/src/query/yes_no_vote_poll_state_query.rs @@ -0,0 +1,274 @@ +use crate::drive::votes::paths::{YesNoVotePollPaths, YES_NO_VOTE_POLL_STORED_INFO_KEY}; +use crate::drive::votes::YesNoAbstainVoteChoiceToKeyTrait; +#[cfg(feature = "server")] +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +#[cfg(feature = "server")] +use crate::fees::op::LowLevelDriveOperation; +#[cfg(feature = "server")] +use crate::query::GroveError; +use crate::query::Query; +use dpp::serialization::PlatformDeserializableUntrusted; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::{VotingPower, YesNoVotePoll}; +#[cfg(feature = "server")] +use grovedb::query_result_type::QueryResultType; +#[cfg(feature = "server")] +use grovedb::TransactionArg; +use grovedb::{Element, PathQuery, SizedQuery}; +use platform_version::version::PlatformVersion; + +/// The state of one yes/no vote poll: its stored info and its tallies. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct YesNoVotePollState { + /// The poll's status and, once finished, its result. `None` when the poll was never opened. + pub stored_info: Option, + /// The yes voting power cast so far. Zero once the poll is finished and its votes removed. + pub yes_voting_power: VotingPower, + /// The no voting power cast so far. Zero once the poll is finished and its votes removed. + pub no_voting_power: VotingPower, + /// The abstaining voting power cast so far. Zero once the poll is finished and its votes + /// removed. + pub abstain_voting_power: VotingPower, +} + +/// The query for the state of a yes/no vote poll: its stored info item and its three vote sum +/// trees, whose sums are the tallies. +#[derive(Debug, PartialEq, Clone)] +pub struct YesNoVotePollStateDriveQuery { + /// The poll asked about + pub vote_poll: YesNoVotePoll, +} + +impl YesNoVotePollStateDriveQuery { + /// The path query: the four keys of the poll's tree, whose path the platform version + /// selects how to derive. + pub fn construct_path_query( + &self, + platform_version: &PlatformVersion, + ) -> Result { + let path = self.vote_poll.poll_path_vec(platform_version)?; + let mut query = Query::new_with_direction(true); + query.insert_key(vec![YES_NO_VOTE_POLL_STORED_INFO_KEY]); + for vote_choice in YesNoAbstainVoteChoice::ALL { + query.insert_key(vec![vote_choice.to_tree_key()]); + } + // Four keys at most; absence proofs need the limit stated. + Ok(PathQuery::new( + path, + SizedQuery::new( + query, + Some(1 + YesNoAbstainVoteChoice::ALL.len() as u16), + None, + ), + )) + } + + /// Reads the state out of the elements of the poll's tree, by key. Keys that are absent + /// leave their part of the state empty: a poll that was never opened has no stored info, + /// and a finished poll has no vote trees. + pub(crate) fn state_from_key_elements( + key_elements: impl IntoIterator, Element)>, + ) -> Result { + let mut state = YesNoVotePollState::default(); + for (key, element) in key_elements { + let [key_byte] = key.as_slice() else { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "unexpected key {} in yes/no vote poll tree", + hex::encode(&key) + )))); + }; + if *key_byte == YES_NO_VOTE_POLL_STORED_INFO_KEY { + let bytes = element.into_item_bytes()?; + state.stored_info = Some( + YesNoVotePollStoredInfo::deserialize_from_bytes_untrusted(&bytes)?, + ); + continue; + } + let Some(vote_choice) = YesNoAbstainVoteChoice::from_tree_key(*key_byte) else { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "unexpected key {} in yes/no vote poll tree", + hex::encode(&key) + )))); + }; + let Element::SumTree(_, sum, _) = element else { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "the {} votes of a yes/no vote poll must be a sum tree", + vote_choice + )))); + }; + let voting_power: VotingPower = sum.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState(format!( + "the {} voting power of a yes/no vote poll must be between 0 and u32::MAX, got {}", + vote_choice, sum + ))) + })?; + match vote_choice { + YesNoAbstainVoteChoice::Yes => state.yes_voting_power = voting_power, + YesNoAbstainVoteChoice::No => state.no_voting_power = voting_power, + YesNoAbstainVoteChoice::Abstain => state.abstain_voting_power = voting_power, + } + } + Ok(state) + } + + /// Refuses the query where yes/no polls are not active (`fetch_yes_no_vote_poll_state` is + /// `None` before protocol version 15), as the proof verifier does there. + #[cfg(feature = "server")] + fn ensure_active(platform_version: &PlatformVersion) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .fetch_yes_no_vote_poll_state + { + Some(0) => Ok(()), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_yes_no_vote_poll_state".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "fetch_yes_no_vote_poll_state".to_string(), + known_versions: vec![0], + })), + } + } + + /// Executes the query and returns the proof. + #[cfg(feature = "server")] + pub fn execute_with_proof( + &self, + drive: &Drive, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + Self::ensure_active(platform_version)?; + let path_query = self.construct_path_query(platform_version)?; + drive.grove_get_proved_path_query( + &path_query, + transaction, + drive_operations, + &platform_version.drive, + ) + } + + /// Executes the query and returns the state. + #[cfg(feature = "server")] + pub fn execute_no_proof( + &self, + drive: &Drive, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + Self::ensure_active(platform_version)?; + // A poll that was never opened has no tree (a chain without the decisions trees has no + // parent either); the proof path reports the same as an empty state, so does this one. + let path_query = self.construct_path_query(platform_version)?; + let key_elements = match drive.grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryKeyElementPairResultType, + drive_operations, + &platform_version.drive, + ) { + Ok((query_result_elements, _)) => query_result_elements.to_key_elements(), + Err(Error::GroveDB(error)) + if matches!( + error.as_ref(), + GroveError::PathKeyNotFound(_) + | GroveError::PathNotFound(_) + | GroveError::PathParentLayerNotFound(_) + ) => + { + return Ok(YesNoVotePollState::default()); + } + Err(error) => return Err(error), + }; + Self::state_from_key_elements(key_elements) + } +} + +#[cfg(all(test, feature = "server", feature = "verify"))] +mod tests { + use super::*; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use crate::util::test_helpers::{open_yes_no_vote_poll_with_votes, test_yes_no_vote_poll}; + use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStatus; + + fn poll() -> YesNoVotePoll { + test_yes_no_vote_poll() + } + + #[test] + fn should_prove_and_verify_the_state_of_a_poll_with_votes() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let vote_poll = poll(); + open_yes_no_vote_poll_with_votes( + &drive, + &vote_poll, + &[ + ([1u8; 32], 4, YesNoAbstainVoteChoice::Yes), + ([2u8; 32], 1, YesNoAbstainVoteChoice::Yes), + ([3u8; 32], 1, YesNoAbstainVoteChoice::No), + ], + platform_version, + ); + let query = YesNoVotePollStateDriveQuery { vote_poll }; + let state = query + .execute_no_proof(&drive, None, &mut vec![], platform_version) + .expect("expected the state"); + assert_eq!( + ( + state.yes_voting_power, + state.no_voting_power, + state.abstain_voting_power + ), + (5, 1, 0) + ); + assert!(matches!( + state.stored_info.as_ref().expect("stored info").status(), + YesNoVotePollStatus::Started(_) + )); + + let proof = query + .execute_with_proof(&drive, None, &mut vec![], platform_version) + .expect("expected a proof"); + let (root_hash, proved_state) = query + .verify_yes_no_vote_poll_state_proof(&proof, platform_version) + .expect("expected the proof to verify"); + assert_eq!( + root_hash, + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("root hash") + ); + assert_eq!(proved_state, state); + } + + #[test] + fn should_prove_and_verify_the_absence_of_a_poll() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let query = YesNoVotePollStateDriveQuery { vote_poll: poll() }; + let proof = query + .execute_with_proof(&drive, None, &mut vec![], platform_version) + .expect("expected a proof"); + let (_, proved_state) = query + .verify_yes_no_vote_poll_state_proof(&proof, platform_version) + .expect("expected the proof to verify"); + assert_eq!(proved_state, YesNoVotePollState::default()); + let state = query + .execute_no_proof(&drive, None, &mut vec![], platform_version) + .expect("expected the state of an absent poll"); + assert_eq!(state, YesNoVotePollState::default()); + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/masternode_vote_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/masternode_vote_transition.rs index 37740ef7900..3c5ff479b36 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/masternode_vote_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/masternode_vote_transition.rs @@ -31,7 +31,9 @@ impl DriveHighLevelOperationConverter for MasternodeVoteTransitionAction { let previous_resource_vote_choice_to_remove = self.take_previous_resource_vote_choice_to_remove(); let vote = self.vote_owned(); - let prefunded_specialized_balance_id = vote.specialized_balance_id()?.ok_or(Error::Protocol(Box::new(ProtocolError::VoteError("vote does not have a specialized balance from where it can use to pay for processing (this should have been caught during validation)".to_string()))))?; + // Only a yes/no poll's id reads the platform version, and no vote naming one + // decodes before protocol version 15. + let prefunded_specialized_balance_id = vote.specialized_balance_id(platform_version)?.ok_or(Error::Protocol(Box::new(ProtocolError::VoteError("vote does not have a specialized balance from where it can use to pay for processing (this should have been caught during validation)".to_string()))))?; let drive_operations = vec![ IdentityOperation(IdentityOperationType::UpdateIdentityNonce { @@ -183,6 +185,7 @@ mod tests { assert_eq!(v0.resource_vote_choice, ResourceVoteChoice::Abstain); } }, + ResolvedVote::YesNoVote(_) => panic!("expected a resource vote"), } } other => panic!("expected MasternodeCastVote, got {:?}", other), @@ -197,7 +200,7 @@ mod tests { // Build the vote and compute its expected balance ID before it is consumed let vote = make_resolved_vote(); let expected_balance_id = vote - .specialized_balance_id() + .specialized_balance_id(platform_version) .expect("expected specialized balance id") .expect("expected Some balance id"); @@ -291,6 +294,7 @@ mod tests { assert_eq!(v0.resource_vote_choice, ResourceVoteChoice::Abstain); } }, + ResolvedVote::YesNoVote(_) => panic!("expected a resource vote"), } } other => panic!("expected MasternodeCastVote, got {:?}", other), diff --git a/packages/rs-drive/src/state_transition_action/identity/masternode_vote/v0/mod.rs b/packages/rs-drive/src/state_transition_action/identity/masternode_vote/v0/mod.rs index 9461bb882cb..55ed2e1bf27 100644 --- a/packages/rs-drive/src/state_transition_action/identity/masternode_vote/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/identity/masternode_vote/v0/mod.rs @@ -6,7 +6,7 @@ use dpp::prelude::IdentityNonce; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; /// The previous vote count -pub type PreviousVoteCount = u16; +pub use crate::drive::votes::PreviousVoteCount; /// action v0 #[derive(Debug, Clone)] diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 46cc279415e..978dd03afc1 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -299,6 +299,7 @@ mod fixtures { use crate::util::storage_flags::StorageFlags; use crate::util::test_helpers::setup::setup_document; use crate::util::test_helpers::setup_contract; + use crate::util::test_helpers::{open_yes_no_vote_poll_with_votes, test_yes_no_vote_poll}; use dpp::address_funds::PlatformAddress; use dpp::block::block_info::BlockInfo; use crate::drive::credit_pools::epochs::paths::EpochProposers; @@ -362,6 +363,7 @@ mod fixtures { use dpp::tokens::status::TokenStatus; use dpp::tokens::token_event::TokenEvent; use dpp::tokens::token_pricing_schedule::TokenPricingSchedule; + use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfo; use rand::rngs::StdRng; use rand::SeedableRng; @@ -1399,6 +1401,24 @@ mod fixtures { conformance_of(&drive, "contested_documents", run); } + /// A yes/no vote poll with one vote per choice, so every node of the decisions branch is + /// reached: the poll's stored info and vote trees, and the voters' index entries. + fn yes_no_vote_polls(run: &mut FixtureRun) { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + open_yes_no_vote_poll_with_votes( + &drive, + &test_yes_no_vote_poll(), + &[ + ([1; 32], 4, YesNoAbstainVoteChoice::Yes), + ([2; 32], 1, YesNoAbstainVoteChoice::No), + ([3; 32], 1, YesNoAbstainVoteChoice::Abstain), + ], + platform_version, + ); + conformance_of(&drive, "yes_no_vote_polls", run); + } + fn apply_operations(drive: &Drive, operations: Vec) { drive .apply_batch_low_level_drive_operations( @@ -1859,6 +1879,7 @@ mod fixtures { core_anchored_withdrawal_accounting(&mut run); current_then_paid_epoch(&mut run); contested_documents(&mut run); + yes_no_vote_polls(&mut run); token_distributions(&mut run); contract_groups_and_bound_keys(&mut run); spent_nullifiers(&mut run); diff --git a/packages/rs-drive/src/util/test_helpers/mod.rs b/packages/rs-drive/src/util/test_helpers/mod.rs index 201d7d76d45..8923cd5d52f 100644 --- a/packages/rs-drive/src/util/test_helpers/mod.rs +++ b/packages/rs-drive/src/util/test_helpers/mod.rs @@ -49,6 +49,8 @@ use ciborium::value::Value; #[cfg(test)] use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +#[cfg(all(test, feature = "server"))] +use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; #[cfg(test)] use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; #[cfg(test)] @@ -65,10 +67,16 @@ use dpp::data_contract::document_type::random_document::{ use dpp::document::DocumentV0Setters; #[cfg(test)] use dpp::platform_value; +#[cfg(all(test, feature = "server"))] +use dpp::platform_value::BinaryData; #[cfg(test)] use dpp::platform_value::Bytes32; +#[cfg(all(test, feature = "server"))] +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; #[cfg(test)] use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfo; +#[cfg(all(test, feature = "server"))] +use dpp::voting::vote_polls::yes_no_vote_poll::{YesNoMinimumVotingPower, YesNoVotePoll}; #[cfg(test)] use rand::rngs::StdRng; #[cfg(test)] @@ -242,6 +250,54 @@ pub(crate) fn add_dpns_name_contenders( vote_poll } +#[cfg(all(test, feature = "server"))] +/// The yes/no poll rs-drive's tests open: it passes on more than two thirds of yes over yes plus +/// no, with at least 400 voting power cast. +pub(crate) fn test_yes_no_vote_poll() -> YesNoVotePoll { + YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![0xc1; 32])], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute { voting_power: 400 }, + } +} + +#[cfg(all(test, feature = "server"))] +/// Opens `vote_poll` to end at 1_000, written straight to Drive at the default block with no +/// validation, and registers each `(voter, strength, choice)` vote on it as a first vote. +pub(crate) fn open_yes_no_vote_poll_with_votes( + drive: &Drive, + vote_poll: &YesNoVotePoll, + votes: &[([u8; 32], u8, YesNoAbstainVoteChoice)], + platform_version: &PlatformVersion, +) { + drive + .open_yes_no_vote_poll( + vote_poll, + 1_000, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected to open the yes/no vote poll"); + for (voter, strength, vote_choice) in votes { + drive + .register_yes_no_identity_vote( + *voter, + *strength, + ResolvedYesNoVote { + vote_poll: vote_poll.clone(), + vote_choice: *vote_choice, + previous_vote_choice_to_remove: None, + }, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected to register the vote"); + } +} + #[cfg(test)] /// Serializes a hex string to CBOR. pub fn cbor_from_hex(hex_string: String) -> Vec { diff --git a/packages/rs-drive/src/verify/bounded_decode.rs b/packages/rs-drive/src/verify/bounded_decode.rs index d86f1f99c5c..e3b63f488ea 100644 --- a/packages/rs-drive/src/verify/bounded_decode.rs +++ b/packages/rs-drive/src/verify/bounded_decode.rs @@ -1,4 +1,5 @@ use crate::drive::votes::storage_form::contested_document_resource_reference_storage_form::ContestedDocumentResourceVoteReferenceStorageForm; +use crate::drive::votes::storage_form::yes_no_vote_reference_storage_form::YesNoVoteReferenceStorageForm; use crate::error::drive::DriveError; use crate::error::Error; use dpp::bincode; @@ -89,6 +90,20 @@ pub(super) fn decode_proof_data_contract( pub(super) fn decode_vote_reference( serialized_reference: &[u8], ) -> Result { + decode_bounded_vote_reference(serialized_reference) +} + +/// A masternode's proved yes/no vote, decoded under the same budget and exact-length rule as a +/// contested vote reference. +pub(super) fn decode_yes_no_vote_reference( + serialized_reference: &[u8], +) -> Result { + decode_bounded_vote_reference(serialized_reference) +} + +fn decode_bounded_vote_reference>( + serialized_reference: &[u8], +) -> Result { if serialized_reference.len() > MAX_VOTE_REFERENCE_DECODE_BYTES { return Err(Error::Drive(DriveError::CorruptedSerialization( "serialized vote reference exceeds the proof decoding limit".to_string(), diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 1ee41e54f6f..3937f173b5f 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -1779,31 +1779,50 @@ impl Drive { StateTransition::MasternodeVote(masternode_vote) => { let pro_tx_hash = masternode_vote.pro_tx_hash(); let vote = masternode_vote.vote(); - let contract = match vote { - Vote::ResourceVote(resource_vote) => match resource_vote.vote_poll() { - VotePoll::ContestedDocumentResourceVotePoll( - contested_document_resource_vote_poll, - ) => known_contracts_provider_fn( - &contested_document_resource_vote_poll.contract_id, + // Shared by versions 0 and 1, and client side only. A resource vote on a + // contested poll verifies exactly as before; a vote naming a yes/no poll exists + // only from protocol version 15. + let (root_hash, vote) = match vote { + Vote::ResourceVote(resource_vote) => { + let contract = match resource_vote.vote_poll() { + VotePoll::ContestedDocumentResourceVotePoll( + contested_document_resource_vote_poll, + ) => known_contracts_provider_fn( + &contested_document_resource_vote_poll.contract_id, + )? + .ok_or(Error::Proof( + ProofError::UnknownContract(format!( + "unknown contract with id {} in resource vote verification", + contested_document_resource_vote_poll.contract_id + )), + ))?, + VotePoll::YesNoVotePoll(_) => { + return Err(Error::Proof(ProofError::InvalidTransition( + "a resource vote cannot answer a yes/no vote poll".to_string(), + ))) + } + }; + // we expect to get a vote that matches the state transition + Drive::verify_masternode_vote( + proof, + pro_tx_hash.to_buffer(), + vote, + &contract, + false, + platform_version, )? - .ok_or(Error::Proof( - ProofError::UnknownContract(format!( - "unknown contract with id {} in resource vote verification", - contested_document_resource_vote_poll.contract_id - )), - ))?, - }, + } + Vote::YesNoVote(yes_no_vote) => { + let (root_hash, proved_vote) = Drive::verify_masternode_yes_no_vote( + proof, + pro_tx_hash.to_buffer(), + yes_no_vote, + false, + platform_version, + )?; + (root_hash, proved_vote.map(Vote::YesNoVote)) + } }; - - // we expect to get a vote that matches the state transition - let (root_hash, vote) = Drive::verify_masternode_vote( - proof, - pro_tx_hash.to_buffer(), - vote, - &contract, - false, - platform_version, - )?; let vote = vote.ok_or(Error::Proof(ProofError::IncorrectProof(format!("proof did not contain actual vote for masternode {} expected to exist because of state transition (masternode vote)", masternode_vote.pro_tx_hash()))))?; Ok((root_hash, VerifiedMasternodeVote(vote))) } @@ -5358,6 +5377,101 @@ mod tests { ); } + // --- MasternodeVote on a yes/no poll: the prover's path query and the verifier agree, + // so a registered vote is proved executed and an unregistered one is refused. + #[test] + fn should_verify_a_yes_no_masternode_vote_executed_and_refuse_one_never_cast() { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use crate::util::test_helpers::{open_yes_no_vote_poll_with_votes, test_yes_no_vote_poll}; + use dpp::state_transition::masternode_vote_transition::v0::MasternodeVoteTransitionV0; + use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; + use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; + use dpp::voting::votes::yes_no_vote::v0::YesNoVoteV0; + use dpp::voting::votes::yes_no_vote::YesNoVote; + use dpp::voting::votes::Vote; + + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let vote_poll = test_yes_no_vote_poll(); + let voter = [1u8; 32]; + open_yes_no_vote_poll_with_votes( + &drive, + &vote_poll, + &[(voter, 4, YesNoAbstainVoteChoice::Yes)], + platform_version, + ); + + let transition_for = |voter: [u8; 32], vote_choice: YesNoAbstainVoteChoice| { + StateTransition::MasternodeVote(MasternodeVoteTransition::V0( + MasternodeVoteTransitionV0 { + pro_tx_hash: dpp::prelude::Identifier::from(voter), + voter_identity_id: dpp::prelude::Identifier::from([2u8; 32]), + vote: Vote::YesNoVote(YesNoVote::V0(YesNoVoteV0 { + vote_poll: vote_poll.clone(), + vote_choice, + })), + nonce: 1, + signature_public_key_id: 0, + signature: Default::default(), + }, + )) + }; + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(None); + + let executed = transition_for(voter, YesNoAbstainVoteChoice::Yes); + let proof = drive + .prove_state_transition(&executed, None, platform_version) + .expect("expected to prove the vote") + .into_data() + .expect("expected proof bytes"); + let (root_hash, verified) = Drive::verify_state_transition_was_executed_with_proof( + &executed, + &BlockInfo::default(), + &proof, + known_contracts_provider_fn, + platform_version, + ) + .expect("expected the executed vote to verify"); + assert_eq!( + root_hash, + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("root hash") + ); + let StateTransition::MasternodeVote(masternode_vote) = &executed else { + unreachable!() + }; + assert_eq!( + verified.into_result(), + VerifiedMasternodeVote(masternode_vote.vote().clone()) + ); + + // A vote the state does not hold is not proved executed. + let not_executed = transition_for([9u8; 32], YesNoAbstainVoteChoice::No); + let proof = drive + .prove_state_transition(¬_executed, None, platform_version) + .expect("expected to prove the absence") + .into_data() + .expect("expected proof bytes"); + let result = Drive::verify_state_transition_was_executed_with_proof( + ¬_executed, + &BlockInfo::default(), + &proof, + known_contracts_provider_fn, + platform_version, + ); + assert!( + matches!( + result, + Err(crate::error::Error::Proof(ProofError::IncorrectProof(_))) + ), + "expected IncorrectProof, got {:?}", + result + ); + } + // --- MasternodeVote: unknown contract returns a specific // `ProofError::UnknownContract` because the provider returns None. #[test] diff --git a/packages/rs-drive/src/verify/voting/mod.rs b/packages/rs-drive/src/verify/voting/mod.rs index 28169ef1537..edfab9061b9 100644 --- a/packages/rs-drive/src/verify/voting/mod.rs +++ b/packages/rs-drive/src/verify/voting/mod.rs @@ -3,7 +3,9 @@ mod verify_contests_proof; mod verify_identity_votes_given_proof; mod verify_masternode_vote; +mod verify_masternode_yes_no_vote; mod verify_specialized_balance; mod verify_vote_poll_vote_state_proof; mod verify_vote_poll_votes_proof; mod verify_vote_polls_end_date_query; +mod verify_yes_no_vote_poll_state_proof; diff --git a/packages/rs-drive/src/verify/voting/verify_masternode_vote/mod.rs b/packages/rs-drive/src/verify/voting/verify_masternode_vote/mod.rs index 4c5669a67ec..8a7afe7dc56 100644 --- a/packages/rs-drive/src/verify/voting/verify_masternode_vote/mod.rs +++ b/packages/rs-drive/src/verify/voting/verify_masternode_vote/mod.rs @@ -4,10 +4,13 @@ mod v0; use crate::drive::Drive; use crate::error::drive::DriveError; +use crate::error::proof::ProofError; use crate::error::Error; use crate::verify::RootHash; use dpp::prelude::DataContract; use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::VotePoll; +use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; use dpp::voting::votes::Vote; impl Drive { @@ -44,6 +47,34 @@ impl Drive { verify_subset_of_proof: bool, platform_version: &PlatformVersion, ) -> Result<(RootHash, Option), Error> { + // The versions below prove the contested resource votes index. A yes/no vote lives in the + // decisions branch, so it goes to its own verifier: querying the contested index for it + // would accept a genuine absence proof from the wrong branch as "not voted". A resource + // vote cannot name a yes/no poll at all. + // + // In place (client side, every version): these arms take only votes naming a yes/no + // poll, which no valid transition carries before protocol version 15 (it does not + // decode there), and the yes/no verifier is not active before 15, so the route refuses + // there. Every contested vote reaches the version match below unchanged. + match vote { + Vote::YesNoVote(yes_no_vote) => { + return Self::verify_masternode_yes_no_vote( + proof, + masternode_pro_tx_hash, + yes_no_vote, + verify_subset_of_proof, + platform_version, + ) + .map(|(root_hash, proved_vote)| (root_hash, proved_vote.map(Vote::YesNoVote))); + } + Vote::ResourceVote(resource_vote) => { + if let VotePoll::YesNoVotePoll(_) = resource_vote.vote_poll() { + return Err(Error::Proof(ProofError::InvalidTransition( + "a resource vote cannot answer a yes/no vote poll".to_string(), + ))); + } + } + } match platform_version .drive .methods diff --git a/packages/rs-drive/src/verify/voting/verify_masternode_vote/v0/mod.rs b/packages/rs-drive/src/verify/voting/verify_masternode_vote/v0/mod.rs index 76d8dfaf3bb..0ed67fec707 100644 --- a/packages/rs-drive/src/verify/voting/verify_masternode_vote/v0/mod.rs +++ b/packages/rs-drive/src/verify/voting/verify_masternode_vote/v0/mod.rs @@ -56,7 +56,9 @@ impl Drive { &masternode_pro_tx_hash, ); - let vote_id = vote.vote_poll_unique_id()?; + // The dispatcher sends a vote on a yes/no poll elsewhere, and a contested poll's id + // does not read the platform version. + let vote_id = vote.vote_poll_unique_id(platform_version)?; let mut query = Query::new(); query.insert_key(vote_id.to_vec()); @@ -156,7 +158,7 @@ mod tests { &masternode_pro_tx_hash, ); let vote_id = vote - .vote_poll_unique_id() + .vote_poll_unique_id(platform_version) .expect("expected vote poll unique id"); let mut query = Query::new(); query.insert_key(vote_id.to_vec()); diff --git a/packages/rs-drive/src/verify/voting/verify_masternode_yes_no_vote/mod.rs b/packages/rs-drive/src/verify/voting/verify_masternode_yes_no_vote/mod.rs new file mode 100644 index 00000000000..50955257c62 --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_masternode_yes_no_vote/mod.rs @@ -0,0 +1,225 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::verify::RootHash; +use dpp::version::PlatformVersion; +use dpp::voting::votes::yes_no_vote::YesNoVote; + +impl Drive { + /// Verifies a proof of a masternode's vote on a yes/no poll. + /// + /// A proved vote whose answer differs from `vote` is an error. + /// + /// # Parameters + /// - `proof`: the proof. + /// - `masternode_pro_tx_hash`: the masternode. + /// - `vote`: the vote the proof is checked against. + /// - `verify_subset_of_proof`: whether the proof may hold more than this vote. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// The root hash and the proved vote, `None` when the masternode has not voted on the poll. + pub fn verify_masternode_yes_no_vote( + proof: &[u8], + masternode_pro_tx_hash: [u8; 32], + vote: &YesNoVote, + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, Option), Error> { + match platform_version + .drive + .methods + .verify + .voting + .verify_masternode_yes_no_vote + { + Some(0) => Drive::verify_masternode_yes_no_vote_v0( + proof, + masternode_pro_tx_hash, + vote, + verify_subset_of_proof, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "verify_masternode_yes_no_vote".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "verify_masternode_yes_no_vote".to_string(), + known_versions: vec![0], + })), + } + } +} + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + use crate::error::proof::ProofError; + use crate::query::IdentityBasedVoteDriveQuery; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use crate::util::test_helpers::{open_yes_no_vote_poll_with_votes, test_yes_no_vote_poll}; + use dpp::platform_value::Identifier; + use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; + use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; + use dpp::voting::vote_polls::VotePoll; + use dpp::voting::votes::yes_no_vote::v0::YesNoVoteV0; + + fn poll() -> YesNoVotePoll { + test_yes_no_vote_poll() + } + + fn vote(vote_choice: YesNoAbstainVoteChoice) -> YesNoVote { + YesNoVote::V0(YesNoVoteV0 { + vote_poll: poll(), + vote_choice, + }) + } + + fn proof_of_vote(drive: &Drive, voter: [u8; 32]) -> Vec { + let platform_version = PlatformVersion::latest(); + let mut path_query = IdentityBasedVoteDriveQuery { + identity_id: Identifier::new(voter), + vote_poll: VotePoll::YesNoVotePoll(poll()), + } + .construct_path_query(platform_version) + .expect("path query"); + path_query.query.limit = None; + drive + .grove_get_proved_path_query(&path_query, None, &mut vec![], &platform_version.drive) + .expect("proof") + } + + #[test] + fn should_prove_a_vote_and_its_absence() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let voter = [7u8; 32]; + open_yes_no_vote_poll_with_votes( + &drive, + &poll(), + &[(voter, 1, YesNoAbstainVoteChoice::No)], + platform_version, + ); + + let proof = proof_of_vote(&drive, voter); + let (_, proved) = Drive::verify_masternode_yes_no_vote( + &proof, + voter, + &vote(YesNoAbstainVoteChoice::No), + false, + platform_version, + ) + .expect("verify"); + assert_eq!(proved, Some(vote(YesNoAbstainVoteChoice::No))); + + // The proof shows the answer; claiming another one is caught. + assert!(matches!( + Drive::verify_masternode_yes_no_vote( + &proof, + voter, + &vote(YesNoAbstainVoteChoice::Yes), + false, + platform_version, + ), + Err(Error::Proof(ProofError::IncorrectProof(_))) + )); + + // A masternode that has not voted proves absent. + let other = [8u8; 32]; + let proof = proof_of_vote(&drive, other); + let (_, proved) = Drive::verify_masternode_yes_no_vote( + &proof, + other, + &vote(YesNoAbstainVoteChoice::No), + false, + platform_version, + ) + .expect("verify"); + assert_eq!(proved, None); + } + + /// The generic masternode vote verifier routes a yes/no vote to this one, so a genuine + /// absence proof taken from the contested resource votes index (where a yes/no vote never + /// lives) cannot pass as "the masternode did not vote". + #[test] + fn should_route_a_yes_no_vote_through_the_generic_verifier() { + use crate::drive::votes::paths::vote_contested_resource_identity_votes_tree_path_for_identity_vec; + use crate::query::Query; + use dpp::tests::json_document::json_document_to_contract; + use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; + use dpp::voting::votes::resource_vote::ResourceVote; + use dpp::voting::votes::Vote; + use grovedb::{PathQuery, SizedQuery}; + + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let data_contract = json_document_to_contract( + "tests/supporting_files/contract/dpns/dpns-contract.json", + false, + platform_version, + ) + .expect("expected a data contract"); + let voter = [7u8; 32]; + open_yes_no_vote_poll_with_votes( + &drive, + &poll(), + &[(voter, 1, YesNoAbstainVoteChoice::No)], + platform_version, + ); + let yes_no_vote = Vote::YesNoVote(vote(YesNoAbstainVoteChoice::No)); + + // The right proof verifies through the generic entry point. + let (_, proved) = Drive::verify_masternode_vote( + &proof_of_vote(&drive, voter), + voter, + &yes_no_vote, + &data_contract, + false, + platform_version, + ) + .expect("verify"); + assert_eq!(proved, Some(yes_no_vote.clone())); + + // An absence proof of the poll id under the contested index is refused. + let mut query = Query::new(); + query.insert_key(poll().unique_id(platform_version).expect("id").to_vec()); + let wrong_branch = PathQuery::new( + vote_contested_resource_identity_votes_tree_path_for_identity_vec(&voter), + SizedQuery::new(query, None, None), + ); + let wrong_branch_proof = drive + .grove_get_proved_path_query(&wrong_branch, None, &mut vec![], &platform_version.drive) + .expect("proof"); + assert!(Drive::verify_masternode_vote( + &wrong_branch_proof, + voter, + &yes_no_vote, + &data_contract, + false, + platform_version, + ) + .is_err()); + + // A resource vote naming a yes/no poll is refused outright. + let resource_vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::YesNoVotePoll(poll()), + resource_vote_choice: ResourceVoteChoice::Abstain, + })); + assert!(matches!( + Drive::verify_masternode_vote( + &wrong_branch_proof, + voter, + &resource_vote, + &data_contract, + false, + platform_version, + ), + Err(Error::Proof(ProofError::InvalidTransition(_))) + )); + } +} diff --git a/packages/rs-drive/src/verify/voting/verify_masternode_yes_no_vote/v0/mod.rs b/packages/rs-drive/src/verify/voting/verify_masternode_yes_no_vote/v0/mod.rs new file mode 100644 index 00000000000..a480d735a3d --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_masternode_yes_no_vote/v0/mod.rs @@ -0,0 +1,69 @@ +use crate::drive::Drive; +use crate::error::proof::ProofError; +use crate::error::Error; +use crate::query::IdentityBasedVoteDriveQuery; +use crate::verify::bounded_decode::decode_yes_no_vote_reference; +use crate::verify::RootHash; +use dpp::identifier::Identifier; +use dpp::voting::vote_polls::VotePoll; +use dpp::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; +use dpp::voting::votes::yes_no_vote::v0::YesNoVoteV0; +use dpp::voting::votes::yes_no_vote::YesNoVote; +use grovedb::GroveDb; +use platform_version::version::PlatformVersion; + +impl Drive { + #[inline(always)] + pub(super) fn verify_masternode_yes_no_vote_v0( + proof: &[u8], + masternode_pro_tx_hash: [u8; 32], + vote: &YesNoVote, + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, Option), Error> { + // The prover's own query, so where a masternode's yes/no vote lives is written once. + let path_query = IdentityBasedVoteDriveQuery { + identity_id: Identifier::new(masternode_pro_tx_hash), + vote_poll: VotePoll::YesNoVotePoll(vote.vote_poll().clone()), + } + .construct_path_query(platform_version)?; + let (root_hash, mut proved_key_values) = if verify_subset_of_proof { + GroveDb::verify_subset_query_with_absence_proof( + proof, + &path_query, + &platform_version.drive.grove_version, + )? + } else { + GroveDb::verify_query_with_absence_proof( + proof, + &path_query, + &platform_version.drive.grove_version, + )? + }; + if proved_key_values.len() != 1 { + return Err(Error::Proof(ProofError::TooManyElements( + "expected one masternode yes/no vote", + ))); + } + let (_, _, maybe_element) = proved_key_values.remove(0); + let maybe_vote = maybe_element + .map(|element| { + let bytes = element.into_item_bytes()?; + let storage_form = decode_yes_no_vote_reference(&bytes)?; + let proved_vote = YesNoVote::V0(YesNoVoteV0 { + vote_poll: vote.vote_poll().clone(), + vote_choice: storage_form.vote_choice, + }); + if &proved_vote != vote { + Err(Error::Proof(ProofError::IncorrectProof(format!( + "returned vote {:?} does not match the vote that was sent {:?}", + proved_vote, vote + )))) + } else { + Ok::(proved_vote) + } + }) + .transpose()?; + Ok((root_hash, maybe_vote)) + } +} diff --git a/packages/rs-drive/src/verify/voting/verify_yes_no_vote_poll_state_proof/mod.rs b/packages/rs-drive/src/verify/voting/verify_yes_no_vote_poll_state_proof/mod.rs new file mode 100644 index 00000000000..d1167d84cb7 --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_yes_no_vote_poll_state_proof/mod.rs @@ -0,0 +1,44 @@ +mod v0; + +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::query::yes_no_vote_poll_state_query::{ + YesNoVotePollState, YesNoVotePollStateDriveQuery, +}; +use crate::verify::RootHash; +use dpp::version::PlatformVersion; + +impl YesNoVotePollStateDriveQuery { + /// Verifies a proof of the state of a yes/no vote poll: its stored info and its tallies. + /// + /// # Parameters + /// - `proof`: the proof. + /// - `platform_version`: the platform version. + /// + /// # Returns + /// The root hash and the poll's proved state. + pub fn verify_yes_no_vote_poll_state_proof( + &self, + proof: &[u8], + platform_version: &PlatformVersion, + ) -> Result<(RootHash, YesNoVotePollState), Error> { + match platform_version + .drive + .methods + .verify + .voting + .verify_yes_no_vote_poll_state_proof + { + Some(0) => self.verify_yes_no_vote_poll_state_proof_v0(proof, platform_version), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "verify_yes_no_vote_poll_state_proof".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "verify_yes_no_vote_poll_state_proof".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/verify/voting/verify_yes_no_vote_poll_state_proof/v0/mod.rs b/packages/rs-drive/src/verify/voting/verify_yes_no_vote_poll_state_proof/v0/mod.rs new file mode 100644 index 00000000000..be313dd81a1 --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_yes_no_vote_poll_state_proof/v0/mod.rs @@ -0,0 +1,28 @@ +use crate::error::Error; +use crate::query::yes_no_vote_poll_state_query::{ + YesNoVotePollState, YesNoVotePollStateDriveQuery, +}; +use crate::verify::RootHash; +use grovedb::GroveDb; +use platform_version::version::PlatformVersion; + +impl YesNoVotePollStateDriveQuery { + #[inline(always)] + pub(super) fn verify_yes_no_vote_poll_state_proof_v0( + &self, + proof: &[u8], + platform_version: &PlatformVersion, + ) -> Result<(RootHash, YesNoVotePollState), Error> { + let path_query = self.construct_path_query(platform_version)?; + let (root_hash, proved_key_values) = GroveDb::verify_query_with_absence_proof( + proof, + &path_query, + &platform_version.drive.grove_version, + )?; + let key_elements = proved_key_values + .into_iter() + .filter_map(|(_, key, maybe_element)| maybe_element.map(|element| (key, element))); + let state = Self::state_from_key_elements(key_elements)?; + Ok((root_hash, state)) + } +} diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs index 900fab5a43c..fef1a6aab79 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs @@ -1,11 +1,25 @@ -use versioned_feature_core::FeatureVersion; +use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v2; +pub mod v3; #[derive(Clone, Debug, Default)] pub struct DPPVotingVersions { pub default_vote_poll_time_duration_mainnet_ms: u64, pub default_vote_poll_time_duration_test_network_ms: u64, pub contested_document_vote_poll_stored_info_version: FeatureVersion, + /// The stored info of a yes/no poll. `None` (not active) before protocol version 15. + pub yes_no_vote_poll_stored_info_version: OptionalFeatureVersion, + /// How a yes/no poll's id is derived (`YesNoVotePoll::unique_id`). The id keys the poll's + /// stored trees and its end date index entry, and names its prefunded balance. `None` (not + /// active) before protocol version 15. + pub yes_no_vote_poll_id: OptionalFeatureVersion, + /// The rules a yes/no poll's parameters must meet (`YesNoVotePoll::validate_parameters`). + /// `None` (not active) before protocol version 15. + pub yes_no_vote_poll_parameters: OptionalFeatureVersion, + /// How a yes/no poll is decided: its required voting power and whether it passes + /// (`YesNoVotePoll::required_voting_power` and `YesNoVotePoll::passes`). `None` (not + /// active) before protocol version 15. + pub yes_no_vote_poll_decision: OptionalFeatureVersion, } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs index 944a295d1fe..6daa84e921c 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs @@ -4,4 +4,8 @@ pub const VOTING_VERSION_V1: DPPVotingVersions = DPPVotingVersions { default_vote_poll_time_duration_mainnet_ms: 1_209_600_000, //2 weeks default_vote_poll_time_duration_test_network_ms: 1_209_600_000, //2 weeks contested_document_vote_poll_stored_info_version: 0, + yes_no_vote_poll_stored_info_version: None, + yes_no_vote_poll_id: None, + yes_no_vote_poll_parameters: None, + yes_no_vote_poll_decision: None, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs index de072e87837..86931cd3d8e 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs @@ -4,4 +4,8 @@ pub const VOTING_VERSION_V2: DPPVotingVersions = DPPVotingVersions { default_vote_poll_time_duration_mainnet_ms: 1_209_600_000, //2 weeks default_vote_poll_time_duration_test_network_ms: 5_400_000, //90 minutes contested_document_vote_poll_stored_info_version: 0, + yes_no_vote_poll_stored_info_version: None, + yes_no_vote_poll_id: None, + yes_no_vote_poll_parameters: None, + yes_no_vote_poll_decision: None, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v3.rs new file mode 100644 index 00000000000..9eaaf9c6353 --- /dev/null +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v3.rs @@ -0,0 +1,13 @@ +use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; +use crate::version::dpp_versions::dpp_voting_versions::DPPVotingVersions; + +/// Voting versions 3. Introduced in protocol version 15 with the yes/no poll kind: its stored +/// info, id, parameter rules and decision rules are `Some(0)` (`None`, not active, in every +/// earlier table). Everything else matches `VOTING_VERSION_V2`. +pub const VOTING_VERSION_V3: DPPVotingVersions = DPPVotingVersions { + yes_no_vote_poll_stored_info_version: Some(0), + yes_no_vote_poll_id: Some(0), + yes_no_vote_poll_parameters: Some(0), + yes_no_vote_poll_decision: Some(0), + ..VOTING_VERSION_V2 +}; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs index 0b4a236b28c..8e3ad58748c 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs @@ -179,6 +179,12 @@ pub struct DriveAbciVotingMethodVersions { pub delay_vote_poll: FeatureVersion, pub run_dao_platform_events: FeatureVersion, pub remove_votes_for_removed_masternodes: FeatureVersion, + /// The yes/no poll kind: its tally, its record, its clean-up and the hook a feature acts on + /// when one of its polls finishes. `None` (not active) before protocol version 15. + pub tally_votes_for_yes_no_vote_poll: OptionalFeatureVersion, + pub keep_record_of_finished_yes_no_vote_poll: OptionalFeatureVersion, + pub clean_up_after_yes_no_vote_polls_end: OptionalFeatureVersion, + pub on_yes_no_vote_poll_finished: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs index 410eb8ba8c8..af852d395eb 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs @@ -105,6 +105,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V1: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: None, + keep_record_of_finished_yes_no_vote_poll: None, + clean_up_after_yes_no_vote_polls_end: None, + on_yes_no_vote_poll_finished: None, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs index d2e1249af35..733e2490659 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs @@ -129,6 +129,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: None, + keep_record_of_finished_yes_no_vote_poll: None, + clean_up_after_yes_no_vote_polls_end: None, + on_yes_no_vote_poll_finished: None, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 1, // changed: deducts what a budgeted signing key spent from its remaining budget, and charges a document batch's gas sponsor instead of its signer diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs index c232a2170be..3961aec78ab 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs @@ -1,6 +1,7 @@ use super::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; use super::{ DkgMiningSchedule, DriveAbciCoreChainLockMethodVersionsAndConstants, DriveAbciMethodVersions, + DriveAbciVotingMethodVersions, }; /// Dash Core's DKG schedule (`llmq/params.h`) of the non-rotating quorum types, the ones chain @@ -86,8 +87,11 @@ const DKG_MINING_SCHEDULES_V1: &[DkgMiningSchedule] = &[ }, ]; -/// Drive ABCI method versions 11, introduced in protocol v15. Differs from v10 in the chain lock -/// verification, which only works as a whole: +/// Drive ABCI method versions 11, introduced in protocol v15. Differs from v10 in the yes/no +/// poll kind's voting methods (`voting.tally_votes_for_yes_no_vote_poll`, +/// `keep_record_of_finished_yes_no_vote_poll`, `clean_up_after_yes_no_vote_polls_end` and +/// `on_yes_no_vote_poll_finished` are `Some(0)`, `None` in every earlier table) and in the chain +/// lock verification, which only works as a whole: /// - `core_chain_lock.verify_chain_lock_locally` is 1 rather than 0: a chain lock is rejected /// locally only when Platform holds exactly the quorums active at its signing height, using /// Dash Core's DKG mining schedule in `core_chain_lock.dkg_mining_schedules`. When a quorum may @@ -105,5 +109,12 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V11: DriveAbciMethodVersions = DriveAbciMet dkg_mining_schedules: Some(DKG_MINING_SCHEDULES_V1), ..DRIVE_ABCI_METHOD_VERSIONS_V10.core_chain_lock }, + voting: DriveAbciVotingMethodVersions { + tally_votes_for_yes_no_vote_poll: Some(0), + keep_record_of_finished_yes_no_vote_poll: Some(0), + clean_up_after_yes_no_vote_polls_end: Some(0), + on_yes_no_vote_poll_finished: Some(0), + ..DRIVE_ABCI_METHOD_VERSIONS_V10.voting + }, ..DRIVE_ABCI_METHOD_VERSIONS_V10 }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs index 741535ee010..02192421217 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs @@ -106,6 +106,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V2: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: None, + keep_record_of_finished_yes_no_vote_poll: None, + clean_up_after_yes_no_vote_polls_end: None, + on_yes_no_vote_poll_finished: None, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs index 1c559a0b1c5..562ef11f09f 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs @@ -105,6 +105,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V3: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: None, + keep_record_of_finished_yes_no_vote_poll: None, + clean_up_after_yes_no_vote_polls_end: None, + on_yes_no_vote_poll_finished: None, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs index fcd85d382b8..a80bf22c093 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs @@ -105,6 +105,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V4: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: None, + keep_record_of_finished_yes_no_vote_poll: None, + clean_up_after_yes_no_vote_polls_end: None, + on_yes_no_vote_poll_finished: None, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs index 34b722d7e0f..df85fd7f139 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs @@ -109,6 +109,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V5: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: None, + keep_record_of_finished_yes_no_vote_poll: None, + clean_up_after_yes_no_vote_polls_end: None, + on_yes_no_vote_poll_finished: None, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs index 9bf22b9afa7..4be3341af89 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs @@ -107,6 +107,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V6: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: None, + keep_record_of_finished_yes_no_vote_poll: None, + clean_up_after_yes_no_vote_polls_end: None, + on_yes_no_vote_poll_finished: None, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs index 2105c268ec5..78ce2f621e0 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs @@ -114,6 +114,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V7: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: None, + keep_record_of_finished_yes_no_vote_poll: None, + clean_up_after_yes_no_vote_polls_end: None, + on_yes_no_vote_poll_finished: None, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs index cbe35a4ea23..f176a345790 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs @@ -116,6 +116,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V8: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: None, + keep_record_of_finished_yes_no_vote_poll: None, + clean_up_after_yes_no_vote_polls_end: None, + on_yes_no_vote_poll_finished: None, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs index d4f38b73665..7a7b56b48bc 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs @@ -125,6 +125,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V9: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: None, + keep_record_of_finished_yes_no_vote_poll: None, + clean_up_after_yes_no_vote_polls_end: None, + on_yes_no_vote_poll_finished: None, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs index 402cb215efb..6557dfc7039 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs @@ -18,6 +18,8 @@ use super::{ /// - the batch `advanced_structure` 2: v1, then the structure checks of token actions protocol /// v15 introduces, which read only the contract the action carries and so run in check_tx too. /// A `TokenConfigUpdate` can make a token tradeable only if it is transferable. +/// - the masternode vote `transform_into_action`, 1 rather than 0: a vote on a yes/no poll looks up +/// the masternode's previous answer on that poll. /// - the contract update `transform_into_action` 1: v0, then in check_tx and recheck, which run no /// state validation, the updated contract is held to the compiled size cap with its time fields /// at their widest, a bound on what a block measures once it restores the stored contract's @@ -34,6 +36,12 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V11: DriveAbciValidationVersions = .state_transitions .identity_update_state_transition }, + masternode_vote_state_transition: DriveAbciStateTransitionValidationVersion { + transform_into_action: 1, // a vote on a yes/no poll looks up the masternode's previous answer + ..DRIVE_ABCI_VALIDATION_VERSIONS_V10 + .state_transitions + .masternode_vote_state_transition + }, contract_update_state_transition: DriveAbciStateTransitionValidationVersion { transform_into_action: 1, // check_tx holds the update to the compiled size cap ..DRIVE_ABCI_VALIDATION_VERSIONS_V10 diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs index 3ba13c9f529..34e55f8bf13 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs @@ -1,8 +1,9 @@ -use versioned_feature_core::FeatureVersion; +use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v2; pub mod v3; +pub mod v4; #[derive(Clone, Debug, Default)] pub struct DriveVerifyMethodVersions { @@ -187,6 +188,11 @@ pub struct DriveVerifyVoteMethodVersions { pub verify_contests_proof: FeatureVersion, pub verify_vote_polls_by_end_date_proof: FeatureVersion, pub verify_specialized_balance: FeatureVersion, + /// The stored info and tallies of a yes/no poll. `None` (not active) before protocol + /// version 15. + pub verify_yes_no_vote_poll_state_proof: OptionalFeatureVersion, + /// A masternode's vote on a yes/no poll. `None` (not active) before protocol version 15. + pub verify_masternode_yes_no_vote: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs index d00538192b0..a14c0478825 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs @@ -131,6 +131,8 @@ pub const DRIVE_VERIFY_METHOD_VERSIONS_V1: DriveVerifyMethodVersions = DriveVeri verify_contests_proof: 0, verify_vote_polls_by_end_date_proof: 0, verify_specialized_balance: 0, + verify_yes_no_vote_poll_state_proof: None, + verify_masternode_yes_no_vote: None, }, address_funds: DriveVerifyAddressFundsMethodVersions { verify_address_info: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v4.rs new file mode 100644 index 00000000000..2eab4d99795 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v4.rs @@ -0,0 +1,17 @@ +use crate::version::drive_versions::drive_verify_method_versions::v3::DRIVE_VERIFY_METHOD_VERSIONS_V3; +use crate::version::drive_versions::drive_verify_method_versions::{ + DriveVerifyMethodVersions, DriveVerifyVoteMethodVersions, +}; + +/// Version 4 of the Drive verify method versions, introduced in protocol version 15 with the +/// yes/no poll kind: `voting.verify_yes_no_vote_poll_state_proof` and +/// `voting.verify_masternode_yes_no_vote` are `Some(0)` (`None`, not active, in every earlier +/// table). Everything else matches `DRIVE_VERIFY_METHOD_VERSIONS_V3`. +pub const DRIVE_VERIFY_METHOD_VERSIONS_V4: DriveVerifyMethodVersions = DriveVerifyMethodVersions { + voting: DriveVerifyVoteMethodVersions { + verify_yes_no_vote_poll_state_proof: Some(0), + verify_masternode_yes_no_vote: Some(0), + ..DRIVE_VERIFY_METHOD_VERSIONS_V3.voting + }, + ..DRIVE_VERIFY_METHOD_VERSIONS_V3 +}; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/mod.rs index 3b268472ea2..e639438cef8 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/mod.rs @@ -1,8 +1,9 @@ -use versioned_feature_core::FeatureVersion; +use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v2; pub mod v3; +pub mod v4; #[derive(Clone, Debug, Default)] pub struct DriveVoteMethodVersions { @@ -12,6 +13,26 @@ pub struct DriveVoteMethodVersions { pub setup: DriveVoteSetupMethodVersions, pub storage_form: DriveVoteStorageFormMethodVersions, pub fetch: DriveVoteFetchMethodVersions, + /// Yes/no polls: the decision polls under the votes tree's `d` branch (protocol version 15). + pub yes_no: DriveVoteYesNoMethodVersions, +} + +/// The methods of the yes/no poll kind. All of them are new in protocol version 15 and `None` +/// (not active) in every earlier table: opening a poll is not active before 15, and a vote +/// naming a yes/no poll does not decode before 15. +#[derive(Clone, Debug, Default)] +pub struct DriveVoteYesNoMethodVersions { + pub open_yes_no_vote_poll: OptionalFeatureVersion, + pub register_yes_no_identity_vote: OptionalFeatureVersion, + pub insert_stored_info_for_yes_no_vote_poll: OptionalFeatureVersion, + pub fetch_yes_no_vote_poll_stored_info: OptionalFeatureVersion, + pub fetch_identity_yes_no_vote: OptionalFeatureVersion, + pub fetch_identities_voting_in_yes_no_vote_poll: OptionalFeatureVersion, + /// Reads a yes/no poll's state (`YesNoVotePollStateDriveQuery::execute_with_proof` and + /// `execute_no_proof`), as its proof verifier does from the same protocol version. + pub fetch_yes_no_vote_poll_state: OptionalFeatureVersion, + pub remove_yes_no_vote_poll_votes_operations: OptionalFeatureVersion, + pub remove_yes_no_vote_references_given_by_identity: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] @@ -43,6 +64,12 @@ pub struct DriveVoteCleanupMethodVersions { pub remove_contested_resource_vote_poll_contenders_operations: FeatureVersion, pub remove_contested_resource_top_level_index_operations: FeatureVersion, pub remove_contested_resource_info_operations: FeatureVersion, + /// Removes every vote a removed masternode cast. Version 1 (protocol version 15) also + /// sweeps its yes/no votes. + pub remove_all_votes_given_by_identities: FeatureVersion, + /// Removes finished polls of every kind from the end date index. `None` (not active) before + /// protocol version 15. + pub remove_vote_poll_end_date_query_operations: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] @@ -54,6 +81,7 @@ pub struct DriveVoteInsertMethodVersions { pub struct DriveVoteContestedResourceInsertMethodVersions { pub register_contested_resource_identity_vote: FeatureVersion, pub insert_stored_info_for_contested_resource_vote_poll: FeatureVersion, - pub register_identity_vote: FeatureVersion, + /// Adds a poll to the end date index. Version 1 (protocol version 15) adds only the entry + /// when the batch already queues the end date's tree with other storage flags. pub add_vote_poll_end_date_query_operations: FeatureVersion, } diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v1.rs index 17c779d2b9d..c4a619848a7 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v1.rs @@ -1,7 +1,7 @@ use crate::version::drive_versions::drive_vote_method_versions::{ DriveVoteCleanupMethodVersions, DriveVoteContestedResourceInsertMethodVersions, DriveVoteFetchMethodVersions, DriveVoteInsertMethodVersions, DriveVoteMethodVersions, - DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, + DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, DriveVoteYesNoMethodVersions, }; pub const DRIVE_VOTE_METHOD_VERSIONS_V1: DriveVoteMethodVersions = DriveVoteMethodVersions { @@ -11,7 +11,6 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V1: DriveVoteMethodVersions = DriveVoteMeth contested_resource_insert: DriveVoteContestedResourceInsertMethodVersions { register_contested_resource_identity_vote: 0, insert_stored_info_for_contested_resource_vote_poll: 0, - register_identity_vote: 0, add_vote_poll_end_date_query_operations: 0, }, cleanup: DriveVoteCleanupMethodVersions { @@ -23,6 +22,8 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V1: DriveVoteMethodVersions = DriveVoteMeth remove_contested_resource_vote_poll_contenders_operations: 0, remove_contested_resource_top_level_index_operations: 0, remove_contested_resource_info_operations: 0, + remove_all_votes_given_by_identities: 0, + remove_vote_poll_end_date_query_operations: None, }, setup: DriveVoteSetupMethodVersions { add_initial_vote_tree_main_structure_operations: 0, @@ -36,4 +37,15 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V1: DriveVoteMethodVersions = DriveVoteMeth fetch_identity_contested_resource_vote: 0, fetch_contested_document_vote_poll_contender_count: 0, }, + yes_no: DriveVoteYesNoMethodVersions { + open_yes_no_vote_poll: None, + register_yes_no_identity_vote: None, + insert_stored_info_for_yes_no_vote_poll: None, + fetch_yes_no_vote_poll_stored_info: None, + fetch_identity_yes_no_vote: None, + fetch_identities_voting_in_yes_no_vote_poll: None, + fetch_yes_no_vote_poll_state: None, + remove_yes_no_vote_poll_votes_operations: None, + remove_yes_no_vote_references_given_by_identity: None, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v2.rs index 4a7c24f091a..c49ee0bdfcd 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v2.rs @@ -1,7 +1,7 @@ use crate::version::drive_versions::drive_vote_method_versions::{ DriveVoteCleanupMethodVersions, DriveVoteContestedResourceInsertMethodVersions, DriveVoteFetchMethodVersions, DriveVoteInsertMethodVersions, DriveVoteMethodVersions, - DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, + DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, DriveVoteYesNoMethodVersions, }; pub const DRIVE_VOTE_METHOD_VERSIONS_V2: DriveVoteMethodVersions = DriveVoteMethodVersions { @@ -11,7 +11,6 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V2: DriveVoteMethodVersions = DriveVoteMeth contested_resource_insert: DriveVoteContestedResourceInsertMethodVersions { register_contested_resource_identity_vote: 0, insert_stored_info_for_contested_resource_vote_poll: 0, - register_identity_vote: 0, add_vote_poll_end_date_query_operations: 0, }, cleanup: DriveVoteCleanupMethodVersions { @@ -23,6 +22,8 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V2: DriveVoteMethodVersions = DriveVoteMeth remove_contested_resource_vote_poll_contenders_operations: 1, remove_contested_resource_top_level_index_operations: 0, remove_contested_resource_info_operations: 0, + remove_all_votes_given_by_identities: 0, + remove_vote_poll_end_date_query_operations: None, }, setup: DriveVoteSetupMethodVersions { add_initial_vote_tree_main_structure_operations: 0, @@ -36,4 +37,15 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V2: DriveVoteMethodVersions = DriveVoteMeth fetch_identity_contested_resource_vote: 0, fetch_contested_document_vote_poll_contender_count: 0, }, + yes_no: DriveVoteYesNoMethodVersions { + open_yes_no_vote_poll: None, + register_yes_no_identity_vote: None, + insert_stored_info_for_yes_no_vote_poll: None, + fetch_yes_no_vote_poll_stored_info: None, + fetch_identity_yes_no_vote: None, + fetch_identities_voting_in_yes_no_vote_poll: None, + fetch_yes_no_vote_poll_state: None, + remove_yes_no_vote_poll_votes_operations: None, + remove_yes_no_vote_references_given_by_identity: None, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v3.rs index f25e7a74859..fd1b4314a4b 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v3.rs @@ -1,7 +1,7 @@ use crate::version::drive_versions::drive_vote_method_versions::{ DriveVoteCleanupMethodVersions, DriveVoteContestedResourceInsertMethodVersions, DriveVoteFetchMethodVersions, DriveVoteInsertMethodVersions, DriveVoteMethodVersions, - DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, + DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, DriveVoteYesNoMethodVersions, }; /// Drive vote methods for protocol v14+. @@ -19,7 +19,6 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V3: DriveVoteMethodVersions = DriveVoteMeth contested_resource_insert: DriveVoteContestedResourceInsertMethodVersions { register_contested_resource_identity_vote: 0, insert_stored_info_for_contested_resource_vote_poll: 0, - register_identity_vote: 0, add_vote_poll_end_date_query_operations: 0, }, cleanup: DriveVoteCleanupMethodVersions { @@ -31,6 +30,8 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V3: DriveVoteMethodVersions = DriveVoteMeth remove_contested_resource_vote_poll_contenders_operations: 1, remove_contested_resource_top_level_index_operations: 0, remove_contested_resource_info_operations: 0, + remove_all_votes_given_by_identities: 0, + remove_vote_poll_end_date_query_operations: None, }, setup: DriveVoteSetupMethodVersions { add_initial_vote_tree_main_structure_operations: 0, @@ -44,4 +45,15 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V3: DriveVoteMethodVersions = DriveVoteMeth fetch_identity_contested_resource_vote: 0, fetch_contested_document_vote_poll_contender_count: 0, }, + yes_no: DriveVoteYesNoMethodVersions { + open_yes_no_vote_poll: None, + register_yes_no_identity_vote: None, + insert_stored_info_for_yes_no_vote_poll: None, + fetch_yes_no_vote_poll_stored_info: None, + fetch_identity_yes_no_vote: None, + fetch_identities_voting_in_yes_no_vote_poll: None, + fetch_yes_no_vote_poll_state: None, + remove_yes_no_vote_poll_votes_operations: None, + remove_yes_no_vote_references_given_by_identity: None, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v4.rs new file mode 100644 index 00000000000..09d68cc9f70 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v4.rs @@ -0,0 +1,48 @@ +use crate::version::drive_versions::drive_vote_method_versions::v3::DRIVE_VOTE_METHOD_VERSIONS_V3; +use crate::version::drive_versions::drive_vote_method_versions::{ + DriveVoteCleanupMethodVersions, DriveVoteContestedResourceInsertMethodVersions, + DriveVoteInsertMethodVersions, DriveVoteMethodVersions, DriveVoteYesNoMethodVersions, +}; + +/// Drive vote method versions 4. Introduced in protocol version 15 with the yes/no poll +/// kind: +/// +/// * `insert.register_identity_vote` 1 registers a resolved vote of either kind (v0 knows only +/// contested resource votes, which v1 still hands to it). +/// * `remove_all_votes_given_by_identities` 1 also removes the yes/no votes of a masternode +/// that left the list. +/// * `cleanup.remove_vote_poll_end_date_query_operations` is `Some(0)`: the yes/no clean-up +/// removes finished polls from the end date index with it (`None`, not active, before). +/// * every `yes_no` method is `Some(0)`: yes/no polls can be opened and voted on from this +/// version (`None`, not active, in every earlier table). +/// * `contested_resource_insert.add_vote_poll_end_date_query_operations` 1 adds only the entry +/// when the batch already queues an insert of the end date's tree with other storage flags (a +/// contest and a yes/no poll opening sharing an end date), where 0 queued the tree twice. +/// +/// Everything else matches `DRIVE_VOTE_METHOD_VERSIONS_V3`. +pub const DRIVE_VOTE_METHOD_VERSIONS_V4: DriveVoteMethodVersions = DriveVoteMethodVersions { + insert: DriveVoteInsertMethodVersions { + register_identity_vote: 1, + }, + contested_resource_insert: DriveVoteContestedResourceInsertMethodVersions { + add_vote_poll_end_date_query_operations: 1, + ..DRIVE_VOTE_METHOD_VERSIONS_V3.contested_resource_insert + }, + cleanup: DriveVoteCleanupMethodVersions { + remove_all_votes_given_by_identities: 1, + remove_vote_poll_end_date_query_operations: Some(0), + ..DRIVE_VOTE_METHOD_VERSIONS_V3.cleanup + }, + yes_no: DriveVoteYesNoMethodVersions { + open_yes_no_vote_poll: Some(0), + register_yes_no_identity_vote: Some(0), + insert_stored_info_for_yes_no_vote_poll: Some(0), + fetch_yes_no_vote_poll_stored_info: Some(0), + fetch_identity_yes_no_vote: Some(0), + fetch_identities_voting_in_yes_no_vote_poll: Some(0), + fetch_yes_no_vote_poll_state: Some(0), + remove_yes_no_vote_poll_votes_operations: Some(0), + remove_yes_no_vote_references_given_by_identity: Some(0), + }, + ..DRIVE_VOTE_METHOD_VERSIONS_V3 +}; diff --git a/packages/rs-platform-version/src/version/drive_versions/v10.rs b/packages/rs-platform-version/src/version/drive_versions/v10.rs index 31b7dd100a0..ff1ce116d60 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v10.rs @@ -1,15 +1,33 @@ use crate::version::drive_versions::drive_contract_method_versions::v5::DRIVE_CONTRACT_METHOD_VERSIONS_V5; +use crate::version::drive_versions::drive_verify_method_versions::v4::DRIVE_VERIFY_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_vote_method_versions::v4::DRIVE_VOTE_METHOD_VERSIONS_V4; use crate::version::drive_versions::v9::DRIVE_VERSION_V9; use crate::version::drive_versions::{DriveMethodVersions, DriveVersion}; -/// Drive version 10, introduced in protocol v15. Differs from v9 only in -/// `DRIVE_CONTRACT_METHOD_VERSIONS_V5`: applying a contract writes nothing when its format -/// version 1 bytes match the stored contract's, whatever format the stored one is in, as -/// contracts are stored compiled from this version (`contract_storage_serialization_version` -/// 2). +/// Drive version 10. +/// Introduced in protocol v15: +/// +/// * **Compiled contract storage**: `DRIVE_CONTRACT_METHOD_VERSIONS_V5`: applying a contract +/// writes nothing when its format version 1 bytes match the stored contract's, whatever +/// format the stored one is in, as contracts are stored compiled from this version +/// (`contract_storage_serialization_version` 2). +/// * **Yes/no vote polls**: `DRIVE_VOTE_METHOD_VERSIONS_V4` bumps +/// `insert.register_identity_vote` to 1, which registers a vote of either poll kind, and +/// `cleanup.remove_all_votes_given_by_identities` to 1, which also removes the yes/no votes of +/// a masternode that left the list. It sets every `yes_no` method to `Some(0)`, so yes/no +/// polls can be opened and voted on (`None`, not active, in every earlier table), and bumps +/// `contested_resource_insert.add_vote_poll_end_date_query_operations` to 1, which adds only +/// the entry when the batch already queues the end date's tree with other storage flags. +/// `cleanup.remove_vote_poll_end_date_query_operations`, which removes finished polls from +/// the end date index, is `Some(0)` (`None` before). `DRIVE_VERIFY_METHOD_VERSIONS_V4` makes +/// the yes/no proof verifiers `Some(0)` (`None` before). +/// +/// Everything else matches `DRIVE_VERSION_V9`. pub const DRIVE_VERSION_V10: DriveVersion = DriveVersion { methods: DriveMethodVersions { - contract: DRIVE_CONTRACT_METHOD_VERSIONS_V5, // changed + contract: DRIVE_CONTRACT_METHOD_VERSIONS_V5, // changed in v10: compiled contract storage + vote: DRIVE_VOTE_METHOD_VERSIONS_V4, // changed in v10: the yes/no poll kind + verify: DRIVE_VERIFY_METHOD_VERSIONS_V4, // changed in v10: the yes/no proof verifiers ..DRIVE_VERSION_V9.methods }, ..DRIVE_VERSION_V9 diff --git a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs index 39632330672..6d2b45d5ea8 100644 --- a/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs +++ b/packages/rs-platform-version/src/version/feature_initial_protocol_versions.rs @@ -35,3 +35,8 @@ pub const MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION: ProtocolVersion /// Below it a token configuration or a configuration update that makes a token tradeable is /// refused without a fee, as software that predates the marketplace cannot decode one. pub const TOKEN_MARKETPLACE_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 15; +/// Yes/no masternode vote polls activate with protocol version 15: a masternode vote naming one +/// (a `YesNoVote`, or a resource vote on a `YesNoVotePoll`) is not active before it, every +/// yes/no method slot is `None` in the tables before it, and the vote decisions trees are +/// created at genesis from it (and by the upgrade to it). +pub const YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION: ProtocolVersion = 15; diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index fb30e0ecc34..469ff4894bb 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -617,6 +617,13 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_yes_no_vote_poll_resource_path_segments: 16, + max_yes_no_vote_poll_resource_path_bytes: 1024, + max_yes_no_vote_poll_minimum_voting_power: 2000, + max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, + max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks + yes_no_vote_poll_masternode_vote_strength: 1, + yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, min_mainnet_contract_moderation_election_window_seconds: 86_400, diff --git a/packages/rs-platform-version/src/version/mocks/v3_test.rs b/packages/rs-platform-version/src/version/mocks/v3_test.rs index f56f03c806c..9a502004273 100644 --- a/packages/rs-platform-version/src/version/mocks/v3_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v3_test.rs @@ -140,6 +140,10 @@ pub const TEST_PLATFORM_V3: PlatformVersion = PlatformVersion { delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: None, + keep_record_of_finished_yes_no_vote_poll: None, + clean_up_after_yes_no_vote_polls_end: None, + on_yes_no_vote_poll_finished: None, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/protocol_version.rs b/packages/rs-platform-version/src/version/protocol_version.rs index d6273f0e766..3049223ba2b 100644 --- a/packages/rs-platform-version/src/version/protocol_version.rs +++ b/packages/rs-platform-version/src/version/protocol_version.rs @@ -325,3 +325,126 @@ mod token_math_gating_tests { ); } } + +#[cfg(test)] +mod yes_no_vote_poll_gating_tests { + use super::*; + use crate::version::feature_initial_protocol_versions::YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION; + + // Every method of the yes/no poll kind is `None` (not active) before protocol version 15 + // and `Some(0)` from it, so a caller on an older version is refused at the method instead + // of running the protocol 15 implementation. + #[test] + fn should_activate_every_yes_no_vote_poll_method_at_protocol_version_15() { + for platform_version in PLATFORM_VERSIONS { + let expected = (platform_version.protocol_version + >= YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION) + .then_some(0); + let voting = &platform_version.dpp.voting_versions; + let abci_voting = &platform_version.drive_abci.methods.voting; + let drive_methods = &platform_version.drive.methods; + let yes_no = &drive_methods.vote.yes_no; + let slots = [ + ( + "yes_no_vote_poll_stored_info_version", + voting.yes_no_vote_poll_stored_info_version, + ), + ("yes_no_vote_poll_id", voting.yes_no_vote_poll_id), + ( + "yes_no_vote_poll_parameters", + voting.yes_no_vote_poll_parameters, + ), + ( + "yes_no_vote_poll_decision", + voting.yes_no_vote_poll_decision, + ), + ( + "tally_votes_for_yes_no_vote_poll", + abci_voting.tally_votes_for_yes_no_vote_poll, + ), + ( + "keep_record_of_finished_yes_no_vote_poll", + abci_voting.keep_record_of_finished_yes_no_vote_poll, + ), + ( + "clean_up_after_yes_no_vote_polls_end", + abci_voting.clean_up_after_yes_no_vote_polls_end, + ), + ( + "on_yes_no_vote_poll_finished", + abci_voting.on_yes_no_vote_poll_finished, + ), + ( + "verify_yes_no_vote_poll_state_proof", + drive_methods + .verify + .voting + .verify_yes_no_vote_poll_state_proof, + ), + ( + "verify_masternode_yes_no_vote", + drive_methods.verify.voting.verify_masternode_yes_no_vote, + ), + ( + "remove_vote_poll_end_date_query_operations", + drive_methods + .vote + .cleanup + .remove_vote_poll_end_date_query_operations, + ), + ("open_yes_no_vote_poll", yes_no.open_yes_no_vote_poll), + ( + "register_yes_no_identity_vote", + yes_no.register_yes_no_identity_vote, + ), + ( + "insert_stored_info_for_yes_no_vote_poll", + yes_no.insert_stored_info_for_yes_no_vote_poll, + ), + ( + "fetch_yes_no_vote_poll_stored_info", + yes_no.fetch_yes_no_vote_poll_stored_info, + ), + ( + "fetch_identity_yes_no_vote", + yes_no.fetch_identity_yes_no_vote, + ), + ( + "fetch_identities_voting_in_yes_no_vote_poll", + yes_no.fetch_identities_voting_in_yes_no_vote_poll, + ), + ( + "fetch_yes_no_vote_poll_state", + yes_no.fetch_yes_no_vote_poll_state, + ), + ( + "remove_yes_no_vote_poll_votes_operations", + yes_no.remove_yes_no_vote_poll_votes_operations, + ), + ( + "remove_yes_no_vote_references_given_by_identity", + yes_no.remove_yes_no_vote_references_given_by_identity, + ), + ]; + for (method, version) in slots { + assert_eq!( + version, expected, + "{method} at protocol version {}", + platform_version.protocol_version + ); + } + } + } + + // Protocol version 15 tallies yes/no votes and share minimums with these weights, so they + // cannot change in its table without changing how its blocks replay. + #[test] + fn should_weigh_yes_no_votes_one_for_a_masternode_and_four_for_an_evonode_at_protocol_version_15( + ) { + let limits = &PlatformVersion::get(YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION) + .expect("expected protocol version 15") + .system_limits; + assert_eq!(limits.yes_no_vote_poll_masternode_vote_strength, 1); + assert_eq!(limits.yes_no_vote_poll_evonode_vote_strength, 4); + } +} diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index af6da317d5a..b20fd65d2bb 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -254,6 +254,30 @@ pub struct SystemLimits { /// warning or a moderator's document deletion carries (`ContractModerationReason::text`). Read by the `ContractUserModeration` basic /// structure validation v0 (protocol version 14) and never reached before. pub max_contract_moderation_reason_length: u16, + /// The most segments a yes/no vote poll's resource path may have, read by + /// `YesNoVotePoll::validate_parameters` (protocol version 15). + pub max_yes_no_vote_poll_resource_path_segments: u8, + /// The most bytes a yes/no vote poll's resource path may hold across its segments, read by + /// `YesNoVotePoll::validate_parameters` (protocol version 15). Every vote carries the poll. + pub max_yes_no_vote_poll_resource_path_bytes: u16, + /// The largest fixed minimum voting power a yes/no vote poll may require, read by + /// `YesNoVotePoll::validate_parameters` (protocol version 15). + pub max_yes_no_vote_poll_minimum_voting_power: u32, + /// The largest share of the total voting power, in percent, a yes/no vote poll's minimum may + /// require, read by `YesNoVotePoll::validate_parameters` (protocol version 15). + pub max_yes_no_vote_poll_minimum_voting_power_percent_of_total: u8, + /// The longest a yes/no vote poll may run, in milliseconds from the block that opens it to + /// its end date: four weeks, the longest election window a contract may declare. Read by + /// `Drive::open_yes_no_vote_poll` (protocol version 15). + pub max_yes_no_vote_poll_duration_ms: u64, + /// The voting power a regular masternode's yes/no vote carries, and what it adds to the + /// masternode list total a share minimum is measured against. Read by masternode vote + /// `transform_into_action` v1 and `check_for_ended_vote_polls` v1 (protocol version 15). + pub yes_no_vote_poll_masternode_vote_strength: u8, + /// The voting power an evonode's yes/no vote carries, and what it adds to the masternode + /// list total a share minimum is measured against. Read by masternode vote + /// `transform_into_action` v1 and `check_for_ended_vote_polls` v1 (protocol version 15). + pub yes_no_vote_poll_evonode_vote_strength: u8, /// Maximum number of warnings one identity may carry on a contract's warning list at a /// time: a warn that would exceed it is refused until the warnings are cleared. Read by /// the `ContractUserModeration` state validation v0 (protocol version 14) and never diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index dc3f9679f2a..38ef587a2e6 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -63,6 +63,13 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_yes_no_vote_poll_resource_path_segments: 16, + max_yes_no_vote_poll_resource_path_bytes: 1024, + max_yes_no_vote_poll_minimum_voting_power: 2000, + max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, + max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks + yes_no_vote_poll_masternode_vote_strength: 1, + yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, min_mainnet_contract_moderation_election_window_seconds: 86_400, // one day diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index 332d6052f85..8c9198f9a44 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -44,6 +44,13 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_yes_no_vote_poll_resource_path_segments: 16, + max_yes_no_vote_poll_resource_path_bytes: 1024, + max_yes_no_vote_poll_minimum_voting_power: 2000, + max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, + max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks + yes_no_vote_poll_masternode_vote_strength: 1, + yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, min_mainnet_contract_moderation_election_window_seconds: 86_400, // one day diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index 2774b855cda..950f730fe29 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -46,6 +46,13 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_yes_no_vote_poll_resource_path_segments: 16, + max_yes_no_vote_poll_resource_path_bytes: 1024, + max_yes_no_vote_poll_minimum_voting_power: 2000, + max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, + max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks + yes_no_vote_poll_masternode_vote_strength: 1, + yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, min_mainnet_contract_moderation_election_window_seconds: 86_400, // one day diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index c629e4c3249..ffce24773b5 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -167,6 +167,13 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_yes_no_vote_poll_resource_path_segments: 16, + max_yes_no_vote_poll_resource_path_bytes: 1024, + max_yes_no_vote_poll_minimum_voting_power: 2000, + max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, + max_yes_no_vote_poll_duration_ms: 2_419_200_000, // four weeks + yes_no_vote_poll_masternode_vote_strength: 1, + yes_no_vote_poll_evonode_vote_strength: 4, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, min_mainnet_contract_moderation_election_window_seconds: 86_400, // one day diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 67f82921101..855e4a41567 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -12,7 +12,7 @@ use crate::version::dpp_versions::dpp_state_transition_serialization_versions::v use crate::version::dpp_versions::dpp_state_transition_versions::v4::STATE_TRANSITION_VERSIONS_V4; use crate::version::dpp_versions::dpp_token_versions::v3::TOKEN_VERSIONS_V3; use crate::version::dpp_versions::dpp_validation_versions::v5::DPP_VALIDATION_VERSIONS_V5; -use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; +use crate::version::dpp_versions::dpp_voting_versions::v3::VOTING_VERSION_V3; use crate::version::dpp_versions::DPPVersion; use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; use crate::version::drive_abci_versions::drive_abci_method_versions::v11::DRIVE_ABCI_METHOD_VERSIONS_V11; @@ -198,13 +198,38 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// contract, performed or a group member's vote, with `DataContractIsReadonlyError` (40001), /// charged, so check_tx refuses it too. Protocol v14 and earlier keep the batch /// `advanced_structure` 1. +/// +/// 10. **Yes/no vote polls**: a second kind of masternode vote poll, which asks +/// "should this happen?" and passes on a supermajority of yes over yes plus +/// no with a minimum of voting power cast. A masternode vote naming a yes/no +/// poll is active from this version (`StateTransition::active_version_range`); +/// the polls live under the votes tree's decisions branch, which genesis +/// (`create_initial_state_structure` 4 behind +/// `>= YES_NO_VOTE_POLL_INITIAL_PROTOCOL_VERSION`, 15) and +/// `transition_to_version_15` create. Masternode vote `transform_into_action` +/// 1 (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) looks up the previous answer, and +/// `DRIVE_VERSION_V10` registers and sweeps the votes. Opening a yes/no poll +/// (`Drive::open_yes_no_vote_poll`; every `yes_no` drive method is `Some(0)`) is active +/// from this version, for an end date at most `max_yes_no_vote_poll_duration_ms` +/// (four weeks, a `SystemLimits` field read only by the opener) after the +/// opening block, and its end date entry goes through +/// `add_vote_poll_end_date_query_operations` 1, which adds only the entry when +/// the batch already queues the end date's tree with other storage flags. +/// Every other yes/no method is active from this version too and `None` +/// (not active) in every earlier table: the poll's id and rules (`VOTING_VERSION_V3`), +/// the end-of-block tally, record and clean-up (`DRIVE_ABCI_METHOD_VERSIONS_V11` +/// voting), removing finished polls from the end date index +/// (`remove_vote_poll_end_date_query_operations`) and the proof verifiers +/// (`DRIVE_VERIFY_METHOD_VERSIONS_V4`). +/// Nothing calls the opener yet, so at this version every yes/no vote is +/// refused for want of a poll. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, - drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8) + drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10) drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, - methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: verify_chain_lock_locally 1 rejects a chain lock locally only when Platform holds the quorums active at its signing height, and verify_chain_lock_through_core 1 has Core's verifychainlock check the others before submitchainlock - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V11, // changed: identity update advanced_structure 1 refuses unpaid an identity update whose added key's proof of possession does not verify (note 2); create_domain_data_trigger 2 (note 3); contract update transform_into_action 1 holds an update to the compiled size cap in check_tx (note 8); batch advanced_structure 2 refuses a token config update on a read-only contract (note 9) + methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: the yes/no poll's tally, record, clean-up and finish hook are Some(0) (note 10); verify_chain_lock_locally 1 rejects a chain lock locally only when Platform holds the quorums active at its signing height, and verify_chain_lock_through_core 1 has Core's verifychainlock check the others before submitchainlock + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V11, // changed: identity update advanced_structure 1 refuses unpaid an identity update whose added key's proof of possession does not verify (note 2); create_domain_data_trigger 2 (note 3); contract update transform_into_action 1 holds an update to the compiled size cap in check_tx (note 8); batch advanced_structure 2 refuses a token config update on a read-only contract (note 9); masternode vote transform_into_action 1 looks up a masternode's previous answer on a yes/no poll (note 10) withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, query: DRIVE_ABCI_QUERY_VERSIONS_V2, checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, @@ -219,7 +244,7 @@ pub const PLATFORM_V15: PlatformVersion = PlatformVersion { contract_versions: CONTRACT_VERSIONS_V7, // changed: parse_property_constraints 1 lets countOf and sumOf match byte array keys (note 4); validate_document 2 checks documents natively (note 6); check_document_type_schema 1 checks schemas natively at registration (note 7); contract_storage_serialization_version 2 stores contracts compiled (note 8) document_versions: DOCUMENT_VERSIONS_V4, identity_versions: IDENTITY_VERSIONS_V1, - voting_versions: VOTING_VERSION_V2, + voting_versions: VOTING_VERSION_V3, // changed: the yes/no poll's stored info, id, parameter rules and decision rules are active (note 10) token_versions: TOKEN_VERSIONS_V3, asset_lock_versions: DPP_ASSET_LOCK_VERSIONS_V1, methods: DPP_METHOD_VERSIONS_V3, diff --git a/packages/rs-sdk/src/platform/documents/contest_fund.rs b/packages/rs-sdk/src/platform/documents/contest_fund.rs index 740f43218ff..c3e4a6ca644 100644 --- a/packages/rs-sdk/src/platform/documents/contest_fund.rs +++ b/packages/rs-sdk/src/platform/documents/contest_fund.rs @@ -14,7 +14,6 @@ use dpp::document::DocumentV0Getters; use dpp::fee::Credits; use dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dpp::voting::contender_structs::ContenderWithSerializedDocument; -use dpp::voting::vote_polls::VotePoll; use drive::config::DEFAULT_QUERY_LIMIT; use drive::query::vote_poll_vote_state_query::{ ContestedDocumentVotePollDriveQuery, ContestedDocumentVotePollDriveQueryResultType, @@ -56,7 +55,7 @@ impl Sdk { // `generatedFrom` property generated from its params as the platform generates it let mut document = document.clone(); document_type.regenerate_generated_properties(document.properties_mut(), self.version())?; - let Some(VotePoll::ContestedDocumentResourceVotePoll(vote_poll)) = + let Some(vote_poll) = document_type.contested_vote_poll_for_document(&document, self.version())? else { return Ok(None); diff --git a/packages/rs-sdk/src/platform/dpns_usernames/contested_queries.rs b/packages/rs-sdk/src/platform/dpns_usernames/contested_queries.rs index 3805c10c7ff..5476ad62314 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/contested_queries.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/contested_queries.rs @@ -479,70 +479,163 @@ impl Sdk { limit: Option, ) -> Result, Error> { let dpns_contract_id = self.get_dpns_contract_id()?; - let query_limit = limit.unwrap_or(100); + // Zero asks for nothing. Checked here because an empty page read at a small limit is + // read again at the default one below. + if limit == Some(0) { + return Ok(BTreeMap::new()); + } + let mut query_limit = limit.unwrap_or(DEFAULT_QUERY_LIMIT); let mut name_to_end_time: BTreeMap = BTreeMap::new(); let mut current_start_time = start_time.map(|t| (t, true)); + // A proved query takes no offset, so the pages follow the end dates. The limit counts + // the polls of every kind, and also each empty end date tree in range, which returns no + // row: a page can be cut short with fewer polls than the limit, so only an empty page + // ends the listing. loop { - let query = VotePollsByEndDateDriveQuery { - start_time: current_start_time, - end_time: end_time.map(|t| (t, true)), - limit: Some(query_limit), - offset: None, - order_ascending: true, + let result = self + .fetch_vote_polls_by_end_date(current_start_time, end_time, query_limit) + .await?; + let Some(&(last_ts, _)) = result.0.last() else { + // Empty end date trees alone can fill a page and leave it empty. Before ending, + // read on at the largest page, which only that many empty end dates in a row + // would leave empty too (each block sweeps the first due ones away). + if query_limit < DEFAULT_QUERY_LIMIT { + query_limit = DEFAULT_QUERY_LIMIT; + continue; + } + break; }; + let polls_in_page: usize = result.0.iter().map(|(_, polls)| polls.len()).sum(); + let single_end_date = result.0.len() == 1; + record_dpns_contests(&mut name_to_end_time, dpns_contract_id, result); + + if single_end_date && polls_in_page >= query_limit as usize { + // This end date alone filled the page, so the limit may have cut it, and a + // proved query cannot continue within one end date: read it again from that end + // date at the largest limit (nothing before it in range held a poll), or refuse + // rather than leave contests out. + if query_limit >= DEFAULT_QUERY_LIMIT { + return Err(too_many_vote_polls_at_end_date(last_ts)); + } + query_limit = DEFAULT_QUERY_LIMIT; + current_start_time = Some((last_ts, true)); + continue; + } - // Execute the query - let result: VotePollsGroupedByTimestamp = VotePoll::fetch_many(self, query).await?; - - // Check if we got any results - if result.0.is_empty() { - break; + match next_vote_polls_page_start(current_start_time, last_ts, end_time) { + Some(next_start_time) => current_start_time = Some(next_start_time), + None => break, } + } - let mut last_timestamp = None; - let mut polls_in_last_group = 0; - - // Process each timestamp group - for (timestamp, polls) in result.0 { - let mut dpns_polls_count = 0; - - for VotePoll::ContestedDocumentResourceVotePoll(contested_poll) in polls { - if contested_poll.contract_id == dpns_contract_id - && contested_poll.document_type_name == "domain" - { - // Extract the contested name from index_values - if contested_poll.index_values.len() >= 2 { - if let Value::Text(label) = &contested_poll.index_values[1] { - name_to_end_time.insert(label.clone(), timestamp); - dpns_polls_count += 1; - } - } - } - } + Ok(name_to_end_time) + } +} - if dpns_polls_count > 0 { - last_timestamp = Some(timestamp); - polls_in_last_group = dpns_polls_count; +impl Sdk { + /// One proved page of vote polls by end date, from `start_time` up to `end_time` included. + async fn fetch_vote_polls_by_end_date( + &self, + start_time: Option<(TimestampMillis, bool)>, + end_time: Option, + limit: u16, + ) -> Result { + let query = VotePollsByEndDateDriveQuery { + start_time, + end_time: end_time.map(|t| (t, true)), + limit: Some(limit), + offset: None, + order_ascending: true, + }; + VotePoll::fetch_many(self, query).await + } +} + +/// Adds the DPNS name contests of a page to `name_to_end_time`; other contests and yes/no polls, +/// which share the end date index, are skipped. A name seen twice is just written again. +fn record_dpns_contests( + name_to_end_time: &mut BTreeMap, + dpns_contract_id: Identifier, + page: VotePollsGroupedByTimestamp, +) { + for (timestamp, polls) in page.0 { + for poll in polls { + let VotePoll::ContestedDocumentResourceVotePoll(contested_poll) = poll else { + continue; + }; + if contested_poll.contract_id == dpns_contract_id + && contested_poll.document_type_name == "domain" + { + // Extract the contested name from index_values + if let Some(Value::Text(label)) = contested_poll.index_values.get(1) { + name_to_end_time.insert(label.clone(), timestamp); } } + } + } +} - // Check if we should continue pagination - // If we got less than the limit, we've reached the end - if polls_in_last_group < query_limit as usize { - break; - } +/// The error for an end date that holds at least as many vote polls as one page can carry, so +/// that its contests cannot all be listed. +fn too_many_vote_polls_at_end_date(end_date: TimestampMillis) -> Error { + Error::Generic(format!( + "at least {DEFAULT_QUERY_LIMIT} vote polls end at {end_date}, more than \ + one page of vote polls by end date can list" + )) +} - // Set up for next query - use the last timestamp as the new start - // with false (not included) to avoid duplicates - if let Some(last_ts) = last_timestamp { - current_start_time = Some((last_ts, false)); - } else { - break; - } - } +/// Where the page after one that ended at `last_end_date` starts, or `None` when nothing is left. +/// +/// The limit may have cut the last end date short, so the next page starts at it again. A page +/// that started at that end date held only it, all of it, so the next one starts past it, unless +/// it is the caller's end time: a range starting past its own end is refused. +fn next_vote_polls_page_start( + page_start_time: Option<(TimestampMillis, bool)>, + last_end_date: TimestampMillis, + end_time: Option, +) -> Option<(TimestampMillis, bool)> { + if page_start_time != Some((last_end_date, true)) { + Some((last_end_date, true)) + } else if end_time == Some(last_end_date) { + None + } else { + Some((last_end_date, false)) + } +} - Ok(name_to_end_time) +#[cfg(test)] +mod vote_polls_by_end_date_paging_tests { + use super::next_vote_polls_page_start; + + #[test] + fn should_start_the_next_page_at_the_last_end_date_again() { + assert_eq!(next_vote_polls_page_start(None, 10, None), Some((10, true))); + assert_eq!( + next_vote_polls_page_start(Some((5, true)), 10, Some(20)), + Some((10, true)) + ); + } + + #[test] + fn should_move_past_an_end_date_a_page_held_alone() { + assert_eq!( + next_vote_polls_page_start(Some((10, true)), 10, None), + Some((10, false)) + ); + assert_eq!( + next_vote_polls_page_start(Some((10, true)), 10, Some(20)), + Some((10, false)) + ); + } + + #[test] + fn should_stop_past_the_end_time_instead_of_asking_for_an_empty_range() { + // A start past the end time at the same instant is refused by the query. + assert_eq!( + next_vote_polls_page_start(Some((20, true)), 20, Some(20)), + None + ); } } @@ -671,6 +764,19 @@ mod tests { assert_eq!(usernames, names(42, 47)); } + #[tokio::test] + async fn should_list_no_current_contests_for_a_zero_limit_without_asking() { + // No expectation is registered, so any request would fail. + let sdk = mock_sdk(); + + let contests = sdk + .get_current_dpns_contests(None, None, Some(0)) + .await + .expect("expected a zero limit to answer without a request"); + + assert!(contests.is_empty()); + } + #[test] fn should_extract_a_label_answered_as_text() { let label = Sdk::extract_label_from_contested_resource(ContestedResource(Value::Text( diff --git a/packages/rs-sdk/src/platform/transition/vote.rs b/packages/rs-sdk/src/platform/transition/vote.rs index 709310913c0..8046e38e9f9 100644 --- a/packages/rs-sdk/src/platform/transition/vote.rs +++ b/packages/rs-sdk/src/platform/transition/vote.rs @@ -11,7 +11,6 @@ use dpp::identity::IdentityPublicKey; use dpp::prelude::Identifier; use dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; -use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; use dpp::voting::votes::Vote; use rs_dapi_client::{DapiRequest, IntoInner}; @@ -116,8 +115,7 @@ impl> PutVote for Vote { let settings = settings.unwrap_or_default(); - let Vote::ResourceVote(resource_vote) = self; - let vote_poll_id = resource_vote.vote_poll().unique_id()?; + let vote_poll_id = self.vote_poll_unique_id(sdk.version())?; let masternode_vote_transition = MasternodeVoteTransition::try_from_vote_with_signer( self.clone(), @@ -141,14 +139,22 @@ impl> PutVote for Vote { Ok(_) => {} //todo make this more reliable Err(e) => { - return if e.to_string().contains("already exists") { - let vote = - Vote::fetch(sdk, VoteQuery::new(voter_pro_tx_hash, vote_poll_id)).await?; - vote.ok_or(Error::Generic( - "vote was proved to not exist but was said to exist".to_string(), - )) - } else { - Err(e.into()) + if !e.to_string().contains("already exists") { + return Err(e.into()); + } + match self { + Vote::ResourceVote(_) => { + let vote = + Vote::fetch(sdk, VoteQuery::new(voter_pro_tx_hash, vote_poll_id)) + .await?; + return vote.ok_or(Error::Generic( + "vote was proved to not exist but was said to exist".to_string(), + )); + } + // `VoteQuery` reads the contested resource votes index only, so no query + // fetches a yes/no vote yet. The duplicate is this very transition, already + // delivered: wait for it as for one just broadcast, which proves it executed. + Vote::YesNoVote(_) => {} } } } diff --git a/packages/rs-sdk/tests/fetch/mock_fetch_many.rs b/packages/rs-sdk/tests/fetch/mock_fetch_many.rs index 9f183f01594..e7f5760a2a9 100644 --- a/packages/rs-sdk/tests/fetch/mock_fetch_many.rs +++ b/packages/rs-sdk/tests/fetch/mock_fetch_many.rs @@ -20,6 +20,7 @@ use dpp::{ use dpp::{ platform_value::Value, prelude::Identifier, + version::PlatformVersion, voting::{ vote_choices::resource_vote_choice::ResourceVoteChoice, vote_polls::{ @@ -142,7 +143,7 @@ async fn should_match_public_mock_poll_expectations_with_effective_query_limits( async fn should_match_public_mock_identity_vote_expectations_with_effective_query_limits() { let poll = mock_vote_poll(); let expected: ResourceVotesByIdentity = [( - poll.unique_id().unwrap(), + poll.unique_id(PlatformVersion::latest()).unwrap(), Some(ResourceVoteWithCount { resource_vote: ResourceVote::V0(ResourceVoteV0 { vote_poll: poll, diff --git a/packages/rs-sdk/tests/fetch/prefunded_specialized_balance.rs b/packages/rs-sdk/tests/fetch/prefunded_specialized_balance.rs index d8b86b1d941..d624c2ff267 100644 --- a/packages/rs-sdk/tests/fetch/prefunded_specialized_balance.rs +++ b/packages/rs-sdk/tests/fetch/prefunded_specialized_balance.rs @@ -61,7 +61,7 @@ async fn test_prefunded_specialized_balance_ok() { // Vote poll specialized balance ID let balance_id = poll - .specialized_balance_id() + .specialized_balance_id(sdk.version()) .expect("vote poll specialized balance ID") .expect("must have specialized balance ID"); diff --git a/packages/rs-sdk/tests/fetch/vote_poll_default_limit.rs b/packages/rs-sdk/tests/fetch/vote_poll_default_limit.rs index f2763fe5bd5..0f371f69b4b 100644 --- a/packages/rs-sdk/tests/fetch/vote_poll_default_limit.rs +++ b/packages/rs-sdk/tests/fetch/vote_poll_default_limit.rs @@ -240,7 +240,7 @@ async fn should_verify_omitted_limit_identity_vote_page_when_more_than_default_v .map(|i| { let poll = fixture::poll(&contract, i); ( - poll.unique_id().expect("poll id"), + poll.unique_id(sdk.version()).expect("poll id"), Some(ResourceVoteWithCount { resource_vote: ResourceVote::V0(ResourceVoteV0 { vote_poll: poll, diff --git a/packages/rs-sdk/tests/fetch/vote_poll_default_limit_controls.rs b/packages/rs-sdk/tests/fetch/vote_poll_default_limit_controls.rs index e7e8a4cb41a..b83e8e8d676 100644 --- a/packages/rs-sdk/tests/fetch/vote_poll_default_limit_controls.rs +++ b/packages/rs-sdk/tests/fetch/vote_poll_default_limit_controls.rs @@ -74,7 +74,7 @@ async fn should_preserve_exact_voting_pages_for_short_full_descending_and_explic .map(|i| { let poll = fixture::poll(&contract, i); ( - poll.unique_id().unwrap(), + poll.unique_id(sdk.version()).unwrap(), Some(ResourceVoteWithCount { resource_vote: ResourceVote::V0(ResourceVoteV0 { vote_poll: poll, diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 7167f3294ad..ebeda1b906c 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -192,6 +192,7 @@ use dpp::consensus::state::voting::masternode_voted_too_many_times::MasternodeVo use dpp::consensus::state::voting::vote_poll_not_available_for_voting_error::VotePollNotAvailableForVotingError; use dpp::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; use dpp::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; +use dpp::consensus::state::voting::yes_no_vote_poll_not_available_for_voting_error::YesNoVotePollNotAvailableForVotingError; use crate::errors::consensus::basic::data_contract::{ DataContractErrorWasm, DataContractHaveNewUniqueIndexErrorWasm, @@ -808,6 +809,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::ReferencedDocumentPrecededError(e) => { generic_consensus_error!(ReferencedDocumentPrecededError, e).into() } + StateError::YesNoVotePollNotAvailableForVotingError(e) => { + generic_consensus_error!(YesNoVotePollNotAvailableForVotingError, e).into() + } } } diff --git a/packages/wasm-dpp/src/voting/state_transition/masternode_vote_transition/mod.rs b/packages/wasm-dpp/src/voting/state_transition/masternode_vote_transition/mod.rs index 78b481c1ee5..5b0e72847d9 100644 --- a/packages/wasm-dpp/src/voting/state_transition/masternode_vote_transition/mod.rs +++ b/packages/wasm-dpp/src/voting/state_transition/masternode_vote_transition/mod.rs @@ -301,8 +301,10 @@ impl MasternodeVoteTransitionWasm { Some(js_object) } + VotePoll::YesNoVotePoll(_) => None, } } + Vote::YesNoVote(_) => None, } } diff --git a/packages/wasm-dpp2/src/voting/resource_vote.rs b/packages/wasm-dpp2/src/voting/resource_vote.rs index c7a474fd97d..2054f8a5dc5 100644 --- a/packages/wasm-dpp2/src/voting/resource_vote.rs +++ b/packages/wasm-dpp2/src/voting/resource_vote.rs @@ -1,3 +1,4 @@ +use crate::error::WasmDppResult; use crate::impl_wasm_conversions_inner; use crate::impl_wasm_type_info; use crate::voting::resource_vote_choice::ResourceVoteChoiceWasm; @@ -57,11 +58,14 @@ impl From for ResourceVote { #[wasm_bindgen(js_class = ResourceVote)] impl ResourceVoteWasm { #[wasm_bindgen(constructor)] - pub fn constructor(poll: &VotePollWasm, choice: &ResourceVoteChoiceWasm) -> Self { - ResourceVoteWasm(ResourceVote::V0(ResourceVoteV0 { - vote_poll: poll.clone().into(), - resource_vote_choice: choice.clone().into(), - })) + pub fn constructor( + poll: &VotePollWasm, + choice: &ResourceVoteChoiceWasm, + ) -> WasmDppResult { + Ok(ResourceVoteWasm(ResourceVote::V0(ResourceVoteV0::try_new( + poll.clone().into(), + choice.clone().into(), + )?))) } #[wasm_bindgen(getter = poll)] @@ -75,26 +79,25 @@ impl ResourceVoteWasm { } #[wasm_bindgen(setter = poll)] - pub fn set_poll(&mut self, poll: &VotePollWasm) { + pub fn set_poll(&mut self, poll: &VotePollWasm) -> WasmDppResult<()> { let ResourceVote::V0(ResourceVoteV0 { resource_vote_choice, .. }) = self.0.clone(); - self.0 = ResourceVote::V0(ResourceVoteV0 { - vote_poll: poll.clone().into(), + self.0 = ResourceVote::V0(ResourceVoteV0::try_new( + poll.clone().into(), resource_vote_choice, - }); + )?); + Ok(()) } #[wasm_bindgen(setter = choice)] - pub fn set_choice(&mut self, choice: &ResourceVoteChoiceWasm) { + pub fn set_choice(&mut self, choice: &ResourceVoteChoiceWasm) -> WasmDppResult<()> { let ResourceVote::V0(ResourceVoteV0 { vote_poll, .. }) = self.0.clone(); - self.0 = ResourceVote::V0(ResourceVoteV0 { - vote_poll, - resource_vote_choice: choice.clone().into(), - }); + self.0 = ResourceVote::V0(ResourceVoteV0::try_new(vote_poll, choice.clone().into())?); + Ok(()) } } diff --git a/packages/wasm-dpp2/src/voting/vote.rs b/packages/wasm-dpp2/src/voting/vote.rs index 5b409af1444..229ab1c3853 100644 --- a/packages/wasm-dpp2/src/voting/vote.rs +++ b/packages/wasm-dpp2/src/voting/vote.rs @@ -1,39 +1,63 @@ +use crate::error::{WasmDppError, WasmDppResult}; use crate::impl_try_from_js_value; use crate::impl_wasm_conversions_inner; use crate::impl_wasm_type_info; use crate::voting::resource_vote_choice::ResourceVoteChoiceWasm; use crate::voting::vote_poll::VotePollWasm; +use dpp::voting::vote_polls::VotePoll; use dpp::voting::votes::Vote; use dpp::voting::votes::resource_vote::ResourceVote; use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; +use dpp::voting::votes::yes_no_vote::YesNoVote; +use dpp::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; +use dpp::voting::votes::yes_no_vote::v0::YesNoVoteV0; use wasm_bindgen::prelude::wasm_bindgen; #[wasm_bindgen(typescript_custom_section)] const TS_TYPES: &str = r#" +/** + * A yes/no vote's answer, the same as a plain object and as JSON. + */ +export type YesNoAbstainVoteChoiceName = "yes" | "no" | "abstain"; + /** * Vote serialized as a plain object. * * Internally tagged with `$type` ($-prefix because the level also carries - * the inner ResourceVote's `$formatVersion`). The single ResourceVote - * variant flattens its V0 body — no `data` wrapper. + * the inner vote's `$formatVersion`). Each variant flattens its V0 body — no + * `data` wrapper. A yes/no vote carries its poll's fields untagged. */ -export interface VoteObject { - $type: "resourceVote"; - $formatVersion: string; - votePoll: VotePollObject; - resourceVoteChoice: ResourceVoteChoiceObject; -} +export type VoteObject = + | { + $type: "resourceVote"; + $formatVersion: string; + votePoll: VotePollObject; + resourceVoteChoice: ResourceVoteChoiceObject; + } + | { + $type: "yesNoVote"; + $formatVersion: string; + votePoll: YesNoVotePollFieldsObject; + voteChoice: YesNoAbstainVoteChoiceName; + }; /** * Vote serialized as JSON. */ -export interface VoteJSON { - $type: "resourceVote"; - $formatVersion: string; - votePoll: VotePollJSON; - resourceVoteChoice: ResourceVoteChoiceJSON; -} +export type VoteJSON = + | { + $type: "resourceVote"; + $formatVersion: string; + votePoll: VotePollJSON; + resourceVoteChoice: ResourceVoteChoiceJSON; + } + | { + $type: "yesNoVote"; + $formatVersion: string; + votePoll: YesNoVotePollFieldsJSON; + voteChoice: YesNoAbstainVoteChoiceName; + }; "#; #[wasm_bindgen] @@ -69,48 +93,90 @@ impl VoteWasm { #[wasm_bindgen(js_name = "votePoll")] vote_poll: &VotePollWasm, #[wasm_bindgen(js_name = "resourceVoteChoice")] resource_vote_choice: &ResourceVoteChoiceWasm, - ) -> Self { - VoteWasm(Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { - vote_poll: vote_poll.clone().into(), - resource_vote_choice: resource_vote_choice.clone().into(), - }))) + ) -> WasmDppResult { + Ok(VoteWasm(Vote::ResourceVote(ResourceVote::V0( + ResourceVoteV0::try_new( + vote_poll.clone().into(), + resource_vote_choice.clone().into(), + )?, + )))) } #[wasm_bindgen(getter = poll)] pub fn poll(&self) -> VotePollWasm { match &self.0 { Vote::ResourceVote(vote) => vote.vote_poll().clone().into(), + Vote::YesNoVote(vote) => VotePoll::YesNoVotePoll(vote.vote_poll().clone()).into(), } } + /// The resource vote choice of a resource vote. A yes/no vote has `yesNoChoice` instead. #[wasm_bindgen(getter = choice)] - pub fn choice(&self) -> ResourceVoteChoiceWasm { + pub fn choice(&self) -> WasmDppResult { match &self.0 { - Vote::ResourceVote(vote) => vote.resource_vote_choice().into(), + Vote::ResourceVote(vote) => Ok(vote.resource_vote_choice().into()), + Vote::YesNoVote(_) => Err(not_a_resource_vote()), + } + } + + /// The answer of a yes/no vote: `yes`, `no` or `abstain`. + #[wasm_bindgen(getter = "yesNoChoice")] + pub fn yes_no_choice(&self) -> WasmDppResult { + match &self.0 { + Vote::ResourceVote(_) => Err(WasmDppError::invalid_argument( + "this vote is a resource vote, not a yes/no vote".to_string(), + )), + // The name the choice's serde writes, so it always matches `toJSON()`. + Vote::YesNoVote(vote) => serde_json::to_value(vote.vote_choice()) + .map_err(|e| WasmDppError::serialization(e.to_string()))? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + WasmDppError::serialization("a yes/no vote choice without a name".to_string()) + }), } } #[wasm_bindgen(setter = poll)] - pub fn set_poll(&mut self, poll: &VotePollWasm) { + pub fn set_poll(&mut self, poll: &VotePollWasm) -> WasmDppResult<()> { self.0 = match self.0.clone() { - Vote::ResourceVote(vote) => Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { - vote_poll: poll.clone().into(), - resource_vote_choice: vote.resource_vote_choice(), - })), - } + Vote::ResourceVote(vote) => Vote::ResourceVote(ResourceVote::V0( + ResourceVoteV0::try_new(poll.clone().into(), vote.resource_vote_choice())?, + )), + Vote::YesNoVote(vote) => { + let VotePoll::YesNoVotePoll(vote_poll) = poll.clone().into() else { + return Err(WasmDppError::invalid_argument( + "a yes/no vote answers a yes/no vote poll".to_string(), + )); + }; + Vote::YesNoVote(YesNoVote::V0(YesNoVoteV0 { + vote_poll, + vote_choice: vote.vote_choice(), + })) + } + }; + + Ok(()) } #[wasm_bindgen(setter = choice)] - pub fn set_choice(&mut self, choice: &ResourceVoteChoiceWasm) { + pub fn set_choice(&mut self, choice: &ResourceVoteChoiceWasm) -> WasmDppResult<()> { self.0 = match self.0.clone() { - Vote::ResourceVote(vote) => Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { - vote_poll: vote.vote_poll().clone(), - resource_vote_choice: choice.clone().into(), - })), - } + Vote::ResourceVote(vote) => Vote::ResourceVote(ResourceVote::V0( + ResourceVoteV0::try_new(vote.vote_poll().clone(), choice.clone().into())?, + )), + Vote::YesNoVote(_) => return Err(not_a_resource_vote()), + }; + + Ok(()) } } +/// The resource vote choice exists on a resource vote only. +fn not_a_resource_vote() -> WasmDppError { + WasmDppError::invalid_argument("this vote is a yes/no vote, not a resource vote".to_string()) +} + impl_try_from_js_value!(VoteWasm, "Vote"); impl_wasm_conversions_inner!(VoteWasm, Vote, Vote, VoteObjectJs, VoteJSONJs); impl_wasm_type_info!(VoteWasm, Vote); diff --git a/packages/wasm-dpp2/src/voting/vote_poll.rs b/packages/wasm-dpp2/src/voting/vote_poll.rs index d1be2052a43..a31fdf70960 100644 --- a/packages/wasm-dpp2/src/voting/vote_poll.rs +++ b/packages/wasm-dpp2/src/voting/vote_poll.rs @@ -29,31 +29,70 @@ export interface VotePollOptions { indexValues: any[]; } +/** + * A yes/no poll's minimum yes plus no voting power, the same as a plain object + * and as JSON: a fixed voting power, or a share of the masternode list's total + * voting power when the poll closes. + */ +export type YesNoMinimumVotingPowerObject = + | { $type: "absolute"; votingPower: number } + | { + $type: "fractionOfTotal"; + numerator: number; + denominator: number; + rounding: "up" | "down" | "downPlusOne" | "upMinusOne"; + }; + +/** + * A yes/no poll's fields as a plain object. A yes/no vote carries its poll + * like this, without a `$type` tag. The minimum is a fixed voting power or a + * share of the masternode list's total voting power when the poll closes. + */ +export interface YesNoVotePollFieldsObject { + resourcePath: Uint8Array[]; + supermajorityNumerator: number; + supermajorityDenominator: number; + minimumVotingPower: YesNoMinimumVotingPowerObject; +} + +/** + * A yes/no poll's fields as JSON (resource path segments in base64). + */ +export interface YesNoVotePollFieldsJSON { + resourcePath: string[]; + supermajorityNumerator: number; + supermajorityDenominator: number; + minimumVotingPower: YesNoMinimumVotingPowerObject; +} + /** * VotePoll serialized as a plain object. * - * Internally tagged with `type` (plain — no `$`-prefixed neighbors at - * this level). Inner ContestedDocumentResourceVotePoll fields flatten at - * the same level — no `data` wrapper. + * Internally tagged with `$type`. The inner poll's fields flatten at the + * same level — no `data` wrapper. */ -export interface VotePollObject { - $type: "contestedDocumentResourceVotePoll"; - contractId: Uint8Array; - documentTypeName: string; - indexName: string; - indexValues: any[]; -} +export type VotePollObject = + | { + $type: "contestedDocumentResourceVotePoll"; + contractId: Uint8Array; + documentTypeName: string; + indexName: string; + indexValues: any[]; + } + | ({ $type: "yesNoVotePoll" } & YesNoVotePollFieldsObject); /** * VotePoll serialized as JSON. */ -export interface VotePollJSON { - $type: "contestedDocumentResourceVotePoll"; - contractId: string; - documentTypeName: string; - indexName: string; - indexValues: any[]; -} +export type VotePollJSON = + | { + $type: "contestedDocumentResourceVotePoll"; + contractId: string; + documentTypeName: string; + indexName: string; + indexValues: any[]; + } + | ({ $type: "yesNoVotePoll" } & YesNoVotePollFieldsJSON); "#; #[wasm_bindgen] @@ -119,26 +158,43 @@ impl VotePollWasm { self.0.to_string() } + /// The kind of poll: `contestedDocumentResourceVotePoll` or `yesNoVotePoll`, the `$type` + /// tag `toJSON()` writes (the specs check they agree). + #[wasm_bindgen(getter = "kind")] + pub fn kind(&self) -> String { + match &self.0 { + VotePoll::ContestedDocumentResourceVotePoll(_) => { + "contestedDocumentResourceVotePoll".to_string() + } + VotePoll::YesNoVotePoll(_) => "yesNoVotePoll".to_string(), + } + } + #[wasm_bindgen(getter = "contractId")] - pub fn contract_id(&self) -> IdentifierWasm { + pub fn contract_id(&self) -> WasmDppResult { match &self.0 { VotePoll::ContestedDocumentResourceVotePoll(poll) => { - IdentifierWasm::from(poll.contract_id) + Ok(IdentifierWasm::from(poll.contract_id)) } + VotePoll::YesNoVotePoll(_) => Err(not_a_contested_poll()), } } #[wasm_bindgen(getter = "documentTypeName")] - pub fn document_type_name(&self) -> String { + pub fn document_type_name(&self) -> WasmDppResult { match &self.0 { - VotePoll::ContestedDocumentResourceVotePoll(poll) => poll.document_type_name.clone(), + VotePoll::ContestedDocumentResourceVotePoll(poll) => { + Ok(poll.document_type_name.clone()) + } + VotePoll::YesNoVotePoll(_) => Err(not_a_contested_poll()), } } #[wasm_bindgen(getter = "indexName")] - pub fn index_name(&self) -> String { + pub fn index_name(&self) -> WasmDppResult { match &self.0 { - VotePoll::ContestedDocumentResourceVotePoll(poll) => poll.index_name.clone(), + VotePoll::ContestedDocumentResourceVotePoll(poll) => Ok(poll.index_name.clone()), + VotePoll::YesNoVotePoll(_) => Err(not_a_contested_poll()), } } @@ -165,6 +221,7 @@ impl VotePollWasm { Ok(js_array) } + VotePoll::YesNoVotePoll(_) => Err(not_a_contested_poll()), } } @@ -181,6 +238,7 @@ impl VotePollWasm { VotePoll::ContestedDocumentResourceVotePoll(poll) } + VotePoll::YesNoVotePoll(_) => return Err(not_a_contested_poll()), }; Ok(()) @@ -190,25 +248,34 @@ impl VotePollWasm { pub fn set_document_type_name( &mut self, #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, - ) { + ) -> WasmDppResult<()> { self.0 = match self.0.clone() { VotePoll::ContestedDocumentResourceVotePoll(mut poll) => { poll.document_type_name = document_type_name; VotePoll::ContestedDocumentResourceVotePoll(poll) } - } + VotePoll::YesNoVotePoll(_) => return Err(not_a_contested_poll()), + }; + + Ok(()) } #[wasm_bindgen(setter = "indexName")] - pub fn set_index_name(&mut self, #[wasm_bindgen(js_name = "indexName")] index_name: String) { + pub fn set_index_name( + &mut self, + #[wasm_bindgen(js_name = "indexName")] index_name: String, + ) -> WasmDppResult<()> { self.0 = match self.0.clone() { VotePoll::ContestedDocumentResourceVotePoll(mut poll) => { poll.index_name = index_name; VotePoll::ContestedDocumentResourceVotePoll(poll) } + VotePoll::YesNoVotePoll(_) => return Err(not_a_contested_poll()), }; + + Ok(()) } #[wasm_bindgen(setter = "indexValues")] @@ -228,12 +295,21 @@ impl VotePollWasm { VotePoll::ContestedDocumentResourceVotePoll(poll) } + VotePoll::YesNoVotePoll(_) => return Err(not_a_contested_poll()), }; Ok(()) } } +/// The contested index fields exist on a contested document resource poll only. +fn not_a_contested_poll() -> WasmDppError { + WasmDppError::invalid_argument( + "this vote poll is a yes/no vote poll, not a contested document resource vote poll" + .to_string(), + ) +} + impl_try_from_js_value!(VotePollWasm, "VotePoll"); impl_try_from_options!(VotePollWasm); impl_wasm_conversions_inner!( diff --git a/packages/wasm-dpp2/tests/unit/ResourceVote.spec.ts b/packages/wasm-dpp2/tests/unit/ResourceVote.spec.ts index f1aeef73113..dcaeb7fb480 100644 --- a/packages/wasm-dpp2/tests/unit/ResourceVote.spec.ts +++ b/packages/wasm-dpp2/tests/unit/ResourceVote.spec.ts @@ -107,4 +107,48 @@ describe('ResourceVote', () => { vote.free(); }); }); + + it('should refuse a yes/no poll', () => { + const yesNoPoll = wasm.VotePoll.fromJSON({ + $type: 'yesNoVotePoll', + resourcePath: ['wcHBwQ=='], + supermajorityNumerator: 2, + supermajorityDenominator: 3, + minimumVotingPower: { $type: 'absolute', votingPower: 400 }, + }); + const choice = wasm.ResourceVoteChoice.Abstain(); + + expect(() => new wasm.ResourceVote(yesNoPoll, choice)).to.throw(); + + const vote = new wasm.ResourceVote(createPoll(), choice); + expect(() => { + vote.poll = yesNoPoll; + }).to.throw(); + + vote.free(); + yesNoPoll.free(); + }); + + it('should refuse a yes/no poll from JSON or an object', () => { + const yesNoPoll = wasm.VotePoll.fromJSON({ + $type: 'yesNoVotePoll', + resourcePath: ['wcHBwQ=='], + supermajorityNumerator: 2, + supermajorityDenominator: 3, + minimumVotingPower: { $type: 'absolute', votingPower: 400 }, + }); + const vote = new wasm.ResourceVote(createPoll(), wasm.ResourceVoteChoice.Abstain()); + + wasm.ResourceVote.fromJSON(vote.toJSON()).free(); + wasm.ResourceVote.fromObject(vote.toObject()).free(); + expect(() => wasm.ResourceVote.fromJSON({ ...vote.toJSON(), votePoll: yesNoPoll.toJSON() })) + .to.throw(); + expect(() => wasm.ResourceVote.fromObject({ + ...vote.toObject(), + votePoll: yesNoPoll.toObject(), + })).to.throw(); + + vote.free(); + yesNoPoll.free(); + }); }); diff --git a/packages/wasm-dpp2/tests/unit/Vote.spec.ts b/packages/wasm-dpp2/tests/unit/Vote.spec.ts index ed5708195f8..2a534ed7f8f 100644 --- a/packages/wasm-dpp2/tests/unit/Vote.spec.ts +++ b/packages/wasm-dpp2/tests/unit/Vote.spec.ts @@ -87,4 +87,100 @@ describe('Vote', () => { vote.free(); }); }); + + describe('yes/no vote', () => { + // A yes/no vote carries its poll's fields untagged. + const yesNoVoteJSON = { + $type: 'yesNoVote', + $formatVersion: '0', + votePoll: { + resourcePath: ['wcHBwQ=='], + supermajorityNumerator: 2, + supermajorityDenominator: 3, + minimumVotingPower: { $type: 'absolute', votingPower: 400 }, + }, + voteChoice: 'yes', + }; + + it('should serialize toJSON() with the yesNoVote tag and the answer', () => { + const vote = wasm.Vote.fromJSON(yesNoVoteJSON); + const json = vote.toJSON(); + + expect(json.$type).to.equal('yesNoVote'); + expect(json.$formatVersion).to.equal('0'); + expect(json.votePoll).to.deep.equal(yesNoVoteJSON.votePoll); + expect(json.voteChoice).to.equal('yes'); + expect(json).to.deep.equal(yesNoVoteJSON); + + vote.free(); + }); + + it('should round-trip through toObject() and fromObject()', () => { + const vote = wasm.Vote.fromJSON(yesNoVoteJSON); + const obj = vote.toObject(); + + expect(obj.$type).to.equal('yesNoVote'); + expect(obj.votePoll.resourcePath[0]).to.be.instanceOf(Uint8Array); + expect(obj.voteChoice).to.equal('yes'); + + const restored = wasm.Vote.fromObject(obj); + expect(restored.toObject()).to.deep.equal(obj); + + vote.free(); + restored.free(); + }); + + it('should expose its answer and poll, and refuse the resource vote choice', () => { + const vote = wasm.Vote.fromJSON(yesNoVoteJSON); + + expect(vote.yesNoChoice).to.equal('yes'); + expect(vote.poll.kind).to.equal('yesNoVotePoll'); + expect(() => vote.choice).to.throw(); + + vote.free(); + }); + + it('should refuse the yes/no answer of a resource vote', () => { + const vote = new wasm.Vote(createPoll(), wasm.ResourceVoteChoice.Abstain()); + + expect(() => vote.yesNoChoice).to.throw(); + + vote.free(); + }); + + it('should refuse a resource vote on a yes/no poll', () => { + const yesNoPoll = wasm.VotePoll.fromJSON({ + $type: 'yesNoVotePoll', + ...yesNoVoteJSON.votePoll, + }); + + expect(() => new wasm.Vote(yesNoPoll, wasm.ResourceVoteChoice.Abstain())).to.throw(); + + const vote = new wasm.Vote(createPoll(), wasm.ResourceVoteChoice.Abstain()); + expect(() => { + vote.poll = yesNoPoll; + }).to.throw(); + + vote.free(); + yesNoPoll.free(); + }); + + it('should refuse a resource vote on a yes/no poll from JSON or an object', () => { + const yesNoPoll = wasm.VotePoll.fromJSON({ + $type: 'yesNoVotePoll', + ...yesNoVoteJSON.votePoll, + }); + const vote = new wasm.Vote(createPoll(), wasm.ResourceVoteChoice.Abstain()); + + wasm.Vote.fromJSON(vote.toJSON()).free(); + wasm.Vote.fromObject(vote.toObject()).free(); + expect(() => wasm.Vote.fromJSON({ ...vote.toJSON(), votePoll: yesNoPoll.toJSON() })) + .to.throw(); + expect(() => wasm.Vote.fromObject({ ...vote.toObject(), votePoll: yesNoPoll.toObject() })) + .to.throw(); + + vote.free(); + yesNoPoll.free(); + }); + }); }); diff --git a/packages/wasm-dpp2/tests/unit/VotePoll.spec.ts b/packages/wasm-dpp2/tests/unit/VotePoll.spec.ts index a5dd2e4f755..3bdb2b504a1 100644 --- a/packages/wasm-dpp2/tests/unit/VotePoll.spec.ts +++ b/packages/wasm-dpp2/tests/unit/VotePoll.spec.ts @@ -23,6 +23,7 @@ describe('VotePoll', () => { const json = poll.toJSON(); expect(json.$type).to.equal('contestedDocumentResourceVotePoll'); + expect(poll.kind).to.equal(json.$type); expect(json.contractId).to.equal(testContractId); expect(json.documentTypeName).to.equal('domain'); expect(json.indexName).to.equal('parentNameAndLabel'); @@ -121,4 +122,75 @@ describe('VotePoll', () => { poll.free(); }); }); + + describe('yes/no vote poll', () => { + // A yes/no poll has no constructor of its own; it comes from its object or JSON form. + const yesNoPollJSON = { + $type: 'yesNoVotePoll', + resourcePath: ['wcHBwQ=='], + supermajorityNumerator: 2, + supermajorityDenominator: 3, + minimumVotingPower: { $type: 'absolute', votingPower: 400 }, + }; + + it('should serialize toJSON() with the yesNoVotePoll tag and its rule', () => { + const poll = wasm.VotePoll.fromJSON(yesNoPollJSON); + const json = poll.toJSON(); + + expect(json.$type).to.equal('yesNoVotePoll'); + expect(json.resourcePath).to.deep.equal(['wcHBwQ==']); + expect(json.supermajorityNumerator).to.equal(2); + expect(json.supermajorityDenominator).to.equal(3); + expect(json.minimumVotingPower).to.deep.equal({ $type: 'absolute', votingPower: 400 }); + + poll.free(); + }); + + it('should round-trip a share minimum through fromJSON() and toJSON()', () => { + const fixture = { + ...yesNoPollJSON, + minimumVotingPower: { + $type: 'fractionOfTotal', + numerator: 1, + denominator: 3, + rounding: 'up', + }, + }; + const poll = wasm.VotePoll.fromJSON(fixture); + + expect(poll.toJSON()).to.deep.equal(fixture); + + poll.free(); + }); + + it('should serialize toObject() with Uint8Array path segments and round-trip fromObject()', () => { + const poll = wasm.VotePoll.fromJSON(yesNoPollJSON); + const obj = poll.toObject(); + + expect(obj.$type).to.equal('yesNoVotePoll'); + expect(obj.resourcePath).to.have.lengthOf(1); + expect(obj.resourcePath[0]).to.be.instanceOf(Uint8Array); + expect(Array.from(obj.resourcePath[0])).to.deep.equal([0xc1, 0xc1, 0xc1, 0xc1]); + expect(obj.minimumVotingPower.$type).to.equal('absolute'); + + const restored = wasm.VotePoll.fromObject(obj); + expect(restored.toObject()).to.deep.equal(obj); + + poll.free(); + restored.free(); + }); + + it('should report its kind and refuse the contested poll getters', () => { + const poll = wasm.VotePoll.fromJSON(yesNoPollJSON); + + expect(poll.kind).to.equal('yesNoVotePoll'); + expect(poll.kind).to.equal(poll.toJSON().$type); + expect(() => poll.contractId).to.throw(); + expect(() => poll.documentTypeName).to.throw(); + expect(() => poll.indexName).to.throw(); + expect(() => poll.indexValues).to.throw(); + + poll.free(); + }); + }); }); diff --git a/packages/wasm-sdk/src/state_transitions/identity.rs b/packages/wasm-sdk/src/state_transitions/identity.rs index 0fd35bb11ab..6aacefde7e8 100644 --- a/packages/wasm-sdk/src/state_transitions/identity.rs +++ b/packages/wasm-sdk/src/state_transitions/identity.rs @@ -793,10 +793,10 @@ impl WasmSdk { let settings = try_from_options_optional::(&options, "settings")?.map(Into::into); - let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0::try_new( vote_poll, resource_vote_choice, - })); + )?)); let recorded_vote = vote .put_to_platform_and_wait_for_response(