diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 59e673febbe..c3de95ae6b3 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -309,6 +309,8 @@ impl ErrorWithCode for StateError { Self::RequiredTokenPaymentInfoNotSetError(_) => 40115, Self::IdentityHasNotAgreedToPayRequiredTokenAmountError(_) => 40116, Self::IdentityTryingToPayWithWrongTokenError(_) => 40117, + Self::DocumentContestIndexMismatchError(_) => 40118, + Self::DocumentContestNotRequiredError(_) => 40119, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, diff --git a/packages/rs-dpp/src/errors/consensus/state/document/document_contest_index_mismatch_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/document_contest_index_mismatch_error.rs new file mode 100644 index 00000000000..87861b5bf1b --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/document_contest_index_mismatch_error.rs @@ -0,0 +1,57 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Contest for document {document_id} prefunded the voting balance of index {provided_index_name}, but the contested index resolved for this document is {expected_index_name}")] +#[platform_serialize(unversioned)] +pub struct DocumentContestIndexMismatchError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_id: Identifier, + + expected_index_name: String, + + provided_index_name: String, +} + +impl DocumentContestIndexMismatchError { + pub fn new( + document_id: Identifier, + expected_index_name: String, + provided_index_name: String, + ) -> Self { + Self { + document_id, + expected_index_name, + provided_index_name, + } + } + + pub fn document_id(&self) -> &Identifier { + &self.document_id + } + + pub fn expected_index_name(&self) -> &str { + &self.expected_index_name + } + + pub fn provided_index_name(&self) -> &str { + &self.provided_index_name + } +} + +impl From for ConsensusError { + fn from(err: DocumentContestIndexMismatchError) -> Self { + Self::StateError(StateError::DocumentContestIndexMismatchError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/document/document_contest_not_required_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/document_contest_not_required_error.rs new file mode 100644 index 00000000000..bb5346b58e0 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/document_contest_not_required_error.rs @@ -0,0 +1,46 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Document {document_id} prefunded the voting balance of index {provided_index_name}, but this document does not resolve to a contested index")] +#[platform_serialize(unversioned)] +pub struct DocumentContestNotRequiredError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_id: Identifier, + + provided_index_name: String, +} + +impl DocumentContestNotRequiredError { + pub fn new(document_id: Identifier, provided_index_name: String) -> Self { + Self { + document_id, + provided_index_name, + } + } + + pub fn document_id(&self) -> &Identifier { + &self.document_id + } + + pub fn provided_index_name(&self) -> &str { + &self.provided_index_name + } +} + +impl From for ConsensusError { + fn from(err: DocumentContestNotRequiredError) -> Self { + Self::StateError(StateError::DocumentContestNotRequiredError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs index 0c59e8741c3..a52e83df43f 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs @@ -2,8 +2,10 @@ pub mod document_already_present_error; pub mod document_contest_currently_locked_error; pub mod document_contest_document_with_same_id_already_present_error; pub mod document_contest_identity_already_contestant; +pub mod document_contest_index_mismatch_error; pub mod document_contest_not_joinable_error; pub mod document_contest_not_paid_for_error; +pub mod document_contest_not_required_error; pub mod document_incorrect_purchase_price_error; pub mod document_not_for_sale_error; pub mod document_not_found_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 672c709198a..225080349a9 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -37,8 +37,10 @@ use crate::consensus::state::data_contract::document_type_update_error::Document use crate::consensus::state::document::document_contest_currently_locked_error::DocumentContestCurrentlyLockedError; use crate::consensus::state::document::document_contest_document_with_same_id_already_present_error::DocumentContestDocumentWithSameIdAlreadyPresentError; use crate::consensus::state::document::document_contest_identity_already_contestant::DocumentContestIdentityAlreadyContestantError; +use crate::consensus::state::document::document_contest_index_mismatch_error::DocumentContestIndexMismatchError; use crate::consensus::state::document::document_contest_not_joinable_error::DocumentContestNotJoinableError; use crate::consensus::state::document::document_contest_not_paid_for_error::DocumentContestNotPaidForError; +use crate::consensus::state::document::document_contest_not_required_error::DocumentContestNotRequiredError; use crate::consensus::state::document::document_incorrect_purchase_price_error::DocumentIncorrectPurchasePriceError; use crate::consensus::state::document::document_not_for_sale_error::DocumentNotForSaleError; use crate::consensus::state::group::{GroupActionAlreadyCompletedError, GroupActionAlreadySignedByIdentityError, GroupActionDoesNotExistError, IdentityMemberOfGroupNotFoundError, IdentityNotMemberOfGroupError, ModificationOfGroupActionMainParametersNotPermittedError}; @@ -354,6 +356,12 @@ pub enum StateError { #[error(transparent)] InsufficientShieldedFeeError(InsufficientShieldedFeeError), + + #[error(transparent)] + DocumentContestIndexMismatchError(DocumentContestIndexMismatchError), + + #[error(transparent)] + DocumentContestNotRequiredError(DocumentContestNotRequiredError), } impl From for ConsensusError { @@ -361,3 +369,64 @@ impl From for ConsensusError { Self::StateError(error) } } + +#[cfg(test)] +mod tests { + use super::*; + use platform_value::Identifier; + + /// `StateError` is encoded by variant position, so inserting a variant + /// anywhere but the end silently reassigns the discriminant of every + /// variant after it — consensus errors travel to WASM and JavaScript + /// clients, which would then decode an existing error as a different one. + /// These are the frozen discriminants of the first variant, of the variant + /// that follows the document contest block (the one an insertion there + /// would shift first), and of the last two. + fn discriminant_of(error: StateError) -> u8 { + let bytes = bincode::encode_to_vec(error, bincode::config::standard()) + .expect("expected to encode the state error"); + // Discriminants below 251 are a single byte under bincode's varint. + bytes[0] + } + + #[test] + fn state_error_discriminants_are_frozen() { + assert_eq!( + discriminant_of(StateError::DataContractAlreadyPresentError( + DataContractAlreadyPresentError::new(Identifier::from([1; 32])) + )), + 0 + ); + assert_eq!( + discriminant_of(StateError::DocumentNotFoundError( + DocumentNotFoundError::new(Identifier::from([1; 32])) + )), + 8 + ); + assert_eq!( + discriminant_of(StateError::InsufficientShieldedFeeError( + InsufficientShieldedFeeError::new("fee".to_string()) + )), + 90 + ); + assert_eq!( + discriminant_of(StateError::DocumentContestIndexMismatchError( + DocumentContestIndexMismatchError::new( + Identifier::from([1; 32]), + "expected".to_string(), + "provided".to_string(), + ) + )), + 91 + ); + assert_eq!( + discriminant_of(StateError::DocumentContestNotRequiredError( + DocumentContestNotRequiredError::new( + Identifier::from([1; 32]), + "provided".to_string(), + ) + )), + 92 + ); + } +} 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 new file mode 100644 index 00000000000..cb4a3593681 --- /dev/null +++ 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 @@ -0,0 +1,399 @@ +use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::document::{DocumentCreationNotAllowedError, InvalidDocumentTypeError}; +use dpp::consensus::state::document::document_contest_index_mismatch_error::DocumentContestIndexMismatchError; +use dpp::consensus::state::document::document_contest_not_paid_for_error::DocumentContestNotPaidForError; +use dpp::consensus::state::document::document_contest_not_required_error::DocumentContestNotRequiredError; +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::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::restricted_creation::CreationRestrictionMode; +use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; +use dpp::identifier::Identifier; +use dpp::validation::{SimpleConsensusValidationResult}; +use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +use dpp::voting::vote_polls::VotePoll; +use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0}; +use dpp::version::PlatformVersion; +use crate::error::Error; + +pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait DocumentCreateTransitionActionStructureValidationV1 { + fn validate_structure_v1( + &self, + owner_id: Identifier, + block_info: &BlockInfo, + network: Network, + platform_version: &PlatformVersion, + ) -> Result; +} +impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTransitionAction { + fn validate_structure_v1( + &self, + owner_id: Identifier, + block_info: &BlockInfo, + network: Network, + platform_version: &PlatformVersion, + ) -> Result { + let contract_fetch_info = self.base().data_contract_fetch_info(); + let data_contract = &contract_fetch_info.contract; + // Make sure that the document type is defined in the contract + let document_type_name = self.base().document_type_name(); + + let Some(document_type) = data_contract.document_type_optional_for_name(document_type_name) + else { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidDocumentTypeError::new(document_type_name.clone(), data_contract.id()) + .into(), + )); + }; + + // Don't do the following validation on testnet before epoch 2080 + // As state transitions already happened that would break this validation + // We want to keep both if-s for better readability + #[allow(clippy::collapsible_if)] + if !(network == Network::Testnet && block_info.epoch.index < 2080) { + let expected_vote_poll = document_type + .contested_vote_poll_for_document_properties(self.data(), platform_version)?; + + match (expected_vote_poll, self.prefunded_voting_balance()) { + ( + 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; + if expected_amount != *paid_amount { + return Ok(SimpleConsensusValidationResult::new_with_error( + DocumentContestNotPaidForError::new( + self.base().id(), + expected_amount, + *paid_amount, + ) + .into(), + )); + } + + // -->> Introduced in V1 <<-- + // The index name in the prefunded voting balance is chosen by the submitter, + // and it is what keys the vote poll, its stored info and its prefunded + // specialized balance. Document insertion however always uses the contested + // index of the document type. Without this check those two can name different + // indexes, so the contest would be funded, resolved and cleaned up under a + // vote poll that does not describe the contest that was actually created. + let provided: ContestedDocumentResourceVotePoll = provided.into(); + if provided != expected { + return Ok(SimpleConsensusValidationResult::new_with_error( + DocumentContestIndexMismatchError::new( + self.base().id(), + expected.index_name, + provided.index_name, + ) + .into(), + )); + } + // -->> End Introduced in V1 <<-- + } + (Some(_), None) => { + let expected_amount = platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount; + return Ok(SimpleConsensusValidationResult::new_with_error( + DocumentContestNotPaidForError::new(self.base().id(), expected_amount, 0) + .into(), + )); + } + // -->> Introduced in V1 <<-- + // A document that resolves to no contested index must not open a contest. + // Otherwise a document that is not a contested resource is stored as a + // contender and only becomes registered if it wins a masternode vote. + (None, Some((provided, _))) => { + let provided: ContestedDocumentResourceVotePoll = provided.into(); + return Ok(SimpleConsensusValidationResult::new_with_error( + DocumentContestNotRequiredError::new(self.base().id(), provided.index_name) + .into(), + )); + } + // -->> End Introduced in V1 <<-- + (None, None) => {} + } + } + + match document_type.creation_restriction_mode() { + CreationRestrictionMode::NoRestrictions => {} + CreationRestrictionMode::OwnerOnly => { + if owner_id != data_contract.owner_id() { + return Ok(SimpleConsensusValidationResult::new_with_error( + DocumentCreationNotAllowedError::new( + self.base().data_contract_id(), + document_type_name.clone(), + document_type.creation_restriction_mode(), + ) + .into(), + )); + } + } + CreationRestrictionMode::NoCreationAllowed => { + return Ok(SimpleConsensusValidationResult::new_with_error( + DocumentCreationNotAllowedError::new( + self.base().data_contract_id(), + document_type_name.clone(), + document_type.creation_restriction_mode(), + ) + .into(), + )); + } + } + // Validate user defined properties + + data_contract + .validate_document_properties(document_type_name, self.data().into(), platform_version) + .map_err(Error::Protocol) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::DocumentCreateTransitionActionValidation; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::fee::Credits; + use dpp::platform_value::Value; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use drive::drive::contract::DataContractFetchInfo; + 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 drive::util::object_size_info::DataContractOwnedResolvedInfo; + use std::collections::BTreeMap; + use std::sync::Arc; + + /// The contested index of the DPNS `domain` document type. + const CONTESTED_INDEX_NAME: &str = "parentNameAndLabel"; + /// A second, non-contested index on the same document type. + const OTHER_INDEX_NAME: &str = "identityId"; + + /// `parentNameAndLabel` is contested for labels matching + /// `^[a-zA-Z01-]{3,19}$`, so this one opens a contest. + const CONTESTED_LABEL: &str = "quantum"; + /// Twenty characters: too long to be contested. + const NON_CONTESTED_LABEL: &str = "quantumcomputingnow1"; + + /// The protocol version whose structure validation is v0, i.e. the last one + /// that accepts a prefunded voting balance naming any index at all. + const PROTOCOL_VERSION_BEFORE_CROSS_CHECK: u32 = 13; + + fn required_amount(platform_version: &PlatformVersion) -> Credits { + platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount + } + + fn domain_properties(label: &str) -> BTreeMap { + BTreeMap::from([ + ( + "normalizedParentDomainName".to_string(), + Value::Text("dash".to_string()), + ), + ( + "normalizedLabel".to_string(), + Value::Text(label.to_string()), + ), + ("label".to_string(), Value::Text(label.to_string())), + ]) + } + + /// Builds the action the transformer would build for a `domain` document + /// with `label`, whose prefunded voting balance names `prefunded_index`. + fn create_action( + label: &str, + prefunded_index: Option<&str>, + platform_version: &PlatformVersion, + ) -> DocumentCreateTransitionAction { + let contract_fetch_info = Arc::new(DataContractFetchInfo::dpns_contract_fixture( + platform_version.protocol_version, + )); + let data = domain_properties(label); + + let prefunded_voting_balance = prefunded_index.map(|index_name| { + // Mirrors the transformer: the index is taken as given and its + // values are extracted from the document data. + let index_values = contract_fetch_info + .contract + .document_type_for_name("domain") + .expect("expected the domain document type") + .indexes() + .get(index_name) + .expect("expected the index to exist on the document type") + .extract_values(&data); + + let vote_poll = ContestedDocumentResourceVotePollWithContractInfo { + contract: DataContractOwnedResolvedInfo::DataContractFetchInfo( + contract_fetch_info.clone(), + ), + document_type_name: "domain".to_string(), + index_name: index_name.to_string(), + index_values, + }; + + (vote_poll, required_amount(platform_version)) + }); + + DocumentCreateTransitionAction::V0(DocumentCreateTransitionActionV0 { + base: DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([0xAA; 32]), + identity_contract_nonce: 1, + document_type_name: "domain".to_string(), + data_contract: contract_fetch_info, + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::default(), + }), + block_info: BlockInfo::default(), + data, + prefunded_voting_balance, + current_store_contest_info: None, + should_store_contest_info: None, + }) + } + + fn validate( + action: &DocumentCreateTransitionAction, + platform_version: &PlatformVersion, + ) -> Vec { + action + .validate_structure( + Identifier::from([0xBB; 32]), + &BlockInfo::default(), + Network::Mainnet, + platform_version, + ) + .expect("expected structure validation to run") + .errors + } + + fn contest_errors(errors: &[ConsensusError]) -> Vec<&StateError> { + errors + .iter() + .filter_map(|error| match error { + ConsensusError::StateError( + state_error @ (StateError::DocumentContestIndexMismatchError(_) + | StateError::DocumentContestNotRequiredError(_) + | StateError::DocumentContestNotPaidForError(_)), + ) => Some(state_error), + _ => None, + }) + .collect() + } + + #[test] + fn should_reject_a_prefunded_voting_balance_naming_another_index() { + let platform_version = PlatformVersion::latest(); + + let action = create_action(CONTESTED_LABEL, Some(OTHER_INDEX_NAME), platform_version); + + let errors = validate(&action, platform_version); + + let [ConsensusError::StateError(StateError::DocumentContestIndexMismatchError(error))] = + errors.as_slice() + else { + panic!("expected a single DocumentContestIndexMismatchError, got {errors:?}"); + }; + assert_eq!(error.expected_index_name(), CONTESTED_INDEX_NAME); + assert_eq!(error.provided_index_name(), OTHER_INDEX_NAME); + } + + #[test] + fn should_reject_a_prefunded_voting_balance_on_a_non_contested_document() { + let platform_version = PlatformVersion::latest(); + + let action = create_action( + NON_CONTESTED_LABEL, + Some(CONTESTED_INDEX_NAME), + platform_version, + ); + + let errors = validate(&action, platform_version); + + let [ConsensusError::StateError(StateError::DocumentContestNotRequiredError(error))] = + errors.as_slice() + else { + panic!("expected a single DocumentContestNotRequiredError, got {errors:?}"); + }; + assert_eq!(error.provided_index_name(), CONTESTED_INDEX_NAME); + } + + #[test] + fn should_still_reject_a_contested_document_that_was_not_paid_for() { + let platform_version = PlatformVersion::latest(); + + let action = create_action(CONTESTED_LABEL, None, platform_version); + + let errors = validate(&action, platform_version); + + assert!( + matches!( + contest_errors(&errors).as_slice(), + [StateError::DocumentContestNotPaidForError(_)] + ), + "expected the contest to still be required to be paid for, got {errors:?}" + ); + } + + #[test] + fn should_accept_a_prefunded_voting_balance_naming_the_contested_index() { + let platform_version = PlatformVersion::latest(); + + let action = create_action( + CONTESTED_LABEL, + Some(CONTESTED_INDEX_NAME), + platform_version, + ); + + // The property map above is deliberately minimal, so document property + // validation is expected to complain; what must not appear is any + // contest-related error. + assert!( + contest_errors(&validate(&action, platform_version)).is_empty(), + "a prefunded voting balance naming the contested index must not be rejected as a contest error" + ); + } + + /// The cross-check is consensus-relevant, so it must not apply before its + /// protocol version: v0 accepted both shapes rejected above. + #[test] + fn should_not_cross_check_the_index_before_its_protocol_version() { + let platform_version = PlatformVersion::get(PROTOCOL_VERSION_BEFORE_CROSS_CHECK) + .expect("expected a platform version before the cross-check"); + + assert_eq!( + platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .document_create_transition_structure_validation, + 0 + ); + + let mismatched = create_action(CONTESTED_LABEL, Some(OTHER_INDEX_NAME), platform_version); + assert!( + contest_errors(&validate(&mismatched, platform_version)).is_empty(), + "the index name was not cross-checked before the cross-check protocol version" + ); + + let not_contested = create_action( + NON_CONTESTED_LABEL, + Some(CONTESTED_INDEX_NAME), + platform_version, + ); + assert!( + contest_errors(&validate(¬_contested, platform_version)).is_empty(), + "an unnecessary prefunded voting balance was not rejected before the cross-check protocol version" + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/mod.rs index af90241f1da..cd28fee88d8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/mod.rs @@ -11,9 +11,11 @@ use crate::execution::types::state_transition_execution_context::StateTransition use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::state_v0::DocumentCreateTransitionActionStateValidationV0; use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::state_v1::DocumentCreateTransitionActionStateValidationV1; use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::advanced_structure_v0::DocumentCreateTransitionActionStructureValidationV0; +use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::advanced_structure_v1::DocumentCreateTransitionActionStructureValidationV1; use crate::platform_types::platform::PlatformStateRef; mod advanced_structure_v0; +mod advanced_structure_v1; mod state_v0; mod state_v1; @@ -53,9 +55,12 @@ impl DocumentCreateTransitionActionValidation for DocumentCreateTransitionAction .document_create_transition_structure_validation { 0 => self.validate_structure_v0(owner_id, block_info, network, platform_version), + // V1 introduces the cross-check that the prefunded voting balance names the same + // contested vote poll that the document itself resolves to + 1 => self.validate_structure_v1(owner_id, block_info, network, platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "DocumentCreateTransitionAction::validate_structure".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } 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 6d1895ab976..6c7bb06e665 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 @@ -1254,6 +1254,562 @@ mod creation_tests { assert!(documents.is_empty()); } + /// The index name in `prefunded_voting_balance` is what keys the vote poll, + /// its stored info and its prefunded specialized balance, while the + /// contested index tree the contender is actually inserted into always + /// comes from the document type's contested index. A transition naming a + /// different index than the one its document resolves to must therefore be + /// rejected outright. + #[tokio::test] + async fn test_document_creation_on_contested_unique_index_should_fail_if_prefunding_another_index( + ) { + let platform_version = PlatformVersion::latest(); + let platform_config = PlatformConfig { + network: Network::Mainnet, + ..Default::default() + }; + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .with_config(platform_config) + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(433); + + let platform_state = platform.state.load(); + + let (identity_1, signer_1, key_1) = + setup_identity(&mut platform, 958, dash_to_credits!(0.5)); + + let dpns = platform + .drive + .cache + .system_data_contracts + .load_dpns(platform_version) + .expect("expected the dpns system contract"); + let dpns_contract = dpns.clone(); + + let preorder = dpns_contract + .document_type_for_name("preorder") + .expect("expected a preorder document type"); + + let domain = dpns_contract + .document_type_for_name("domain") + .expect("expected a domain document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut preorder_document_1 = preorder + .random_document_with_identifier_and_entropy( + &mut rng, + identity_1.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + + let mut document_1 = domain + .random_document_with_identifier_and_entropy( + &mut rng, + identity_1.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + + document_1.set("parentDomainName", "dash".into()); + document_1.set("normalizedParentDomainName", "dash".into()); + document_1.set("label", "quantum".into()); + document_1.set("normalizedLabel", "quantum".into()); + document_1.set("records.identity", document_1.owner_id().into()); + document_1.set("subdomainRules.allowSubdomains", false.into()); + + let salt_1: [u8; 32] = rng.gen(); + + let mut salted_domain_buffer_1: Vec = vec![]; + salted_domain_buffer_1.extend(salt_1); + salted_domain_buffer_1.extend("quantum.dash".as_bytes()); + + let salted_domain_hash_1 = hash_double(salted_domain_buffer_1); + + preorder_document_1.set("saltedDomainHash", salted_domain_hash_1.into()); + + document_1.set("preorderSalt", salt_1.into()); + + let documents_batch_create_preorder_transition_1 = + BatchTransition::new_document_creation_transition_from_document( + preorder_document_1, + preorder, + entropy.0, + &key_1, + 2, + 0, + None, + &signer_1, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_create_serialized_preorder_transition_1 = + documents_batch_create_preorder_transition_1 + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let owner_id = document_1.owner_id(); + let create_transition: DocumentCreateTransition = DocumentCreateTransitionV0 { + base: DocumentBaseTransition::from_document( + &document_1, + domain, + None, + 3, + platform_version, + None, + ) + .expect("expected a base transition"), + entropy: entropy.0, + data: document_1.clone().properties_consumed(), + // Paying the right amount, but for `identityId` instead of the + // contested `parentNameAndLabel` index this document resolves to. + prefunded_voting_balance: Some(( + "identityId".to_string(), + platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount, + )), + } + .into(); + let documents_batch_inner_create_transition_1: BatchTransition = BatchTransitionV0 { + owner_id, + transitions: vec![create_transition.into()], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + let mut documents_batch_create_transition_1: StateTransition = + documents_batch_inner_create_transition_1.into(); + documents_batch_create_transition_1 + .sign_external(&key_1, &signer_1, Some(|_, _| Ok(SecurityLevel::HIGH))) + .await + .expect("expected to sign"); + + let documents_batch_create_serialized_transition_1 = documents_batch_create_transition_1 + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![documents_batch_create_serialized_preorder_transition_1.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + assert_eq!(processing_result.valid_count(), 1); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![documents_batch_create_serialized_transition_1.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError(StateError::DocumentContestIndexMismatchError(_)), + .. + }] + ); + + // The contest must not exist: no contender was added under the real + // contested index, and no document was created. + let config = bincode::config::standard() + .with_big_endian() + .with_no_limit(); + + let dash_encoded = bincode::encode_to_vec(Value::Text("dash".to_string()), config) + .expect("expected to encode the word dash"); + + let quantum_encoded = bincode::encode_to_vec(Value::Text("quantum".to_string()), config) + .expect("expected to encode the word quantum"); + + let query_validation_result = platform + .query_contested_resource_vote_state( + GetContestedResourceVoteStateRequest { + version: Some(get_contested_resource_vote_state_request::Version::V0( + GetContestedResourceVoteStateRequestV0 { + contract_id: dpns_contract.id().to_vec(), + document_type_name: domain.name().clone(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![dash_encoded, quantum_encoded], + result_type: ResultType::DocumentsAndVoteTally as i32, + allow_include_locked_and_abstaining_vote_tally: false, + start_at_identifier_info: None, + count: None, + prove: false, + }, + )), + }, + &platform_state, + platform_version, + ) + .expect("expected to execute query") + .into_data() + .expect("expected query to be valid"); + + let get_contested_resource_vote_state_response::Version::V0( + GetContestedResourceVoteStateResponseV0 { + metadata: _, + result, + }, + ) = query_validation_result.version.expect("expected a version"); + + let Some( + get_contested_resource_vote_state_response_v0::Result::ContestedResourceContenders( + get_contested_resource_vote_state_response_v0::ContestedResourceContenders { + contenders, + .. + }, + ), + ) = result + else { + panic!("expected contenders") + }; + + assert_eq!(contenders.len(), 0); + + let drive_query = + DriveDocumentQuery::new_primary_key_single_item_query(&dpns, domain, document_1.id()); + + let documents = platform + .drive + .query_documents(drive_query, None, false, None, None) + .expect("expected to get back documents") + .documents_owned(); + + assert!(documents.is_empty()); + } + + /// The stored info of a contest — which carries its status and start time, + /// and therefore drives the locked / joinable / already-a-contestant checks + /// — is looked up under the vote poll built from the transition's own + /// prefunded voting balance. Naming an index other than the contested one + /// makes that lookup miss, so every one of those checks is skipped while + /// the contender is still inserted into the real contest. This test pins + /// the join window specifically. + #[tokio::test] + async fn test_contest_can_not_be_joined_after_the_join_window_by_prefunding_another_index() { + let platform_version = PlatformVersion::latest(); + let platform_config = PlatformConfig { + network: Network::Mainnet, + ..Default::default() + }; + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .with_config(platform_config) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_state = platform.state.load(); + + let (_contender_1, _contender_2, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 9, + "quantum", + platform_version, + ) + .await; + + // Move past the window in which other contenders may still join. + let after_join_window = platform_version + .dpp + .validation + .voting + .allow_other_contenders_time_mainnet_ms + + 100_000; + + fast_forward_to_block(&platform, after_join_window, 900, 42, 0, false); + + let platform_state = platform.state.load(); + + let mut rng = StdRng::seed_from_u64(11); + + let (identity_3, signer_3, key_3) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let preorder = dpns_contract + .document_type_for_name("preorder") + .expect("expected a preorder document type"); + + let domain = dpns_contract + .document_type_for_name("domain") + .expect("expected a domain document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut preorder_document_3 = preorder + .random_document_with_identifier_and_entropy( + &mut rng, + identity_3.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + + let mut document_3 = domain + .random_document_with_identifier_and_entropy( + &mut rng, + identity_3.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + + document_3.set("parentDomainName", "dash".into()); + document_3.set("normalizedParentDomainName", "dash".into()); + document_3.set("label", "quantum".into()); + document_3.set("normalizedLabel", "quantum".into()); + document_3.set("records.identity", document_3.owner_id().into()); + document_3.set("subdomainRules.allowSubdomains", false.into()); + + let salt_3: [u8; 32] = rng.gen(); + + let mut salted_domain_buffer_3: Vec = vec![]; + salted_domain_buffer_3.extend(salt_3); + salted_domain_buffer_3.extend("quantum.dash".as_bytes()); + + preorder_document_3.set( + "saltedDomainHash", + hash_double(salted_domain_buffer_3).into(), + ); + + document_3.set("preorderSalt", salt_3.into()); + + let preorder_transition_3 = + BatchTransition::new_document_creation_transition_from_document( + preorder_document_3, + preorder, + entropy.0, + &key_3, + 2, + 0, + None, + &signer_3, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition") + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let owner_id = document_3.owner_id(); + let create_transition: DocumentCreateTransition = DocumentCreateTransitionV0 { + base: DocumentBaseTransition::from_document( + &document_3, + domain, + None, + 3, + platform_version, + None, + ) + .expect("expected a base transition"), + entropy: entropy.0, + data: document_3.clone().properties_consumed(), + // Naming `identityId` instead of the contested `parentNameAndLabel` + // makes the stored info of the running contest invisible to this + // transition. + prefunded_voting_balance: Some(( + "identityId".to_string(), + platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount, + )), + } + .into(); + let inner_transition_3: BatchTransition = BatchTransitionV0 { + owner_id, + transitions: vec![create_transition.into()], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + let mut domain_transition_3: StateTransition = inner_transition_3.into(); + domain_transition_3 + .sign_external(&key_3, &signer_3, Some(|_, _| Ok(SecurityLevel::HIGH))) + .await + .expect("expected to sign"); + + let domain_transition_3 = domain_transition_3 + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let block_info = BlockInfo { + time_ms: after_join_window + 3000, + height: 901, + core_height: 42, + epoch: Default::default(), + }; + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![preorder_transition_3], + &platform_state, + &block_info, + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + assert_eq!(processing_result.valid_count(), 1); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![domain_transition_3], + &platform_state, + &block_info, + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [PaidConsensusError { + error: ConsensusError::StateError(StateError::DocumentContestIndexMismatchError(_)), + .. + }] + ); + + // The contest still has exactly its two original contenders. + let config = bincode::config::standard() + .with_big_endian() + .with_no_limit(); + + let dash_encoded = bincode::encode_to_vec(Value::Text("dash".to_string()), config) + .expect("expected to encode the word dash"); + + let quantum_encoded = bincode::encode_to_vec(Value::Text("quantum".to_string()), config) + .expect("expected to encode the word quantum"); + + let query_validation_result = platform + .query_contested_resource_vote_state( + GetContestedResourceVoteStateRequest { + version: Some(get_contested_resource_vote_state_request::Version::V0( + GetContestedResourceVoteStateRequestV0 { + contract_id: dpns_contract.id().to_vec(), + document_type_name: domain.name().clone(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![dash_encoded, quantum_encoded], + result_type: ResultType::DocumentsAndVoteTally as i32, + allow_include_locked_and_abstaining_vote_tally: false, + start_at_identifier_info: None, + count: None, + prove: false, + }, + )), + }, + &platform_state, + platform_version, + ) + .expect("expected to execute query") + .into_data() + .expect("expected query to be valid"); + + let get_contested_resource_vote_state_response::Version::V0( + GetContestedResourceVoteStateResponseV0 { + metadata: _, + result, + }, + ) = query_validation_result.version.expect("expected a version"); + + let Some( + get_contested_resource_vote_state_response_v0::Result::ContestedResourceContenders( + get_contested_resource_vote_state_response_v0::ContestedResourceContenders { + contenders, + .. + }, + ), + ) = result + else { + panic!("expected contenders") + }; + + assert_eq!( + contenders.len(), + 2, + "the contest must not have gained a contender after its join window closed" + ); + } + #[tokio::test] async fn test_document_creation_on_contested_unique_index_should_not_fail_if_not_paying_for_it_on_testnet_before_epoch_2080( ) { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 3cb2fa7a7c5..91b3532908a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -3543,4 +3543,267 @@ pub(in crate::execution) mod tests { ); } } + + /// End-to-end regression test for the contested-index cross-check. + /// + /// The index name in a create transition's `prefunded_voting_balance` is + /// what keys the vote poll, its stored info, its end-date entry and its + /// prefunded specialized balance, while the contested index tree the + /// contender is inserted into always comes from + /// `DocumentType::find_contested_index`. Before the cross-check nothing + /// tied the two together. + /// + /// The contract used here has a non-contested index (`parentName`) whose + /// properties are a strict prefix of the contested index + /// (`parentNameAndLabel`). That is what makes the mismatch survive: the + /// bogus vote poll's contenders path is a tree the contested insert + /// creates anyway, so the poll is registered — one level above the contest + /// it describes. + /// + /// Run against the pre-fix structure validation (v0) this exact test shows + /// the transition executing successfully and the resolver then returning + /// `Err(Drive(CorruptedCodeExecution("expected a locked tally")))`, because + /// the tally query finds no lock/abstain sum trees where the poll points. + /// That `Err` propagates through `run_dao_platform_events` -> + /// `run_block_proposal` (bare `?`), so it is not caught into a + /// per-state-transition result — it halts every validator at the block + /// where the poll ends. The end-date entry is never cleaned up, so every + /// subsequent block proposal fails the same way. + mod contested_index_mismatch_chain_halt { + use super::*; + use crate::config::PlatformConfig; + use crate::test::helpers::fast_forward_to_block::fast_forward_to_block; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::dashcore::Network; + use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; + use dpp::state_transition::batch_transition::document_create_transition::DocumentCreateTransitionV0; + use dpp::state_transition::batch_transition::{ + BatchTransitionV0, DocumentCreateTransition, + }; + + /// A contested `domain` document type that also carries a + /// non-contested `parentName` index on `[normalizedParentDomainName]`, + /// a strict prefix of the contested `[normalizedParentDomainName, + /// normalizedLabel]`. + const CONTRACT_WITH_PREFIX_INDEX: &str = + "tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-and-prefix-index.json"; + + /// Opens a contest on the prefix-index contract with the voting + /// balance prefunded for `prefunded_index`, then advances past the + /// vote poll end date and runs the per-block resolver. + /// + /// Returns the execution result of the create transition and, when it + /// was executed, the resolver's result. + async fn run_contest_then_resolve( + prefunded_index: &str, + ) -> ( + StateTransitionExecutionResult, + Option>, + ) { + // Mainnet: on testnet the contested structure validation is skipped + // altogether before epoch 2080. + let platform_config = PlatformConfig { + network: Network::Mainnet, + ..Default::default() + }; + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .with_config(platform_config) + .build_with_mock_rpc() + .set_initial_state_structure(); + + let platform_version = PlatformVersion::latest(); + + let mut rng = StdRng::seed_from_u64(0x0C01_7E57); + + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let contract_owner = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + + let contract = setup_contract( + &platform.drive, + CONTRACT_WITH_PREFIX_INDEX, + None, + Some(contract_owner.0.id().to_buffer()), + None::, + None, + Some(platform_version), + ); + + let domain = contract + .document_type_for_name("domain") + .expect("expected a domain document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = domain + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random domain document"); + + document.set("parentDomainName", "dash".into()); + document.set("normalizedParentDomainName", "dash".into()); + document.set("label", "quantum".into()); + document.set("normalizedLabel", "quantum".into()); + document.set("records.identity", document.owner_id().into()); + document.set("subdomainRules.allowSubdomains", false.into()); + document.set("preorderSalt", rng.gen::<[u8; 32]>().into()); + + let owner_id = document.owner_id(); + let create_transition: DocumentCreateTransition = DocumentCreateTransitionV0 { + base: DocumentBaseTransition::from_document( + &document, + domain, + None, + 2, + platform_version, + None, + ) + .expect("expected a base transition"), + entropy: entropy.0, + data: document.clone().properties_consumed(), + prefunded_voting_balance: Some(( + prefunded_index.to_string(), + platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount, + )), + } + .into(); + + let inner_transition: BatchTransition = BatchTransitionV0 { + owner_id, + transitions: vec![create_transition.into()], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + let mut transition: StateTransition = inner_transition.into(); + transition + .sign_external(&key, &signer, Some(|_, _| Ok(SecurityLevel::HIGH))) + .await + .expect("expected to sign"); + + let serialized_transition = transition + .serialize_to_bytes() + .expect("expected to serialize the transition"); + + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized_transition], + &platform_state, + &BlockInfo::default_with_time(3000), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let execution_result = processing_result.into_execution_results().remove(0); + + if !matches!( + execution_result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ) { + return (execution_result, None); + } + + let after_vote_poll_end = platform_version + .dpp + .voting_versions + .default_vote_poll_time_duration_mainnet_ms + + 10_000; + + fast_forward_to_block(&platform, after_vote_poll_end, 900, 42, 0, false); + + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + + let resolver_result = platform.check_for_ended_vote_polls( + &platform_state, + &platform_state, + &BlockInfo { + time_ms: after_vote_poll_end, + height: 900, + core_height: 42, + epoch: Default::default(), + }, + Some(&transaction), + platform_version, + ); + + (execution_result, Some(resolver_result)) + } + + /// THE FIX. A vote poll prefunded for `parentName` describes a + /// different resource than the contest the document would create, so + /// the transition never executes and no vote poll is registered. + #[tokio::test] + async fn prefunding_a_prefix_index_is_rejected() { + let (execution_result, resolver_result) = run_contest_then_resolve("parentName").await; + + assert_matches!( + execution_result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::DocumentContestIndexMismatchError(_) + ), + .. + }, + "a prefunded voting balance naming an index other than the contested one must be \ + rejected before it can register a vote poll" + ); + + assert!( + resolver_result.is_none(), + "the transition must not have executed" + ); + } + + /// CONTROL (causation proof). The exact same contest prefunded for the + /// contested index executes, and its vote poll resolves without error + /// when it ends. The only difference between the two runs is the index + /// name in the prefunded voting balance. + #[tokio::test] + async fn prefunding_the_contested_index_resolves_successfully() { + let (execution_result, resolver_result) = + run_contest_then_resolve("parentNameAndLabel").await; + + assert_matches!( + execution_result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let resolver_result = + resolver_result.expect("expected the vote poll resolver to have run"); + + assert!( + resolver_result.is_ok(), + "control: a contest whose vote poll names its own contested index must resolve; \ + got {:?}", + resolver_result + ); + } + } } diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-and-prefix-index.json b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-and-prefix-index.json new file mode 100644 index 00000000000..f597ce6bc0b --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-and-prefix-index.json @@ -0,0 +1,177 @@ +{ + "$formatVersion": "0", + "id": "EJvbTjnGuXK2m5cJj9GaQ4qBqCbXQvJRvsdw6MPU2Vsh", + "ownerId": "2QjL594djCH2NyDsn45vd6yQjEDHupMKo7CEGVTHtQxU", + "version": 1, + "documentSchemas": { + "domain": { + "documentsMutable": false, + "canBeDeleted": true, + "transferable": 1, + "tradeMode": 1, + "type": "object", + "indices": [ + { + "name": "parentNameAndLabel", + "properties": [ + { + "normalizedParentDomainName": "asc" + }, + { + "normalizedLabel": "asc" + } + ], + "unique": true, + "contested": { + "fieldMatches": [ + { + "field": "normalizedLabel", + "regexPattern": "^[a-zA-Z01]{3,19}$" + } + ], + "resolution": 0, + "description": "If the normalized label part of this index is less than 20 characters (all alphabet a-z and 0 and 1) then this index is non unique while contest resolution takes place." + } + }, + { + "name": "identityId", + "nullSearchable": false, + "properties": [ + { + "records.identity": "asc" + } + ] + }, + { + "name": "parentName", + "properties": [ + { + "normalizedParentDomainName": "asc" + } + ] + } + ], + "properties": { + "label": { + "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$", + "minLength": 3, + "maxLength": 63, + "position": 0, + "description": "Domain label. e.g. 'Bob'." + }, + "normalizedLabel": { + "type": "string", + "pattern": "^[a-hj-km-np-z0-9][a-hj-km-np-z0-9-]{0,61}[a-hj-km-np-z0-9]$", + "maxLength": 63, + "position": 1, + "description": "Domain label converted to lowercase for case-insensitive uniqueness validation. \"o\", \"i\" and \"l\" replaced with \"0\" and \"1\" to mitigate homograph attack. e.g. 'b0b'", + "$comment": "Must be equal to the label in lowercase. \"o\", \"i\" and \"l\" must be replaced with \"0\" and \"1\"." + }, + "parentDomainName": { + "type": "string", + "pattern": "^$|^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$", + "minLength": 0, + "maxLength": 63, + "position": 2, + "description": "A full parent domain name. e.g. 'dash'." + }, + "normalizedParentDomainName": { + "type": "string", + "pattern": "^$|^[a-hj-km-np-z0-9][a-hj-km-np-z0-9-\\.]{0,61}[a-hj-km-np-z0-9]$", + "minLength": 0, + "maxLength": 63, + "position": 3, + "description": "A parent domain name in lowercase for case-insensitive uniqueness validation. \"o\", \"i\" and \"l\" replaced with \"0\" and \"1\" to mitigate homograph attack. e.g. 'dash'", + "$comment": "Must either be equal to an existing domain or empty to create a top level domain. \"o\", \"i\" and \"l\" must be replaced with \"0\" and \"1\". Only the data contract owner can create top level domains." + }, + "preorderSalt": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 4, + "description": "Salt used in the preorder document" + }, + "records": { + "type": "object", + "properties": { + "identity": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 1, + "contentMediaType": "application/x.dash.dpp.identifier", + "description": "Identifier name record that refers to an Identity" + } + }, + "minProperties": 1, + "position": 5, + "additionalProperties": false + }, + "subdomainRules": { + "type": "object", + "properties": { + "allowSubdomains": { + "type": "boolean", + "description": "This option defines who can create subdomains: true - anyone; false - only the domain owner", + "$comment": "Only the domain owner is allowed to create subdomains for non top-level domains", + "position": 0 + } + }, + "position": 6, + "description": "Subdomain rules allow domain owners to define rules for subdomains", + "additionalProperties": false, + "required": [ + "allowSubdomains" + ] + } + }, + "required": [ + "$createdAt", + "$updatedAt", + "$transferredAt", + "label", + "normalizedLabel", + "normalizedParentDomainName", + "preorderSalt", + "records", + "subdomainRules" + ], + "additionalProperties": false, + "$comment": "In order to register a domain you need to create a preorder. The preorder step is needed to prevent man-in-the-middle attacks. normalizedLabel + '.' + normalizedParentDomain must not be longer than 253 chars length as defined by RFC 1035. Domain documents are immutable: modification and deletion are restricted" + }, + "preorder": { + "documentsMutable": false, + "canBeDeleted": true, + "type": "object", + "indices": [ + { + "name": "saltedHash", + "properties": [ + { + "saltedDomainHash": "asc" + } + ], + "unique": true + } + ], + "properties": { + "saltedDomainHash": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 0, + "description": "Double sha-256 of the concatenation of a 32 byte random salt and a normalized domain name" + } + }, + "required": [ + "saltedDomainHash" + ], + "additionalProperties": false, + "$comment": "Preorder documents are immutable: modification and deletion are restricted" + } + } +} diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs index 6d78b001ce2..4c053895190 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs @@ -1,4 +1,5 @@ pub mod v1; +pub mod v10; pub mod v2; pub mod v3; pub mod v4; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs new file mode 100644 index 00000000000..757d5770fbf --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -0,0 +1,342 @@ +use crate::version::drive_abci_versions::drive_abci_validation_versions::{ + DriveAbciAssetLockValidationVersions, DriveAbciDocumentsStateTransitionValidationVersions, + DriveAbciStateTransitionCommonValidationVersions, DriveAbciStateTransitionValidationVersion, + DriveAbciStateTransitionValidationVersions, DriveAbciValidationConstants, + DriveAbciValidationDataTriggerAndBindingVersions, DriveAbciValidationDataTriggerVersions, + DriveAbciValidationVersions, PenaltyAmounts, +}; + +// PROTOCOL_VERSION_14: bump `document_create_transition_structure_validation` to +// 1, which cross-checks the index named by a document create transition's +// prefunded voting balance against the contested index the document itself +// resolves to. v9 remains unchanged for PROTOCOL_VERSION_13 chain replay. +pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = + DriveAbciValidationVersions { + state_transitions: DriveAbciStateTransitionValidationVersions { + common_validation_methods: DriveAbciStateTransitionCommonValidationVersions { + asset_locks: DriveAbciAssetLockValidationVersions { + fetch_asset_lock_transaction_output_sync: 0, + verify_asset_lock_is_not_spent_and_has_enough_balance: 0, + }, + validate_identity_public_key_contract_bounds: 1, + validate_identity_public_key_ids_dont_exist_in_state: 0, + validate_identity_public_key_ids_exist_in_state: 0, + validate_state_transition_identity_signed: 0, + validate_unique_identity_public_key_hashes_in_state: 1, + validate_master_key_uniqueness: 0, + validate_non_masternode_identity_exists: 0, + validate_identity_exists: 0, + }, + max_asset_lock_usage_attempts: 16, + identity_create_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: Some(0), + identity_signatures: Some(0), + nonce: None, + state: 0, + transform_into_action: 0, + }, + identity_update_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: Some(0), + identity_signatures: Some(0), + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, + identity_top_up_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, + identity_credit_withdrawal_state_transition: + DriveAbciStateTransitionValidationVersion { + // v1 adds config min_version enforcement: since protocol version 12, V0 config is no longer + // accepted because it lacks sized_integer_types support. + basic_structure: Some(1), + advanced_structure: None, + identity_signatures: None, + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, + identity_credit_withdrawal_state_transition_purpose_matches_requirements: 0, + identity_credit_transfer_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, + identity_credit_transfer_to_addresses_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, + masternode_vote_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: None, + advanced_structure: Some(0), + identity_signatures: None, + nonce: Some(1), + state: 0, + transform_into_action: 0, + }, + masternode_vote_state_transition_balance_pre_check: 0, + contract_create_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(1), + advanced_structure: Some(1), + identity_signatures: None, + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, + contract_update_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(1), + advanced_structure: None, + identity_signatures: None, + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, + batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { + basic_structure: 0, + advanced_structure: 0, + state: 0, + revision: 0, + // PROTOCOL_VERSION_12 (v3.1 hard fork): batch state transition + // fee accounting fixes. This single field gates multiple + // related billing changes so they all activate together at + // the same hard fork. On v0 every behavior below is the + // legacy under-billing, preserved verbatim for + // PROTOCOL_VERSION_11 chain replay. + // + // Gated by `transform_into_action: 1`: + // * B7 — outer `execution_context` is threaded through the + // batch transformer (was a dropped local) so per- + // transition fee_results in `try_into_action_v0` are + // billed. + // * B4 — `query_documents` cost in + // `fetch_documents_for_transitions_knowing_contract_and_document_type` + // is added to `execution_context`. + // * B5 — `query_documents` cost in `fetch_document_with_id` + // is added to `execution_context`. + // * T1 — DPNS data trigger parent-domain + // `query_documents` cost. + // * T2 — DPNS data trigger preorder `query_documents` cost. + // * T3 — DashPay data trigger recipient identity-balance + // fetch cost (switched to `fetch_identity_balance_with_costs`). + // * T4 — withdrawals data trigger `query_documents` cost. + transform_into_action: 1, + // PROTOCOL_VERSION_12 (v3.1 hard fork): per-transition + // failure paths in `transform_document_transition` now emit + // a `BumpIdentityDataContractNonce` action so the user pays + // for the validation work that already ran (fetch + + // ownership/revision check). v0 stays for chain + // reproducibility on PROTOCOL_VERSION_11 and below. + failed_per_transition_action: 1, + // PROTOCOL_VERSION_12 (v3.1 hard fork): fetch_documents + // helpers bumped to v1 which bill the grovedb cost of + // their query_documents calls. v0 stays for PV11 chain + // replay (the v0 helpers pass epoch=None and never call + // add_operation — byte-identical to pre-PR behavior). + fetch_documents_for_transitions_knowing_contract_and_document_type: 1, + fetch_document_with_id: 1, + data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { + // PROTOCOL_VERSION_13: v1 drops the reject bindings for + // Transfer, Purchase and UpdatePrice on DPNS `domain` + // documents, enabling username transfers and sales. + bindings: 1, + triggers: DriveAbciValidationDataTriggerVersions { + // PROTOCOL_VERSION_12 (v3.1 hard fork): triggers + // that perform drive reads now have `_v1` versions + // that bill the cost via add_operation on the + // outer execution_context. v0 versions remain + // byte-identical to PV11 (don't bill). + create_contact_request_data_trigger: 1, + create_domain_data_trigger: 1, + create_identity_data_trigger: 0, + create_feature_flag_data_trigger: 0, + create_masternode_reward_shares_data_trigger: 0, + delete_withdrawal_data_trigger: 1, + // Reject does no drive reads — stays at v0. + reject_data_trigger: 0, + }, + }, + is_allowed: 0, + document_create_transition_structure_validation: 1, + document_delete_transition_structure_validation: 0, + document_replace_transition_structure_validation: 0, + document_transfer_transition_structure_validation: 0, + document_purchase_transition_structure_validation: 0, + document_update_price_transition_structure_validation: 0, + document_base_transition_state_validation: 0, + document_create_transition_state_validation: 1, + document_delete_transition_state_validation: 0, + document_replace_transition_state_validation: 0, + document_transfer_transition_state_validation: 0, + document_purchase_transition_state_validation: 0, + document_update_price_transition_state_validation: 0, + token_mint_transition_structure_validation: 0, + token_burn_transition_structure_validation: 0, + token_transfer_transition_structure_validation: 0, + token_mint_transition_state_validation: 0, + token_burn_transition_state_validation: 0, + token_transfer_transition_state_validation: 0, + token_base_transition_structure_validation: 0, + token_base_transition_state_validation: 1, + token_freeze_transition_structure_validation: 0, + token_unfreeze_transition_structure_validation: 0, + token_freeze_transition_state_validation: 0, + token_unfreeze_transition_state_validation: 0, + token_destroy_frozen_funds_transition_structure_validation: 0, + token_destroy_frozen_funds_transition_state_validation: 0, + token_emergency_action_transition_structure_validation: 0, + token_emergency_action_transition_state_validation: 0, + token_config_update_transition_structure_validation: 0, + token_config_update_transition_state_validation: 1, + token_base_transition_group_action_validation: 1, + token_claim_transition_structure_validation: 0, + token_claim_transition_state_validation: 0, + token_direct_purchase_transition_structure_validation: 0, + token_direct_purchase_transition_state_validation: 0, + token_set_price_for_direct_purchase_transition_structure_validation: 0, + token_set_price_for_direct_purchase_transition_state_validation: 0, + }, + identity_create_from_addresses_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: Some(0), + identity_signatures: Some(0), + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, + identity_top_up_from_addresses_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, + address_credit_withdrawal: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, + address_funds_from_asset_lock: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: Some(0), + identity_signatures: None, + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, + address_funds_transfer: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: Some(0), + state: 0, + transform_into_action: 0, + }, + shield_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, + shielded_transfer_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, + unshield_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, + shield_from_asset_lock_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, + shielded_withdrawal_state_transition: DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, + identity_create_from_shielded_pool_state_transition: + DriveAbciStateTransitionValidationVersion { + basic_structure: Some(0), + advanced_structure: None, + identity_signatures: None, + nonce: None, + state: 0, + transform_into_action: 0, + }, + }, + has_nonce_validation: 1, + has_address_witness_validation: 0, + validate_address_witnesses: 0, + validate_shielded_proof: 0, + validate_minimum_shielded_fee: 0, + process_state_transition: 0, + state_transition_to_execution_event_for_check_tx: 0, + penalties: PenaltyAmounts { + identity_id_not_correct: 50000000, + unique_key_already_present: 10000000, + validation_of_added_keys_structure_failure: 10000000, + validation_of_added_keys_proof_of_possession_failure: 50000000, + address_funds_insufficient_balance: 10000000, + shielded_proof_verification_failure: 50000000, + }, + event_constants: DriveAbciValidationConstants { + maximum_vote_polls_to_process: 2, + maximum_contenders_to_consider: 100, + minimum_pool_notes_for_outgoing: 250, + shielded_anchor_retention_blocks: 1000, + shielded_anchor_pruning_interval: 100, + shielded_proof_verification_fee: 100_000_000, + // Per-action processing prices the ~1.1 ms/action Halo 2 verification CPU at the + // same rate the flat fee prices the ~5 ms base (100M ≈ 4.5× this), so the fee + // tracks the per-action cost and the margin stays uniform as actions grow. + shielded_per_action_processing_fee: 22_000_000, + shielded_implicit_fee_cap: 20_000_000_000, + // 0.1, 0.3, 0.5, 1.0 DASH in credits (1 DASH = 10^8 duffs, CREDITS_PER_DUFF = 1000). + // v13 revises the v8 set: adds 0.03 and 0.25 DASH, retires 0.3 DASH. + shielded_identity_create_denominations: &[ + 3_000_000_000, // 0.03 DASH + 10_000_000_000, // 0.1 DASH + 25_000_000_000, // 0.25 DASH + 50_000_000_000, // 0.5 DASH + 100_000_000_000, // 1 DASH + ], + }, + }; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 451e1daa06d..717d0d25924 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -18,7 +18,7 @@ use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::D use crate::version::drive_abci_versions::drive_abci_method_versions::v9::DRIVE_ABCI_METHOD_VERSIONS_V9; use crate::version::drive_abci_versions::drive_abci_query_versions::v2::DRIVE_ABCI_QUERY_VERSIONS_V2; use crate::version::drive_abci_versions::drive_abci_structure_versions::v1::DRIVE_ABCI_STRUCTURE_VERSIONS_V1; -use crate::version::drive_abci_versions::drive_abci_validation_versions::v9::DRIVE_ABCI_VALIDATION_VERSIONS_V9; +use crate::version::drive_abci_versions::drive_abci_validation_versions::v10::DRIVE_ABCI_VALIDATION_VERSIONS_V10; use crate::version::drive_abci_versions::drive_abci_withdrawal_constants::v2::DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V2; use crate::version::drive_abci_versions::DriveAbciVersion; use crate::version::drive_versions::v9::DRIVE_VERSION_V9; @@ -30,7 +30,7 @@ use crate::version::ProtocolVersion; pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; -/// v14 hosts two consensus changes: +/// v14 hosts three consensus changes: /// /// 1. **Contract-level ranked aggregates** (this branch): an index can /// declare that its groups are rankable by an aggregate, so a query like @@ -56,16 +56,27 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// insertable pre-v14 only through an unenforced grovedb batch guard) /// simply gets `CountSumTree` value trees for values first seen at /// v14+, which readers treat identically. +/// 3. **The contested vote poll index cross-check**: the index named by a +/// document create transition's prefunded voting balance keys the vote +/// poll, its stored info, its end-date entry and its prefunded +/// specialized balance, while the contested index the contender is +/// inserted under always comes from the document type. Up to v13 nothing +/// tied the two together, so a submitter could register and fund a +/// contest under a vote poll describing a different index than the one +/// the contest was created on — which halts the chain when that poll +/// ends — or open a contest for a document that is not a contested +/// resource at all. /// -/// The two are orthogonal by construction: the ranked upgrade decides the +/// The first two are orthogonal by construction: the ranked upgrade decides the /// *property-name* tree type, the demotion decides the *value* tree type /// one level below it, and a demoted `CountSumTree` value tree contributes /// its (count, sum) to a ranked indexed parent exactly as the provable /// variant did — so ranked secondaries keep ranking correctly over /// shared-prefix shapes. /// -/// Until a contract uses the ranked grammar, the only v14 behavior change -/// is the shared-prefix fix; everything else matches v13: +/// Until a contract uses the ranked grammar, the only v14 behavior changes +/// are the shared-prefix fix and the contested-index cross-check; everything +/// else matches v13: /// /// * `CONTRACT_VERSIONS_V6` points `document_type_schema` at the v3 document /// meta-schema, which hosts the ranked index keywords @@ -86,6 +97,13 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// executor. v13 and earlier keep the v1 table and therefore keep /// rejecting that shape, so mixed-version networks agree across the /// upgrade. +/// * `DRIVE_ABCI_VALIDATION_VERSIONS_V10` bumps +/// `document_create_transition_structure_validation` 0 → 1, requiring a +/// contested create transition's prefunded voting balance to name the +/// same vote poll the document itself resolves to, and rejecting one on a +/// document that resolves to no contested index. v13 keeps the v9 table +/// and therefore keeps accepting both, so replay of pre-upgrade blocks is +/// unchanged. /// /// The wire surface is deliberately unchanged: `GetDocumentsRequestV1` /// already carries `selects` / `group_by` / `order_by` / `limit` / @@ -97,7 +115,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V1, methods: DRIVE_ABCI_METHOD_VERSIONS_V9, - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V9, + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested create transitions must name the contested index they resolve to withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V2, query: DRIVE_ABCI_QUERY_VERSIONS_V2, // changed: ranked HAVING routing gate checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, @@ -256,4 +274,32 @@ mod tests { 0 ); } + + /// The contested vote poll index cross-check changes accept/reject + /// behavior for document create transitions, so it lives in v14's own + /// validation table: a v13 node keeps running structure validation v0, + /// which validates only the prefunded amount and ignores the index name. + /// A change that made v13 non-zero here would retroactively reject + /// transitions already in the chain. + #[test] + fn contested_index_cross_check_is_v14_only() { + assert_eq!( + PLATFORM_V13 + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .document_create_transition_structure_validation, + 0 + ); + assert_eq!( + PLATFORM_V14 + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .document_create_transition_structure_validation, + 1 + ); + } } diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index be1f11bca18..9701a5407d2 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -74,8 +74,10 @@ use dpp::consensus::state::data_contract::document_type_update_error::DocumentTy use dpp::consensus::state::document::document_contest_currently_locked_error::DocumentContestCurrentlyLockedError; use dpp::consensus::state::document::document_contest_document_with_same_id_already_present_error::DocumentContestDocumentWithSameIdAlreadyPresentError; use dpp::consensus::state::document::document_contest_identity_already_contestant::DocumentContestIdentityAlreadyContestantError; +use dpp::consensus::state::document::document_contest_index_mismatch_error::DocumentContestIndexMismatchError; use dpp::consensus::state::document::document_contest_not_joinable_error::DocumentContestNotJoinableError; use dpp::consensus::state::document::document_contest_not_paid_for_error::DocumentContestNotPaidForError; +use dpp::consensus::state::document::document_contest_not_required_error::DocumentContestNotRequiredError; use dpp::consensus::state::document::document_incorrect_purchase_price_error::DocumentIncorrectPurchasePriceError; use dpp::consensus::state::document::document_not_for_sale_error::DocumentNotForSaleError; use dpp::consensus::state::group::{GroupActionAlreadyCompletedError, GroupActionAlreadySignedByIdentityError, GroupActionDoesNotExistError, IdentityMemberOfGroupNotFoundError, IdentityNotMemberOfGroupError, ModificationOfGroupActionMainParametersNotPermittedError}; @@ -325,6 +327,12 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::DocumentContestNotPaidForError(e) => { generic_consensus_error!(DocumentContestNotPaidForError, e).into() } + StateError::DocumentContestIndexMismatchError(e) => { + generic_consensus_error!(DocumentContestIndexMismatchError, e).into() + } + StateError::DocumentContestNotRequiredError(e) => { + generic_consensus_error!(DocumentContestNotRequiredError, e).into() + } StateError::RecipientIdentityDoesNotExistError(e) => { generic_consensus_error!(RecipientIdentityDoesNotExistError, e).into() }