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 @@ -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