Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions book/src/contract-keywords/contested.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ The fund, the windows, the tallies and the special case of moderation elections
- A document type has at most one contested index, and no other unique index beside it (`ContestedUniqueIndexWithUniqueIndexError`, 10249).
- `resolution` is present and is `0` or `1`; `1` is refused before protocol version 14.
- `fieldMatches`, when present, holds at least one entry, and each `regexPattern` is a valid regular expression (`RegexError`, 10247).
- From protocol version 15, the contest is decided by the index values, so that two documents with the same values are both contested or both ordinary: every `fieldMatches` field is a property of the index, and a match on a string property that may be empty (no `minLength` of at least 1) treats `""` and `"\0"` alike, since the two share one index key (`InvalidContractStructure`, 10231). Otherwise an ordinary document could take the values of a contested one, and the contest would have nothing left to award. A contract registered earlier keeps its contest, but can no longer be updated, since an update is checked like a registration; while such a contest is live, a create outside it with the same index values is refused (`DuplicateUniqueIndexError`).
- A contested index cannot carry a [`timeRange`](time-range.md), an [`integerRange`](integer-range.md) or a ranking (a ranking needs a non-unique index), and an [index-only type](index-only.md) cannot have one.
- A document type with a contested index cannot set [`ttl`](ttl.md), nor `moderatorAbilities.delete` (see [Deletion](deletion.md)): a moderator's restore puts a document back by an ordinary insert, and a contested value is only awarded through a vote.
- From protocol version 14, a document type with a contested index that sums a property ([`summable`, `averageable`, `documentsSummable` or `documentsAverageable`](aggregates.md)) declares that property with a `minimum` of at least -134217728 and a `maximum` of at most 134217728 (±2^27, `max_contested_summed_value_magnitude`). The end of a contest adds the winner's value to the type's sums, after any number of other documents were written, and a sum that left the signed 64-bit range there could not be stored. Values this small keep the sums in range short of 2^36 documents. Checked when a contract is registered or updated, so contracts registered earlier keep their bounds.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, DocumentProperty>,
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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DocumentType, ProtocolError> {
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<DocumentType, ProtocolError> {
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<u64>) -> 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<DocumentType, ProtocolError>) {
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");
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, DocumentProperty>,
) -> 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()))
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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,
})),
}
Expand Down
Loading
Loading