From a63b9360208e24d2971dedbd4f8f66ded5a0e434 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 24 Sep 2026 01:39:03 +0700 Subject: [PATCH 1/5] feat(platform)!: charter elections use the target contract's windows and a 0.5 Dash fund (PV14) An electedCharter contest of the moderation charters contract now runs on the join window and vote window of its target contract's elected moderation declaration, on every network, and each application prefunds the masternode votes with the new moderation fund (0.5 Dash) instead of the 0.1 Dash contested document fund. Every other contest, DPNS included, keeps the generic windows and fund. - VoteResolutionFundFees gains moderation_vote_resolution_fund_required_amount: 0.5 Dash in VOTE_RESOLUTION_FUND_FEES_VERSION2 (PV14), the contested amount in VERSION1 so shipped paths charge what they did. The pre-1.4 platform state decoder keeps a frozen three-field struct. - dpp: is_charter_election / charter_election_target and required_vote_resolution_fund, used by the PV14 create structure check and by prefunded_voting_balance_for_document (clients). - drive: fetch_charter_election_windows (optional slot, None before 14) reads the target and bills the read; add_contested_document_for_contract_operations v1 uses it for the end date and the move on the second applicant. - drive-abci: the create join check reads the target's join window (state v1 in place: the helper answers None before 14). - A missing or non-elected target falls back to the generic windows; nothing at the end of a contest reads the target. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/contested-documents.md | 20 + book/src/fees/overview.md | 18 + docs/protocol/moderation-charters.md | 12 +- .../methods/versioned_methods.rs | 16 +- packages/rs-dpp/src/moderation_charter/mod.rs | 27 + .../mod.rs | 31 + .../advanced_structure_v1/mod.rs | 153 ++- .../state_v1/mod.rs | 26 +- .../batch/tests/document/creation.rs | 21 +- .../masternode_vote/charter_election_tests.rs | 1079 +++++++++++++++++ .../state_transitions/masternode_vote/mod.rs | 3 + .../src/drive/contract/contract_fetch_info.rs | 19 + .../v1/mod.rs | 93 +- .../fetch_charter_election_windows/mod.rs | 86 ++ .../fetch_charter_election_windows/v0/mod.rs | 292 +++++ .../drive/document/insert_contested/mod.rs | 5 + packages/rs-drive/src/drive/document/mod.rs | 2 + .../drive_document_method_versions/mod.rs | 7 +- .../drive_document_method_versions/v1.rs | 1 + .../drive_document_method_versions/v2.rs | 1 + .../drive_document_method_versions/v3.rs | 1 + .../drive_document_method_versions/v4.rs | 1 + .../src/version/fee/mod.rs | 8 +- .../fee/vote_resolution_fund_fees/mod.rs | 63 +- .../fee/vote_resolution_fund_fees/v1.rs | 3 + .../fee/vote_resolution_fund_fees/v2.rs | 2 + .../rs-platform-version/src/version/v14.rs | 20 +- 27 files changed, 1959 insertions(+), 51 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs create mode 100644 packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs create mode 100644 packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/v0/mod.rs diff --git a/book/src/data-model/contested-documents.md b/book/src/data-model/contested-documents.md index 6637722ba8d..5ec09ecdaa6 100644 --- a/book/src/data-model/contested-documents.md +++ b/book/src/data-model/contested-documents.md @@ -33,6 +33,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 18cb9f962e4..3160033c87d 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.0001 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 7196c69bafd..83e9ab5f39a 100644 --- a/docs/protocol/moderation-charters.md +++ b/docs/protocol/moderation-charters.md @@ -169,9 +169,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 with the seating, 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. ## Validation beyond the schema 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 bf5194d7b64..b630f8e7ec5 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 @@ -20,7 +20,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; @@ -673,12 +675,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 2a3bfdcc937..5cc0f139ba8 100644 --- a/packages/rs-dpp/src/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/moderation_charter/mod.rs @@ -62,6 +62,33 @@ 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`. `None` for every other contest, and for index values that do not +/// name one contract, which an `electedCharter` never produces. +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] => target.to_identifier().ok(), + _ => None, + } +} + /// The properties of the charter document types. pub mod property_names { pub const TARGET_CONTRACT_ID: &str = "targetContractId"; 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..af02ad4631b 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 @@ -250,9 +250,29 @@ 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)?; + 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, + None => 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 + }, }; 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..e9b892a5642 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 test_that_a_contested_document_can_not_be_added_to_after_a_week_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..11b96cffcef --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs @@ -0,0 +1,1079 @@ +//! 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::dashcore::Network; +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::votes::resolved::vote_polls::contested_document_resource_vote_poll::resolve::ContestedDocumentResourceVotePollResolver; +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; + +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 { + create_transition( + charters, + applicant, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + BTreeMap::from([ + ( + property_names::TARGET_CONTRACT_ID.to_string(), + Value::Identifier(target.to_buffer()), + ), + ( + 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") +} + +fn processing_credits( + platform: &TempPlatform, + platform_version: &PlatformVersion, +) -> Credits { + platform + .drive + .get_epoch_processing_credits_for_distribution( + &Epoch::new(0).expect("epoch"), + None, + platform_version, + ) + .expect("expected the epoch's processing credits") +} + +#[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 = match platform.drive.config.network { + Network::Mainnet => { + platform_version + .dpp + .voting_versions + .default_vote_poll_time_duration_mainnet_ms + } + _ => { + platform_version + .dpp + .voting_versions + .default_vote_poll_time_duration_test_network_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; + process_refused(&platform, late, start + 60_000, platform_version); + + 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; + process_refused(&platform, late, start + 60_000, platform_version); + + 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()); +} 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 cd22aea53d8..1bd7c6a83a6 100644 --- a/packages/rs-drive/src/drive/contract/contract_fetch_info.rs +++ b/packages/rs-drive/src/drive/contract/contract_fetch_info.rs @@ -83,4 +83,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..9f049f7b9d5 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,4 @@ +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, @@ -32,6 +33,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 +73,31 @@ 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 + let generic_windows = match self.config.network { + Network::Mainnet => ContestWindows { + join_window_ms: platform_version .dpp .validation .voting .allow_other_contenders_time_mainnet_ms, - ), - _ => ( - platform_version + poll_duration_ms: platform_version .dpp .voting_versions - .default_vote_poll_time_duration_test_network_ms, - platform_version + .default_vote_poll_time_duration_mainnet_ms, + }, + _ => ContestWindows { + join_window_ms: platform_version .dpp .validation .voting .allow_other_contenders_time_testing_ms, - ), + poll_duration_ms: platform_version + .dpp + .voting_versions + .default_vote_poll_time_duration_test_network_ms, + }, }; + let estimating = estimated_costs_only_with_layer_info.is_some(); let no_locking = contested_document_resource_vote_poll .index()? @@ -122,13 +128,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 +160,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,8 +202,19 @@ 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(), ); @@ -230,4 +257,34 @@ 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 { + 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)) + } } 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..f40ef00049a --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert_contested/fetch_charter_election_windows/mod.rs @@ -0,0 +1,86 @@ +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::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 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-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..d8d2fec8261 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 @@ -133,6 +133,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/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..ab2a2378b29 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,40 @@ 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 three amounts; it must still decode, and it converts + /// to the schedule every protocol version before 14 uses. + #[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 = 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, + }; + 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!( + 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 c13e7f8c94e..dfe051329d7 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 @@ -4,5 +4,7 @@ use crate::version::fee::vote_resolution_fund_fees::VoteResolutionFundFees; /// Introduced in protocol version 14 (4.2). pub const VOTE_RESOLUTION_FUND_FEES_VERSION2: VoteResolutionFundFees = VoteResolutionFundFees { contested_document_vote_resolution_fund_required_amount: 10_000_000_000, // 0.1 DASH + // An application in a moderation election prefunds 5,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 9a1a7e1ab0e..480cdaddf2f 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1072,7 +1072,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; registration surcharge for once-per-identity token distributions + fee_version: FEE_VERSION3, // changed: contested document contribution reduced to 0.1 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, @@ -1088,20 +1088,28 @@ 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" ); + // 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(); expected_fees .vote_resolution_fund_fees .contested_document_vote_resolution_fund_required_amount = 10_000_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!( From 6b1d23694cd25f2485249f5382426ca88c621ed6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 24 Sep 2026 01:55:11 +0700 Subject: [PATCH 2/5] test(drive-abci): read a missing epoch processing credits item as none in the charter fund test The item is written by the epoch's first credit, so before the clean-up releases the remainder it does not exist yet. Co-Authored-By: Claude Opus 5.5 --- .../masternode_vote/charter_election_tests.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs index 11b96cffcef..ef72e66589d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs @@ -51,6 +51,8 @@ use dpp::voting::votes::resource_vote::ResourceVote; use dpp::voting::votes::Vote; use drive::drive::contract::paths::contract_root_path; 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; @@ -560,18 +562,23 @@ fn balance_of( .expect("expected the identity") } +/// The processing credits epoch 0 will distribute; the item is written by the first credit, so +/// before any it is missing, which is none. fn processing_credits( platform: &TempPlatform, platform_version: &PlatformVersion, ) -> Credits { - platform + match platform .drive .get_epoch_processing_credits_for_distribution( &Epoch::new(0).expect("epoch"), None, platform_version, - ) - .expect("expected the epoch's processing credits") + ) { + 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] From cc16ca110c6553742865011557dd6d356960716b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 24 Sep 2026 02:32:56 +0700 Subject: [PATCH 3/5] fix(platform): make the charter election move and join check total, estimate the target read Review fixes for the charter election parameters: - The second applicant's move looks up the join-window entry it recomputed and leaves the end date where it is when the entry is not there, instead of failing to delete a missing key. - The create join check applies no join limit to a moderation election whose target gives no windows, so the reference validation that follows refuses it with 40120/40135 on every network. - Fee estimation counts the target read as a contract read at estimated_contract_max_serialized_size. - ContestWindows::generic holds the per-network generic windows. - charter_election_target reads only 32-byte forms (no base58 text or integer arrays). - The frozen pre-1.4 decoder test decodes a plain three-u64 encoding; the PV13 twin test starts with "should"; version table docs name the new slot. Co-Authored-By: Claude Opus 5.5 --- packages/rs-dpp/src/moderation_charter/mod.rs | 8 +- .../rs-dpp/src/moderation_charter/tests.rs | 60 +++++++++++- .../state_v1/mod.rs | 14 ++- .../batch/tests/document/creation.rs | 2 +- .../masternode_vote/charter_election_tests.rs | 95 ++++++++++++++---- .../v1/mod.rs | 98 ++++++++++++------- .../fetch_charter_election_windows/mod.rs | 18 ++++ .../drive_document_method_versions/v4.rs | 8 +- .../src/version/drive_versions/v9.rs | 4 + .../fee/vote_resolution_fund_fees/mod.rs | 26 +++-- .../rs-platform-version/src/version/v14.rs | 6 +- 11 files changed, 266 insertions(+), 73 deletions(-) diff --git a/packages/rs-dpp/src/moderation_charter/mod.rs b/packages/rs-dpp/src/moderation_charter/mod.rs index 5cc0f139ba8..f53e78ed9b5 100644 --- a/packages/rs-dpp/src/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/moderation_charter/mod.rs @@ -73,8 +73,9 @@ pub fn is_charter_election(contract_id: &Identifier, document_type_name: &str) - } /// The contract a moderation election contends for: the single value of the contested index's -/// key, `targetContractId`. `None` for every other contest, and for index values that do not -/// name one contract, which an `electedCharter` never produces. +/// key, `targetContractId`, in one of the 32-byte forms a document's identifier takes. `None` +/// for every other contest, and for index values that do not name one contract that way (a +/// base58 string or an array of integers included), which an `electedCharter` never produces. pub fn charter_election_target( contract_id: &Identifier, document_type_name: &str, @@ -84,7 +85,8 @@ pub fn charter_election_target( return None; } match index_values { - [target] => target.to_identifier().ok(), + [Value::Identifier(bytes) | Value::Bytes32(bytes)] => Some(Identifier::new(*bytes)), + [Value::Bytes(bytes)] => Identifier::from_bytes(bytes).ok(), _ => None, } } diff --git a/packages/rs-dpp/src/moderation_charter/tests.rs b/packages/rs-dpp/src/moderation_charter/tests.rs index 95cb9c8e73e..d4279bd3b7c 100644 --- a/packages/rs-dpp/src/moderation_charter/tests.rs +++ b/packages/rs-dpp/src/moderation_charter/tests.rs @@ -1,6 +1,7 @@ use super::{ - property_names, validate_submitted_charter, ElectedCharter, ModerationCharterRewardSplit, - SubmittedCharter, FULL_MODERATORS_SHARE, + charter_election_target, 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::consensus::basic::BasicError; use crate::consensus::ConsensusError; @@ -239,3 +240,58 @@ fn should_combine_the_elected_members_the_additions_and_the_removals() { [id(2), id(3), id(4)].into() ); } + +#[test] +fn should_read_a_charter_election_target_only_from_a_32_byte_value() { + 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]) + }; + + for value in [ + Value::Identifier([0x7A; 32]), + Value::Bytes32([0x7A; 32]), + Value::Bytes(vec![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 or as an array of integers, 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(vec![Value::U8(0x7A); 32]), + ), + ( + 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); + } +} 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 af02ad4631b..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; @@ -264,15 +264,19 @@ impl DocumentCreateTransitionActionStateValidationV1 for DocumentCreateTransitio 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, - None => 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 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 e9b892a5642..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 @@ -3370,7 +3370,7 @@ mod creation_tests { /// 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 test_that_a_contested_document_can_not_be_added_to_after_a_week_protocol_version_13() { + 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; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs index ef72e66589d..33576efeedb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs @@ -17,7 +17,6 @@ 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::dashcore::Network; use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; use dpp::data_contract::config::moderation::{ ContractModerationConfig, ContractModerators, ElectedModerators, InterimModerators, @@ -50,6 +49,7 @@ 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; @@ -71,6 +71,7 @@ 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); @@ -938,20 +939,8 @@ async fn should_keep_the_generic_windows_and_fund_for_a_dpns_contest() { else { panic!("expected the contest to run"); }; - let generic_poll_duration = match platform.drive.config.network { - Network::Mainnet => { - platform_version - .dpp - .voting_versions - .default_vote_poll_time_duration_mainnet_ms - } - _ => { - platform_version - .dpp - .voting_versions - .default_vote_poll_time_duration_test_network_ms - } - }; + 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), @@ -1006,7 +995,16 @@ async fn should_end_an_election_whose_target_changed_kind() { platform_version, ) .await; - process_refused(&platform, late, start + 60_000, platform_version); + // 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!( @@ -1075,7 +1073,16 @@ async fn should_end_an_election_whose_target_disappeared() { platform_version, ) .await; - process_refused(&platform, late, start + 60_000, platform_version); + // 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!( @@ -1084,3 +1091,57 @@ async fn should_end_an_election_whose_target_disappeared() { ); 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()); +} 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 9f049f7b9d5..82648cceda5 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,4 @@ +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, @@ -11,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, @@ -23,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 { @@ -73,30 +77,7 @@ impl Drive { platform_version, )?; - let generic_windows = match self.config.network { - Network::Mainnet => ContestWindows { - join_window_ms: platform_version - .dpp - .validation - .voting - .allow_other_contenders_time_mainnet_ms, - poll_duration_ms: platform_version - .dpp - .voting_versions - .default_vote_poll_time_duration_mainnet_ms, - }, - _ => ContestWindows { - join_window_ms: platform_version - .dpp - .validation - .voting - .allow_other_contenders_time_testing_ms, - poll_duration_ms: platform_version - .dpp - .voting_versions - .default_vote_poll_time_duration_test_network_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 @@ -219,16 +200,40 @@ impl Drive { 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 { @@ -274,6 +279,31 @@ impl Drive { 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( 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 index f40ef00049a..c16c0fe37f4 100644 --- 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 @@ -5,6 +5,7 @@ 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; @@ -23,6 +24,23 @@ pub struct ContestWindows { } 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 { 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 d8d2fec8261..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 /// 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/vote_resolution_fund_fees/mod.rs b/packages/rs-platform-version/src/version/fee/vote_resolution_fund_fees/mod.rs index ab2a2378b29..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 @@ -73,23 +73,33 @@ mod tests { 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 three amounts; it must still decode, and it converts - /// to the schedule every protocol version before 14 uses. + /// 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 = 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, - }; - let bytes = bincode::encode_to_vec(&stored, config).expect("encodes"); + 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/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 480cdaddf2f..25a3e989b06 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 From fd4565113ed7f01a4562293c7314317580d9f9d2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 24 Sep 2026 07:30:29 +0700 Subject: [PATCH 4/5] fix(platform)!: write identifier contest index values as identifiers from PV14 Validation accepts an identifier property as an identifier, as 32 bytes or as an array of byte values, and the contested index keys store them alike, but a contest's poll is hashed from its raw index values. Two applicants writing the same target two ways named one contest with two polls (two prefunded balances, an end date the second could not find), and an array-valued target was not recognized, so the election fell back to the generic windows. Index::extract_contested_values writes identifier-typed index values as Value::Identifier, gated by the new canonical_contested_index_values slot (None before 14, Some(0) in CONTRACT_VERSIONS_V6). The create action transformer and contested_vote_poll_for_document_properties use it; before 14 they take the values as given, as before. charter_election_target reads every accepted identifier form. Tests: production-path regressions for an array-valued application and for mixed-form applicants (both fail with the slot off), a dpp test of the canonicalizer at 13 and 14, and a focused estimate of the target read (present, absent and non-charter). Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/contested-documents.md | 7 + .../data_contract/document_type/index/mod.rs | 138 ++++++++++++++++ .../document_type/methods/mod.rs | 7 +- .../methods/versioned_methods.rs | 35 ++-- packages/rs-dpp/src/moderation_charter/mod.rs | 11 +- .../rs-dpp/src/moderation_charter/tests.rs | 15 +- .../masternode_vote/charter_election_tests.rs | 151 +++++++++++++++++- .../v1/mod.rs | 108 +++++++++++++ .../v0/transformer.rs | 9 +- .../dpp_versions/dpp_contract_versions/mod.rs | 5 + .../dpp_versions/dpp_contract_versions/v1.rs | 1 + .../dpp_versions/dpp_contract_versions/v2.rs | 1 + .../dpp_versions/dpp_contract_versions/v3.rs | 1 + .../dpp_versions/dpp_contract_versions/v4.rs | 1 + .../dpp_versions/dpp_contract_versions/v5.rs | 1 + .../dpp_versions/dpp_contract_versions/v6.rs | 1 + 16 files changed, 466 insertions(+), 26 deletions(-) diff --git a/book/src/data-model/contested-documents.md b/book/src/data-model/contested-documents.md index 5ec09ecdaa6..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. 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..c178c8d1c47 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 @@ -15,11 +15,14 @@ use crate::data_contract::errors::DataContractError; use crate::ProtocolError; use anyhow::anyhow; +use crate::data_contract::document_type::property::{DocumentProperty, DocumentPropertyType}; use crate::data_contract::document_type::property_names; use crate::data_contract::document_type::ContestedIndexResolution::MasternodeVote; #[cfg(feature = "validation")] use crate::data_contract::errors::DataContractError::RegexError; +use indexmap::IndexMap; use platform_value::{Value, ValueMap}; +use platform_version::version::PlatformVersion; use regex::Regex; use std::cmp::Ordering; use std::sync::OnceLock; @@ -935,6 +938,79 @@ impl Index { .map(|property| data.get(&property.name).cloned().unwrap_or(Value::Null)) .collect() } + + /// 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. + pub fn extract_contested_values( + &self, + data: &BTreeMap, + document_properties: &IndexMap, + platform_version: &PlatformVersion, + ) -> Result, ProtocolError> { + let values = self.extract_values(data); + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .canonical_contested_index_values + { + None => Ok(values), + Some(0) => Ok(self + .properties + .iter() + .zip(values) + .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()), + 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, + } +} + +/// 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) } #[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd)] @@ -2630,6 +2706,68 @@ impl IndexProperty { mod tests { use super::*; + /// 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() { + use crate::data_contract::document_type::property::ByteArrayPropertySizes; + + 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])] + ); + } + } + fn make_index_property(name: &str, ascending: bool) -> IndexProperty { IndexProperty { name: name.to_string(), 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 3ceb6d8eace..4b8c34e352e 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 @@ -519,7 +519,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], @@ -540,7 +540,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 b630f8e7ec5..7f4b15ad48c 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 @@ -362,14 +362,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| { @@ -393,14 +398,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( diff --git a/packages/rs-dpp/src/moderation_charter/mod.rs b/packages/rs-dpp/src/moderation_charter/mod.rs index f53e78ed9b5..81d6c793445 100644 --- a/packages/rs-dpp/src/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/moderation_charter/mod.rs @@ -29,6 +29,7 @@ mod v0; 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}; @@ -73,9 +74,10 @@ pub fn is_charter_election(contract_id: &Identifier, document_type_name: &str) - } /// The contract a moderation election contends for: the single value of the contested index's -/// key, `targetContractId`, in one of the 32-byte forms a document's identifier takes. `None` -/// for every other contest, and for index values that do not name one contract that way (a -/// base58 string or an array of integers included), which an `electedCharter` never produces. +/// 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, @@ -85,8 +87,7 @@ pub fn charter_election_target( return None; } match index_values { - [Value::Identifier(bytes) | Value::Bytes32(bytes)] => Some(Identifier::new(*bytes)), - [Value::Bytes(bytes)] => Identifier::from_bytes(bytes).ok(), + [target] => contested_index_identifier(target).map(Identifier::new), _ => None, } } diff --git a/packages/rs-dpp/src/moderation_charter/tests.rs b/packages/rs-dpp/src/moderation_charter/tests.rs index d4279bd3b7c..29603a0d8a1 100644 --- a/packages/rs-dpp/src/moderation_charter/tests.rs +++ b/packages/rs-dpp/src/moderation_charter/tests.rs @@ -242,16 +242,19 @@ fn should_combine_the_elected_members_the_additions_and_the_removals() { } #[test] -fn should_read_a_charter_election_target_only_from_a_32_byte_value() { +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( @@ -263,8 +266,8 @@ fn should_read_a_charter_election_target_only_from_a_32_byte_value() { ); } - // The same contract written as base58 text or as an array of integers, a short byte - // string, another type of the charter contract, and another contract + // 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, @@ -274,7 +277,11 @@ fn should_read_a_charter_election_target_only_from_a_32_byte_value() { ( MODERATION_CHARTERS_CONTRACT_ID, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, - Value::Array(vec![Value::U8(0x7A); 32]), + Value::Array( + std::iter::once(Value::U64(256)) + .chain(std::iter::repeat_n(Value::U8(0x7A), 31)) + .collect(), + ), ), ( MODERATION_CHARTERS_CONTRACT_ID, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs index 33576efeedb..ed9ced82493 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/charter_election_tests.rs @@ -364,16 +364,34 @@ async fn application( 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(), - Value::Identifier(target.to_buffer()), - ), + (property_names::TARGET_CONTRACT_ID.to_string(), target), ( property_names::SUBMITTED_CHARTER_ID.to_string(), Value::Identifier(proposal_id.to_buffer()), @@ -1145,3 +1163,128 @@ async fn should_keep_the_end_date_when_the_windows_changed_during_an_election() ); 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/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 82648cceda5..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 @@ -318,3 +318,111 @@ impl Drive { 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/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 2f55e2dd4c6..66b19560abc 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 @@ -84,6 +84,11 @@ pub struct DocumentTypeMethodVersions { /// that predate the keyword: the method returns an empty result there, so the /// shipped create and replace structure validations that call it are inert. pub validate_encrypted_property_shapes: 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 a8d3161f6c4..8e529db5703 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 @@ -67,6 +67,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: 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 5f35727e72f..0fe6fbbecdd 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 @@ -67,6 +67,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: 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 0c21c7188d4..ae535b2bfca 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 @@ -69,6 +69,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: 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 4e381e26d00..3d3201e69f0 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 @@ -69,6 +69,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: 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 afc06a4c41d..7b64f43294d 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 @@ -71,6 +71,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: 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 a43ce0d7464..a9e642b2e3f 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 @@ -115,6 +115,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: Some(0), // changed: `validate_distinct_from_properties` refuses a document whose `distinctFrom` property equals what it must differ from (DocumentPropertyNotDistinctError, 10419); None before this version, where no property can carry the keyword 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 + canonical_contested_index_values: Some(0), // new: identifier index values of a contest are written as identifiers }, }, token_versions: TokenVersions { From 7fb696ae898b161058954951619e5b936e001167 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 24 Sep 2026 12:04:39 +0700 Subject: [PATCH 5/5] refactor(dpp): give Index::extract_contested_values its own directory and v0 The dispatcher, its version gate and the unversioned contested_index_identifier helper move to index/extract_contested_values/mod.rs with the test that runs both protocol versions; the identifier conversion is extract_contested_values_v0 in v0/mod.rs. No behaviour change. Co-Authored-By: Claude Opus 5.5 --- .../index/extract_contested_values/mod.rs | 135 +++++++++++++++++ .../index/extract_contested_values/v0/mod.rs | 43 ++++++ .../data_contract/document_type/index/mod.rs | 142 +----------------- 3 files changed, 181 insertions(+), 139 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/index/extract_contested_values/mod.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/index/extract_contested_values/v0/mod.rs 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 c178c8d1c47..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 @@ -15,23 +15,22 @@ use crate::data_contract::errors::DataContractError; use crate::ProtocolError; use anyhow::anyhow; -use crate::data_contract::document_type::property::{DocumentProperty, DocumentPropertyType}; use crate::data_contract::document_type::property_names; use crate::data_contract::document_type::ContestedIndexResolution::MasternodeVote; #[cfg(feature = "validation")] use crate::data_contract::errors::DataContractError::RegexError; -use indexmap::IndexMap; use platform_value::{Value, ValueMap}; -use platform_version::version::PlatformVersion; use regex::Regex; 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; @@ -938,79 +937,6 @@ impl Index { .map(|property| data.get(&property.name).cloned().unwrap_or(Value::Null)) .collect() } - - /// 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. - pub fn extract_contested_values( - &self, - data: &BTreeMap, - document_properties: &IndexMap, - platform_version: &PlatformVersion, - ) -> Result, ProtocolError> { - let values = self.extract_values(data); - match platform_version - .dpp - .contract_versions - .document_type_versions - .methods - .canonical_contested_index_values - { - None => Ok(values), - Some(0) => Ok(self - .properties - .iter() - .zip(values) - .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()), - 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, - } -} - -/// 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) } #[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd)] @@ -2706,68 +2632,6 @@ impl IndexProperty { mod tests { use super::*; - /// 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() { - use crate::data_contract::document_type::property::ByteArrayPropertySizes; - - 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])] - ); - } - } - fn make_index_property(name: &str, ascending: bool) -> IndexProperty { IndexProperty { name: name.to_string(), @@ -2775,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