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
2 changes: 2 additions & 0 deletions book/src/data-model/contract-moderation.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ The declaration lives in `packages/rs-dpp/src/data_contract/config/moderation/el

**The interim block.** The batch transformer's `contract_moderation_gate` v0 runs it before the lists: on an elected contract whose interim is `NotYetUsable`, every document transition of a moderated document type, deletions included (nothing of those types was ever written), is refused, paid, with `ContractModeratedDocumentTypeNotYetUsableError` (41200) and its contract nonce bump, in a block and in the mempool. The lists are read only for the transitions on the other types, and not at all when nothing is left. The interim moderators of the other two kinds moderate through the same transition, the same gate and the same claim as the merged kinds; a moderation transition against a `NotYetUsable` contract fails as by a non-moderator (41101).

**Referencing an elected contract.** A document type that must point at a contract of this kind says so in its reference: `"refersTo": { "type": "contract", "contractRequirements": { "moderation": "elected" } }`. `contractRequirements` holds what the referenced contract must declare beyond existing, each key an aspect of the contract with a closed set of values (`moderation: "elected"` is the first). Consensus checks it when the referring document is written, against the contract it has already fetched for the existence check, so it costs no further read; a contract that exists but does not declare an elected team refuses the write, paid, with `ReferencedContractRequirementNotMetError` (40135), where a contract that does not exist is still 40120. A changed `contractRequirements` is an incompatible schema change on update, like the rest of a `refersTo`. The charter system contract's `targetContractId` is the first user.

**What comes next.** The charter system contract, applications and the election (new vote poll kinds), the seated team under the contract with its per-ability powers, charter-priced moderators amounts within the maximums, and challenges and amendments. Issue #4865 holds the design.

## Versioning Touchpoints
Expand Down
25 changes: 25 additions & 0 deletions packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,19 @@
"maxLength": 64,
"pattern": "^[a-zA-Z0-9-_]{1,64}$"
},
"contractRequirements": {
"description": "contract references only: what the referenced contract must declare beyond existing, checked when the referring document is written against the contract already fetched for the existence check, so a requirement costs no further read. Each key names an aspect of the referenced contract and its value the requirement: moderation \"elected\" requires the contract to declare an elected moderation team. An unmet requirement refuses the write (ReferencedContractRequirementNotMetError, 40135)",
"type": "object",
"properties": {
"moderation": {
"enum": [
"elected"
]
}
},
"minProperties": 1,
"additionalProperties": false
},
"keyIdProperty": {
"description": "The property of the same document type whose value carries the referenced key id; the reference property's value carries the identity id",
"type": "string",
Expand Down Expand Up @@ -207,6 +220,18 @@
"keyIdProperty": false
}
}
},
{
"if": {
"properties": { "type": { "const": "contract" } },
"required": ["type"]
},
"then": {},
"else": {
"properties": {
"contractRequirements": false
}
}
}
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use crate::data_contract::document_type::v0::DocumentTypeV0;
use crate::data_contract::document_type::v1::DocumentTypeV1;
use crate::data_contract::document_type::{
is_referenced_system_agreement_property, is_referring_system_agreement_property,
property_names, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType,
DocumentType,
property_names, ContractReferenceModeration, ContractReferenceRequirements, DocumentProperty,
DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType,
};
use crate::data_contract::errors::DataContractError;
use crate::data_contract::{TokenConfiguration, TokenContractPosition};
Expand Down Expand Up @@ -367,12 +367,25 @@ fn apply_property_reference_v0(

let refers_to_map = refers_to_value.to_btree_ref_string_map()?;

let target = match refers_to_map
let reference_type = refers_to_map
.get_str(property_names::TYPE)
.map_err(|e| DataContractError::ValueWrongType(e.to_string()))?
.map_err(|e| DataContractError::ValueWrongType(e.to_string()))?;

// Requirements on the referenced contract belong to contract references alone
if reference_type != "contract"
&& refers_to_map.contains_key(property_names::CONTRACT_REQUIREMENTS)
{
return Err(DataContractError::InvalidContractStructure(format!(
"{} refersTo does not take contractRequirements",
reference_type
)));
}

let target = match reference_type {
"identity" => DocumentPropertyReferenceTarget::Identity,
"contract" => DocumentPropertyReferenceTarget::Contract,
"contract" => DocumentPropertyReferenceTarget::Contract {
contract_requirements: parse_contract_reference_requirements(&refers_to_map)?,
},
"token" => DocumentPropertyReferenceTarget::Token,
// The two document targets share one declaration shape; they differ
// only in whether the referenced document type must forbid deletion,
Expand Down Expand Up @@ -512,6 +525,47 @@ fn apply_property_reference_v0(
Ok(DocumentPropertyType::IdentifierWithReference(target))
}

/// The `contractRequirements` of a `contract` reference: each key an aspect of the referenced
/// contract with a closed set of values, at least one when the object is given at all.
fn parse_contract_reference_requirements(
refers_to_map: &BTreeMap<String, &Value>,
) -> Result<ContractReferenceRequirements, DataContractError> {
let Some(fields_value) = refers_to_map.get(property_names::CONTRACT_REQUIREMENTS) else {
return Ok(ContractReferenceRequirements::default());
};
let fields_map = fields_value.to_btree_ref_string_map()?;
if fields_map.is_empty() {
return Err(DataContractError::InvalidContractStructure(
"contract refersTo contractRequirements must declare at least one requirement"
.to_string(),
));
}
let mut fields = ContractReferenceRequirements::default();
for (field, value) in fields_map {
match field.as_str() {
property_names::MODERATION => {
let name = value.as_text().ok_or_else(|| {
DataContractError::InvalidContractStructure(
"contract refersTo contractRequirements moderation must be a string"
.to_string(),
)
})?;
fields.moderation = Some(ContractReferenceModeration::from_wire_name(name).ok_or_else(|| {
DataContractError::InvalidContractStructure(format!(
"contract refersTo contractRequirements moderation {name:?} is unknown, expected \"elected\""
))
})?);
}
other => {
return Err(DataContractError::InvalidContractStructure(format!(
"contract refersTo contractRequirements {other:?} is unknown"
)));
}
}
}
Ok(fields)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -986,6 +1040,97 @@ mod tests {
);
}

fn contract_reference_schema(refers_to: serde_json::Value) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"targetContractId": {
"type": "array",
"byteArray": true,
"minItems": 32,
"maxItems": 32,
"contentMediaType": "application/x.dash.dpp.identifier",
"position": 0,
"refersTo": refers_to
}
},
"required": [],
"additionalProperties": false
})
}

fn contract_reference_target(refers_to: serde_json::Value) -> DocumentPropertyType {
try_document_type_from_schema(contract_reference_schema(refers_to))
.expect("should parse")
.as_ref()
.flattened_properties()
.get("targetContractId")
.map(|p| p.property_type.clone())
.expect("property should be present")
}

#[test]
fn should_parse_contract_refers_to_without_contract_requirements_as_no_requirement() {
assert_eq!(
contract_reference_target(json!({ "type": "contract" })),
DocumentPropertyType::IdentifierWithReference(
DocumentPropertyReferenceTarget::Contract {
contract_requirements: ContractReferenceRequirements::default(),
}
)
);
}

#[test]
fn should_parse_contract_refers_to_requiring_elected_moderation() {
assert_eq!(
contract_reference_target(json!({
"type": "contract",
"contractRequirements": { "moderation": "elected" }
})),
DocumentPropertyType::IdentifierWithReference(
DocumentPropertyReferenceTarget::Contract {
contract_requirements: ContractReferenceRequirements {
moderation: Some(ContractReferenceModeration::Elected),
},
}
)
);
}

#[test]
fn should_reject_contract_requirements_that_are_empty_unknown_or_on_another_type() {
for (refers_to, fragment) in [
(
json!({ "type": "contract", "contractRequirements": {} }),
"at least one requirement",
),
(
json!({ "type": "contract", "contractRequirements": { "moderation": "appointed" } }),
"is unknown",
),
(
json!({ "type": "contract", "contractRequirements": { "moderation": 1 } }),
"must be a string",
),
(
json!({ "type": "contract", "contractRequirements": { "tokens": "any" } }),
"is unknown",
),
(
json!({ "type": "identity", "contractRequirements": { "moderation": "elected" } }),
"does not take contractRequirements",
),
] {
let err = try_document_type_from_schema(contract_reference_schema(refers_to.clone()))
.expect_err("should be refused");
assert!(
err.to_string().contains(fragment),
"{refers_to}: expected {fragment:?}, got {err}"
);
}
}

#[test]
fn should_reject_permanent_document_refers_to_with_invalid_contract_id() {
let err = try_document_type_from_schema(json!({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1996,6 +1996,40 @@ mod tests {
);
}
}
#[test]
fn should_return_invalid_result_when_a_contract_reference_requirement_changes() {
let platform_version = PlatformVersion::latest();

for (old_fields, new_fields, changed_path) in [
(
platform_value!({ "type": "contract" }),
platform_value!({ "type": "contract", "contractRequirements": { "moderation": "elected" } }),
"/properties/toUserId/refersTo/contractRequirements",
),
(
platform_value!({ "type": "contract", "contractRequirements": { "moderation": "elected" } }),
platform_value!({ "type": "contract" }),
"/properties/toUserId/refersTo/contractRequirements",
),
] {
let old_document_type =
identifier_document_type(Some(old_fields), platform_version);
let new_document_type =
identifier_document_type(Some(new_fields), platform_version);

let result = old_document_type
.as_ref()
.validate_schema(new_document_type.as_ref(), platform_version)
.expect("failed to validate schema compatibility");

assert_matches!(
result.errors.as_slice(),
[ConsensusError::BasicError(
BasicError::IncompatibleDocumentTypeSchemaError(e)
)] if e.property_path() == changed_path
);
}
}
}

mod validate_byte_array_encoding {
Expand Down
2 changes: 2 additions & 0 deletions packages/rs-dpp/src/data_contract/document_type/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ pub(crate) mod property_names {
pub const DOCUMENT_TYPE: &str = "documentType";
pub const KEY_ID_PROPERTY: &str = "keyIdProperty";
pub const PROPERTY_AGREEMENT: &str = "propertyAgreement";
pub const CONTRACT_REQUIREMENTS: &str = "contractRequirements";
pub const MODERATION: &str = "moderation";
pub const DOCUMENTS_COUNTABLE: &str = "documentsCountable";
pub const RANGE_COUNTABLE: &str = "rangeCountable";
/// Doctype-level flag naming the property whose values are summed into
Expand Down
Loading
Loading