diff --git a/book/src/contract-keywords/contested.md b/book/src/contract-keywords/contested.md index ba06ac33e63..d54c64a66b0 100644 --- a/book/src/contract-keywords/contested.md +++ b/book/src/contract-keywords/contested.md @@ -71,6 +71,7 @@ The fund, the windows, the tallies and the special case of moderation elections - A document type has at most one contested index, and no other unique index beside it (`ContestedUniqueIndexWithUniqueIndexError`, 10249). - `resolution` is present and is `0` or `1`; `1` is refused before protocol version 14. - `fieldMatches`, when present, holds at least one entry, and each `regexPattern` is a valid regular expression (`RegexError`, 10247). +- From protocol version 15, the contest is decided by the index values, so that two documents with the same values are both contested or both ordinary: every `fieldMatches` field is a property of the index, and a match on a string property that may be empty (no `minLength` of at least 1) treats `""` and `"\0"` alike, since the two share one index key (`InvalidContractStructure`, 10231). Otherwise an ordinary document could take the values of a contested one, and the contest would have nothing left to award. A contract registered earlier keeps its contest, but can no longer be updated, since an update is checked like a registration; while such a contest is live, a create outside it with the same index values is refused (`DuplicateUniqueIndexError`). - A contested index cannot carry a [`timeRange`](time-range.md), an [`integerRange`](integer-range.md) or a ranking (a ranking needs a non-unique index), and an [index-only type](index-only.md) cannot have one. - A document type with a contested index cannot set [`ttl`](ttl.md), nor `moderatorAbilities.delete` (see [Deletion](deletion.md)): a moderator's restore puts a document back by an ordinary insert, and a contested value is only awarded through a vote. - From protocol version 14, a document type with a contested index that sums a property ([`summable`, `averageable`, `documentsSummable` or `documentsAverageable`](aggregates.md)) declares that property with a `minimum` of at least -134217728 and a `maximum` of at most 134217728 (±2^27, `max_contested_summed_value_magnitude`). The end of a contest adds the winner's value to the type's sums, after any number of other documents were written, and a sum that left the signed 64-bit range there could not be stored. Values this small keep the sums in range short of 2^36 documents. Checked when a contract is registered or updated, so contracts registered earlier keep their bounds. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 4d78b8d6f1d..f4d034b78c9 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -756,6 +756,50 @@ pub(super) fn refuse_a_repeated_doctype_key( } } +/// A rule of full validation under `check_document_type_schema` 1 (protocol version 15): the +/// contested `index` of the type `name` must be decided by its index values +/// ([`Index::contest_is_decided_by_index_values`]), or a document outside the contest could +/// take the values of a document in it and leave the contest's award no unique index entry to +/// take. Version 0 refuses nothing, so a contract registered earlier keeps such a contest; the +/// document create's state validation guards it from protocol version 15. +#[cfg(feature = "validation")] +fn refuse_a_contest_not_decided_by_index_values( + index: &Index, + flattened_document_properties: &IndexMap, + name: &str, + platform_version: &PlatformVersion, +) -> Result<(), ProtocolError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .check_document_type_schema + { + 0 => Ok(()), + 1 => { + if index.contest_is_decided_by_index_values(flattened_document_properties) { + Ok(()) + } else { + Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "the contest of index \"{}\" on document type \"{name}\" must be decided \ + by the index values: every `fieldMatches` field must be a property of \ + the index, and a match on a string that may be empty must judge \"\" \ + and \"\\0\" alike, which share one index key", + index.name + )), + )) + } + } + version => Err(ProtocolError::UnknownVersionMismatch { + method: "DocumentType::check_document_type_schema".to_string(), + known_versions: vec![0, 1], + received: version, + }), + } +} + /// The schema check of full validation, versioned on `check_document_type_schema`: 0 checks /// the enriched schema against the document meta-schema through the `jsonschema` crate and /// compiles it; 1 (protocol version 15) checks it natively ([`native_schema`]), with the @@ -1487,6 +1531,14 @@ fn parse_indices( flattened_document_properties, )?; + // A contest decided by more than its index values (protocol version 15) + refuse_a_contest_not_decided_by_index_values( + &index, + flattened_document_properties, + ctx.name, + ctx.platform_version, + )?; + if ctx.generation.admit_indexed_arrays { validate_indexed_array_index( ctx, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/contest_decided_by_index_values_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/contest_decided_by_index_values_tests.rs new file mode 100644 index 00000000000..eb549d7aca8 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/contest_decided_by_index_values_tests.rs @@ -0,0 +1,147 @@ +//! A contested index whose contest its index values do not decide, which registration refuses +//! from protocol version 15 (`check_document_type_schema` 1): a `fieldMatches` field outside +//! the index, or a match telling `""` from `"\0"`, which share an index key, on a string that +//! may be empty. A registration rule: a stored contract still parses, and protocol version 14 +//! keeps registering such a contest. +use super::*; +use crate::consensus::basic::BasicError; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use platform_value::platform_value; + +fn parse_at( + schema: Value, + protocol_version: u32, + full_validation: bool, +) -> Result { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected platform version"); + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "name", + schema, + None, + &BTreeMap::new(), + &config, + full_validation, + &mut vec![], + platform_version, + ) +} + +fn parse(schema: Value) -> Result { + parse_at(schema, PlatformVersion::latest().protocol_version, true) +} + +/// An immutable `name` type of `label` and `normalizedLabel`, whose unique `byNormalizedLabel` +/// index on `normalizedLabel` is contested on `field` matching `pattern`. `normalizedLabel` +/// declares `normalized_min_length` when given. +fn name_schema(field: &str, pattern: &str, normalized_min_length: Option) -> Value { + let mut normalized_label = platform_value!({ + "type": "string", + "maxLength": 20, + "position": 1, + }); + if let Some(min_length) = normalized_min_length { + normalized_label + .insert("minLength".to_string(), Value::U64(min_length)) + .expect("expected to set the minimum length"); + } + platform_value!({ + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "label": { "type": "string", "maxLength": 20, "position": 0 }, + "normalizedLabel": normalized_label, + }, + "required": ["label", "normalizedLabel"], + "indices": [{ + "name": "byNormalizedLabel", + "properties": [{ "normalizedLabel": "asc" }], + "unique": true, + "contested": { + "fieldMatches": [{ "field": field, "regexPattern": pattern }], + "resolution": 0, + }, + }], + "additionalProperties": false, + }) +} + +fn assert_refused(result: Result) { + let error = result.expect_err("the document type must be refused"); + let ProtocolError::ConsensusError(consensus_error) = &error else { + panic!("expected a paid consensus error, got {error:?}"); + }; + let ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message), + )) = consensus_error.as_ref() + else { + panic!("expected InvalidContractStructure, got {consensus_error:?}"); + }; + assert!( + message.contains("the contest of index \"byNormalizedLabel\" on document type \"name\""), + "{message}" + ); +} + +#[test] +fn should_refuse_a_contest_matching_a_field_outside_the_index() { + assert_refused(parse(name_schema("label", "^[a-z]{3,5}$", None))); +} + +#[test] +fn should_register_a_contest_matching_a_field_outside_the_index_at_protocol_version_14() { + parse_at(name_schema("label", "^[a-z]{3,5}$", None), 14, true) + .expect("protocol version 14 keeps registering it"); +} + +#[test] +fn should_load_a_stored_contest_matching_a_field_outside_the_index() { + parse_at( + name_schema("label", "^[a-z]{3,5}$", None), + PlatformVersion::latest().protocol_version, + false, + ) + .expect("a stored contract runs no registration rule"); +} + +#[test] +fn should_register_a_contest_matching_a_field_of_the_index() { + let document_type = parse(name_schema("normalizedLabel", "^[a-z]{3,5}$", None)) + .expect("a match on an index property that judges \"\" and \"\\0\" alike is decided"); + let index = document_type + .find_contested_index() + .expect("expected the contested index"); + assert!(index.contest_is_decided_by_index_values(document_type.flattened_properties())); +} + +#[test] +fn should_refuse_a_match_telling_the_empty_string_from_nul_on_a_string_that_may_be_empty() { + assert_refused(parse(name_schema("normalizedLabel", "^[a-z]{0,5}$", None))); +} + +#[test] +fn should_register_a_match_telling_the_empty_string_from_nul_on_a_string_that_cannot_be_empty() { + parse(name_schema("normalizedLabel", "^[a-z]{0,5}$", Some(1))) + .expect("a string of at least one character is never the empty string"); +} + +#[test] +fn should_register_a_contest_without_field_matches() { + let mut schema = name_schema("normalizedLabel", "^[a-z]{3,5}$", None); + let Some(Value::Array(indices)) = schema.get_mut("indices").expect("indices") else { + panic!("expected the indices"); + }; + indices[0] + .insert( + "contested".to_string(), + platform_value!({ "resolution": 0 }), + ) + .expect("expected to replace the contest"); + parse(schema).expect("a contest taking every document is decided by its index values"); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 55f4b66f1ee..0abae10018f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -2087,6 +2087,8 @@ mod choose_first_tests; #[cfg(all(test, feature = "validation"))] mod commit_reveal_lookup_tests; #[cfg(all(test, feature = "validation"))] +mod contest_decided_by_index_values_tests; +#[cfg(all(test, feature = "validation"))] mod contested_summed_value_bounds_tests; #[cfg(all(test, feature = "validation"))] mod delete_constraints_tests; diff --git a/packages/rs-dpp/src/data_contract/document_type/index/contest_decided_by_values.rs b/packages/rs-dpp/src/data_contract/document_type/index/contest_decided_by_values.rs new file mode 100644 index 00000000000..b00a1eb9312 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/index/contest_decided_by_values.rs @@ -0,0 +1,52 @@ +//! Whether the `fieldMatches` of a contested index judge alike every document with the same +//! index values. When they do not, a document outside the contest can take the index values +//! of a document in it, and the award at the end of the contest finds its unique index entry +//! taken. + +use crate::data_contract::document_type::property::{DocumentProperty, DocumentPropertyType}; +use crate::data_contract::document_type::Index; +use indexmap::IndexMap; +use platform_value::Value; + +impl Index { + /// Whether this index's contest, if it has one, is decided by the index values alone, so + /// that two documents with the same index values are both in the contest or both outside + /// it. Every field a `fieldMatches` entry names must be a property of the index, and a + /// match on a string property that admits the empty string must judge the empty string and + /// `"\0"` alike: the tree key encoder stores both under the one key `[0]`. A match on a + /// field that is not a string judges every document with the same value alike. An index + /// without a contest is decided by its values, as is a contest without `fieldMatches`, + /// which takes every document. + /// + /// `document_properties` are the type's flattened properties. Shared by registration, + /// which refuses a contest its values do not decide from protocol version 15, and the + /// document create's state validation, which keeps a document outside such a contest, + /// registered earlier, from taking the values of a live one. + pub fn contest_is_decided_by_index_values( + &self, + document_properties: &IndexMap, + ) -> bool { + let Some(contested_index) = &self.contested_index else { + return true; + }; + contested_index + .field_matches + .iter() + .all(|(field, field_match)| { + if !self + .properties + .iter() + .any(|property| &property.name == field) + { + return false; + } + let admits_the_empty_string = matches!( + document_properties.get(field).map(|property| &property.property_type), + Some(DocumentPropertyType::String(sizes)) if sizes.min_length.unwrap_or(0) == 0 + ); + !admits_the_empty_string + || field_match.matches(&Value::Text(String::new())) + == field_match.matches(&Value::Text("\0".to_string())) + }) + } +} 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 07a8ef15019..d310375c939 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 @@ -31,6 +31,7 @@ use std::sync::OnceLock; use std::{collections::BTreeMap, convert::TryFrom}; mod bucketing; +mod contest_decided_by_values; mod derived_index_property; mod extract_contested_values; pub mod integer_range; 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 af05a192850..4019b88a92d 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,6 +11,7 @@ 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::state_v2::DocumentCreateTransitionActionStateValidationV2; +use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::state_v3::DocumentCreateTransitionActionStateValidationV3; 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::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::advanced_structure_v2::DocumentCreateTransitionActionStructureValidationV2; @@ -23,6 +24,7 @@ mod advanced_structure_v2; mod state_v0; mod state_v1; mod state_v2; +mod state_v3; pub trait DocumentCreateTransitionActionValidation { fn validate_structure( @@ -127,9 +129,20 @@ impl DocumentCreateTransitionActionValidation for DocumentCreateTransitionAction transaction, platform_version, ), + // V3 keeps a non-contested create from taking the index values of a live contest + // its index values do not decide, on top of V2 + 3 => self.validate_state_v3( + platform, + owner_id, + block_info, + consumed_documents, + execution_context, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "DocumentCreateTransitionAction::validate_state".to_string(), - known_versions: vec![0, 1, 2], + known_versions: vec![0, 1, 2, 3], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v3/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v3/mod.rs new file mode 100644 index 00000000000..f54ca388c23 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v3/mod.rs @@ -0,0 +1,169 @@ +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::document::duplicate_unique_index_error::DuplicateUniqueIndexError; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::ConsensusError; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::identifier::Identifier; +use dpp::platform_value::Value; +use dpp::document::Document; +use dpp::validation::{ConsensusValidationResult, SimpleConsensusValidationResult}; +use dpp::version::PlatformVersion; +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 drive::drive::document::shared_unique_entry::unique_index_entry_is_shared; +use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::resolve::ContestedDocumentResourceVotePollResolver; +use drive::query::TransactionArg; +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, + DocumentFromCreateTransitionAction, +}; +use drive::util::object_size_info::DocumentInfo; + +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::state_v2::DocumentCreateTransitionActionStateValidationV2; +use crate::execution::validation::state_transition::batch::action_validation::document::document_reference_validation::ConsumedLookupDocument; +use crate::platform_types::platform::PlatformStateRef; + +pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait DocumentCreateTransitionActionStateValidationV3 +{ + #[allow(clippy::too_many_arguments)] + fn validate_state_v3( + &mut self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + consumed_documents: &mut Vec, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl DocumentCreateTransitionActionStateValidationV3 for DocumentCreateTransitionAction { + fn validate_state_v3( + &mut self, + platform: &PlatformStateRef, + owner_id: Identifier, + block_info: &BlockInfo, + consumed_documents: &mut Vec, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let validation_result = self.validate_state_v2( + platform, + owner_id, + block_info, + consumed_documents, + execution_context, + transaction, + platform_version, + )?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + + // A non-contested create must not take the index values of a live contest. A contest + // decided by its index values takes every document with them, so this holds by + // itself; a contest whose `fieldMatches` read something else, which registration + // refuses from protocol version 15 but a contract registered earlier may keep, lets a + // document with the same values bypass it. Such a document would take the unique index + // entry the contest awards at its end, leaving the award nothing to take. + // The poll is read at its path, which encodes the values as index keys, so a create of + // `"\0"` finds the live contest of `""` (both are the key `[0]`) and is refused too. + // A batch holds one document transition (`max_transitions_in_documents_batch`, pinned + // to 1 at every protocol version by a test), so no earlier create of the same batch can + // open the contest before this one is checked. + if self.prefunded_voting_balance().is_some() { + return Ok(SimpleConsensusValidationResult::new()); + } + let document_type = self.base().document_type()?; + let Some(contested_index) = document_type.find_contested_index() else { + return Ok(SimpleConsensusValidationResult::new()); + }; + if contested_index.contest_is_decided_by_index_values(document_type.flattened_properties()) + { + return Ok(SimpleConsensusValidationResult::new()); + } + + let index_values = contested_index + .extract_contested_values( + self.data(), + document_type.flattened_properties(), + platform_version, + ) + .map_err(Error::Protocol)?; + // The values are read from the create's data, as a contested create reads the values of + // the poll it opens. A missing one is either a user property the document lacks, whose + // unique index entry Drive keeps per document, or a system property (`$ownerId`), which + // the data never holds: a contested create can not address a poll through it, so no + // contest over this index can be live. Either way nothing is read + if index_values.iter().any(Value::is_null) { + return Ok(SimpleConsensusValidationResult::new()); + } + // Nor for a document whose unique index entry Drive keeps per document because a value + // has an empty index key, such as an empty byte array + let document = + Document::try_from_create_transition_action(self, owner_id, platform_version)?; + if !unique_index_entry_is_shared( + &DocumentInfo::DocumentRefInfo((&document, None)), + contested_index, + document_type, + Some(owner_id.to_buffer()), + platform_version, + )? { + return Ok(SimpleConsensusValidationResult::new()); + } + let vote_poll = ContestedDocumentResourceVotePoll { + contract_id: self.base().data_contract_id(), + document_type_name: self.base().document_type_name().clone(), + index_name: contested_index.name.clone(), + index_values, + } + .resolve_owned_with_provided_arc_contract_fetch_info( + self.base().data_contract_fetch_info(), + )?; + + let (fee_result, stored_info) = platform + .drive + .fetch_contested_document_vote_poll_stored_info( + &vote_poll, + Some(&block_info.epoch), + transaction, + platform_version, + )?; + if let Some(fee_result) = fee_result { + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + } + + if stored_info.is_some_and(|stored_info| { + matches!( + stored_info.vote_poll_status(), + ContestedDocumentVotePollStatus::Started(_) + ) + }) { + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::DuplicateUniqueIndexError( + DuplicateUniqueIndexError::new( + self.base().id(), + contested_index + .properties + .iter() + .map(|property| property.name.clone()) + .collect(), + ), + )), + )); + } + + Ok(SimpleConsensusValidationResult::new()) + } +} 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 0d8c5b3d6eb..eff3ea6b211 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 @@ -4204,4 +4204,878 @@ pub(in crate::execution) mod tests { ); } } + + /// A contested index whose `fieldMatches` names a property outside the index (`label`, + /// while the index is `[normalizedParentDomainName, normalizedLabel]`). Two documents with + /// the same index values then take different paths: the one whose label matches joins a + /// contest, the other goes straight into the unique index. Through protocol version 14 no + /// check compares the two, and awarding the contest inserts into a unique index entry + /// already taken. From protocol version 15 registration + /// refuses such a contest, and for a contract registered earlier the document create's + /// state validation 3 refuses the second document while the contest is live. + mod contest_decided_by_index_values { + use super::*; + use crate::config::PlatformConfig; + use crate::test::helpers::fast_forward_to_block::fast_forward_to_block; + use dpp::consensus::basic::BasicError; + use dpp::consensus::state::state_error::StateError; + use dpp::dashcore::Network; + use dpp::data_contract::config::DataContractConfig; + use dpp::data_contract::errors::DataContractError; + 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, + }; + use dpp::state_transition::data_contract_update_transition::methods::DataContractUpdateTransitionMethodsV0; + use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; + use dpp::ProtocolError; + use crate::execution::types::execution_operation::ValidationOperation; + use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, + }; + use crate::execution::validation::state_transition::batch::action_validation::document::document_create_transition_action::DocumentCreateTransitionActionValidation; + use crate::platform_types::platform::PlatformStateRef; + use dpp::validation::SimpleConsensusValidationResult; + use dpp::version::DefaultForPlatformVersion; + use drive::error::drive::DriveError as DriveInnerError; + 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::{ + DocumentCreateTransitionAction, DocumentCreateTransitionActionV0, + }; + + const CONTRACT: &str = "tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index.json"; + + /// [`CONTRACT`] with the parent domain optional, so a domain may leave a contested index + /// property out. + const OPTIONAL_PARENT_CONTRACT: &str = "tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-optional-parent.json"; + + /// A contest on `normalizedLabel`, a property of the index, whose match takes `""` but + /// not `"\0"`, two values sharing one index key. + const EMPTY_STRING_SPLIT_CONTRACT: &str = + "tests/supporting_files/contract/dpns/dpns-contract-contested-empty-string-split.json"; + + /// [`CONTRACT`] with the contested index over `[$ownerId, normalizedLabel]`. + const OWNER_ID_CONTRACT: &str = "tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-owner-id.json"; + + /// [`CONTRACT`] with an optional byte array `tag` in the contested index. + const BYTE_ARRAY_CONTRACT: &str = "tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-byte-array.json"; + + /// A `domain` of `owner_id` with the labels, under `parent` (none when `None`) and with + /// the `extra` properties, its id set for creation, and the entropy that set it. + #[allow(clippy::too_many_arguments)] + fn domain_document( + contract: &DataContract, + owner_id: Identifier, + rng: &mut StdRng, + label: &str, + normalized_label: &str, + parent: Option<&str>, + extra: &[(&str, Value)], + platform_version: &PlatformVersion, + ) -> (Document, Bytes32) { + let domain = contract + .document_type_for_name("domain") + .expect("expected a domain document type"); + + let entropy = Bytes32::random_with_rng(rng); + + let mut document = domain + .random_document_with_identifier_and_entropy( + rng, + owner_id, + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random domain document"); + + match parent { + Some(parent) => { + document.set("parentDomainName", parent.into()); + document.set("normalizedParentDomainName", parent.into()); + } + None => { + document.remove("parentDomainName"); + document.remove("normalizedParentDomainName"); + } + } + document.set("label", label.into()); + document.set("normalizedLabel", normalized_label.into()); + document.set("records.identity", document.owner_id().into()); + document.set("subdomainRules.allowSubdomains", false.into()); + document.set("preorderSalt", rng.gen::<[u8; 32]>().into()); + for (property, value) in extra { + document.set(property, value.clone()); + } + + document + .set_id_for_creation(domain, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + (document, entropy) + } + + #[allow(clippy::too_many_arguments)] + async fn create_domain( + platform: &TempPlatform, + contract: &DataContract, + identity: &(Identity, SimpleSigner, IdentityPublicKey), + rng: &mut StdRng, + label: &str, + normalized_label: &str, + parent: Option<&str>, + extra: &[(&str, Value)], + contested: bool, + time_ms: u64, + platform_version: &PlatformVersion, + ) -> StateTransitionExecutionResult { + let (identity, signer, key) = identity; + let domain = contract + .document_type_for_name("domain") + .expect("expected a domain document type"); + let (document, entropy) = domain_document( + contract, + identity.id(), + rng, + label, + normalized_label, + parent, + extra, + platform_version, + ); + 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: contested.then(|| { + ( + "parentNameAndLabel".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(time_ms), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + processing_result.into_execution_results().remove(0) + } + + /// [`run_on`] with [`CONTRACT`]: a contest on `dash`/`quantum` with the lowercase label, + /// then a second domain under `dash`. + async fn run( + protocol_version: u32, + second_label: &str, + second_normalized_label: &str, + ) -> ( + StateTransitionExecutionResult, + StateTransitionExecutionResult, + Result<(), crate::error::Error>, + ) { + run_on( + CONTRACT, + protocol_version, + ("quantum", "quantum"), + (second_label, second_normalized_label), + Some("dash"), + &[], + ) + .await + } + + /// Stores the contract at `contract_path` without validating it, as a contract + /// registered at protocol version 14 is, opens a contest with the `contender`'s label and + /// normalized label, creates a second domain with `second`'s without prefunding, both + /// under `parent` (none when `None`) and with the `extra` properties, then advances past + /// the poll's end date and runs the resolver. + async fn run_on( + contract_path: &str, + protocol_version: u32, + contender_labels: (&str, &str), + second: (&str, &str), + parent: Option<&str>, + extra: &[(&str, Value)], + ) -> ( + StateTransitionExecutionResult, + StateTransitionExecutionResult, + Result<(), crate::error::Error>, + ) { + let platform_config = PlatformConfig { + network: Network::Mainnet, + ..Default::default() + }; + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) + .with_config(platform_config) + .build_with_mock_rpc() + .set_initial_state_structure(); + + let platform_version = + PlatformVersion::get(protocol_version).expect("expected a platform version"); + + let mut rng = StdRng::seed_from_u64(0x0F1E_1D00); + + let contender = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let other = 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_path, + None, + Some(contract_owner.0.id().to_buffer()), + None::, + None, + Some(platform_version), + ); + + let contested_result = create_domain( + &platform, + &contract, + &contender, + &mut rng, + contender_labels.0, + contender_labels.1, + parent, + extra, + true, + 3000, + platform_version, + ) + .await; + + let second_result = create_domain( + &platform, + &contract, + &other, + &mut rng, + second.0, + second.1, + parent, + extra, + false, + 4000, + platform_version, + ) + .await; + + 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, + ); + + (contested_result, second_result, resolver_result) + } + + /// Through protocol version 14 the second document is created outside the contest + /// with the contender's index values, and the award finds its unique index entry taken. + #[tokio::test] + async fn should_find_the_awarded_entry_taken_at_protocol_version_14() { + let platform_version = PlatformVersion::get(14).expect("expected protocol version 14"); + json_document_to_contract_with_ids(CONTRACT, None, None, true, platform_version) + .expect("protocol version 14 registers the contract"); + + let (contested_result, second_result, resolver_result) = + run(14, "Quantum", "quantum").await; + assert_matches!( + contested_result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "the contender must join the contest" + ); + assert_matches!( + second_result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "the uppercase label does not match, so the document is created outside the \ + contest with the same index values" + ); + assert_matches!( + resolver_result, + Err(crate::error::Error::Drive(DriveError::Drive( + DriveInnerError::CorruptedContractIndexes(_) + ))), + "awarding the contest must collide in the unique index" + ); + } + + /// From protocol version 15 registration refuses the contest. + #[test] + fn should_refuse_registering_the_contest_at_the_latest_protocol_version() { + let error = json_document_to_contract_with_ids( + CONTRACT, + None, + None, + true, + PlatformVersion::latest(), + ) + .expect_err("registration must refuse a contest its index values do not decide"); + assert_matches!( + error, + ProtocolError::ConsensusError(consensus_error) + if matches!( + consensus_error.as_ref(), + ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(_) + )) + ) + ); + } + + /// From protocol version 15, for a contract registered earlier, the second document is + /// refused while the contest is live, and the award goes through. + #[tokio::test] + async fn should_refuse_the_create_taking_a_live_contests_values_at_the_latest_protocol_version( + ) { + let (contested_result, second_result, resolver_result) = run( + PlatformVersion::latest().protocol_version, + "Quantum", + "quantum", + ) + .await; + assert_matches!( + contested_result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "the contender must join the contest" + ); + assert_matches!( + second_result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::DuplicateUniqueIndexError(_)), + .. + }, + "a document outside the contest must not take the values of a live one" + ); + assert!( + resolver_result.is_ok(), + "the award must find its unique index entry free; got {:?}", + resolver_result + ); + } + + /// CONTROL: the second domain has other index values, so it is created and the award + /// has a free slot. + #[tokio::test] + async fn should_award_when_the_second_document_has_other_index_values() { + let latest = PlatformVersion::latest().protocol_version; + let (contested_result, second_result, resolver_result) = + run(latest, "Quantun", "quantun").await; + assert_matches!( + contested_result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + second_result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert!( + resolver_result.is_ok(), + "control: got {:?}", + resolver_result + ); + } + + /// A domain leaving a contested index property out holds a null index value, and a + /// unique index entry holding a null is kept per document, apart from the entry the + /// award takes: protocol version 15 neither looks its contest up nor refuses it, even + /// while a contest for the same values is live, and the award goes through. + #[tokio::test] + async fn should_admit_a_create_with_a_null_index_value_beside_a_live_contest_at_the_latest_protocol_version( + ) { + let (contested_result, second_result, resolver_result) = run_on( + OPTIONAL_PARENT_CONTRACT, + PlatformVersion::latest().protocol_version, + ("quantum", "quantum"), + ("Quantum", "quantum"), + None, + &[], + ) + .await; + assert_matches!( + contested_result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "the contender must join the contest" + ); + assert_matches!( + second_result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "a document holding a null index value takes no entry from the contest" + ); + assert!( + resolver_result.is_ok(), + "the award must go through; got {:?}", + resolver_result + ); + } + + /// An empty byte array encodes to an empty index key, like a missing value, so its + /// unique index entry is kept per document too: protocol version 15 admits a create + /// holding one beside a live contest for the same values, and the award goes through. + #[tokio::test] + async fn should_admit_a_create_with_an_empty_byte_array_index_value_beside_a_live_contest_at_the_latest_protocol_version( + ) { + let (contested_result, second_result, resolver_result) = run_on( + BYTE_ARRAY_CONTRACT, + PlatformVersion::latest().protocol_version, + ("quantum", "quantum"), + ("Quantum", "quantum"), + Some("dash"), + &[("tag", Value::Bytes(vec![]))], + ) + .await; + assert_matches!( + contested_result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "the contender must join the contest" + ); + assert_matches!( + second_result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "a document with an empty index key takes no entry from the contest" + ); + assert!( + resolver_result.is_ok(), + "the award must go through; got {:?}", + resolver_result + ); + } + + /// A create's data never holds a system property, so no create can address the poll of a + /// contest over an index holding one (`$ownerId`), and no such contest can be live: a + /// create outside it is admitted, as at protocol version 14, with nothing read for it. + #[tokio::test] + async fn should_admit_a_create_on_a_contested_index_holding_a_system_property_at_the_latest_protocol_version( + ) { + 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(0x0F1E_1D02); + let creator = 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, + OWNER_ID_CONTRACT, + None, + Some(contract_owner.0.id().to_buffer()), + None::, + None, + Some(platform_version), + ); + + let result = create_domain( + &platform, + &contract, + &creator, + &mut rng, + "Quantum", + "quantum", + Some("dash"), + &[], + false, + 4000, + platform_version, + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "a create outside a contest that can not be opened must be admitted" + ); + } + + /// `""` and `"\0"` share one index key, so a contest whose match tells them apart splits + /// documents with the same key. The poll is looked up by its path, which encodes the + /// values as index keys, so protocol version 15 finds the contest of `""` from a create + /// of `"\0"` and refuses it. + #[tokio::test] + async fn should_refuse_a_create_sharing_a_live_contests_index_key_at_the_latest_protocol_version( + ) { + let (contested_result, second_result, resolver_result) = run_on( + EMPTY_STRING_SPLIT_CONTRACT, + PlatformVersion::latest().protocol_version, + ("abc", ""), + ("abd", "\0"), + Some("dash"), + &[], + ) + .await; + assert_matches!( + contested_result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "the empty normalized label matches, so its domain joins the contest" + ); + assert_matches!( + second_result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::DuplicateUniqueIndexError(_)), + .. + }, + "\"\\0\" does not match, and its domain would take the contest's index key" + ); + assert!( + resolver_result.is_ok(), + "the award must find its unique index entry free; got {:?}", + resolver_result + ); + } + + /// From protocol version 15 a contract registered earlier with such a contest can no + /// longer be updated: an update carries the whole contract, which full validation + /// checks like a registration. + #[tokio::test] + async fn should_refuse_updating_a_contract_keeping_the_contest_at_the_latest_protocol_version( + ) { + 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(0x0F1E_1D01); + let (identity, signer, key) = + setup_identity(&mut platform, rng.gen(), dash_to_credits!(2.0)); + + // The fixture's config is version 0, which an update can no longer keep + let contract = setup_contract( + &platform.drive, + CONTRACT, + None, + Some(identity.id().to_buffer()), + Some(|contract: &mut DataContract| { + contract.set_config( + DataContractConfig::default_for_version(platform_version) + .expect("expected the default config"), + ) + }), + None, + Some(platform_version), + ); + let mut updated_contract = contract.clone(); + updated_contract.set_version(2); + + let transition = DataContractUpdateTransition::new_from_data_contract( + updated_contract, + &identity.into_partial_identity_info(), + key.id(), + 1, + 0, + &signer, + platform_version, + None, + ) + .await + .expect("expected to create the update transition"); + 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"); + + assert_matches!( + processing_result.into_execution_results().remove(0), + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(_) + )), + .. + } + ); + } + + /// The create the transformer builds for `document`, outside any contest, in the block + /// [`create_domain`] gives the second domain. + fn create_action( + platform: &TempPlatform, + contract: &DataContract, + document: &Document, + platform_version: &PlatformVersion, + ) -> DocumentCreateTransitionAction { + let contract_fetch_info = platform + .drive + .get_contract_with_fetch_info( + contract.id().to_buffer(), + false, + None, + platform_version, + ) + .expect("expected to fetch the contract") + .expect("expected the contract to be stored"); + DocumentCreateTransitionAction::V0(DocumentCreateTransitionActionV0 { + base: DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: document.id(), + identity_contract_nonce: 2, + document_type_name: "domain".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, + shielded_token_payment: None, + }), + block_info: BlockInfo::default_with_time(4000), + data: document.properties().clone(), + prefunded_voting_balance: None, + current_store_contest_info: None, + should_store_contest_info: None, + property_constraint_reads: Default::default(), + moderated: false, + consumed_documents: Vec::new(), + derived_index_values: None, + decision_proposal: None, + }) + } + + /// Validates `action` of `owner_id` against the committed state at `platform_version`, + /// returning the result and the operations billed for it. + fn validate_create_against_state( + platform: &TempPlatform, + action: &DocumentCreateTransitionAction, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> (SimpleConsensusValidationResult, Vec) { + let platform_state = platform.state.load(); + let platform_ref = PlatformStateRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + }; + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("expected an execution context"); + let result = action + .clone() + .validate_state( + &platform_ref, + owner_id, + &BlockInfo::default_with_time(4000), + &mut Vec::new(), + &mut execution_context, + None, + platform_version, + ) + .expect("expected to validate the create against state"); + (result, execution_context.operations_consume()) + } + + /// Validates the second domain of [`CONTRACT`], `Quantum` under `dash`, against the + /// same state at document create state validation 2 and 3 of the latest protocol + /// version, after opening a contest for its index values when `open_contest`. Asserts + /// that version 3 bills what version 2 bills and then the poll's read, as one + /// precalculated operation with a processing fee and no storage fee, and returns + /// version 3's result. + async fn assert_the_contest_lookup_is_billed( + open_contest: bool, + ) -> SimpleConsensusValidationResult { + 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(0x0F1E_1D03); + let contender = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let other = 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, + None, + Some(contract_owner.0.id().to_buffer()), + None::, + None, + Some(platform_version), + ); + + if open_contest { + assert_matches!( + create_domain( + &platform, + &contract, + &contender, + &mut rng, + "quantum", + "quantum", + Some("dash"), + &[], + true, + 3000, + platform_version, + ) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "the contender must join the contest" + ); + } + + let owner_id = other.0.id(); + let (document, _) = domain_document( + &contract, + owner_id, + &mut rng, + "Quantum", + "quantum", + Some("dash"), + &[], + platform_version, + ); + let action = create_action(&platform, &contract, &document, platform_version); + + let mut without_lookup = platform_version.clone(); + without_lookup + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .document_create_transition_state_validation = 2; + + let (result_without_lookup, operations_without_lookup) = + validate_create_against_state(&platform, &action, owner_id, &without_lookup); + assert!( + result_without_lookup.is_valid(), + "version 2 looks no contest up and admits the create: {:?}", + result_without_lookup.errors + ); + let (result, operations) = + validate_create_against_state(&platform, &action, owner_id, platform_version); + + let (lookup, before_lookup) = operations + .split_last() + .expect("expected version 3 to bill operations"); + assert_eq!( + before_lookup, + operations_without_lookup.as_slice(), + "version 3 must bill what version 2 bills before the lookup" + ); + assert_matches!( + lookup, + ValidationOperation::PrecalculatedOperation(fee) + if fee.processing_fee > 0 && fee.storage_fee == 0, + "version 3 must bill the poll's read last" + ); + result + } + + /// A lookup finding the live contest is billed with the refusal. + #[tokio::test] + async fn should_bill_the_contest_lookup_refusing_a_create_beside_a_live_contest_at_the_latest_protocol_version( + ) { + let result = assert_the_contest_lookup_is_billed(true).await; + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DuplicateUniqueIndexError(_) + )] + ); + } + + /// A lookup finding no contest is billed with the admitted create. + #[tokio::test] + async fn should_bill_the_contest_lookup_admitting_a_create_without_a_contest_at_the_latest_protocol_version( + ) { + let result = assert_the_contest_lookup_is_billed(false).await; + assert!(result.is_valid(), "{:?}", result.errors); + } + } } diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-empty-string-split.json b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-empty-string-split.json new file mode 100644 index 00000000000..a2551bec310 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-empty-string-split.json @@ -0,0 +1,168 @@ +{ + "$formatVersion": "0", + "id": "DWBXe9EXFPHxvbArQgT45uQR5gMmi8dfMpLhR5KSbwnZ", + "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-z01]{0,19}$" + } + ], + "resolution": 0, + "description": "Contested while the normalized label is short and lowercase, the empty string included." + } + }, + { + "name": "identityId", + "nullSearchable": false, + "properties": [ + { + "records.identity": "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", + "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-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-byte-array.json b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-byte-array.json new file mode 100644 index 00000000000..8e6b57f70e0 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-byte-array.json @@ -0,0 +1,179 @@ +{ + "$formatVersion": "0", + "id": "DWBXe9EXFPHxvbArQgT45uQR5gMmi8dfMpLhR5KSbwnZ", + "ownerId": "2QjL594djCH2NyDsn45vd6yQjEDHupMKo7CEGVTHtQxU", + "version": 1, + "documentSchemas": { + "domain": { + "documentsMutable": false, + "canBeDeleted": true, + "transferable": 1, + "tradeMode": 1, + "type": "object", + "indices": [ + { + "name": "parentNameAndLabel", + "properties": [ + { + "normalizedParentDomainName": "asc" + }, + { + "normalizedLabel": "asc" + }, + { + "tag": "asc" + } + ], + "unique": true, + "contested": { + "fieldMatches": [ + { + "field": "label", + "regexPattern": "^[a-z01]{3,19}$" + } + ], + "resolution": 0, + "description": "Contested while the label, which is not part of this index, is short and lowercase." + } + }, + { + "name": "identityId", + "nullSearchable": false, + "properties": [ + { + "records.identity": "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" + ] + }, + "tag": { + "type": "array", + "byteArray": true, + "maxItems": 8, + "position": 7, + "description": "An optional tag, part of the contested index." + } + }, + "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-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-optional-parent.json b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-optional-parent.json new file mode 100644 index 00000000000..1133cc5a361 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-optional-parent.json @@ -0,0 +1,168 @@ +{ + "$formatVersion": "0", + "id": "DWBXe9EXFPHxvbArQgT45uQR5gMmi8dfMpLhR5KSbwnZ", + "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": "label", + "regexPattern": "^[a-z01]{3,19}$" + } + ], + "resolution": 0, + "description": "Contested while the label, which is not part of this index, is short and lowercase." + } + }, + { + "name": "identityId", + "nullSearchable": false, + "properties": [ + { + "records.identity": "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", + "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-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-owner-id.json b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-owner-id.json new file mode 100644 index 00000000000..05fa15579da --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-owner-id.json @@ -0,0 +1,169 @@ +{ + "$formatVersion": "0", + "id": "DWBXe9EXFPHxvbArQgT45uQR5gMmi8dfMpLhR5KSbwnZ", + "ownerId": "2QjL594djCH2NyDsn45vd6yQjEDHupMKo7CEGVTHtQxU", + "version": 1, + "documentSchemas": { + "domain": { + "documentsMutable": false, + "canBeDeleted": true, + "transferable": 1, + "tradeMode": 1, + "type": "object", + "indices": [ + { + "name": "parentNameAndLabel", + "properties": [ + { + "$ownerId": "asc" + }, + { + "normalizedLabel": "asc" + } + ], + "unique": true, + "contested": { + "fieldMatches": [ + { + "field": "label", + "regexPattern": "^[a-z01]{3,19}$" + } + ], + "resolution": 0, + "description": "Contested while the label, which is not part of this index, is short and lowercase." + } + }, + { + "name": "identityId", + "nullSearchable": false, + "properties": [ + { + "records.identity": "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-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index.json b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index.json new file mode 100644 index 00000000000..e04d2711cd9 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index.json @@ -0,0 +1,169 @@ +{ + "$formatVersion": "0", + "id": "DWBXe9EXFPHxvbArQgT45uQR5gMmi8dfMpLhR5KSbwnZ", + "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": "label", + "regexPattern": "^[a-z01]{3,19}$" + } + ], + "resolution": 0, + "description": "Contested while the label, which is not part of this index, is short and lowercase." + } + }, + { + "name": "identityId", + "nullSearchable": false, + "properties": [ + { + "records.identity": "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-drive/src/drive/document/mod.rs b/packages/rs-drive/src/drive/document/mod.rs index 2e38d05666e..e9db8f85e46 100644 --- a/packages/rs-drive/src/drive/document/mod.rs +++ b/packages/rs-drive/src/drive/document/mod.rs @@ -110,6 +110,10 @@ pub(crate) mod index_level_tree_types; #[cfg(any(feature = "server", feature = "verify"))] pub mod layout; +/// Whether a document's unique index entry sits under the key of its values +#[cfg(feature = "server")] +pub mod shared_unique_entry; + /// What creating one document costs, element by element, from the elements /// the index walkers write #[cfg(any(feature = "server", feature = "verify"))] diff --git a/packages/rs-drive/src/drive/document/shared_unique_entry.rs b/packages/rs-drive/src/drive/document/shared_unique_entry.rs new file mode 100644 index 00000000000..501db38d344 --- /dev/null +++ b/packages/rs-drive/src/drive/document/shared_unique_entry.rs @@ -0,0 +1,39 @@ +//! Whether a document's entry in a unique index sits under the one key that index holds for +//! its values, or under the document's id. + +use crate::error::Error; +use crate::util::object_size_info::{DocumentInfo, DocumentInfoV0Methods}; +use dpp::data_contract::document_type::{DocumentTypeRef, Index}; +use dpp::version::PlatformVersion; + +/// Whether the entry of `document_info` in the unique `index` sits under the one key the index +/// holds for its values, the key a contest's award takes. `index` is one the insert walkers key +/// by single values, as they key a contested index (no typed array, no bucketing). +/// +/// The walkers read each level's key with the call made here (`level_keys`), and a level whose +/// key is empty puts the entry under the document's id instead (`any_fields_null`), where no +/// other document's entry meets it: a value the document lacks, or one that encodes to no +/// bytes, such as an empty byte array. +pub fn unique_index_entry_is_shared( + document_info: &DocumentInfo, + index: &Index, + document_type: DocumentTypeRef, + owner_id: Option<[u8; 32]>, + platform_version: &PlatformVersion, +) -> Result { + for property in &index.properties { + let key = document_info + .get_raw_for_document_type( + &property.name, + document_type, + owner_id, + None, + platform_version, + )? + .unwrap_or_default(); + if key.is_empty() { + return Ok(false); + } + } + Ok(true) +} diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs index 67533814d1c..290e6f0c136 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v11.rs @@ -33,6 +33,12 @@ use super::{ /// under the proposal's name, its rule must make a yes/no poll within the system limits, and /// it must end one to four weeks after the block. The create conversion 1 opens and funds its /// poll. +/// - document create `state_validation` 3: v2, then a create outside a contest, of a type whose +/// contest reads more than its index values (`fieldMatches` on a property outside the index, +/// which registration refuses from protocol version 15), is refused +/// (`DuplicateUniqueIndexError`) while a contest for the same index values is live: the +/// document would take the unique index entry the contest awards. Only such a type pays the +/// lookup, so a DPNS domain create costs what it did. /// - `document_reference_validation` 1: v0, but the `refersTo` lookup of a reference declaring /// `consume` reads the commitment it finds with its stored element's storage flags /// (`query_documents_with_flags`) and keeps that read in the `ConsumedDocument`, so the create @@ -65,6 +71,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V11: DriveAbciValidationVersions = advanced_structure: 2, // a tradeable token must be transferable, checked in check_tx too document_reference_validation: 1, // a consumed commitment keeps what its lookup read document_create_transition_structure_validation: 2, // a decision proposal states its fund, a rule within the limits and an end date one to four weeks away + document_create_transition_state_validation: 3, // a non-contested create cannot take the values of a live contest its index values do not decide data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { bindings: 3, // the token market contract's orders are written by the marketplace transitions only triggers: DriveAbciValidationDataTriggerVersions { diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 5e959fd86cf..1a0874a0dcd 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -161,6 +161,8 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// first entry the parser may read where every check reads the last /// (`InvalidContractStructure`; a stored contract with one, which no JSON /// could have written, must drop it to be updated), +/// refuses a contested index whose contest its index values do not decide +/// (`InvalidContractStructure`, note 19), /// and refuses a document type the typed validation schema does not represent /// (`InvalidContractStructure`), so every type registered from this version /// has its documents checked natively (note 6). The parser is generation 3 @@ -407,13 +409,32 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// error or a coinbase without a payload fails the block, as other Core errors in /// `update_core_info` do. Protocol v14 and earlier keep `update_core_info` 0, which /// neither asks nor records. +/// 19. **A contest is decided by its index values**: a document joins its type's contest +/// when every `fieldMatches` entry matches it, and two documents with the same index +/// values share one unique index entry. A `fieldMatches` field outside the index, or a +/// match telling `""` from `"\0"` (which share an index key) on a string that may be +/// empty, lets a document outside the contest take the index values of one in it, and +/// the award at the end of the contest then finds the entry taken. Registration under +/// `check_document_type_schema` 1 (note 7) refuses such a contest +/// (`Index::contest_is_decided_by_index_values`, `InvalidContractStructure`), +/// and document create state validation 3 (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) +/// refuses a create outside the contest of a type registered earlier with one while a +/// contest for the same index keys is live (`DuplicateUniqueIndexError`), reading the +/// poll at its path, which encodes the values as index keys, so a create of `"\0"` finds +/// the contest of `""`. Only such a type pays that lookup, and not for a document +/// with an empty index key (a missing value, or an empty byte array), whose unique index +/// entry Drive keeps per document (`unique_index_entry_is_shared`), nor for an index +/// holding a system property (`$ownerId`), whose poll no create can address. A contract +/// registered earlier with such a contest keeps it, but an update, which carries the +/// whole contract, is refused like a registration. Protocol v14 and earlier keep +/// `check_document_type_schema` 0 and document create state validation 2. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V10, // changed: apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10) and verify_private_document_entries Some(0) a page of a private type's store (note 13); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11); document method versions v5: insert_empty_private_document_store_operation Some(0) creates a private document type's store, and fetch_private_document_entries and prove_private_document_entries Some(0) read and prove a page of it (note 13); apply_drive_operations 2 resolves a batch's estimate, remove_reference_for_index_level_for_contract_operations 2, stateless_delete_of_non_tree_for_costs 1 and for_total_system_credits_update 1 describe their layers as they are (note 14); create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description (note 17); fetch_core_fork_activation_height and store_core_fork_activation_height Some(0) keep the Core fork activations (note 18) drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: the yes/no poll's tally, record, clean-up and finish hook are Some(0) (note 10); verify_chain_lock_locally 1 rejects a chain lock locally only when Platform holds the quorums active at its signing height, and verify_chain_lock_through_core 1 has Core's verifychainlock check the others before submitchainlock; consensus_params_update 2 installs the full evidence policy (note 16); update_core_info 1 records the chain locked height at which Core's V24 is first seen active (note 18) - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V11, // changed: identity update advanced_structure 1 refuses unpaid an identity update whose added key's proof of possession does not verify (note 2); create_domain_data_trigger 2 (note 3); contract update transform_into_action 1 holds an update to the compiled size cap in check_tx (note 8); batch advanced_structure 2 refuses a token config update on a read-only contract (note 9); masternode vote transform_into_action 1 looks up a masternode's previous answer on a yes/no poll (note 10); document_reference_validation 1 keeps what a consumed commitment's lookup read (note 11); document create structure_validation 2 holds a decision proposal to its fund, rule and end date (note 12); data trigger bindings 3 refuse a replace or a delete of a token market order (note 17) + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V11, // changed: identity update advanced_structure 1 refuses unpaid an identity update whose added key's proof of possession does not verify (note 2); create_domain_data_trigger 2 (note 3); contract update transform_into_action 1 holds an update to the compiled size cap in check_tx (note 8); batch advanced_structure 2 refuses a token config update on a read-only contract (note 9); masternode vote transform_into_action 1 looks up a masternode's previous answer on a yes/no poll (note 10); document_reference_validation 1 keeps what a consumed commitment's lookup read (note 11); document create structure_validation 2 holds a decision proposal to its fund, rule and end date (note 12); data trigger bindings 3 refuse a replace or a delete of a token market order (note 17); document create state_validation 3 keeps a create outside a contest off the values of a live one its index values do not decide (note 19) withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, query: DRIVE_ABCI_QUERY_VERSIONS_V2, checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1,