diff --git a/book/src/data-model/contested-documents.md b/book/src/data-model/contested-documents.md index 6637722ba8d..93b1cf63977 100644 --- a/book/src/data-model/contested-documents.md +++ b/book/src/data-model/contested-documents.md @@ -13,6 +13,13 @@ document's owner may not be joined by the same identity twice. The index's `contested.resolution` says how the contest is decided. +From protocol version 14, an identifier property among the index values is written as an +identifier in the poll (`Index::extract_contested_values`), whether the document gave it as an +identifier, as 32 bytes or as an array of byte values. The index keys store all of these alike, +but a poll is hashed from its values, and that hash keys the contest's prefunded balance and end +date: two contenders writing the same identifier two ways would otherwise split one contest into +two polls. Before 14 the values are taken as given. + ## Resolution 0: masternode vote The DPNS rule. The choices are a contender, abstain, or **lock**, which gives the value to nobody. @@ -33,6 +40,26 @@ poll duration, which opens the vote window. `getVotePollsByEndDate` shows whiche The moderation charters contract uses this resolution to elect moderation teams. +## Moderation elections + +An `electedCharter` contest of the moderation charters contract (protocol version 14), keyed by +the target contract id, is a **moderation election** and does not take the generic parameters: + +- Its join window and vote window are the `joinWindow` and `voteWindow` of the target contract's + elected moderation declaration (one day to four weeks each, one week by default), on every + network. A single applicant wins when the join window closes; a second applicant moves the end + to the join window plus the vote window. A late applicant is refused with + `DocumentContestNotJoinableError` naming the target's join window. +- Each application prefunds the votes with the moderation fund, 0.5 Dash + (`moderation_vote_resolution_fund_required_amount`), instead of the contested document fund. + +The target's declaration is read, and billed, when an application opens the contest and when a +later one joins it; it is frozen at the target's creation, so both reads agree. Nothing at the +end of a contest reads the target: the end date was written when the contest opened or was +joined. A target that is missing or declares something else leaves a contest on the generic +windows instead of failing, and the application's own reference validation refuses it. Every +other contest, DPNS included, keeps the generic windows and fund. + ## Ties From protocol version 14, a tie among the top contenders goes to the **earliest** contender: diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index 5021ba86f99..6dc8a66b8d5 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -137,6 +137,24 @@ to prevent namespace squatting: Before protocol version 9, all registration fees were zero. +### Contest Funds + +A document create that opens or joins a contest (a contested unique index) +prefunds the masternode votes: the amount leaves the contender's balance for the +contest's prefunded balance, each vote takes a fixed cost from it, and what is +left when the contest ends is released as processing fees. The amounts are +`VoteResolutionFundFees` in the fee version: + +| Component | Protocol versions 1 to 13 | Protocol version 14 | +|---|---|---| +| Contested document fund (DPNS and every other contest) | 0.2 Dash | 0.1 Dash | +| Moderation election fund (an `electedCharter` application) | none exist | 0.5 Dash | +| One vote | 0.0001 Dash | 0.00002 Dash | + +`required_vote_resolution_fund` in `rs-dpp` picks between the two funds; the +schedules before 14 carry the contested document amount in the moderation +field, so the choice changes nothing there. + ## User Fee Increase Every state transition carries a `user_fee_increase` field (a `UserFeeIncrease` diff --git a/docs/protocol/moderation-charters.md b/docs/protocol/moderation-charters.md index c8aa0fa901d..9ad85e756b6 100644 --- a/docs/protocol/moderation-charters.md +++ b/docs/protocol/moderation-charters.md @@ -176,8 +176,15 @@ masternodes (weight 1) and evonodes (weight 4) vote for a contender or abstain, with no Lock choice, so the contest always ends with a winner, a tie goes to the earliest contender, and a contest with a single contender at the end of the join window is awarded at once. An elected charter create opens or joins that -contest for its target contract. Reading the join window, the vote window and -the fund from the target contract comes in a later pull request. +contest for its target contract. + +The contest runs on the target contract's own windows, on every network: the +join window is the target's `joinWindow`, and a second applicant moves the end +to `joinWindow` plus `voteWindow`. An application prefunds the masternode +votes with 0.5 Dash (`moderation_vote_resolution_fund_required_amount`), not +the 0.1 Dash of other contests; what the votes leave is released as processing +fees when the contest is cleaned up. The target's declaration is read when an +application opens or joins the contest, never when it ends. ## Seating diff --git a/packages/rs-dpp/src/data_contract/document_type/index/extract_contested_values/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/extract_contested_values/mod.rs new file mode 100644 index 00000000000..ce4335043f3 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/index/extract_contested_values/mod.rs @@ -0,0 +1,135 @@ +mod v0; + +use crate::data_contract::document_type::property::DocumentProperty; +use crate::data_contract::document_type::Index; +use crate::ProtocolError; +use indexmap::IndexMap; +use platform_value::Value; +use platform_version::version::PlatformVersion; +use std::collections::BTreeMap; + +impl Index { + /// The values a contest on this index names its resource by: those of + /// [`Self::extract_values`], with every identifier property written as + /// `Value::Identifier` from protocol version 14. Validation also accepts an identifier as + /// bytes or as an array of 32 byte values, and the index keys store all of them alike, + /// but a contest's poll is hashed from these values: two contenders writing the same + /// identifier in two forms would otherwise name one contest with two polls, each with + /// its own prefunded balance and end date. `document_properties` are the document type's + /// flattened properties. Before 14 the values are taken as given. + /// + /// # Parameters + /// * `data`: the document's properties. + /// * `document_properties`: the document type's flattened properties. + /// * `platform_version`: the platform version. + /// + /// # Returns + /// The index values of the contest, one per index property, in index order. + pub fn extract_contested_values( + &self, + data: &BTreeMap, + document_properties: &IndexMap, + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .canonical_contested_index_values + { + None => Ok(self.extract_values(data)), + Some(0) => Ok(self.extract_contested_values_v0(data, document_properties)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "Index::extract_contested_values".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} + +/// The identifier a contest's index value names, in every form validation accepts for an +/// identifier property: `Value::Identifier`, 32 bytes, or an array of 32 byte values. `None` +/// for any other value, a base58 string included. +pub fn contested_index_identifier(value: &Value) -> Option<[u8; 32]> { + match value { + Value::Identifier(bytes) | Value::Bytes32(bytes) => Some(*bytes), + Value::Bytes(bytes) => <[u8; 32]>::try_from(bytes.as_slice()).ok(), + Value::Array(items) if items.len() == 32 => items + .iter() + .map(|item| item.to_integer::().ok()) + .collect::>>() + .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_contract::document_type::index::tests::make_index; + use crate::data_contract::document_type::property::{ + ByteArrayPropertySizes, DocumentPropertyType, + }; + + /// From protocol version 14 an identifier index value in any accepted form is written as + /// `Value::Identifier`, so every contender of one contest names it with the same poll; a + /// byte array property keeps its bytes. Before 14 the values are taken as given. + #[test] + fn should_write_identifier_contest_values_as_identifiers_from_version_14() { + let index = make_index( + "byTargetContract", + vec![("targetContractId", true), ("salt", true)], + true, + ); + let property = |property_type| DocumentProperty { + property_type, + required: true, + transient: false, + required_since: None, + distinct_from: None, + encrypted_for: None, + }; + let document_properties = IndexMap::from([ + ( + "targetContractId".to_string(), + property(DocumentPropertyType::Identifier), + ), + ( + "salt".to_string(), + property(DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(32), + max_size: Some(32), + })), + ), + ]); + let latest = PlatformVersion::latest(); + let before_14 = PlatformVersion::get(13).expect("protocol version 13"); + + for target in [ + Value::Identifier([0x7A; 32]), + Value::Bytes32([0x7A; 32]), + Value::Bytes(vec![0x7A; 32]), + Value::Array(vec![Value::U8(0x7A); 32]), + ] { + let data = BTreeMap::from([ + ("targetContractId".to_string(), target.clone()), + ("salt".to_string(), Value::Bytes(vec![0x01; 32])), + ]); + assert_eq!( + index + .extract_contested_values(&data, &document_properties, latest) + .expect("values"), + vec![Value::Identifier([0x7A; 32]), Value::Bytes(vec![0x01; 32])], + "{target:?}" + ); + assert_eq!( + index + .extract_contested_values(&data, &document_properties, before_14) + .expect("values"), + vec![target, Value::Bytes(vec![0x01; 32])] + ); + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/index/extract_contested_values/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/extract_contested_values/v0/mod.rs new file mode 100644 index 00000000000..f57bdf4b3c6 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/index/extract_contested_values/v0/mod.rs @@ -0,0 +1,43 @@ +use super::contested_index_identifier; +use crate::data_contract::document_type::property::{DocumentProperty, DocumentPropertyType}; +use crate::data_contract::document_type::Index; +use indexmap::IndexMap; +use platform_value::Value; +use std::collections::BTreeMap; + +impl Index { + /// The index values of `data` with every identifier property written as + /// `Value::Identifier`. + #[inline(always)] + pub(super) fn extract_contested_values_v0( + &self, + data: &BTreeMap, + document_properties: &IndexMap, + ) -> Vec { + self.properties + .iter() + .zip(self.extract_values(data)) + .map(|(index_property, value)| { + let is_identifier = matches!( + document_properties + .get(&index_property.name) + .map(|property| &property.property_type), + Some( + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) + ) + ); + if is_identifier { + canonical_identifier_value(value) + } else { + value + } + }) + .collect() + } +} + +/// An identifier value in any accepted form as `Value::Identifier`; any other value as it is. +fn canonical_identifier_value(value: Value) -> Value { + contested_index_identifier(&value).map_or(value, Value::Identifier) +} diff --git a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs index 35f857b897a..c9fa0eeab26 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs @@ -25,10 +25,12 @@ use std::cmp::Ordering; use std::sync::OnceLock; use std::{collections::BTreeMap, convert::TryFrom}; +mod extract_contested_values; pub mod preallocation; pub mod random_index; pub mod time_range; +pub use extract_contested_values::contested_index_identifier; pub use preallocation::{PreallocatedKeySource, PreallocationBinding}; pub use time_range::TimeRangeTransform; @@ -2637,7 +2639,7 @@ mod tests { } } - fn make_index(name: &str, properties: Vec<(&str, bool)>, unique: bool) -> Index { + pub(super) fn make_index(name: &str, properties: Vec<(&str, bool)>, unique: bool) -> Index { Index { name: name.to_string(), properties: properties 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 a4cf636d440..ff0288ddcf3 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 @@ -611,7 +611,7 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe .methods .contested_vote_poll_for_document { - 0 => Ok(self.contested_vote_poll_for_document_v0(document)), + 0 => self.contested_vote_poll_for_document_v0(document, platform_version), version => Err(ProtocolError::UnknownVersionMismatch { method: "contested_vote_poll_for_document".to_string(), known_versions: vec![0], @@ -632,7 +632,10 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe .methods .contested_vote_poll_for_document { - 0 => Ok(self.contested_vote_poll_for_document_properties_v0(document_properties)), + 0 => self.contested_vote_poll_for_document_properties_v0( + document_properties, + platform_version, + ), version => Err(ProtocolError::UnknownVersionMismatch { method: "contested_vote_poll_for_document_properties".to_string(), known_versions: vec![0], 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 259a8a61454..1de35069aa3 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 @@ -21,7 +21,9 @@ use crate::fee::Credits; use crate::identity::TimestampMillis; use crate::prelude::{BlockHeight, CoreBlockHeight}; use crate::validation::SimpleConsensusValidationResult; -use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +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; @@ -361,14 +363,19 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa } /// Figures out the prefunded voting balance (v0) for a document in a document type - fn contested_vote_poll_for_document_v0(&self, document: &Document) -> Option { - self.contested_vote_poll_for_document_properties_v0(document.properties()) + fn contested_vote_poll_for_document_v0( + &self, + document: &Document, + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { + self.contested_vote_poll_for_document_properties_v0(document.properties(), platform_version) } fn contested_vote_poll_for_document_properties_v0( &self, document_properties: &BTreeMap, - ) -> Option { + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { self.indexes() .values() .find(|index| { @@ -392,14 +399,24 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa } }) .map(|index| { - let index_values = index.extract_values(document_properties); - VotePoll::ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll { - contract_id: self.data_contract_id(), - document_type_name: self.name().clone(), - index_name: index.name.clone(), - index_values, - }) + // Identifier values are written one way from protocol version 14, so every + // contender of a contest names it with the same poll; before 14 they are taken + // as given, as they always were + let index_values = index.extract_contested_values( + document_properties, + 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, + }, + )) }) + .transpose() } fn index_for_types_v0( @@ -674,12 +691,16 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa } }) .map(|index| { + // A moderation election is prefunded with the moderation fund. Every schedule + // before protocol version 14 carries the contested document fund there, so + // the amount is unchanged wherever this ran before ( index.name.clone(), - platform_version - .fee_version - .vote_resolution_fund_fees - .contested_document_vote_resolution_fund_required_amount, + required_vote_resolution_fund( + &self.data_contract_id(), + self.name(), + platform_version, + ), ) }) } diff --git a/packages/rs-dpp/src/moderation_charter/mod.rs b/packages/rs-dpp/src/moderation_charter/mod.rs index 2186174f26c..7d9e1fb26fd 100644 --- a/packages/rs-dpp/src/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/moderation_charter/mod.rs @@ -39,6 +39,7 @@ mod v0; use crate::balances::credits::Credits; use crate::consensus::basic::moderation_charter::ModerationCharterMalformedFieldError; +use crate::data_contract::document_type::contested_index_identifier; use crate::validation::{ConsensusValidationResult, SimpleConsensusValidationResult}; use crate::ProtocolError; use platform_value::{Identifier, IdentifierBytes32, Value, ValueMap}; @@ -72,6 +73,35 @@ pub const RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME: &str = "resignationRequest"; /// The moderators share a proposal takes when it declares none: the full declared fee. pub const FULL_MODERATORS_SHARE: u8 = 100; +/// Whether a contest on the contested index of `document_type_name` in the contract +/// `contract_id` is a moderation election: an `electedCharter` of the moderation charters +/// contract, contending for the seat of its target contract. A moderation election runs on the +/// join and vote windows its target declares and is prefunded with the moderation fund; every +/// other contest keeps the generic windows and fund. +pub fn is_charter_election(contract_id: &Identifier, document_type_name: &str) -> bool { + *contract_id == MODERATION_CHARTERS_CONTRACT_ID + && document_type_name == ELECTED_CHARTER_DOCUMENT_TYPE_NAME +} + +/// The contract a moderation election contends for: the single value of the contested index's +/// key, `targetContractId`, in any form validation accepts for an identifier (from protocol +/// version 14 a contest's index values are written as `Value::Identifier` anyway, see +/// `Index::extract_contested_values`). `None` for every other contest, and for index values +/// that do not name one contract, a base58 string included. +pub fn charter_election_target( + contract_id: &Identifier, + document_type_name: &str, + index_values: &[Value], +) -> Option { + if !is_charter_election(contract_id, document_type_name) { + return None; + } + match index_values { + [target] => contested_index_identifier(target).map(Identifier::new), + _ => None, + } +} + /// The moderators part a seated charter's team charges for an action whose document type /// declares `declared_moderators`: `moderators_share` percent of it, rounded down to the credit. /// A document action on a type the target moderates may agree to exactly this amount instead diff --git a/packages/rs-dpp/src/moderation_charter/tests.rs b/packages/rs-dpp/src/moderation_charter/tests.rs index 97fa7cc7af4..5f571f81366 100644 --- a/packages/rs-dpp/src/moderation_charter/tests.rs +++ b/packages/rs-dpp/src/moderation_charter/tests.rs @@ -1,6 +1,8 @@ use super::{ - moderators_share_of, property_names, validate_submitted_charter, ElectedCharter, - ModerationCharterRewardSplit, SubmittedCharter, FULL_MODERATORS_SHARE, + charter_election_target, moderators_share_of, property_names, validate_submitted_charter, + ElectedCharter, ModerationCharterRewardSplit, SubmittedCharter, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, FULL_MODERATORS_SHARE, MODERATION_CHARTERS_CONTRACT_ID, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, }; use crate::balances::credits::MAX_CREDITS; use crate::consensus::basic::BasicError; @@ -186,6 +188,68 @@ fn should_combine_the_elected_members_the_additions_and_the_removals() { ); } +#[test] +fn should_read_a_charter_election_target_from_every_accepted_identifier_form() { + let target = Identifier::new([0x7A; 32]); + let target_of = |contract_id: &Identifier, document_type_name: &str, value: Value| { + charter_election_target(contract_id, document_type_name, &[value]) + }; + + // Every form validation accepts for an identifier property + for value in [ + Value::Identifier([0x7A; 32]), + Value::Bytes32([0x7A; 32]), + Value::Bytes(vec![0x7A; 32]), + Value::Array(vec![Value::U8(0x7A); 32]), + Value::Array(vec![Value::U64(0x7A); 32]), + ] { + assert_eq!( + target_of( + &MODERATION_CHARTERS_CONTRACT_ID, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + value + ), + Some(target) + ); + } + + // The same contract written as base58 text, an array holding a value that is not a byte, + // a short byte string, another type of the charter contract, and another contract + for (contract_id, document_type_name, value) in [ + ( + MODERATION_CHARTERS_CONTRACT_ID, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + Value::Text(bs58::encode([0x7A; 32]).into_string()), + ), + ( + MODERATION_CHARTERS_CONTRACT_ID, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + Value::Array( + std::iter::once(Value::U64(256)) + .chain(std::iter::repeat_n(Value::U8(0x7A), 31)) + .collect(), + ), + ), + ( + MODERATION_CHARTERS_CONTRACT_ID, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + Value::Bytes(vec![0x7A; 31]), + ), + ( + MODERATION_CHARTERS_CONTRACT_ID, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + Value::Identifier([0x7A; 32]), + ), + ( + Identifier::new([0x01; 32]), + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + Value::Identifier([0x7A; 32]), + ), + ] { + assert_eq!(target_of(&contract_id, document_type_name, value), None); + } +} + #[test] fn should_take_the_share_of_the_declared_moderators_part_rounded_down() { assert_eq!(moderators_share_of(100_000_000, 60), 60_000_000); diff --git a/packages/rs-dpp/src/voting/vote_polls/contested_document_resource_vote_poll/mod.rs b/packages/rs-dpp/src/voting/vote_polls/contested_document_resource_vote_poll/mod.rs index 675d7f18f78..0397144c406 100644 --- a/packages/rs-dpp/src/voting/vote_polls/contested_document_resource_vote_poll/mod.rs +++ b/packages/rs-dpp/src/voting/vote_polls/contested_document_resource_vote_poll/mod.rs @@ -1,3 +1,5 @@ +use crate::fee::Credits; +use crate::moderation_charter::is_charter_election; #[cfg(feature = "json-conversion")] use crate::serialization::json_safe_fields; #[cfg(feature = "json-conversion")] @@ -12,6 +14,7 @@ use platform_serialization_derive::{ PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, }; use platform_value::{Identifier, Value}; +use platform_version::version::PlatformVersion; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; use std::fmt; @@ -87,6 +90,34 @@ impl ContestedDocumentResourceVotePoll { pub fn unique_id(&self) -> Result { self.sha256_2_hash().map(Identifier::new) } + + /// The prefunded voting balance a contender pays into this contest, see + /// [`required_vote_resolution_fund`]. + pub fn required_vote_resolution_fund(&self, platform_version: &PlatformVersion) -> Credits { + required_vote_resolution_fund( + &self.contract_id, + &self.document_type_name, + platform_version, + ) + } +} + +/// The prefunded voting balance a contender pays into a contest on the contested index of +/// `document_type_name` in the contract `contract_id`: the moderation fund for a moderation +/// election (an `electedCharter` of the moderation charters contract), the contested document +/// fund for every other contest. Whatever the votes leave of it is released as processing fees +/// when the contest is cleaned up. +pub fn required_vote_resolution_fund( + contract_id: &Identifier, + document_type_name: &str, + platform_version: &PlatformVersion, +) -> Credits { + let fund_fees = &platform_version.fee_version.vote_resolution_fund_fees; + if is_charter_election(contract_id, document_type_name) { + fund_fees.moderation_vote_resolution_fund_required_amount + } else { + fund_fees.contested_document_vote_resolution_fund_required_amount + } } #[cfg(all( 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 50123948016..2d3d2bd589c 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 @@ -61,10 +61,9 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans Some(VotePoll::ContestedDocumentResourceVotePoll(expected)), Some((provided, paid_amount)), ) => { - let expected_amount = platform_version - .fee_version - .vote_resolution_fund_fees - .contested_document_vote_resolution_fund_required_amount; + // A moderation election is prefunded with the moderation fund, every other + // contest with the contested document fund + let expected_amount = expected.required_vote_resolution_fund(platform_version); if expected_amount != *paid_amount { return Ok(SimpleConsensusValidationResult::new_with_error( DocumentContestNotPaidForError::new( @@ -96,11 +95,8 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans } // -->> End Introduced in V1 <<-- } - (Some(_), None) => { - let expected_amount = platform_version - .fee_version - .vote_resolution_fund_fees - .contested_document_vote_resolution_fund_required_amount; + (Some(VotePoll::ContestedDocumentResourceVotePoll(expected)), None) => { + let expected_amount = expected.required_vote_resolution_fund(platform_version); return Ok(SimpleConsensusValidationResult::new_with_error( DocumentContestNotPaidForError::new(self.base().id(), expected_amount, 0) .into(), @@ -191,6 +187,7 @@ mod tests { use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionV0}; use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionActionV0; + use dpp::moderation_charter::ELECTED_CHARTER_DOCUMENT_TYPE_NAME; use drive::util::object_size_info::DataContractOwnedResolvedInfo; use std::collections::BTreeMap; use std::sync::Arc; @@ -471,6 +468,144 @@ mod tests { ); } + /// The `electedCharter` properties of an application for the contract `[0x7A; 32]`. + fn charter_application_properties() -> BTreeMap { + BTreeMap::from([ + ( + "targetContractId".to_string(), + Value::Identifier([0x7A; 32]), + ), + ( + "submittedCharterId".to_string(), + Value::Identifier([0x5C; 32]), + ), + ("members".to_string(), Value::Array(vec![])), + ]) + } + + /// The action the transformer builds for an application in a moderation election whose + /// prefunded voting balance pays `paid_amount`, if any. + fn charter_application_action( + paid_amount: Option, + platform_version: &PlatformVersion, + ) -> DocumentCreateTransitionAction { + let contract_fetch_info = + Arc::new(DataContractFetchInfo::moderation_charters_contract_fixture( + platform_version.protocol_version, + )); + let data = charter_application_properties(); + let prefunded_voting_balance = paid_amount.map(|paid_amount| { + let index_values = contract_fetch_info + .contract + .document_type_for_name(ELECTED_CHARTER_DOCUMENT_TYPE_NAME) + .expect("expected the electedCharter document type") + .indexes() + .get("byTargetContract") + .expect("expected the contested index") + .extract_values(&data); + ( + ContestedDocumentResourceVotePollWithContractInfo { + contract: DataContractOwnedResolvedInfo::DataContractFetchInfo( + contract_fetch_info.clone(), + ), + document_type_name: ELECTED_CHARTER_DOCUMENT_TYPE_NAME.to_string(), + index_name: "byTargetContract".to_string(), + index_values, + }, + paid_amount, + ) + }); + + DocumentCreateTransitionAction::V0(DocumentCreateTransitionActionV0 { + base: DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([0xAA; 32]), + identity_contract_nonce: 1, + document_type_name: ELECTED_CHARTER_DOCUMENT_TYPE_NAME.to_string(), + data_contract: contract_fetch_info, + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::default(), + contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, + }), + block_info: BlockInfo::default(), + data, + prefunded_voting_balance, + current_store_contest_info: None, + should_store_contest_info: None, + }) + } + + /// An application in a moderation election prefunds the moderation fund, 0.5 Dash; the + /// contested document fund every other contest takes is refused. + #[test] + fn should_require_the_moderation_fund_of_a_charter_application() { + let platform_version = PlatformVersion::latest(); + let moderation_fund = platform_version + .fee_version + .vote_resolution_fund_fees + .moderation_vote_resolution_fund_required_amount; + assert_eq!(moderation_fund, 50_000_000_000); + + for paid_amount in [ + None, + Some(required_amount(platform_version)), + Some(moderation_fund - 1), + Some(moderation_fund + 1), + Some(moderation_fund), + ] { + let action = charter_application_action(paid_amount, platform_version); + let errors = validate(&action, platform_version); + let contest_errors = contest_errors(&errors); + if paid_amount == Some(moderation_fund) { + assert!(contest_errors.is_empty(), "{errors:?}"); + } else { + let [StateError::DocumentContestNotPaidForError(error)] = contest_errors.as_slice() + else { + panic!("paid {paid_amount:?}: expected a fee error, got {errors:?}"); + }; + assert_eq!(error.expected_amount(), moderation_fund); + assert_eq!(error.paid_amount(), paid_amount.unwrap_or_default()); + } + } + } + + #[test] + fn should_construct_charter_applications_with_the_moderation_fund() { + use dpp::document::{Document, DocumentV0}; + use dpp::state_transition::batch_transition::document_create_transition::DocumentCreateTransition; + + let platform_version = PlatformVersion::latest(); + let contract = DataContractFetchInfo::moderation_charters_contract_fixture( + platform_version.protocol_version, + ); + let document_type = contract + .contract + .document_type_for_name(ELECTED_CHARTER_DOCUMENT_TYPE_NAME) + .expect("electedCharter type"); + let document = Document::V0(DocumentV0 { + id: Identifier::from([0xAA; 32]), + owner_id: Identifier::from([0xBB; 32]), + properties: charter_application_properties(), + ..Default::default() + }); + let DocumentCreateTransition::V0(transition) = DocumentCreateTransition::from_document( + document, + document_type, + [0xCC; 32], + None, + 1, + platform_version, + None, + None, + ) + .expect("create transition"); + + assert_eq!( + transition.prefunded_voting_balance, + Some(("byTargetContract".to_string(), 50_000_000_000)) + ); + } + /// The cross-check is consensus-relevant, so it must not apply before its /// protocol version: v0 accepted both shapes rejected above. #[test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v1/mod.rs index 2943b10fa16..b6dde5bf5c8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v1/mod.rs @@ -7,7 +7,6 @@ use dpp::consensus::state::document::document_contest_document_with_same_id_alre use dpp::consensus::state::document::document_contest_identity_already_contestant::DocumentContestIdentityAlreadyContestantError; use dpp::consensus::state::document::document_contest_not_joinable_error::DocumentContestNotJoinableError; use dpp::consensus::state::state_error::StateError; -use dpp::dashcore::Network; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; @@ -18,6 +17,7 @@ use drive::state_transition_action::batch::batched_transition::document_transiti use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0}; use dpp::version::PlatformVersion; use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ContestedDocumentVotePollStatus, ContestedDocumentVotePollStoredInfoV0Getters}; +use drive::drive::document::ContestWindows; use drive::error::drive::DriveError; use drive::query::TransactionArg; use crate::error::Error; @@ -250,9 +250,33 @@ impl DocumentCreateTransitionActionStateValidationV1 for DocumentCreateTransitio // We need to make sure that if there is a contest, it is in its first week // The week might be more or less, as it's a versioned parameter let time_ms_since_start = block_info.time_ms.checked_sub(start_block.time_ms).ok_or(Error::Drive(drive::error::Error::Drive(DriveError::CorruptedDriveState(format!("it makes no sense that the start block time {} is before our current block time {}", start_block.time_ms, block_info.time_ms)))))?; - let join_time_allowed = match platform.config.network { - Network::Mainnet => platform_version.dpp.validation.voting.allow_other_contenders_time_mainnet_ms, - _ => platform_version.dpp.validation.voting.allow_other_contenders_time_testing_ms + // A moderation election (protocol version 14) is joinable for the join + // window its target contract declares. Before 14 the method's version is + // `None`: it reads nothing, bills nothing and answers `None`, so every + // protocol version that selects this module directly keeps the generic + // window below + let (charter_election_fee, charter_election_windows) = platform + .drive + .fetch_charter_election_windows( + contested_document_resource_vote_poll, + &block_info.epoch, + transaction, + platform_version, + ) + .map_err(Error::Drive)?; + // The target is read, and billed, exactly for a moderation election + let is_charter_election = charter_election_fee.is_some(); + if let Some(fee_result) = charter_election_fee { + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + } + let join_time_allowed = match charter_election_windows { + Some(windows) => windows.join_window_ms, + // A moderation election whose target is missing or declares no + // elected moderation has no join window to refuse it by: the + // reference validation that follows at 14 refuses it with the + // error that names why + None if is_charter_election => u64::MAX, + None => ContestWindows::generic(platform.config.network, platform_version).join_window_ms, }; if time_ms_since_start > join_time_allowed { return Ok(SimpleConsensusValidationResult::new_with_error(ConsensusError::StateError(StateError::DocumentContestNotJoinableError( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index b7bac857a5c..53923724347 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -3361,9 +3361,26 @@ mod creation_tests { #[tokio::test] async fn test_that_a_contested_document_can_not_be_added_to_after_a_week() { - let platform_version = PlatformVersion::latest(); + run_contested_document_can_not_be_added_to_after_a_week_at_protocol_version( + PlatformVersion::latest().protocol_version, + ) + .await; + } + + /// PROTOCOL_VERSION_13: the join check reads the generic join window there too; the + /// target contract's window of a moderation election is read only from 14 on. + #[tokio::test] + async fn should_refuse_joining_a_contest_after_the_join_window_protocol_version_13() { + run_contested_document_can_not_be_added_to_after_a_week_at_protocol_version(13).await; + } + + async fn run_contested_document_can_not_be_added_to_after_a_week_at_protocol_version( + protocol_version: dpp::version::ProtocolVersion, + ) { + let platform_version = PlatformVersion::get(protocol_version) + .expect("expected platform version for the requested protocol_version"); let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() + .with_initial_protocol_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/charter_election_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs new file mode 100644 index 00000000000..ed9ced82493 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs @@ -0,0 +1,1290 @@ +//! Moderation elections (protocol version 14): an `electedCharter` contest of the moderation +//! charters contract runs on the join window and the vote window its target contract declares, +//! and every application prefunds the masternode votes with the moderation fund (0.5 Dash), what +//! the votes leave of it released as processing fees at clean-up. Every other contest, DPNS +//! included, keeps the generic windows and fund. + +use crate::execution::validation::state_transition::state_transitions::tests::{ + create_dpns_identity_name_contest, 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; +use dpp::dash_to_credits; +use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; +use dpp::data_contract::config::moderation::{ + ContractModerationConfig, ContractModerators, ElectedModerators, InterimModerators, + ModerationAbility, +}; +use dpp::data_contract::DataContract; +use dpp::document::{Document, DocumentV0, DocumentV0Getters}; +use dpp::fee::fee_result::FeeResult; +use dpp::fee::Credits; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::{Identity, IdentityPublicKey, TimestampMillis}; +use dpp::moderation_charter::{ + property_names, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, MODERATION_CHARTERS_CONTRACT_ID, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, +}; +use dpp::platform_value::{Bytes32, Identifier, Value}; +use dpp::prelude::IdentityNonce; +use dpp::serialization::PlatformSerializable; +use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; +use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; +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_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +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 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; +use rand::prelude::StdRng; +use rand::{Rng, SeedableRng}; +use simple_signer::signer::SimpleSigner; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +/// The contract whose moderation seat is contended for: any contract will do, with an elected +/// moderation declaration written into its config. +const TARGET_CONTRACT: &str = + "tests/supporting_files/contract/family/family-contract-countable.json"; + +const ONE_DAY: u32 = 86_400; +const ONE_WEEK: u32 = 604_800; +const FOUR_WEEKS: u32 = 2_419_200; +const DAY_MS: TimestampMillis = 86_400_000; +const TWO_HOURS_MS: TimestampMillis = 7_200_000; + +type IdentityInfo = (Identity, SimpleSigner, IdentityPublicKey); + +/// An identity writing to the charter contract, with the identity contract nonce its next +/// transition uses. +struct Applicant { + info: IdentityInfo, + next_nonce: IdentityNonce, +} + +impl Applicant { + fn id(&self) -> Identifier { + self.info.0.id() + } +} + +fn block(time_ms: TimestampMillis, height: u64) -> BlockInfo { + BlockInfo { + time_ms, + height, + core_height: 42, + epoch: Default::default(), + } +} + +fn setup() -> ( + TempPlatform, + &'static PlatformVersion, + Arc, + StdRng, +) { + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let charters = platform + .drive + .cache + .system_data_contracts + .load_moderation_charters(platform_version) + .expect("expected the moderation charters contract at genesis"); + ( + platform, + platform_version, + charters, + StdRng::seed_from_u64(0xC4A2_7E25), + ) +} + +fn elected_moderation(join_window: u32, vote_window: u32) -> ContractModerationConfig { + ContractModerationConfig { + banlist: true, + suspensions: false, + warnings: false, + moderators: ContractModerators::Elected(Box::new(ElectedModerators { + join_window, + vote_window, + challenge_cool_down: 1_209_600, + election_delay: None, + max_added_moderators: 0, + moderated_document_types: BTreeMap::from([( + "person".to_string(), + BTreeSet::from([ModerationAbility::Ban]), + )]), + interim: InterimModerators::ContractOwner, + owner_protected: false, + })), + } +} + +/// Writes the target contract `[id_byte; 32]` to state directly, the way the fixtures are, +/// with `moderation` in its config. +fn write_target( + platform: &TempPlatform, + id_byte: u8, + moderation: Option, + platform_version: &PlatformVersion, +) -> Identifier { + let contract = setup_contract( + &platform.drive, + TARGET_CONTRACT, + Some([id_byte; 32]), + None, + Some(|contract: &mut DataContract| { + contract.set_config(contract.config().clone().with_moderation(moderation)); + }), + None, + Some(platform_version), + ); + // A rewritten target must be read again, as a node that never cached it would + platform.drive.cache.data_contracts.clear(); + contract.id() +} + +/// A target contract declaring elected moderation with these windows, in seconds. +fn elected_target( + platform: &TempPlatform, + id_byte: u8, + join_window: u32, + vote_window: u32, + platform_version: &PlatformVersion, +) -> Identifier { + write_target( + platform, + id_byte, + Some(elected_moderation(join_window, vote_window)), + platform_version, + ) +} + +fn applicant(platform: &mut TempPlatform, rng: &mut StdRng) -> Applicant { + Applicant { + info: setup_identity(platform, rng.gen(), dash_to_credits!(3.0)), + next_nonce: 1, + } +} + +/// The contest for the moderation seat of `target`. +fn charter_poll(target: Identifier) -> ContestedDocumentResourceVotePoll { + ContestedDocumentResourceVotePoll { + contract_id: MODERATION_CHARTERS_CONTRACT_ID, + document_type_name: ELECTED_CHARTER_DOCUMENT_TYPE_NAME.to_string(), + index_name: "byTargetContract".to_string(), + index_values: vec![Value::Identifier(target.to_buffer())], + } +} + +/// A serialized create of a `document_type_name` document of the charter contract holding +/// `properties`, signed by `applicant`, and the id of the document it creates. +async fn create_transition( + charters: &DataContract, + applicant: &mut Applicant, + document_type_name: &str, + properties: BTreeMap, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> (Vec, Identifier) { + let document_type = charters + .document_type_for_name(document_type_name) + .expect("expected the charter document type"); + let entropy = Bytes32::random_with_rng(rng); + let nonce = applicant.next_nonce; + applicant.next_nonce += 1; + let mut document: Document = DocumentV0 { + owner_id: applicant.id(), + properties, + ..Default::default() + } + .into(); + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected to set the document id"); + let id = document.id(); + let (_, signer, key) = &applicant.info; + let transition = BatchTransition::new_document_creation_transition_from_document( + document, + document_type, + entropy.0, + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected to create the batch transition"); + ( + transition + .serialize_to_bytes() + .expect("expected to serialize the batch transition"), + id, + ) +} + +/// Processes one transition in a block at `time_ms` and returns its execution result. +fn process( + platform: &TempPlatform, + transition: Vec, + time_ms: TimestampMillis, + platform_version: &PlatformVersion, +) -> StateTransitionExecutionResult { + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[transition], + &platform_state, + &block(time_ms, 1), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) +} + +/// Processes one transition that must pass, and returns the fee it paid. +fn process_valid( + platform: &TempPlatform, + transition: Vec, + time_ms: TimestampMillis, + platform_version: &PlatformVersion, +) -> FeeResult { + match process(platform, transition, time_ms, platform_version) { + StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. } => fee_result, + other => panic!("expected the transition to pass, got {other:?}"), + } +} + +/// Processes one transition that must be refused, paid, and returns why. +fn process_refused( + platform: &TempPlatform, + transition: Vec, + time_ms: TimestampMillis, + platform_version: &PlatformVersion, +) -> ConsensusError { + match process(platform, transition, time_ms, platform_version) { + StateTransitionExecutionResult::PaidConsensusError { error, .. } => error, + other => panic!("expected a paid refusal, got {other:?}"), + } +} + +/// `applicant`'s proposal for `target`, filed at `time_ms`; returns its id. +async fn propose( + platform: &TempPlatform, + charters: &DataContract, + applicant: &mut Applicant, + target: Identifier, + time_ms: TimestampMillis, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> Identifier { + let (proposal, proposal_id) = create_transition( + charters, + applicant, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + BTreeMap::from([ + ( + property_names::TARGET_CONTRACT_ID.to_string(), + Value::Identifier(target.to_buffer()), + ), + ( + property_names::DESCRIPTION.to_string(), + Value::Text("Keeps the family tree civil".to_string()), + ), + (property_names::REASONS.to_string(), Value::Array(vec![])), + ( + property_names::REWARD_SPLIT.to_string(), + Value::Map(vec![ + ( + Value::Text(property_names::REWARD_SPLIT_LEADER.to_string()), + Value::U8(10), + ), + ( + Value::Text(property_names::REWARD_SPLIT_EQUAL.to_string()), + Value::U8(40), + ), + ( + Value::Text(property_names::REWARD_SPLIT_ACTIONS.to_string()), + Value::U8(50), + ), + ]), + ), + ]), + rng, + platform_version, + ) + .await; + process_valid(platform, proposal, time_ms, platform_version); + proposal_id +} + +/// The serialized application of `applicant` for `target` on its proposal `proposal_id`: an +/// `electedCharter` create with no members, which opens or joins the contest. +async fn application( + charters: &DataContract, + applicant: &mut Applicant, + target: Identifier, + proposal_id: Identifier, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> Vec { + application_naming_the_target_as( + charters, + applicant, + Value::Identifier(target.to_buffer()), + proposal_id, + rng, + platform_version, + ) + .await +} + +/// [`application`] with the target written as `target`: an identifier property is accepted as +/// an identifier, as bytes or as an array of byte values. +async fn application_naming_the_target_as( + charters: &DataContract, + applicant: &mut Applicant, + target: Value, + proposal_id: Identifier, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> Vec { + create_transition( + charters, + applicant, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + BTreeMap::from([ + (property_names::TARGET_CONTRACT_ID.to_string(), target), + ( + property_names::SUBMITTED_CHARTER_ID.to_string(), + Value::Identifier(proposal_id.to_buffer()), + ), + (property_names::MEMBERS.to_string(), Value::Array(vec![])), + ]), + rng, + platform_version, + ) + .await + .0 +} + +/// `applicant` files a proposal for `target` at `time_ms` and applies with it a second later. +/// Returns the application's block time, when the contest starts or is joined, and its fee. +async fn apply( + platform: &TempPlatform, + charters: &DataContract, + applicant: &mut Applicant, + target: Identifier, + time_ms: TimestampMillis, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> (TimestampMillis, FeeResult) { + let proposal_id = propose( + platform, + charters, + applicant, + target, + time_ms, + rng, + platform_version, + ) + .await; + let application = application( + charters, + applicant, + target, + proposal_id, + rng, + platform_version, + ) + .await; + let fee = process_valid(platform, application, time_ms + 1000, platform_version); + (time_ms + 1000, fee) +} + +/// A masternode votes `choice` in `poll` at `time_ms`. +async fn vote( + platform: &mut TempPlatform, + poll: &ContestedDocumentResourceVotePoll, + choice: ResourceVoteChoice, + masternode_seed: u64, + time_ms: TimestampMillis, + platform_version: &PlatformVersion, +) { + let (pro_tx_hash, _, signer, voting_key) = + setup_masternode_voting_identity(platform, masternode_seed, platform_version); + let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::ContestedDocumentResourceVotePoll(poll.clone()), + resource_vote_choice: choice, + })); + let transition = MasternodeVoteTransition::try_from_vote_with_signer( + vote, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + None, + ) + .await + .expect("expected to make the vote") + .serialize_to_bytes() + .expect("expected to serialize the vote"); + process_valid(platform, transition, time_ms, platform_version); +} + +fn end_dates( + platform: &TempPlatform, + platform_version: &PlatformVersion, +) -> Vec<(TimestampMillis, VotePoll)> { + VotePollsByEndDateDriveQuery { + start_time: None, + end_time: None, + limit: None, + offset: None, + order_ascending: true, + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("expected the end date entries") + .into_iter() + .flat_map(|(time, polls)| polls.into_iter().map(move |poll| (time, poll))) + .collect() +} + +/// Ends every poll due at `time_ms`, as the block at that time would. +fn end_polls_at( + platform: &TempPlatform, + time_ms: TimestampMillis, + height: u64, + platform_version: &PlatformVersion, +) { + let mut platform_state = (**platform.state.load()).clone(); + let block_info = block(time_ms, height); + platform_state.set_last_committed_block_info(Some( + ExtendedBlockInfoV0 { + basic_info: block_info, + app_hash: platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(), + quorum_hash: [0u8; 32], + block_id_hash: [0u8; 32], + proposer_pro_tx_hash: [0u8; 32], + signature: [0u8; 96], + round: 0, + } + .into(), + )); + platform.state.store(Arc::new(platform_state)); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + platform + .check_for_ended_vote_polls( + &platform_state, + &platform_state, + &block_info, + Some(&transaction), + platform_version, + ) + .expect("ending the polls due must never fail"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); +} + +fn status( + platform: &TempPlatform, + poll: &ContestedDocumentResourceVotePoll, + platform_version: &PlatformVersion, +) -> ContestedDocumentVotePollStatus { + let resolved = poll + .resolve(&platform.drive, None, platform_version) + .expect("expected to resolve the contest"); + let (_, stored_info) = platform + .drive + .fetch_contested_document_vote_poll_stored_info(&resolved, None, None, platform_version) + .expect("expected to read the contest"); + stored_info + .expect("expected the contest to exist") + .vote_poll_status() +} + +fn prefunded_balance( + platform: &TempPlatform, + poll: &ContestedDocumentResourceVotePoll, + platform_version: &PlatformVersion, +) -> Credits { + platform + .drive + .fetch_prefunded_specialized_balance( + poll.specialized_balance_id() + .expect("expected the balance id") + .to_buffer(), + None, + platform_version, + ) + .expect("expected to read the balance") + .unwrap_or_default() +} + +fn balance_of( + platform: &TempPlatform, + identity_id: Identifier, + platform_version: &PlatformVersion, +) -> Credits { + platform + .drive + .fetch_identity_balance(identity_id.to_buffer(), None, platform_version) + .expect("expected to read the balance") + .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(); + let target = elected_target(&platform, 0xA1, ONE_DAY, ONE_WEEK, platform_version); + let mut alice = applicant(&mut platform, &mut rng); + + let (start, _) = apply( + &platform, + &charters, + &mut alice, + target, + 10_000, + &mut rng, + platform_version, + ) + .await; + + let poll = charter_poll(target); + assert_eq!( + end_dates(&platform, platform_version), + vec![( + start + DAY_MS, + VotePoll::ContestedDocumentResourceVotePoll(poll.clone()) + )], + "a single applicant's election ends with the target's one-day join window" + ); + + end_polls_at(&platform, start + DAY_MS - 1, 10, platform_version); + assert!(matches!( + status(&platform, &poll, platform_version), + ContestedDocumentVotePollStatus::Started(_) + )); + + end_polls_at(&platform, start + DAY_MS, 11, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + ContestedDocumentVotePollStatus::Awarded(alice.id()) + ); + assert!(end_dates(&platform, platform_version).is_empty()); +} + +#[tokio::test] +async fn should_move_the_end_to_the_join_and_vote_windows_when_a_second_applicant_joins() { + let (mut platform, platform_version, charters, mut rng) = setup(); + let target = elected_target(&platform, 0xA2, FOUR_WEEKS, ONE_DAY, platform_version); + let mut alice = applicant(&mut platform, &mut rng); + let mut bob = applicant(&mut platform, &mut rng); + let mut carol = applicant(&mut platform, &mut rng); + let poll = charter_poll(target); + + let (start, _) = apply( + &platform, + &charters, + &mut alice, + target, + 10_000, + &mut rng, + platform_version, + ) + .await; + + // Two weeks in: past the generic join window of every network, inside the target's + let two_weeks = 14 * DAY_MS; + apply( + &platform, + &charters, + &mut bob, + target, + start + two_weeks - 1000, + &mut rng, + platform_version, + ) + .await; + + let join_end = start + u64::from(FOUR_WEEKS) * 1000; + let vote_end = join_end + DAY_MS; + assert_eq!( + end_dates(&platform, platform_version), + vec![( + vote_end, + VotePoll::ContestedDocumentResourceVotePoll(poll.clone()) + )], + "the second applicant moves the end to the join window and the vote window" + ); + + // One more applicant after the target's join window closed is refused + let proposal_id = propose( + &platform, + &charters, + &mut carol, + target, + join_end, + &mut rng, + platform_version, + ) + .await; + let late = application( + &charters, + &mut carol, + target, + proposal_id, + &mut rng, + platform_version, + ) + .await; + let refusal = process_refused(&platform, late, join_end + 1000, platform_version); + let ConsensusError::StateError(StateError::DocumentContestNotJoinableError(error)) = refusal + else { + panic!("expected the contest not to be joinable, got {refusal:?}"); + }; + assert_eq!( + error.joinable_time(), + u64::from(FOUR_WEEKS) * 1000, + "the refusal names the target's join window" + ); + + vote( + &mut platform, + &poll, + ResourceVoteChoice::TowardsIdentity(bob.id()), + 0xB0B1, + join_end + DAY_MS / 2, + platform_version, + ) + .await; + vote( + &mut platform, + &poll, + ResourceVoteChoice::TowardsIdentity(bob.id()), + 0xB0B2, + join_end + DAY_MS / 2, + platform_version, + ) + .await; + vote( + &mut platform, + &poll, + ResourceVoteChoice::TowardsIdentity(alice.id()), + 0xA11C, + join_end + DAY_MS / 2, + platform_version, + ) + .await; + + end_polls_at(&platform, join_end, 10, platform_version); + assert!( + matches!( + status(&platform, &poll, platform_version), + ContestedDocumentVotePollStatus::Started(_) + ), + "the join window closing no longer ends the election" + ); + + end_polls_at(&platform, vote_end, 11, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + ContestedDocumentVotePollStatus::Awarded(bob.id()) + ); + assert!(end_dates(&platform, platform_version).is_empty()); +} + +#[tokio::test] +async fn should_end_elections_of_targets_with_different_windows_at_different_heights() { + let (mut platform, platform_version, charters, mut rng) = setup(); + let one_day_target = elected_target(&platform, 0xA3, ONE_DAY, ONE_WEEK, platform_version); + let four_week_target = elected_target(&platform, 0xA4, FOUR_WEEKS, ONE_WEEK, platform_version); + let mut alice = applicant(&mut platform, &mut rng); + let mut bob = applicant(&mut platform, &mut rng); + + let (start, _) = apply( + &platform, + &charters, + &mut alice, + one_day_target, + 10_000, + &mut rng, + platform_version, + ) + .await; + let (same_start, _) = apply( + &platform, + &charters, + &mut bob, + four_week_target, + 10_000, + &mut rng, + platform_version, + ) + .await; + assert_eq!(start, same_start); + + let one_day_poll = charter_poll(one_day_target); + let four_week_poll = charter_poll(four_week_target); + let four_weeks_end = start + u64::from(FOUR_WEEKS) * 1000; + assert_eq!( + end_dates(&platform, platform_version), + vec![ + ( + start + DAY_MS, + VotePoll::ContestedDocumentResourceVotePoll(one_day_poll.clone()) + ), + ( + four_weeks_end, + VotePoll::ContestedDocumentResourceVotePoll(four_week_poll.clone()) + ), + ] + ); + + end_polls_at(&platform, start + DAY_MS, 10, platform_version); + assert_eq!( + status(&platform, &one_day_poll, platform_version), + ContestedDocumentVotePollStatus::Awarded(alice.id()), + "the one-day election ends at height 10" + ); + assert!(matches!( + status(&platform, &four_week_poll, platform_version), + ContestedDocumentVotePollStatus::Started(_) + )); + + end_polls_at(&platform, four_weeks_end - 1, 11, platform_version); + assert!(matches!( + status(&platform, &four_week_poll, platform_version), + ContestedDocumentVotePollStatus::Started(_) + )); + + end_polls_at(&platform, four_weeks_end, 12, platform_version); + assert_eq!( + status(&platform, &four_week_poll, platform_version), + ContestedDocumentVotePollStatus::Awarded(bob.id()), + "the four-week election ends at height 12" + ); +} + +#[tokio::test] +async fn should_prefund_each_application_with_half_a_dash_and_release_the_remainder_at_clean_up() { + let (mut platform, platform_version, charters, mut rng) = setup(); + let fund_fees = &platform_version.fee_version.vote_resolution_fund_fees; + let moderation_fund = fund_fees.moderation_vote_resolution_fund_required_amount; + assert_eq!(moderation_fund, dash_to_credits!(0.5)); + let vote_cost = fund_fees.contested_document_single_vote_cost; + + let target = elected_target(&platform, 0xA5, ONE_DAY, ONE_DAY, platform_version); + let poll = charter_poll(target); + let mut alice = applicant(&mut platform, &mut rng); + let mut bob = applicant(&mut platform, &mut rng); + + let proposal_id = propose( + &platform, + &charters, + &mut alice, + target, + 10_000, + &mut rng, + platform_version, + ) + .await; + let alice_before = balance_of(&platform, alice.id(), platform_version); + let alice_application = application( + &charters, + &mut alice, + target, + proposal_id, + &mut rng, + platform_version, + ) + .await; + let start = 11_000; + let fee = process_valid(&platform, alice_application, start, platform_version); + assert_eq!( + alice_before - balance_of(&platform, alice.id(), platform_version), + moderation_fund + fee.total_base_fee(), + "applying costs the moderation fund on top of the document fee" + ); + assert_eq!( + prefunded_balance(&platform, &poll, platform_version), + moderation_fund + ); + + apply( + &platform, + &charters, + &mut bob, + target, + start + 60_000, + &mut rng, + platform_version, + ) + .await; + assert_eq!( + prefunded_balance(&platform, &poll, platform_version), + 2 * moderation_fund, + "every applicant prefunds the votes" + ); + + for (seed, choice) in [ + (0xF001, ResourceVoteChoice::TowardsIdentity(bob.id())), + (0xF002, ResourceVoteChoice::TowardsIdentity(bob.id())), + (0xF003, ResourceVoteChoice::Abstain), + ] { + vote( + &mut platform, + &poll, + choice, + seed, + start + DAY_MS / 2, + platform_version, + ) + .await; + } + let remainder = 2 * moderation_fund - 3 * vote_cost; + assert_eq!( + prefunded_balance(&platform, &poll, platform_version), + remainder + ); + + let processing_before = processing_credits(&platform, platform_version); + end_polls_at(&platform, start + 2 * DAY_MS, 10, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + ContestedDocumentVotePollStatus::Awarded(bob.id()) + ); + assert_eq!( + prefunded_balance(&platform, &poll, platform_version), + 0, + "the clean-up empties the prefunded balance" + ); + assert_eq!( + processing_credits(&platform, platform_version) - processing_before, + remainder, + "what the votes left is released as processing fees" + ); +} + +#[tokio::test] +async fn should_keep_the_generic_windows_and_fund_for_a_dpns_contest() { + let (mut platform, platform_version, _, _) = setup(); + let platform_state = platform.state.load(); + let (_, _, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version, + ) + .await; + + let [(end_date, VotePoll::ContestedDocumentResourceVotePoll(poll))]: [_; 1] = + end_dates(&platform, platform_version) + .try_into() + .expect("expected one contest"); + assert_eq!(poll.contract_id, dpns_contract.id()); + + let ContestedDocumentVotePollStatus::Started(start_block) = + status(&platform, &poll, platform_version) + else { + panic!("expected the contest to run"); + }; + let generic_poll_duration = + ContestWindows::generic(platform.drive.config.network, platform_version).poll_duration_ms; + assert_eq!(end_date - start_block.time_ms, generic_poll_duration); + assert_eq!( + prefunded_balance(&platform, &poll, platform_version), + 2 * platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount, + "each DPNS contender prefunds the contested document fund" + ); +} + +/// Elected moderation is frozen at a contract's creation, so a target cannot change kind; the +/// target is rewritten here with no moderation at all. Nothing at the election's end reads the +/// target, and a later applicant is refused with a consensus error, never an internal one. +#[tokio::test] +async fn should_end_an_election_whose_target_changed_kind() { + let (mut platform, platform_version, charters, mut rng) = setup(); + let target = elected_target(&platform, 0xA6, ONE_DAY, ONE_WEEK, platform_version); + let poll = charter_poll(target); + let mut alice = applicant(&mut platform, &mut rng); + let mut bob = applicant(&mut platform, &mut rng); + + let proposal_id = propose( + &platform, + &charters, + &mut bob, + target, + 5_000, + &mut rng, + platform_version, + ) + .await; + let (start, _) = apply( + &platform, + &charters, + &mut alice, + target, + 10_000, + &mut rng, + platform_version, + ) + .await; + + write_target(&platform, 0xA6, None, platform_version); + + let late = application( + &charters, + &mut bob, + target, + proposal_id, + &mut rng, + platform_version, + ) + .await; + // Two hours in: past the generic join window of test networks, so only the reference + // validation can refuse it, and it names why + let refusal = process_refused(&platform, late, start + TWO_HOURS_MS, platform_version); + assert!( + matches!( + refusal, + ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(_)) + ), + "expected the target's missing elected moderation to refuse it, got {refusal:?}" + ); + + end_polls_at(&platform, start + DAY_MS, 10, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + ContestedDocumentVotePollStatus::Awarded(alice.id()) + ); + assert!(end_dates(&platform, platform_version).is_empty()); +} + +/// A contract can not be deleted, so a target cannot disappear; its stored contract is removed +/// from state here. Nothing at the election's end reads the target, and a later applicant is +/// refused with a consensus error, never an internal one. +#[tokio::test] +async fn should_end_an_election_whose_target_disappeared() { + let (mut platform, platform_version, charters, mut rng) = setup(); + let target = elected_target(&platform, 0xA7, ONE_DAY, ONE_WEEK, platform_version); + let poll = charter_poll(target); + let mut alice = applicant(&mut platform, &mut rng); + let mut bob = applicant(&mut platform, &mut rng); + + let proposal_id = propose( + &platform, + &charters, + &mut bob, + target, + 5_000, + &mut rng, + platform_version, + ) + .await; + let (start, _) = apply( + &platform, + &charters, + &mut alice, + target, + 10_000, + &mut rng, + platform_version, + ) + .await; + + let target_bytes = target.to_buffer(); + platform + .drive + .grove_delete( + (&contract_root_path(&target_bytes)).into(), + &[0], + None, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to remove the stored target contract"); + platform.drive.cache.data_contracts.clear(); + assert!(platform + .drive + .get_contract_with_fetch_info(target_bytes, false, None, platform_version) + .expect("expected to look the target up") + .is_none()); + + let late = application( + &charters, + &mut bob, + target, + proposal_id, + &mut rng, + platform_version, + ) + .await; + // Two hours in: past the generic join window of test networks, so only the reference + // validation can refuse it, and it names why + let refusal = process_refused(&platform, late, start + TWO_HOURS_MS, platform_version); + assert!( + matches!( + refusal, + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(_)) + ), + "expected the missing target to refuse it, got {refusal:?}" + ); + + end_polls_at(&platform, start + DAY_MS, 10, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + ContestedDocumentVotePollStatus::Awarded(alice.id()) + ); + assert!(end_dates(&platform, platform_version).is_empty()); +} + +/// A contest moves its end from the join window to the vote window by finding the entry it opened +/// with. The target's windows are frozen, so they are rewritten here to force what a later +/// protocol version reading the windows differently could cause: the join-window entry is not +/// where the windows now say. The second applicant is admitted, the contest keeps the end it has, +/// and nothing fails. +#[tokio::test] +async fn should_keep_the_end_date_when_the_windows_changed_during_an_election() { + let (mut platform, platform_version, charters, mut rng) = setup(); + let target = elected_target(&platform, 0xA8, ONE_DAY, ONE_WEEK, platform_version); + let poll = charter_poll(target); + let mut alice = applicant(&mut platform, &mut rng); + let mut bob = applicant(&mut platform, &mut rng); + + let (start, _) = apply( + &platform, + &charters, + &mut alice, + target, + 10_000, + &mut rng, + platform_version, + ) + .await; + + elected_target(&platform, 0xA8, 2 * ONE_DAY, ONE_WEEK, platform_version); + + apply( + &platform, + &charters, + &mut bob, + target, + start + TWO_HOURS_MS, + &mut rng, + platform_version, + ) + .await; + assert_eq!( + end_dates(&platform, platform_version), + vec![( + start + DAY_MS, + VotePoll::ContestedDocumentResourceVotePoll(poll.clone()) + )], + "the contest keeps the end it opened with" + ); + + end_polls_at(&platform, start + DAY_MS, 10, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + ContestedDocumentVotePollStatus::Awarded(alice.id()), + "with no votes the earliest applicant wins" + ); + assert!(end_dates(&platform, platform_version).is_empty()); +} + +/// An application may write the target contract as an array of byte values, which validation +/// accepts for an identifier: the contest still names it as the identifier and runs on the +/// target's own join window. +#[tokio::test] +async fn should_honor_the_target_windows_of_an_application_writing_the_target_as_an_array() { + let (mut platform, platform_version, charters, mut rng) = setup(); + let target = elected_target(&platform, 0xA9, ONE_DAY, ONE_WEEK, platform_version); + let poll = charter_poll(target); + let mut alice = applicant(&mut platform, &mut rng); + + let proposal_id = propose( + &platform, + &charters, + &mut alice, + target, + 10_000, + &mut rng, + platform_version, + ) + .await; + let as_array = application_naming_the_target_as( + &charters, + &mut alice, + Value::Array(target.to_buffer().into_iter().map(Value::U8).collect()), + proposal_id, + &mut rng, + platform_version, + ) + .await; + let start = 11_000; + process_valid(&platform, as_array, start, platform_version); + + assert_eq!( + end_dates(&platform, platform_version), + vec![( + start + DAY_MS, + VotePoll::ContestedDocumentResourceVotePoll(poll.clone()) + )], + "the contest names the target as an identifier and ends with its one-day join window" + ); + assert_eq!( + prefunded_balance(&platform, &poll, platform_version), + platform_version + .fee_version + .vote_resolution_fund_fees + .moderation_vote_resolution_fund_required_amount + ); + + end_polls_at(&platform, start + DAY_MS, 10, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + ContestedDocumentVotePollStatus::Awarded(alice.id()) + ); +} + +/// Two applicants may write the same target in two accepted forms: both name one contest with +/// one poll, so the second one moves the end to the join and vote windows and both prefunds +/// land in the one balance the clean-up releases. +#[tokio::test] +async fn should_move_the_end_when_applicants_write_the_target_in_different_forms() { + let (mut platform, platform_version, charters, mut rng) = setup(); + let target = elected_target(&platform, 0xAA, ONE_DAY, ONE_WEEK, platform_version); + let poll = charter_poll(target); + let mut alice = applicant(&mut platform, &mut rng); + let mut bob = applicant(&mut platform, &mut rng); + + let (start, _) = apply( + &platform, + &charters, + &mut alice, + target, + 10_000, + &mut rng, + platform_version, + ) + .await; + + let proposal_id = propose( + &platform, + &charters, + &mut bob, + target, + start + 60_000, + &mut rng, + platform_version, + ) + .await; + let as_bytes = application_naming_the_target_as( + &charters, + &mut bob, + Value::Bytes(target.to_buffer().to_vec()), + proposal_id, + &mut rng, + platform_version, + ) + .await; + process_valid(&platform, as_bytes, start + 61_000, platform_version); + + let vote_end = start + DAY_MS + u64::from(ONE_WEEK) * 1000; + assert_eq!( + end_dates(&platform, platform_version), + vec![( + vote_end, + VotePoll::ContestedDocumentResourceVotePoll(poll.clone()) + )], + "the second applicant moves the end to the join and vote windows" + ); + assert_eq!( + prefunded_balance(&platform, &poll, platform_version), + 2 * platform_version + .fee_version + .vote_resolution_fund_fees + .moderation_vote_resolution_fund_required_amount, + "both applications prefund the one contest" + ); + + end_polls_at(&platform, vote_end, 10, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + ContestedDocumentVotePollStatus::Awarded(alice.id()), + "with no votes the earliest applicant wins" + ); + assert_eq!(prefunded_balance(&platform, &poll, platform_version), 0); +} 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 5e0edc8c823..e834e3727ab 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 @@ -97,6 +97,9 @@ impl StateTransitionStateValidation for MasternodeVoteTransition { #[cfg(test)] mod no_locking_contest_tests; +#[cfg(test)] +mod charter_election_tests; + #[cfg(test)] mod tests { use crate::test::helpers::setup::TestPlatformBuilder; diff --git a/packages/rs-drive/src/drive/contract/contract_fetch_info.rs b/packages/rs-drive/src/drive/contract/contract_fetch_info.rs index 35a3186a236..203659ac6b1 100644 --- a/packages/rs-drive/src/drive/contract/contract_fetch_info.rs +++ b/packages/rs-drive/src/drive/contract/contract_fetch_info.rs @@ -92,4 +92,23 @@ impl DataContractFetchInfo { fee: Some(FeeResult::new_from_processing_fee(30000)), } } + + /// This should ONLY be used for tests + pub fn moderation_charters_contract_fixture(protocol_version: u32) -> Self { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected to get version"); + + let contract = load_system_data_contract( + data_contracts::SystemDataContract::ModerationCharters, + platform_version, + ) + .expect("to load system data contract"); + + DataContractFetchInfo { + contract, + storage_flags: None, + cost: OperationCost::with_seek_count(1), //Just so there's a cost + fee: Some(FeeResult::new_from_processing_fee(30000)), + } + } } 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 0543952b4a1..073bfc9cfbd 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 @@ -1,3 +1,5 @@ +use crate::drive::contract::paths::contract_root_path; +use crate::drive::document::ContestWindows; use crate::drive::votes::paths::{ vote_contested_resource_end_date_queries_at_time_tree_path_vec, vote_end_date_queries_tree_path_vec, @@ -10,11 +12,14 @@ use crate::fees::op::LowLevelDriveOperation; use crate::query::vote_poll_vote_state_query::{ ContestedDocumentVotePollDriveQueryResultType, ResolvedContestedDocumentVotePollDriveQuery, }; -use crate::util::grove_operations::BatchDeleteUpTreeApplyType; +use crate::query::GroveError; +use crate::util::grove_operations::QueryTarget::QueryTargetValue; +use crate::util::grove_operations::{BatchDeleteUpTreeApplyType, DirectQueryType}; use crate::util::object_size_info::DocumentAndContractInfo; use dpp::block::block_info::BlockInfo; -use dpp::dashcore::Network; +use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::ContestedIndexResolution; +use dpp::moderation_charter::charter_election_target; use dpp::version::PlatformVersion; use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ ContestedDocumentVotePollStatus, ContestedDocumentVotePollStoredInfo, @@ -22,7 +27,7 @@ use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ }; use dpp::voting::vote_polls::VotePoll; use grovedb::batch::KeyInfoPath; -use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg}; +use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg, TreeType}; use std::collections::HashMap; impl Drive { @@ -32,6 +37,10 @@ impl Drive { /// resolved without locking (`MasternodeVoteNoLocking`) ends when its join window closes /// while it has a single contender; the first additional contender moves its end date to /// the full poll duration, opening the vote window. + /// + /// A moderation election (an `electedCharter` contest) runs on the join window and the + /// vote window its target contract declares, on every network; every other contest runs on + /// the generic windows of the version tables. #[inline(always)] #[allow(clippy::too_many_arguments)] pub(super) fn add_contested_document_for_contract_operations_v1( @@ -68,30 +77,8 @@ impl Drive { platform_version, )?; - let (poll_time, join_time) = match self.config.network { - Network::Mainnet => ( - platform_version - .dpp - .voting_versions - .default_vote_poll_time_duration_mainnet_ms, - platform_version - .dpp - .validation - .voting - .allow_other_contenders_time_mainnet_ms, - ), - _ => ( - platform_version - .dpp - .voting_versions - .default_vote_poll_time_duration_test_network_ms, - platform_version - .dpp - .validation - .voting - .allow_other_contenders_time_testing_ms, - ), - }; + let generic_windows = ContestWindows::generic(self.config.network, platform_version); + let estimating = estimated_costs_only_with_layer_info.is_some(); let no_locking = contested_document_resource_vote_poll .index()? @@ -122,13 +109,23 @@ impl Drive { batch_operations.append(&mut operations); } + let windows = self.contest_windows_v1( + &contested_document_resource_vote_poll, + generic_windows, + estimating, + block_info, + transaction, + &mut batch_operations, + platform_version, + )?; + // Without locking, a contest runs only to the end of its join window until a // second contender joins; with locking, it always runs the full poll duration // so the masternodes may lock a single contender out let end_date = if no_locking { - block_info.time_ms.saturating_add(join_time) + block_info.time_ms.saturating_add(windows.join_window_ms) } else { - block_info.time_ms.saturating_add(poll_time) + block_info.time_ms.saturating_add(windows.poll_duration_ms) }; self.add_vote_poll_end_date_query_operations( @@ -144,7 +141,7 @@ impl Drive { transaction, platform_version, )?; - } else if no_locking && estimated_costs_only_with_layer_info.is_none() { + } else if no_locking && !estimating { // The first additional contender opens the vote window: the end date moves from // the end of the join window to the full poll duration. Later contenders find // it there already. An estimation never reaches this branch, since a stateless @@ -186,22 +183,57 @@ impl Drive { ))); }; - let join_end = start_block.time_ms.saturating_add(join_time); - let vote_end = start_block.time_ms.saturating_add(poll_time); + // The same windows the contest started on: a moderation election's target + // declared them at its creation and can never change them + let windows = self.contest_windows_v1( + &contested_document_resource_vote_poll, + generic_windows, + estimating, + block_info, + transaction, + &mut batch_operations, + platform_version, + )?; + let join_end = start_block.time_ms.saturating_add(windows.join_window_ms); + let vote_end = start_block.time_ms.saturating_add(windows.poll_duration_ms); let vote_poll = VotePoll::ContestedDocumentResourceVotePoll( contested_document_resource_vote_poll.into(), ); - if join_end != vote_end { - let unique_id = vote_poll.unique_id()?; + let unique_id = vote_poll.unique_id()?; + 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 + // opened with is looked up before it is moved: should the windows ever differ + // from those it opened on (a later protocol version reading them differently), + // the contest keeps the end it has instead of failing to delete a missing entry + let join_entry_exists = match self.grove_get_raw_optional( + join_end_path.as_slice().into(), + unique_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + &mut batch_operations, + &platform_version.drive, + ) { + Ok(entry) => entry.is_some(), + Err(Error::GroveDB(error)) + if matches!( + *error, + GroveError::PathNotFound(_) + | GroveError::PathParentLayerNotFound(_) + | GroveError::PathKeyNotFound(_) + ) => + { + false + } + Err(error) => return Err(error), + }; + + if join_end != vote_end && join_entry_exists { // The join-window entry goes, and its time tree with it when it was // the only entry at that time self.batch_delete_up_tree_while_empty( - KeyInfoPath::from_known_owned_path( - vote_contested_resource_end_date_queries_at_time_tree_path_vec( - join_end, - ), - ), + KeyInfoPath::from_known_owned_path(join_end_path), unique_id.as_slice(), Some(vote_end_date_queries_tree_path_vec().len() as u16), BatchDeleteUpTreeApplyType::StatefulBatchDelete { @@ -230,4 +262,167 @@ impl Drive { Ok(batch_operations) } + + /// The windows of a contest: those its target contract declares for a moderation election, + /// `generic_windows` for every other contest. The read of the target is billed into + /// `batch_operations`. An estimation reads nothing: the end date it writes has the same + /// size whatever the windows. + #[allow(clippy::too_many_arguments)] + fn contest_windows_v1( + &self, + contested_document_resource_vote_poll: &ContestedDocumentResourceVotePollWithContractInfo, + generic_windows: ContestWindows, + estimating: bool, + block_info: &BlockInfo, + transaction: TransactionArg, + batch_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + if estimating { + // The windows change nothing an estimate measures, but the read of a moderation + // election's target does: it is estimated as a read of the target's stored + // contract at the largest size contracts are estimated at + if let Some(target_contract_id) = charter_election_target( + &contested_document_resource_vote_poll.contract.as_ref().id(), + &contested_document_resource_vote_poll.document_type_name, + &contested_document_resource_vote_poll.index_values, + ) { + self.grove_get_raw_optional( + (&contract_root_path(target_contract_id.as_bytes())).into(), + &[0], + DirectQueryType::StatelessDirectQuery { + in_tree_type: TreeType::NormalTree, + query_target: QueryTargetValue( + platform_version + .system_limits + .estimated_contract_max_serialized_size + as u32, + ), + }, + transaction, + batch_operations, + &platform_version.drive, + )?; + } + return Ok(generic_windows); + } + let (fee_result, charter_election_windows) = self.fetch_charter_election_windows( + contested_document_resource_vote_poll, + &block_info.epoch, + transaction, + platform_version, + )?; + if let Some(fee_result) = fee_result { + batch_operations.push(LowLevelDriveOperation::PreCalculatedFeeResult(fee_result)); + } + Ok(charter_election_windows.unwrap_or(generic_windows)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::object_size_info::DataContractOwnedResolvedInfo; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use crate::util::test_helpers::setup_contract; + use dpp::dashcore::Network; + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::data_contract::DataContract; + use dpp::moderation_charter::{ + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, MODERATION_CHARTERS_CONTRACT_ID, + }; + use dpp::platform_value::Value; + use grovedb::batch::key_info::KeyInfo; + use grovedb::GroveDb; + + const CONTRACT_PATH: &str = "tests/supporting_files/contract/family/family-contract.json"; + + /// A contest on `document_type_name` of the contract `resolved_as` (only its id is read), + /// keyed by `target`. + fn contest( + resolved_as: &DataContract, + document_type_name: &str, + target: [u8; 32], + ) -> ContestedDocumentResourceVotePollWithContractInfo { + ContestedDocumentResourceVotePollWithContractInfo { + contract: DataContractOwnedResolvedInfo::OwnedDataContract(resolved_as.clone()), + document_type_name: document_type_name.to_string(), + index_name: "byTargetContract".to_string(), + index_values: vec![Value::Identifier(target)], + } + } + + /// An estimate reads nothing, so a moderation election's target read is estimated as the + /// read of a stored contract at `estimated_contract_max_serialized_size`, whether the target + /// exists or not; every other contest estimates no target read. + #[test] + fn should_estimate_the_target_read_of_a_moderation_election_whatever_the_state() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let stored = setup_contract( + &drive, + CONTRACT_PATH, + Some([0x7A; 32]), + None, + None::, + None, + Some(platform_version), + ); + let mut charters = stored.clone(); + charters.set_id(MODERATION_CHARTERS_CONTRACT_ID); + let generic = ContestWindows::generic(Network::Testnet, platform_version); + let block_info = BlockInfo::default(); + let estimated_read = |target: [u8; 32]| { + GroveDb::average_case_for_get_raw( + &KeyInfoPath::from_known_path(contract_root_path(&target)), + &KeyInfo::KnownKey(vec![0]), + platform_version + .system_limits + .estimated_contract_max_serialized_size as u32, + TreeType::NormalTree, + &platform_version.drive.grove_version, + ) + .expect("expected an average case cost") + }; + + for target in [stored.id().to_buffer(), [0x7B; 32]] { + let mut operations = vec![]; + let windows = drive + .contest_windows_v1( + &contest(&charters, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, target), + generic, + true, + &block_info, + None, + &mut operations, + platform_version, + ) + .expect("expected the windows"); + assert_eq!(windows, generic, "an estimate keeps the generic windows"); + assert_eq!( + operations, + vec![LowLevelDriveOperation::CalculatedCostOperation( + estimated_read(target) + )] + ); + } + + let mut operations = vec![]; + drive + .contest_windows_v1( + &contest( + &stored, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + stored.id().to_buffer(), + ), + generic, + true, + &block_info, + None, + &mut operations, + platform_version, + ) + .expect("expected the windows"); + assert!(operations.is_empty(), "{operations:?}"); + } } diff --git a/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs new file mode 100644 index 00000000000..c16c0fe37f4 --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs @@ -0,0 +1,104 @@ +mod v0; + +use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::block::epoch::Epoch; +use dpp::dashcore::Network; +use dpp::data_contract::config::moderation::ElectedModerators; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +/// The windows a contest runs on, in milliseconds from the block that started it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ContestWindows { + /// How long other contenders may join once the first one applied. A contest resolved + /// without locking ends here while it has a single contender. + pub join_window_ms: TimestampMillis, + /// How long the whole poll runs once a second contender joined: the join window and the + /// vote window. + pub poll_duration_ms: TimestampMillis, +} + +impl ContestWindows { + /// The generic windows of the version tables, which every contest but a moderation election + /// runs on: the mainnet ones on mainnet, the shorter test ones on every other network. + pub fn generic(network: Network, platform_version: &PlatformVersion) -> Self { + let validation = &platform_version.dpp.validation.voting; + let voting = &platform_version.dpp.voting_versions; + match network { + Network::Mainnet => ContestWindows { + join_window_ms: validation.allow_other_contenders_time_mainnet_ms, + poll_duration_ms: voting.default_vote_poll_time_duration_mainnet_ms, + }, + _ => ContestWindows { + join_window_ms: validation.allow_other_contenders_time_testing_ms, + poll_duration_ms: voting.default_vote_poll_time_duration_test_network_ms, + }, + } + } + + /// The windows an elected moderation declaration gives the elections for its contract. Its + /// windows are seconds, each one day to four weeks. + pub fn of_elected_moderators(elected: &ElectedModerators) -> Self { + let join_window_ms = u64::from(elected.join_window).saturating_mul(1000); + let vote_window_ms = u64::from(elected.vote_window).saturating_mul(1000); + ContestWindows { + join_window_ms, + poll_duration_ms: join_window_ms.saturating_add(vote_window_ms), + } + } +} + +impl Drive { + /// Reads the windows of a moderation election: an `electedCharter` contest of the moderation + /// charters contract runs on the join window and the vote window of the elected moderation + /// declaration of the contract it contends for, the one value of its contested index. + /// Every other contest keeps the generic windows of the version tables, and is answered + /// without a read. + /// + /// # Parameters + /// * `contested_document_resource_vote_poll`: the contest. + /// * `epoch`: the epoch the read of the target contract is billed in. + /// * `transaction`: the transaction to read in. + /// * `platform_version`: the platform version. + /// + /// # Returns + /// The fee of reading the target contract, `Some` whenever it was read, and the windows, + /// `Some` only for a moderation election whose target exists and declares elected + /// moderation. A missing target, or one that declares something else, gives `None`, so + /// the contest keeps the generic windows instead of failing: the application's reference + /// validation refuses both. Before protocol version 14 no moderation election exists, + /// nothing is read and both are `None`. + pub fn fetch_charter_election_windows( + &self, + contested_document_resource_vote_poll: &ContestedDocumentResourceVotePollWithContractInfo, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(Option, Option), Error> { + match platform_version + .drive + .methods + .document + .insert_contested + .fetch_charter_election_windows + { + None => Ok((None, None)), + Some(0) => self.fetch_charter_election_windows_v0( + contested_document_resource_vote_poll, + epoch, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_charter_election_windows".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/v0/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/v0/mod.rs new file mode 100644 index 00000000000..a827d7c9ca6 --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/v0/mod.rs @@ -0,0 +1,292 @@ +use super::ContestWindows; +use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use crate::drive::Drive; +use crate::error::Error; +use dpp::block::epoch::Epoch; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::v2::DataContractConfigGettersV2; +use dpp::fee::fee_result::FeeResult; +use dpp::moderation_charter::charter_election_target; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + #[inline(always)] + pub(super) fn fetch_charter_election_windows_v0( + &self, + contested_document_resource_vote_poll: &ContestedDocumentResourceVotePollWithContractInfo, + epoch: &Epoch, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(Option, Option), Error> { + let Some(target_contract_id) = charter_election_target( + &contested_document_resource_vote_poll.contract.as_ref().id(), + &contested_document_resource_vote_poll.document_type_name, + &contested_document_resource_vote_poll.index_values, + ) else { + return Ok((None, None)); + }; + + // Billed with the fee this read returns, never the fee a cached contract carries, + // which depends on how the cache was filled + let (fee_result, target) = self.get_contract_with_fetch_info_and_fee( + target_contract_id.to_buffer(), + Some(epoch), + false, + transaction, + platform_version, + )?; + + let windows = target.as_ref().and_then(|target| { + target + .contract + .config() + .moderation() + .and_then(|moderation| moderation.moderators.elected()) + .map(ContestWindows::of_elected_moderators) + }); + + Ok((fee_result, windows)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; + use crate::util::object_size_info::DataContractOwnedResolvedInfo; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use crate::util::test_helpers::setup_contract; + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::data_contract::config::moderation::{ + ContractModerationConfig, ContractModerators, ElectedModerators, InterimModerators, + ModerationAbility, + }; + use dpp::data_contract::DataContract; + use dpp::moderation_charter::{ + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, MODERATION_CHARTERS_CONTRACT_ID, + }; + use dpp::platform_value::{Identifier, Value}; + use std::collections::{BTreeMap, BTreeSet}; + + const TARGET_CONTRACT_PATH: &str = + "tests/supporting_files/contract/family/family-contract.json"; + + fn elected(join_window: u32, vote_window: u32) -> ContractModerationConfig { + ContractModerationConfig { + banlist: true, + suspensions: false, + warnings: false, + moderators: ContractModerators::Elected(Box::new(ElectedModerators { + join_window, + vote_window, + challenge_cool_down: 1_209_600, + election_delay: None, + max_added_moderators: 0, + moderated_document_types: BTreeMap::from([( + "person".to_string(), + BTreeSet::from([ModerationAbility::Ban]), + )]), + interim: InterimModerators::ContractOwner, + owner_protected: false, + })), + } + } + + /// A contest on the charter contract's `electedCharter` for `target`, with the charter + /// contract resolved from `resolved_as` (only its id is read). + fn charter_contest( + resolved_as: &DataContract, + document_type_name: &str, + index_values: Vec, + ) -> ContestedDocumentResourceVotePollWithContractInfo { + ContestedDocumentResourceVotePollWithContractInfo { + contract: DataContractOwnedResolvedInfo::OwnedDataContract(resolved_as.clone()), + document_type_name: document_type_name.to_string(), + index_name: "byTargetContract".to_string(), + index_values, + } + } + + #[test] + fn should_read_the_windows_of_an_elected_target_and_bill_the_read() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let target = setup_contract( + &drive, + TARGET_CONTRACT_PATH, + Some([0x7A; 32]), + None, + Some(|contract: &mut DataContract| { + contract.set_config( + contract + .config() + .clone() + .with_moderation(Some(elected(86_400, 2_419_200))), + ); + }), + None, + Some(platform_version), + ); + let mut charters = target.clone(); + charters.set_id(MODERATION_CHARTERS_CONTRACT_ID); + + let (fee, windows) = drive + .fetch_charter_election_windows( + &charter_contest( + &charters, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + vec![Value::Identifier(target.id().to_buffer())], + ), + &Epoch::new(0).expect("epoch"), + None, + platform_version, + ) + .expect("expected to read the windows"); + + assert_eq!( + windows, + Some(ContestWindows { + join_window_ms: 86_400_000, + poll_duration_ms: 86_400_000 + 2_419_200_000, + }) + ); + assert!( + fee.expect("the read of the target is billed") + .processing_fee + > 0 + ); + } + + #[test] + fn should_answer_every_other_contest_without_a_read() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let target = setup_contract( + &drive, + TARGET_CONTRACT_PATH, + Some([0x7B; 32]), + None, + Some(|contract: &mut DataContract| { + contract.set_config( + contract + .config() + .clone() + .with_moderation(Some(elected(86_400, 86_400))), + ); + }), + None, + Some(platform_version), + ); + let target_value = Value::Identifier(target.id().to_buffer()); + let mut charters = target.clone(); + charters.set_id(MODERATION_CHARTERS_CONTRACT_ID); + + // Another contract's contest keyed by the same identifier, another type of the charter + // contract, and an electedCharter key that is not one identifier + for contest in [ + charter_contest( + &target, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + vec![target_value.clone()], + ), + charter_contest(&charters, "submittedCharter", vec![target_value.clone()]), + charter_contest( + &charters, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + vec![target_value.clone(), target_value.clone()], + ), + charter_contest( + &charters, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + vec![Value::U64(7)], + ), + ] { + assert_eq!( + drive + .fetch_charter_election_windows( + &contest, + &Epoch::new(0).expect("epoch"), + None, + platform_version, + ) + .expect("expected an answer"), + (None, None) + ); + } + } + + #[test] + fn should_leave_a_missing_or_unelected_target_on_the_generic_windows() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let unmoderated = setup_contract( + &drive, + TARGET_CONTRACT_PATH, + Some([0x7C; 32]), + None, + None::, + None, + Some(platform_version), + ); + let mut charters = unmoderated.clone(); + charters.set_id(MODERATION_CHARTERS_CONTRACT_ID); + + for target_id in [unmoderated.id(), Identifier::new([0x7D; 32])] { + let (fee, windows) = drive + .fetch_charter_election_windows( + &charter_contest( + &charters, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + vec![Value::Identifier(target_id.to_buffer())], + ), + &Epoch::new(0).expect("epoch"), + None, + platform_version, + ) + .expect("a missing or unelected target is not an error"); + assert_eq!(windows, None); + assert!(fee.is_some(), "the read is billed either way"); + } + } + + #[test] + fn should_read_nothing_before_protocol_version_14() { + let drive = setup_drive_with_initial_state_structure(None); + let target = setup_contract( + &drive, + TARGET_CONTRACT_PATH, + Some([0x7E; 32]), + None, + Some(|contract: &mut DataContract| { + contract.set_config( + contract + .config() + .clone() + .with_moderation(Some(elected(86_400, 86_400))), + ); + }), + None, + None, + ); + let mut charters = target.clone(); + charters.set_id(MODERATION_CHARTERS_CONTRACT_ID); + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + + assert_eq!( + drive + .fetch_charter_election_windows( + &charter_contest( + &charters, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + vec![Value::Identifier(target.id().to_buffer())], + ), + &Epoch::new(0).expect("epoch"), + None, + platform_version, + ) + .expect("expected an answer"), + (None, None) + ); + } +} diff --git a/packages/rs-drive/src/drive/document/insert_contested/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/mod.rs index 6267362ce03..0354148c72e 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/mod.rs @@ -36,6 +36,11 @@ mod add_contested_indices_for_contract_operations; mod add_contested_reference_and_vote_subtree_to_document_operations; mod add_contested_vote_subtrees_for_non_identities_operations; +// Module: fetch_charter_election_windows +// This module reads the windows a moderation election takes from its target contract +mod fetch_charter_election_windows; +pub use fetch_charter_election_windows::ContestWindows; + // TODO: Disabled module add_contested_indices_for_index_level_for_contract_operations #[cfg(test)] diff --git a/packages/rs-drive/src/drive/document/mod.rs b/packages/rs-drive/src/drive/document/mod.rs index 6ad61c2253a..8e36d616157 100644 --- a/packages/rs-drive/src/drive/document/mod.rs +++ b/packages/rs-drive/src/drive/document/mod.rs @@ -34,6 +34,8 @@ mod insert; #[cfg(any(feature = "server", feature = "fixtures-and-mocks"))] mod insert_contested; #[cfg(any(feature = "server", feature = "fixtures-and-mocks"))] +pub use insert_contested::ContestWindows; +#[cfg(any(feature = "server", feature = "fixtures-and-mocks"))] pub mod query; #[cfg(all(feature = "server", any(test, feature = "structure")))] pub(crate) mod structure; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs index 40d3f5efa43..7ed705fbc66 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs @@ -91,7 +91,14 @@ impl DocumentCreateTransitionActionV0 { document_type.name() )), )?; - let index_values = index.extract_values(data); + // Identifier values are written one way from protocol version 14, so every + // contender of a contest names it with the same poll and prefunds the same + // balance; before 14 they are taken as given, as they always were + let index_values = index.extract_contested_values( + data, + document_type.flattened_properties(), + platform_version, + )?; let vote_poll = ContestedDocumentResourceVotePoll { contract_id: base.data_contract_id(), diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs index 248a3fad22c..132030fa120 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs @@ -94,6 +94,11 @@ pub struct DocumentTypeMethodVersions { /// keyword: the method returns an empty result there, so the shipped /// `DataContract::validate_document_properties` 0 that calls it is inert. pub validate_property_constraints: OptionalFeatureVersion, + /// `Index::extract_contested_values`: writes an identifier property given as bytes or as + /// an array of byte values as `Value::Identifier` in a contest's index values, so every + /// contender names one contest with one poll. `None` on versions that predate it, where + /// the values are taken as given. + pub canonical_contested_index_values: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs index 2d19c83d6a5..72310706d4c 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs @@ -71,6 +71,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { validate_encrypted_property_shapes: None, validate_max_bytes: None, validate_property_constraints: None, + canonical_contested_index_values: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs index 7e867a9e23b..50dac063540 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs @@ -71,6 +71,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { validate_encrypted_property_shapes: None, validate_max_bytes: None, validate_property_constraints: None, + canonical_contested_index_values: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs index 28643b992ed..aed49ded327 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs @@ -73,6 +73,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { validate_encrypted_property_shapes: None, validate_max_bytes: None, validate_property_constraints: None, + canonical_contested_index_values: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs index b94870ac478..4192b021d1e 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs @@ -73,6 +73,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { validate_encrypted_property_shapes: None, validate_max_bytes: None, validate_property_constraints: None, + canonical_contested_index_values: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs index 5115ccbe477..e4933053905 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs @@ -75,6 +75,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { validate_encrypted_property_shapes: None, validate_max_bytes: None, validate_property_constraints: None, + canonical_contested_index_values: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs index 4f107af650c..5adef48dc13 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs @@ -119,6 +119,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { validate_encrypted_property_shapes: Some(0), // changed: refuses an `encryptedFor` property whose bytes are not the shape its scheme produces; None before this version returns an empty result validate_max_bytes: Some(0), // changed: refuses a string longer in UTF-8 bytes than its property's `maxBytes` (DocumentPropertyMaxBytesExceededError, 10421); None before this version returns an empty result validate_property_constraints: Some(0), // changed: `validate_property_constraints` refuses a created or replaced document that breaks one of its type's `propertyConstraints` (DocumentPropertyConstraintViolatedError, 10422); None before this version returns an empty result + canonical_contested_index_values: Some(0), // new: identifier index values of a contest are written as identifiers }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs index 18ce386f787..0465a5a7368 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs @@ -1,4 +1,4 @@ -use versioned_feature_core::FeatureVersion; +use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v2; @@ -117,6 +117,11 @@ pub struct DriveDocumentInsertContestedMethodVersions { pub add_contested_indices_for_contract_operations: FeatureVersion, pub add_contested_reference_and_vote_subtree_to_document_operations: FeatureVersion, pub add_contested_vote_subtree_for_non_identities_operations: FeatureVersion, + /// Reads the join window and the vote window of a moderation election (an + /// `electedCharter` contest) from the elected moderation declaration of the contract it + /// contends for. `None` before protocol version 14, when no such contest exists and every + /// contest keeps the generic windows. + pub fetch_charter_election_windows: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs index eebb341adfb..9f0136de316 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs @@ -61,6 +61,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V1: DriveDocumentMethodVersions = add_contested_indices_for_contract_operations: 0, add_contested_reference_and_vote_subtree_to_document_operations: 0, add_contested_vote_subtree_for_non_identities_operations: 0, + fetch_charter_election_windows: None, }, update: DriveDocumentUpdateMethodVersions { add_update_multiple_documents_operations: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs index 144c40f7edd..5735710677b 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs @@ -63,6 +63,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V2: DriveDocumentMethodVersions = add_contested_indices_for_contract_operations: 0, add_contested_reference_and_vote_subtree_to_document_operations: 0, add_contested_vote_subtree_for_non_identities_operations: 0, + fetch_charter_election_windows: None, }, update: DriveDocumentUpdateMethodVersions { add_update_multiple_documents_operations: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs index a7039e6d48f..f2758493d02 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs @@ -82,6 +82,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V3: DriveDocumentMethodVersions = add_contested_indices_for_contract_operations: 0, add_contested_reference_and_vote_subtree_to_document_operations: 0, add_contested_vote_subtree_for_non_identities_operations: 0, + fetch_charter_election_windows: None, }, update: DriveDocumentUpdateMethodVersions { add_update_multiple_documents_operations: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs index 1388834b000..a7c06f0add7 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs @@ -5,7 +5,7 @@ use crate::version::drive_versions::drive_document_method_versions::{ DriveDocumentQueryMethodVersions, DriveDocumentUpdateMethodVersions, }; -/// V4 is protocol version 14's document-method table. It hosts four +/// V4 is protocol version 14's document-method table. It hosts five /// independent changes that all gate at v14 (ranked aggregates, the /// shared-prefix aggregate index fix, the reworked non-primary-key /// query lowering via `query.non_primary_key_path_query: 1` — multiple @@ -17,7 +17,11 @@ use crate::version::drive_versions::drive_document_method_versions::{ /// which lets a resource be contested again over the storage of an /// abstain or lock vote tree an earlier poll's cleanup left orphaned: the /// raw existence probe reports it, v0 raised `CorruptedContractIndexes`, -/// v1 checks that nothing is reachable there and creates the tree). +/// v1 checks that nothing is reachable there and creates the tree), and +/// `insert_contested.fetch_charter_election_windows: Some(0)` (`None` in +/// every earlier table), which reads the join and vote windows of a +/// moderation election (an `electedCharter` contest) from its target +/// contract's elected moderation declaration. /// /// ## 1. Contract-level ranked aggregates /// @@ -133,6 +137,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = add_contested_indices_for_contract_operations: 0, add_contested_reference_and_vote_subtree_to_document_operations: 0, add_contested_vote_subtree_for_non_identities_operations: 1, // changed in v4: recreates the abstain or lock vote tree over the storage an earlier poll's cleanup left orphaned when a resource is contested again + fetch_charter_election_windows: Some(0), // new in v14: a moderation election runs on the join and vote windows its target contract declares }, update: DriveDocumentUpdateMethodVersions { add_update_multiple_documents_operations: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/v9.rs b/packages/rs-platform-version/src/version/drive_versions/v9.rs index c6590a4f08e..da2702ba33d 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v9.rs @@ -54,6 +54,10 @@ use grovedb_version::version::v4::GROVE_V4; /// to 1 so a contract whose tokens release at the same time queues the /// shared release-time tree once. v0 queued it once per token in one batch, /// which a node verifying batch consistency refuses as an internal error. +/// * **Moderation election windows**: the same V4 table adds +/// `insert_contested.fetch_charter_election_windows` (`None` before), so +/// an `electedCharter` contest runs on the join and vote windows of its +/// target contract instead of the generic ones. /// /// Everything else matches `DRIVE_VERSION_V8`. pub const DRIVE_VERSION_V9: DriveVersion = DriveVersion { diff --git a/packages/rs-platform-version/src/version/fee/mod.rs b/packages/rs-platform-version/src/version/fee/mod.rs index 39a11808433..29e02b56012 100644 --- a/packages/rs-platform-version/src/version/fee/mod.rs +++ b/packages/rs-platform-version/src/version/fee/mod.rs @@ -13,7 +13,9 @@ use crate::version::fee::state_transition_min_fees::{ }; use crate::version::fee::storage::FeeStorageVersion; use crate::version::fee::v1::FEE_VERSION1; -use crate::version::fee::vote_resolution_fund_fees::VoteResolutionFundFees; +use crate::version::fee::vote_resolution_fund_fees::{ + VoteResolutionFundFees, VoteResolutionFundFeesFieldsBeforeVersion4, +}; use bincode::{Decode, Encode}; pub mod data_contract_registration; @@ -126,7 +128,7 @@ pub struct FeeVersionFieldsBeforeVersion4 { pub processing: FeeProcessingVersionFieldsBeforeVersion1Point4, pub data_contract: FeeDataContractValidationVersion, pub state_transition_min_fees: StateTransitionMinFeesBeforeProtocolVersion11, - pub vote_resolution_fund_fees: VoteResolutionFundFees, + pub vote_resolution_fund_fees: VoteResolutionFundFeesFieldsBeforeVersion4, } impl From for FeeVersion { @@ -143,7 +145,7 @@ impl From for FeeVersion { state_transition_min_fees: StateTransitionMinFees::from( value.state_transition_min_fees, ), - vote_resolution_fund_fees: value.vote_resolution_fund_fees, + vote_resolution_fund_fees: value.vote_resolution_fund_fees.into(), } } } diff --git a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs index fefd39924b2..d11fe374805 100644 --- a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs +++ b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs @@ -10,11 +10,47 @@ pub struct VoteResolutionFundFees { pub contested_document_vote_resolution_unlock_fund_required_amount: u64, /// This is the amount that a single vote will cost pub contested_document_single_vote_cost: u64, + /// The amount deducted from an identity that applies in a moderation election (an + /// `electedCharter` create on the moderation charters contract), prefunding the + /// masternode votes; what is left when the poll ends is released as processing fees. + /// Shared by the application, challenge and amendment polls. Moderation elections exist + /// from protocol version 14; every earlier schedule carries the contested document + /// amount here, so choosing between the two changes nothing before 14. + pub moderation_vote_resolution_fund_required_amount: u64, +} + +/// The vote resolution fund fees exactly as every pre-1.4 release serialized them inside +/// the platform state's fee versions (3 fields). `VoteResolutionFundFees` gained +/// `moderation_vote_resolution_fund_required_amount` in 4.2; embedding the live struct in +/// `FeeVersionFieldsBeforeVersion4` would shift that frozen wire format. Any future field +/// added to `VoteResolutionFundFees` must NOT be added here. +#[derive(Clone, Debug, Encode, Decode, Default, PartialEq, Eq)] +pub struct VoteResolutionFundFeesFieldsBeforeVersion4 { + pub contested_document_vote_resolution_fund_required_amount: u64, + pub contested_document_vote_resolution_unlock_fund_required_amount: u64, + pub contested_document_single_vote_cost: u64, +} + +impl From for VoteResolutionFundFees { + fn from(value: VoteResolutionFundFeesFieldsBeforeVersion4) -> Self { + VoteResolutionFundFees { + contested_document_vote_resolution_fund_required_amount: value + .contested_document_vote_resolution_fund_required_amount, + contested_document_vote_resolution_unlock_fund_required_amount: value + .contested_document_vote_resolution_unlock_fund_required_amount, + contested_document_single_vote_cost: value.contested_document_single_vote_cost, + // Pre-4.2 tables predate moderation elections, which pay the contested document + // amount wherever they could be reached before protocol version 14. + moderation_vote_resolution_fund_required_amount: value + .contested_document_vote_resolution_fund_required_amount, + } + } } #[cfg(test)] mod tests { - use super::VoteResolutionFundFees; + use super::{VoteResolutionFundFees, VoteResolutionFundFeesFieldsBeforeVersion4}; + use crate::version::fee::vote_resolution_fund_fees::v1::VOTE_RESOLUTION_FUND_FEES_VERSION1; #[test] // If this test failed, then a new field was added in VoteResolutionFundFees. And the corresponding eq needs to be updated as well @@ -23,15 +59,50 @@ mod tests { contested_document_vote_resolution_fund_required_amount: 1, contested_document_vote_resolution_unlock_fund_required_amount: 2, contested_document_single_vote_cost: 3, + moderation_vote_resolution_fund_required_amount: 4, }; let version2 = VoteResolutionFundFees { contested_document_vote_resolution_fund_required_amount: 1, contested_document_vote_resolution_unlock_fund_required_amount: 2, contested_document_single_vote_cost: 3, + moderation_vote_resolution_fund_required_amount: 4, }; // This assertion will check if all fields are considered in the equality comparison assert_eq!(version1, version2, "VoteResolutionFundFees equality test failed. If a field was added or removed, update the Eq implementation."); } + + /// A pre-1.4 platform state stored exactly three amounts, in this order. Their encoding + /// is built here from a plain tuple, not from the frozen struct, so a field added to or + /// reordered in the struct fails to decode it or decodes it wrongly. + #[test] + fn should_decode_the_three_field_encoding_of_pre_1_4_platform_states() { + let config = bincode::config::standard() + .with_big_endian() + .with_no_limit(); + let stored: (u64, u64, u64) = (20_000_000_000, 400_000_000_000, 10_000_000); + let bytes = bincode::encode_to_vec(stored, config).expect("encodes"); + + let (decoded, read): (VoteResolutionFundFeesFieldsBeforeVersion4, usize) = + bincode::decode_from_slice(&bytes, config).expect("decodes"); + assert_eq!(read, bytes.len(), "no trailing bytes are expected"); + assert_eq!( + decoded, + VoteResolutionFundFeesFieldsBeforeVersion4 { + contested_document_vote_resolution_fund_required_amount: 20_000_000_000, + contested_document_vote_resolution_unlock_fund_required_amount: 400_000_000_000, + contested_document_single_vote_cost: 10_000_000, + } + ); + assert_eq!( + bincode::encode_to_vec(&decoded, config).expect("encodes"), + bytes, + "the frozen struct encodes as the three amounts and nothing else" + ); + assert_eq!( + VoteResolutionFundFees::from(decoded), + VOTE_RESOLUTION_FUND_FEES_VERSION1 + ); + } } diff --git a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v1.rs b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v1.rs index 4275b122c5c..5d3e3004d22 100644 --- a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v1.rs +++ b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v1.rs @@ -4,4 +4,7 @@ pub const VOTE_RESOLUTION_FUND_FEES_VERSION1: VoteResolutionFundFees = VoteResol contested_document_vote_resolution_fund_required_amount: 20000000000, // 0.2 Dash contested_document_vote_resolution_unlock_fund_required_amount: 400000000000, // 4 Dash contested_document_single_vote_cost: 10000000, // 0.0001 Dash + // Moderation elections exist from protocol version 14; before it they would pay what + // every other contest pays. + moderation_vote_resolution_fund_required_amount: 20000000000, // 0.2 Dash }; diff --git a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v2.rs b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v2.rs index e31728180fb..a49da4189ca 100644 --- a/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v2.rs +++ b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/v2.rs @@ -6,5 +6,7 @@ pub const VOTE_RESOLUTION_FUND_FEES_VERSION2: VoteResolutionFundFees = VoteResol contested_document_vote_resolution_fund_required_amount: 10_000_000_000, // 0.1 DASH // A fifth of the 0.0001 DASH before: a contest's fund covers five times the votes contested_document_single_vote_cost: 2_000_000, // 0.00002 DASH + // An application in a moderation election prefunds 25,000 masternode votes + moderation_vote_resolution_fund_required_amount: 50_000_000_000, // 0.5 DASH ..VOTE_RESOLUTION_FUND_FEES_VERSION1 }; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 12c73e885c1..8d7825ba0fc 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -176,7 +176,11 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `verify_ranked_top_k_proof`. All are 0 today. The same table bumps the /// four index walkers to v2 and the document update walker to v1 for the /// shared-prefix fix; those same walker versions carry the time-range -/// bucket fan-out, so both features gate on one table entry. +/// bucket fan-out, so both features gate on one table entry. It also sets +/// `insert_contested.fetch_charter_election_windows` to `Some(0)`: a +/// moderation election (an `electedCharter` contest) runs on its target +/// contract's join and vote windows, which the document create join check +/// and the contested insert read. /// * `DRIVE_ABCI_QUERY_VERSIONS_V3` bumps /// `document_query_helpers.compute_aggregate_mode_and_check_limit` 0 → 2, /// opening two routes on the v1 document-query handler: the ranked path @@ -1155,7 +1159,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { // The TTL ephemeral-bytes rate (270 credits/byte to processing) rides // the shared storage table; it is dead below v14 (the `ttl` grammar // does not parse), so no table fork is needed. - fee_version: FEE_VERSION3, // changed: contested document contribution reduced to 0.1 DASH; masternode vote cost reduced to 0.00002 DASH; registration surcharge for once-per-identity token distributions + fee_version: FEE_VERSION3, // changed: contested document contribution reduced to 0.1 DASH; masternode vote cost reduced to 0.00002 DASH; moderation election fund of 0.5 DASH; registration surcharge for once-per-identity token distributions system_limits: SYSTEM_LIMITS_V4, // changed: daily withdrawal limit becomes 15% of the total credits a day ago + time-range overlap-factor cap (24) + time-range TTL cap (1 week) and per-write drop cap (32) + GroveDB proof envelope floor (V1); max_contract_moderators, max_contract_suspension_until, max_contract_moderation_reason_length, max_contract_warnings_per_identity, max_contract_moderation_reason_documents and contract_document_restore_window_ms (a week) consensus: ConsensusVersions { tenderdash_consensus_version: 1, @@ -1171,12 +1175,9 @@ mod tests { fn should_change_only_the_contested_document_and_once_per_identity_fees_at_protocol_14() { for protocol_version in 1..14 { let version = PlatformVersion::get(protocol_version).expect("known protocol version"); + let fund_fees = &version.fee_version.vote_resolution_fund_fees; assert_eq!( - version - .fee_version - .vote_resolution_fund_fees - .contested_document_vote_resolution_fund_required_amount, - 20_000_000_000, + fund_fees.contested_document_vote_resolution_fund_required_amount, 20_000_000_000, "protocol {protocol_version} must preserve the 0.2 DASH contribution" ); assert_eq!( @@ -1187,6 +1188,13 @@ mod tests { 10_000_000, "protocol {protocol_version} must preserve the 0.0001 DASH vote" ); + // No moderation election exists before 14; its amount is the contested one, so + // a shipped path choosing between the two cannot change what it charges + assert_eq!( + fund_fees.moderation_vote_resolution_fund_required_amount, + fund_fees.contested_document_vote_resolution_fund_required_amount, + "protocol {protocol_version}" + ); } let mut expected_fees = PLATFORM_V13.fee_version.clone(); @@ -1197,6 +1205,10 @@ mod tests { expected_fees .vote_resolution_fund_fees .contested_document_single_vote_cost = 2_000_000; + // An application in a moderation election prefunds its masternode votes with 0.5 DASH + expected_fees + .vote_resolution_fund_fees + .moderation_vote_resolution_fund_required_amount = 50_000_000_000; // The once-per-identity token distribution exists from protocol version 14 on, and a // token that uses it pays the surcharge of the other distribution kinds. assert_eq!(