Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 @@ -72,6 +72,7 @@ The fund, the windows, the tallies and the special case of moderation elections
- `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`).
- From protocol version 15, every property of the index is a top-level property of the document: no property path such as `records.identity` and no system property such as `$ownerId` (`InvalidContractStructure`, 10231). A contested create finds the contest by reading each index value from the document's top-level properties, where such a property is never found, so no contested create could open or join the contest. A contract registered earlier keeps such an index, but can no longer be updated.
- 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 @@ -800,6 +800,47 @@ fn refuse_a_contest_not_decided_by_index_values(
}
}

/// A rule of full validation under `check_document_type_schema` 1 (protocol version 15): the
/// contested `index` of the type `name` must be keyed by top-level properties of the document
/// ([`Index::contest_is_keyed_by_top_level_properties`]), or no contested create could open or
/// join its contest. Version 0 refuses nothing, so a contract registered earlier keeps such an
/// index.
#[cfg(feature = "validation")]
fn refuse_a_contest_keyed_by_a_nested_or_system_property(
index: &Index,
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_keyed_by_top_level_properties() {
Ok(())
} else {
Err(consensus_or_protocol_data_contract_error(
DataContractError::InvalidContractStructure(format!(
"the contest of index \"{}\" on document type \"{name}\" must be keyed \
by top-level properties of the document: a property path or a system \
property (starting with `$`) can not key a contest",
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 @@ -1539,6 +1580,13 @@ fn parse_indices(
ctx.platform_version,
)?;

// A contest keyed by a nested or system property (protocol version 15)
refuse_a_contest_keyed_by_a_nested_or_system_property(
&index,
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,148 @@
//! A contested index keyed by a property path or a system property, which registration
//! refuses from protocol version 15 (`check_document_type_schema` 1): no contested create can
//! open or join such a contest. A registration rule: a stored contract still parses, and
//! protocol version 14 keeps registering such an index.
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 `normalizedLabel` and a `records` object holding an `identity`,
/// whose unique `byKeyAndLabel` index on `key` and `normalizedLabel` is contested on
/// `normalizedLabel`.
fn name_schema(key: &str) -> Value {
platform_value!({
"type": "object",
"documentsMutable": false,
"canBeDeleted": false,
"properties": {
"normalizedLabel": { "type": "string", "maxLength": 20, "position": 0 },
"records": {
"type": "object",
"properties": {
"identity": {
"type": "array",
"byteArray": true,
"minItems": 32,
"maxItems": 32,
"contentMediaType": "application/x.dash.dpp.identifier",
"position": 0,
},
},
"required": ["identity"],
"additionalProperties": false,
"position": 1,
},
"group": { "type": "string", "maxLength": 20, "position": 2 },
},
"required": ["normalizedLabel", "records", "group"],
"indices": [{
"name": "byKeyAndLabel",
"properties": [{ key: "asc" }, { "normalizedLabel": "asc" }],
"unique": true,
"contested": {
"fieldMatches": [
{ "field": "normalizedLabel", "regexPattern": "^[a-z]{3,5}$" }
],
"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 \"byKeyAndLabel\" on document type \"name\" must be keyed by \
top-level properties"
),
"{message}"
);
}

#[test]
fn should_refuse_a_contest_keyed_by_a_property_path() {
assert_refused(parse(name_schema("records.identity")));
}

#[test]
fn should_refuse_a_contest_keyed_by_a_system_property() {
assert_refused(parse(name_schema("$ownerId")));
}

#[test]
fn should_register_a_contest_keyed_by_top_level_properties() {
let document_type =
parse(name_schema("group")).expect("a contest on top-level properties registers");
let index = document_type
.find_contested_index()
.expect("expected the contested index");
assert!(index.contest_is_keyed_by_top_level_properties());
}

#[test]
fn should_register_a_unique_index_without_a_contest_keyed_by_a_property_path() {
let mut schema = name_schema("records.identity");
let Some(Value::Array(indices)) = schema.get_mut("indices").expect("indices") else {
panic!("expected the indices");
};
indices[0]
.remove("contested")
.expect("expected to remove the contest");
parse(schema).expect("only a contest must be keyed by top-level properties");
}

#[test]
fn should_register_a_contest_keyed_by_a_property_path_at_protocol_version_14() {
parse_at(name_schema("records.identity"), 14, true)
.expect("protocol version 14 keeps registering it");
parse_at(name_schema("$ownerId"), 14, true).expect("protocol version 14 keeps registering it");
}

#[test]
fn should_load_a_stored_contest_keyed_by_a_property_path() {
let latest = PlatformVersion::latest().protocol_version;
parse_at(name_schema("records.identity"), latest, false)
.expect("a stored contract runs no registration rule");
parse_at(name_schema("$ownerId"), latest, false)
.expect("a stored contract runs no registration rule");
}
Original file line number Diff line number Diff line change
Expand Up @@ -2089,6 +2089,8 @@ mod commit_reveal_lookup_tests;
#[cfg(all(test, feature = "validation"))]
mod contest_decided_by_index_values_tests;
#[cfg(all(test, feature = "validation"))]
mod contest_keyed_by_top_level_properties_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,24 @@
//! Whether every property of a contested index is a top-level property of the document. The
//! vote poll of a contest takes each index value from the document's top-level properties by
//! name, so a nested or system property gives the poll a null value, and no contested create
//! can open or join the contest.

use crate::data_contract::document_type::Index;

impl Index {
/// Whether this index's contest, if it has one, is keyed only by top-level properties of
/// the document: no property path (`records.identity`) and no system property
/// (`$ownerId`). A contested create keys its vote poll by looking each index property up
/// by name among the document's top-level properties ([`Index::extract_values`]), where a
/// property path or a system property is never found. The poll then holds a null value
/// where the contender's index entry holds the real one, and Drive fails every contested
/// create of the type without storing it, so the contest can never start. An index without
/// a contest is keyed by any of its properties.
pub fn contest_is_keyed_by_top_level_properties(&self) -> bool {
self.contested_index.is_none()
|| self
.properties
.iter()
.all(|property| !property.name.starts_with('$') && !property.name.contains('.'))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use std::{collections::BTreeMap, convert::TryFrom};

mod bucketing;
mod contest_decided_by_values;
mod contest_keyed_by_top_level_properties;
mod derived_index_property;
mod extract_contested_values;
pub mod integer_range;
Expand Down
Loading
Loading