From b2dc951334fae9170fd9468c4895935c757140b6 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 3 Nov 2026 19:02:07 +0700 Subject: [PATCH 1/7] fix(platform)!: decide a contest by its index values (PV15) A document joins its type's contest when every `fieldMatches` entry matches it, and 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 a document in it, and the award at the end of the contest then finds its entry taken. From protocol version 15: - registration refuses such a contest (`check_document_type_schema` 1, `InvalidContractStructure`), decided once by `Index::contest_is_decided_by_index_values`; - document create state validation 3 refuses a create outside the contest, on a type registered earlier with such a contest, while a contest for the same values is live (`DuplicateUniqueIndexError`). Only such a type pays the lookup, so a DPNS domain create costs what it did. Protocol version 14 and earlier are unchanged. Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords/contested.md | 1 + .../try_from_schema/common/mod.rs | 52 +++ .../contest_decided_by_index_values_tests.rs | 147 ++++++++ .../class_methods/try_from_schema/v3/mod.rs | 2 + .../index/contest_decided_by_values.rs | 52 +++ .../data_contract/document_type/index/mod.rs | 1 + .../document_create_transition_action/mod.rs | 15 +- .../state_v3/mod.rs | 140 ++++++++ .../state_transition/state_transitions/mod.rs | 338 ++++++++++++++++++ ...t-contested-field-match-outside-index.json | 169 +++++++++ .../drive_abci_validation_versions/v11.rs | 7 + .../rs-platform-version/src/version/v15.rs | 18 +- 12 files changed, 940 insertions(+), 2 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/contest_decided_by_index_values_tests.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/index/contest_decided_by_values.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v3/mod.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index.json diff --git a/book/src/contract-keywords/contested.md b/book/src/contract-keywords/contested.md index ba06ac33e63..c7cfbd08d2b 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; 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..96ae08e41fa --- /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,140 @@ +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::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::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, +}; + +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 looked up by the values as given, so the one remaining split, `""` and + // `"\0"` sharing an index key, is not caught here. + 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)?; + 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..66a87ebb6ae 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,342 @@ 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::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::ProtocolError; + use drive::error::drive::DriveError as DriveInnerError; + + const CONTRACT: &str = "tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index.json"; + + #[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, + 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 entropy = Bytes32::random_with_rng(rng); + + let mut document = domain + .random_document_with_identifier_and_entropy( + 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", 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()); + + document + .set_id_for_creation(domain, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + 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) + } + + /// Stores the contract without validating it, as a contract registered at protocol + /// version 14 is, opens a contest on `dash`/`quantum` with the lowercase label, creates + /// a second domain with `second_label` / `second_normalized_label` without prefunding, + /// then advances past the poll's end date and runs the resolver. + async fn run( + protocol_version: u32, + second_label: &str, + second_normalized_label: &str, + ) -> ( + 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, + None, + Some(contract_owner.0.id().to_buffer()), + None::, + None, + Some(platform_version), + ); + + let contested_result = create_domain( + &platform, + &contract, + &contender, + &mut rng, + "quantum", + "quantum", + true, + 3000, + platform_version, + ) + .await; + + let second_result = create_domain( + &platform, + &contract, + &other, + &mut rng, + second_label, + second_normalized_label, + 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 + ); + } + } } 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-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 be36896eedc..c10eb10a41e 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 @@ -30,6 +30,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 @@ -62,6 +68,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 { triggers: DriveAbciValidationDataTriggerVersions { create_domain_data_trigger: 2, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 1967ce01969..ef32c03d75f 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -160,6 +160,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 16), /// 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 @@ -366,13 +368,27 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// document. Those call sites were extended in place and are inert before /// this version, where no type declares such a function. Protocol v14 and /// earlier keep `apply_generated_from` 0. +/// +/// 16. **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 values is live (`DuplicateUniqueIndexError`), looking the poll +/// up by the values as given. Only such a type pays that lookup. 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) 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 - 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) + 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); 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 16) withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, query: DRIVE_ABCI_QUERY_VERSIONS_V2, checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, From b0c57a84ca6bc73bd303cad3d56dc730bf991140 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 3 Nov 2026 19:41:05 +0700 Subject: [PATCH 2/7] fix(drive-abci): skip the contest lookup for a create with a null index value A unique index entry holding a null is kept per document, not under the one key a contest's award takes, so such a create takes nothing from a contest: state validation 3 no longer looks up the poll or refuses it. Also adds a test that, from protocol version 15, an update keeping a contest its index values do not decide is refused (InvalidContractStructure), and says so in the v15 note and the book. Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords/contested.md | 2 +- .../state_v3/mod.rs | 6 ++ .../state_transition/state_transitions/mod.rs | 84 +++++++++++++++++++ .../rs-platform-version/src/version/v15.rs | 7 +- 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/book/src/contract-keywords/contested.md b/book/src/contract-keywords/contested.md index c7cfbd08d2b..d54c64a66b0 100644 --- a/book/src/contract-keywords/contested.md +++ b/book/src/contract-keywords/contested.md @@ -71,7 +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; while such a contest is live, a create outside it with the same index values is refused (`DuplicateUniqueIndexError`). +- 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-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 index 96ae08e41fa..567f01d3b90 100644 --- 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 @@ -4,6 +4,7 @@ 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::validation::{ConsensusValidationResult, SimpleConsensusValidationResult}; use dpp::version::PlatformVersion; use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ @@ -92,6 +93,11 @@ impl DocumentCreateTransitionActionStateValidationV3 for DocumentCreateTransitio platform_version, ) .map_err(Error::Protocol)?; + // A unique index entry holding a null is kept per document, not under the one key the + // award takes, so such a document takes nothing from a contest: nothing is read + if index_values.iter().any(Value::is_null) { + return Ok(SimpleConsensusValidationResult::new()); + } let vote_poll = ContestedDocumentResourceVotePoll { contract_id: self.base().data_contract_id(), document_type_name: self.base().document_type_name().clone(), 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 66a87ebb6ae..6e4caea96af 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 @@ -4220,12 +4220,15 @@ pub(in crate::execution) mod tests { 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 drive::error::drive::DriveError as DriveInnerError; @@ -4541,5 +4544,86 @@ pub(in crate::execution) mod tests { 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(_) + )), + .. + } + ); + } } } diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index ef32c03d75f..252b2fa1911 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -380,8 +380,11 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// 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 values is live (`DuplicateUniqueIndexError`), looking the poll -/// up by the values as given. Only such a type pays that lookup. Protocol v14 and -/// earlier keep `check_document_type_schema` 0 and document create state validation 2. +/// up by the values as given. Only such a type pays that lookup, and not for a document +/// with a null index value, whose unique index entry is kept per document. 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) From 063221f9b9853e3021ad6e5eb76f2062b9c7a86f Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 3 Nov 2026 21:06:04 +0700 Subject: [PATCH 3/7] test(drive-abci): cover the null index value and the shared empty-string key in the contest guard - A create leaving a contested index property out holds a null index value, whose unique index entry is kept per document: it is admitted beside a live contest, and the award goes through. - A create of "\0" beside a live contest of "" (both encode to the index key [0]) is refused: the stored info is read at the poll's path, which encodes the values as index keys. Corrects the guard's comment and the v15 note, which said this split was not caught. Co-Authored-By: Claude Opus 5.5 --- .../state_v3/mod.rs | 4 +- .../state_transition/state_transitions/mod.rs | 133 ++++++++++++-- ...contract-contested-empty-string-split.json | 168 ++++++++++++++++++ ...d-match-outside-index-optional-parent.json | 168 ++++++++++++++++++ .../rs-platform-version/src/version/v15.rs | 5 +- 5 files changed, 463 insertions(+), 15 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-empty-string-split.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-optional-parent.json 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 index 567f01d3b90..39c7194a9ac 100644 --- 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 @@ -72,8 +72,8 @@ impl DocumentCreateTransitionActionStateValidationV3 for DocumentCreateTransitio // 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 looked up by the values as given, so the one remaining split, `""` and - // `"\0"` sharing an index key, is not caught here. + // 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. if self.prefunded_voting_balance().is_some() { return 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 6e4caea96af..bf8d9be5af4 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 @@ -4234,6 +4234,15 @@ pub(in crate::execution) mod tests { 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"; + #[allow(clippy::too_many_arguments)] async fn create_domain( platform: &TempPlatform, @@ -4242,6 +4251,7 @@ pub(in crate::execution) mod tests { rng: &mut StdRng, label: &str, normalized_label: &str, + parent: Option<&str>, contested: bool, time_ms: u64, platform_version: &PlatformVersion, @@ -4264,8 +4274,16 @@ pub(in crate::execution) mod tests { ) .expect("expected a random domain document"); - document.set("parentDomainName", "dash".into()); - document.set("normalizedParentDomainName", "dash".into()); + 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()); @@ -4344,10 +4362,8 @@ pub(in crate::execution) mod tests { processing_result.into_execution_results().remove(0) } - /// Stores the contract without validating it, as a contract registered at protocol - /// version 14 is, opens a contest on `dash`/`quantum` with the lowercase label, creates - /// a second domain with `second_label` / `second_normalized_label` without prefunding, - /// then advances past the poll's end date and runs the resolver. + /// [`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, @@ -4356,6 +4372,32 @@ pub(in crate::execution) mod tests { 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`), 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>, + ) -> ( + StateTransitionExecutionResult, + StateTransitionExecutionResult, + Result<(), crate::error::Error>, ) { let platform_config = PlatformConfig { network: Network::Mainnet, @@ -4378,7 +4420,7 @@ pub(in crate::execution) mod tests { let contract = setup_contract( &platform.drive, - CONTRACT, + contract_path, None, Some(contract_owner.0.id().to_buffer()), None::, @@ -4391,8 +4433,9 @@ pub(in crate::execution) mod tests { &contract, &contender, &mut rng, - "quantum", - "quantum", + contender_labels.0, + contender_labels.1, + parent, true, 3000, platform_version, @@ -4404,8 +4447,9 @@ pub(in crate::execution) mod tests { &contract, &other, &mut rng, - second_label, - second_normalized_label, + second.0, + second.1, + parent, false, 4000, platform_version, @@ -4545,6 +4589,73 @@ pub(in crate::execution) mod tests { ); } + /// 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 + ); + } + + /// `""` 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. 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-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-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 252b2fa1911..bc04ac9631a 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -379,8 +379,9 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// (`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 values is live (`DuplicateUniqueIndexError`), looking the poll -/// up by the values as given. Only such a type pays that lookup, and not for a document +/// 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 a null index value, whose unique index entry is kept per document. 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 From 4d78f86d197edb0dced009b96338aeae775eac44 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 3 Nov 2026 21:44:20 +0700 Subject: [PATCH 4/7] fix(drive-abci): exempt a create from the contest guard by its index keys, not null values Drive keeps a unique index entry per document, apart from the key a contest awards, whenever one of its index keys is empty: a missing value, but also an empty byte array. The guard exempted only null values, so it refused a create holding an empty byte array beside a live contest although its entry could not meet the winner's. `drive::document::shared_unique_entry::unique_index_entry_is_shared` reads each level's key with the call the insert walkers make (`level_keys`) and says whether the entry sits under the shared key; the guard asks it before the poll lookup. Adds a test admitting an empty byte array create beside a live contest, and updates the v15 note. Co-Authored-By: Claude Opus 5.5 --- .../state_v3/mod.rs | 20 +- .../state_transition/state_transitions/mod.rs | 49 ++++- ...-field-match-outside-index-byte-array.json | 179 ++++++++++++++++++ packages/rs-drive/src/drive/document/mod.rs | 4 + .../src/drive/document/shared_unique_entry.rs | 39 ++++ .../rs-platform-version/src/version/v15.rs | 3 +- 6 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-byte-array.json create mode 100644 packages/rs-drive/src/drive/document/shared_unique_entry.rs 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 index 39c7194a9ac..779d2db1c53 100644 --- 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 @@ -4,19 +4,22 @@ 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; @@ -93,9 +96,18 @@ impl DocumentCreateTransitionActionStateValidationV3 for DocumentCreateTransitio platform_version, ) .map_err(Error::Protocol)?; - // A unique index entry holding a null is kept per document, not under the one key the - // award takes, so such a document takes nothing from a contest: nothing is read - if index_values.iter().any(Value::is_null) { + // A document whose unique index entry Drive keeps per document (a value it lacks, or + // one with an empty index key, such as an empty byte array) takes nothing from a + // contest: nothing is read + 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 { 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 bf8d9be5af4..85779c108c0 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 @@ -4243,6 +4243,9 @@ pub(in crate::execution) mod tests { const EMPTY_STRING_SPLIT_CONTRACT: &str = "tests/supporting_files/contract/dpns/dpns-contract-contested-empty-string-split.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"; + #[allow(clippy::too_many_arguments)] async fn create_domain( platform: &TempPlatform, @@ -4252,6 +4255,7 @@ pub(in crate::execution) mod tests { label: &str, normalized_label: &str, parent: Option<&str>, + extra: &[(&str, Value)], contested: bool, time_ms: u64, platform_version: &PlatformVersion, @@ -4289,6 +4293,9 @@ pub(in crate::execution) mod tests { 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) @@ -4379,6 +4386,7 @@ pub(in crate::execution) mod tests { ("quantum", "quantum"), (second_label, second_normalized_label), Some("dash"), + &[], ) .await } @@ -4386,14 +4394,15 @@ pub(in crate::execution) mod tests { /// 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`), then advances past the poll's end date and runs - /// the resolver. + /// 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, @@ -4436,6 +4445,7 @@ pub(in crate::execution) mod tests { contender_labels.0, contender_labels.1, parent, + extra, true, 3000, platform_version, @@ -4450,6 +4460,7 @@ pub(in crate::execution) mod tests { second.0, second.1, parent, + extra, false, 4000, platform_version, @@ -4602,6 +4613,7 @@ pub(in crate::execution) mod tests { ("quantum", "quantum"), ("Quantum", "quantum"), None, + &[], ) .await; assert_matches!( @@ -4621,6 +4633,38 @@ pub(in crate::execution) mod tests { ); } + /// 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 + ); + } + /// `""` 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 @@ -4634,6 +4678,7 @@ pub(in crate::execution) mod tests { ("abc", ""), ("abd", "\0"), Some("dash"), + &[], ) .await; assert_matches!( 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/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/v15.rs b/packages/rs-platform-version/src/version/v15.rs index bc04ac9631a..f45c6b3173c 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -382,7 +382,8 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// 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 a null index value, whose unique index entry is kept per document. A contract +/// 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`). 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. From e3fbb9a6d69327c5b8df585aa1cff912d2dd1469 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 3 Nov 2026 22:23:29 +0700 Subject: [PATCH 5/7] docs(drive-abci): note the contest guard relies on one document transition per batch Co-Authored-By: Claude Opus 5.5 --- .../document/document_create_transition_action/state_v3/mod.rs | 3 +++ 1 file changed, 3 insertions(+) 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 index 779d2db1c53..ae0c0aa9488 100644 --- 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 @@ -77,6 +77,9 @@ impl DocumentCreateTransitionActionStateValidationV3 for DocumentCreateTransitio // 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()); } From 92811e8ac6d2fc1cb48263d6ea7a8f78b27cc7d5 Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 3 Nov 2026 23:17:56 +0700 Subject: [PATCH 6/7] fix(drive-abci): skip the contest lookup when a contested index value is missing The guard reads the poll's values from the create's data, as a contested create does. The data never holds a system property, so an index holding one (`$ownerId`) yields a null value whose poll path can not be built: the lookup failed with an internal error although no create can open such a contest. A missing value now skips the lookup, whether it is a user property the document lacks (its unique index entry is kept per document) or a system property (no contest over the index can be live). Adds a test admitting a create on a contested index over `[$ownerId, normalizedLabel]`, and updates the v15 note. Co-Authored-By: Claude Opus 5.5 --- .../state_v3/mod.rs | 14 +- .../state_transition/state_transitions/mod.rs | 54 ++++++ ...ed-field-match-outside-index-owner-id.json | 169 ++++++++++++++++++ .../rs-platform-version/src/version/v15.rs | 3 +- 4 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-field-match-outside-index-owner-id.json 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 index ae0c0aa9488..f54ca388c23 100644 --- 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 @@ -4,6 +4,7 @@ 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; @@ -99,9 +100,16 @@ impl DocumentCreateTransitionActionStateValidationV3 for DocumentCreateTransitio platform_version, ) .map_err(Error::Protocol)?; - // A document whose unique index entry Drive keeps per document (a value it lacks, or - // one with an empty index key, such as an empty byte array) takes nothing from a - // contest: nothing is read + // 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( 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 85779c108c0..2134da5c5bf 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 @@ -4243,6 +4243,9 @@ pub(in crate::execution) mod tests { 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"; @@ -4665,6 +4668,57 @@ pub(in crate::execution) mod tests { ); } + /// 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 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-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 7e99f3cc1d7..d24655860d9 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -409,7 +409,8 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// 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`). A contract +/// 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. From a803206617a6510826e504ce4ce43541b733f8c1 Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 4 Nov 2026 11:06:42 +0700 Subject: [PATCH 7/7] test(drive-abci): pin the fee of the contest lookup on a create Validate the same create against the same state at document create state validation 2 and 3, with a live contest and without one, and assert that version 3 bills what version 2 bills and then the poll's read as one precalculated operation. Dropping that operation now fails both tests. Co-Authored-By: Claude Opus 5.5 --- .../state_transition/state_transitions/mod.rs | 258 +++++++++++++++++- 1 file changed, 250 insertions(+), 8 deletions(-) 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 2134da5c5bf..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 @@ -4230,7 +4230,21 @@ pub(in crate::execution) mod tests { 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"; @@ -4249,21 +4263,19 @@ pub(in crate::execution) mod tests { /// [`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)] - async fn create_domain( - platform: &TempPlatform, + fn domain_document( contract: &DataContract, - identity: &(Identity, SimpleSigner, IdentityPublicKey), + owner_id: Identifier, 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; + ) -> (Document, Bytes32) { let domain = contract .document_type_for_name("domain") .expect("expected a domain document type"); @@ -4273,7 +4285,7 @@ pub(in crate::execution) mod tests { let mut document = domain .random_document_with_identifier_and_entropy( rng, - identity.id(), + owner_id, entropy, DocumentFieldFillType::FillIfNotRequired, DocumentFieldFillSize::AnyDocumentFillSize, @@ -4303,6 +4315,37 @@ pub(in crate::execution) mod tests { 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( @@ -4835,5 +4878,204 @@ pub(in crate::execution) mod tests { } ); } + + /// 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); + } } }