diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 7eeabd3acaf..5ce4e5b6e56 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -293,7 +293,7 @@ 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. +**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 or a bound: `moderation: "elected"`; `minimumAgeSeconds`, which requires the contract's recorded creation time to be at least that many seconds before the block time of the write (a delay between a contract's creation and the first charter against it, so a team cannot be seated before anyone has seen the contract); and `minimumSecondsSinceUpdate`, the same of the later of the contract's creation and last update times (so an old contract updated to declare elected moderation gets the same notice before its first charter; any update restarts the clock). A contract created before contracts recorded their creation time never meets either duration. Consensus checks them when the referring document is written, against the contract it has already fetched for the existence check and the block time, so they cost no further read; a contract that exists but does not meet a requirement refuses the write, paid, with `ReferencedContractRequirementNotMetError` (40135) naming the requirement, 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. diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 2b5e528b794..54b86552727 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -133,13 +133,23 @@ "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)", + "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 and the block time, 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; minimumAgeSeconds requires the contract's recorded creation time to be at least that many seconds before the block time of the write, and minimumSecondsSinceUpdate the later of its recorded creation and last update times (a contract without a recorded creation time never meets either). An unmet requirement refuses the write (ReferencedContractRequirementNotMetError, 40135)", "type": "object", "properties": { "moderation": { "enum": [ "elected" ] + }, + "minimumAgeSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 + }, + "minimumSecondsSinceUpdate": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 } }, "minProperties": 1, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 798166ad26f..672b4feb1ae 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -526,7 +526,8 @@ fn apply_property_reference_v0( } /// 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. +/// contract with a closed set of values (`moderation`), or a bound on it (`minimumAgeSeconds`), +/// at least one when the object is given at all. fn parse_contract_reference_requirements( refers_to_map: &BTreeMap, ) -> Result { @@ -556,6 +557,13 @@ fn parse_contract_reference_requirements( )) })?); } + property_names::MINIMUM_AGE_SECONDS => { + fields.minimum_age_seconds = Some(parse_contract_reference_seconds(&field, value)?); + } + property_names::MINIMUM_SECONDS_SINCE_UPDATE => { + fields.minimum_seconds_since_update = + Some(parse_contract_reference_seconds(&field, value)?); + } other => { return Err(DataContractError::InvalidContractStructure(format!( "contract refersTo contractRequirements {other:?} is unknown" @@ -566,6 +574,22 @@ fn parse_contract_reference_requirements( Ok(fields) } +/// A duration requirement of a `contract` reference (`minimumAgeSeconds`, +/// `minimumSecondsSinceUpdate`): a whole number of seconds from 1 to `u32::MAX`. +fn parse_contract_reference_seconds(field: &str, value: &Value) -> Result { + let seconds: u32 = value.to_integer().map_err(|_| { + DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements {field} must be an integer from 1 to 4294967295" + )) + })?; + if seconds == 0 { + return Err(DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements {field} must be at least 1" + ))); + } + Ok(seconds) +} + #[cfg(test)] mod tests { use super::*; @@ -1092,10 +1116,90 @@ mod tests { DocumentPropertyReferenceTarget::Contract { contract_requirements: ContractReferenceRequirements { moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: None, + minimum_seconds_since_update: None, + }, + } + ) + ); + } + + #[test] + fn should_parse_contract_refers_to_requiring_a_minimum_age_or_time_since_update() { + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { "minimumAgeSeconds": 604800 } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: None, + minimum_age_seconds: Some(604_800), + minimum_seconds_since_update: None, }, } ) ); + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { "minimumSecondsSinceUpdate": 86400 } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: None, + minimum_age_seconds: None, + minimum_seconds_since_update: Some(86_400), + }, + } + ) + ); + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { + "moderation": "elected", + "minimumAgeSeconds": u32::MAX, + "minimumSecondsSinceUpdate": 1 + } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: Some(u32::MAX), + minimum_seconds_since_update: Some(1), + }, + } + ) + ); + } + + #[test] + fn should_reject_a_duration_requirement_that_is_zero_negative_too_large_or_not_an_integer() { + for field in ["minimumAgeSeconds", "minimumSecondsSinceUpdate"] { + for (seconds, fragment) in [ + (json!(0), "must be at least 1"), + (json!(-1), "must be an integer"), + (json!(u64::from(u32::MAX) + 1), "must be an integer"), + (json!(1.5), "must be an integer"), + (json!("3600"), "must be an integer"), + ] { + let refers_to = json!({ + "type": "contract", + "contractRequirements": { field: seconds } + }); + let err = + try_document_type_from_schema(contract_reference_schema(refers_to.clone())) + .expect_err("should be refused"); + assert!( + err.to_string().contains(fragment) && err.to_string().contains(field), + "{refers_to}: expected {fragment:?} naming {field}, got {err}" + ); + } + } } #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs index 4a955ea64d6..fb1fbf67dc1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -2011,6 +2011,16 @@ mod tests { platform_value!({ "type": "contract" }), "/properties/toUserId/refersTo/contractRequirements", ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "minimumAgeSeconds": 3600 } }), + platform_value!({ "type": "contract", "contractRequirements": { "minimumAgeSeconds": 7200 } }), + "/properties/toUserId/refersTo/contractRequirements/minimumAgeSeconds", + ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "minimumSecondsSinceUpdate": 60 } }), + platform_value!({ "type": "contract", "contractRequirements": { "minimumSecondsSinceUpdate": 61 } }), + "/properties/toUserId/refersTo/contractRequirements/minimumSecondsSinceUpdate", + ), ] { let old_document_type = identifier_document_type(Some(old_fields), platform_version); diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 0b69e95b725..5d19f4f47f3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -109,6 +109,8 @@ pub(crate) mod property_names { pub const PROPERTY_AGREEMENT: &str = "propertyAgreement"; pub const CONTRACT_REQUIREMENTS: &str = "contractRequirements"; pub const MODERATION: &str = "moderation"; + pub const MINIMUM_AGE_SECONDS: &str = "minimumAgeSeconds"; + pub const MINIMUM_SECONDS_SINCE_UPDATE: &str = "minimumSecondsSinceUpdate"; pub const DOCUMENTS_COUNTABLE: &str = "documentsCountable"; pub const RANGE_COUNTABLE: &str = "rangeCountable"; /// Doctype-level flag naming the property whose values are summed into diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index 577cd9d9553..12ef6abbe3f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -11,6 +11,7 @@ use platform_serialization_derive::{ use crate::consensus::basic::decode::DecodingError; use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::accessors::v1::DataContractV1Getters; use crate::data_contract::config::moderation::ContractModerators; use crate::data_contract::config::v1::DataContractConfigGettersV1; use crate::data_contract::config::v2::DataContractConfigGettersV2; @@ -93,8 +94,9 @@ pub struct ByteArrayPropertySizes { /// Declared as `refersTo: { "type": "contract", "contractRequirements": { ... } }`: each key names an /// aspect of the referenced contract and its value the requirement on it. Consensus checks the /// requirements when the referring document is written, against the contract it has already -/// fetched for the existence check, so a requirement costs no further read. An unmet one -/// refuses the write with `ReferencedContractRequirementNotMetError` (40135). +/// fetched for the existence check and the block time of the write, so a requirement costs no +/// further read. An unmet one refuses the write with `ReferencedContractRequirementNotMetError` +/// (40135). #[derive( Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize, Encode, Decode, DecodeUntrusted, )] @@ -103,6 +105,17 @@ pub struct ContractReferenceRequirements { /// The moderation the referenced contract must declare. #[serde(default, skip_serializing_if = "Option::is_none")] pub moderation: Option, + /// How long, in seconds, the referenced contract must have existed when the referring + /// document is written: its recorded creation time plus this many seconds must not be + /// after the block time. A contract that never recorded a creation time does not meet it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minimum_age_seconds: Option, + /// How long, in seconds, the referenced contract must have been unchanged when the + /// referring document is written: the later of its recorded creation and last update + /// times plus this many seconds must not be after the block time. A contract that never + /// recorded a creation time does not meet it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minimum_seconds_since_update: Option, } /// The moderation a `contract` reference may require of the referenced contract. @@ -149,6 +162,8 @@ impl ContractReferenceModeration { #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum ContractReferenceRequirement { Moderation(ContractReferenceModeration), + MinimumAgeSeconds(u32), + MinimumSecondsSinceUpdate(u32), } impl ContractReferenceRequirement { @@ -156,13 +171,62 @@ impl ContractReferenceRequirement { pub fn field(&self) -> &'static str { match self { ContractReferenceRequirement::Moderation(_) => property_names::MODERATION, + ContractReferenceRequirement::MinimumAgeSeconds(_) => { + property_names::MINIMUM_AGE_SECONDS + } + ContractReferenceRequirement::MinimumSecondsSinceUpdate(_) => { + property_names::MINIMUM_SECONDS_SINCE_UPDATE + } } } /// The value the declaration requires, as spelled in the schema. - pub fn required(&self) -> &'static str { + pub fn required(&self) -> String { match self { - ContractReferenceRequirement::Moderation(moderation) => moderation.as_str(), + ContractReferenceRequirement::Moderation(moderation) => moderation.as_str().to_string(), + ContractReferenceRequirement::MinimumAgeSeconds(seconds) + | ContractReferenceRequirement::MinimumSecondsSinceUpdate(seconds) => { + seconds.to_string() + } + } + } + + /// Whether `contract` meets this requirement at `block_time_ms`, the time of the block + /// writing the referring document. + pub fn is_met_by(&self, contract: &DataContract, block_time_ms: TimestampMillis) -> bool { + match self { + ContractReferenceRequirement::Moderation(moderation) => moderation.is_met_by(contract), + ContractReferenceRequirement::MinimumAgeSeconds(seconds) => { + Self::minimum_age_is_met(contract.created_at(), *seconds, block_time_ms) + } + ContractReferenceRequirement::MinimumSecondsSinceUpdate(seconds) => { + Self::minimum_age_is_met(Self::last_change_time(contract), *seconds, block_time_ms) + } + } + } + + /// Whether something that happened at `since` is at least `minimum_seconds` in the past + /// at `block_time_ms`. A contract without the recorded time (one created before contracts + /// recorded it) is of unknown age and does not meet any minimum. + pub fn minimum_age_is_met( + since: Option, + minimum_seconds: u32, + block_time_ms: TimestampMillis, + ) -> bool { + let Some(since) = since else { + return false; + }; + let old_enough_at = + since.saturating_add(TimestampMillis::from(minimum_seconds).saturating_mul(1000)); + block_time_ms >= old_enough_at + } + + /// When `contract` last changed: its last update, or its creation for a contract never + /// updated. `None` when it recorded neither. + pub fn last_change_time(contract: &DataContract) -> Option { + match (contract.created_at(), contract.updated_at()) { + (Some(created_at), Some(updated_at)) => Some(created_at.max(updated_at)), + (created_at, updated_at) => updated_at.or(created_at), } } } @@ -171,6 +235,8 @@ impl ContractReferenceRequirements { /// Whether the declaration requires nothing beyond the contract's existence. pub fn is_empty(&self) -> bool { self.moderation.is_none() + && self.minimum_age_seconds.is_none() + && self.minimum_seconds_since_update.is_none() } /// The requirements, in declaration order. @@ -178,13 +244,27 @@ impl ContractReferenceRequirements { self.moderation .into_iter() .map(ContractReferenceRequirement::Moderation) + .chain( + self.minimum_age_seconds + .into_iter() + .map(ContractReferenceRequirement::MinimumAgeSeconds), + ) + .chain( + self.minimum_seconds_since_update + .into_iter() + .map(ContractReferenceRequirement::MinimumSecondsSinceUpdate), + ) } - /// The first requirement `contract` does not meet, `None` when it meets them all. - pub fn first_unmet_by(&self, contract: &DataContract) -> Option { - self.requirements().find(|requirement| match requirement { - ContractReferenceRequirement::Moderation(moderation) => !moderation.is_met_by(contract), - }) + /// The first requirement `contract` does not meet at `block_time_ms`, the time of the + /// block writing the referring document, `None` when it meets them all. + pub fn first_unmet_by( + &self, + contract: &DataContract, + block_time_ms: TimestampMillis, + ) -> Option { + self.requirements() + .find(|requirement| !requirement.is_met_by(contract, block_time_ms)) } } @@ -386,6 +466,12 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { if let Some(moderation) = contract_requirements.moderation { write!(f, " with {} moderation", moderation.as_str())?; } + if let Some(seconds) = contract_requirements.minimum_age_seconds { + write!(f, " at least {seconds} seconds old")?; + } + if let Some(seconds) = contract_requirements.minimum_seconds_since_update { + write!(f, " unchanged for at least {seconds} seconds")?; + } Ok(()) } DocumentPropertyReferenceTarget::Token => write!(f, "token"), @@ -7630,6 +7716,105 @@ mod tests { ); } + #[test] + fn should_meet_a_minimum_age_from_the_recorded_creation_time_at_the_block_time() { + let created_at: TimestampMillis = 1_700_000_000_000; + let one_hour_ms: TimestampMillis = 3_600_000; + // One millisecond short of the minimum is not old enough; the exact minimum is + assert!(!ContractReferenceRequirement::minimum_age_is_met( + Some(created_at), + 3600, + created_at + one_hour_ms - 1 + )); + assert!(ContractReferenceRequirement::minimum_age_is_met( + Some(created_at), + 3600, + created_at + one_hour_ms + )); + assert!(ContractReferenceRequirement::minimum_age_is_met( + Some(created_at), + 3600, + TimestampMillis::MAX + )); + // A contract that never recorded its creation time is of unknown age + assert!(!ContractReferenceRequirement::minimum_age_is_met( + None, + 1, + TimestampMillis::MAX + )); + // The bound saturates rather than wrapping around into the past + assert!(!ContractReferenceRequirement::minimum_age_is_met( + Some(TimestampMillis::MAX - 1), + u32::MAX, + TimestampMillis::MAX - 1 + )); + + let requirements = ContractReferenceRequirements { + moderation: None, + minimum_age_seconds: Some(3600), + minimum_seconds_since_update: Some(60), + }; + assert_eq!( + requirements.requirements().collect::>(), + vec![ + ContractReferenceRequirement::MinimumAgeSeconds(3600), + ContractReferenceRequirement::MinimumSecondsSinceUpdate(60) + ] + ); + assert_eq!( + ContractReferenceRequirement::MinimumAgeSeconds(3600).field(), + "minimumAgeSeconds" + ); + assert_eq!( + ContractReferenceRequirement::MinimumAgeSeconds(3600).required(), + "3600" + ); + assert_eq!( + ContractReferenceRequirement::MinimumSecondsSinceUpdate(60).field(), + "minimumSecondsSinceUpdate" + ); + assert_eq!( + ContractReferenceRequirement::MinimumSecondsSinceUpdate(60).required(), + "60" + ); + } + + #[test] + fn should_take_the_last_change_time_from_the_later_of_creation_and_update() { + use crate::data_contract::accessors::v1::DataContractV1Setters; + use crate::tests::fixtures::get_dashpay_contract_fixture; + + let platform_version = PlatformVersion::latest(); + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + + contract.set_created_at(None); + contract.set_updated_at(None); + assert_eq!( + ContractReferenceRequirement::last_change_time(&contract), + None + ); + + contract.set_created_at(Some(1_000)); + assert_eq!( + ContractReferenceRequirement::last_change_time(&contract), + Some(1_000) + ); + + contract.set_updated_at(Some(5_000)); + assert_eq!( + ContractReferenceRequirement::last_change_time(&contract), + Some(5_000) + ); + + // A recorded update alone counts as the last change + contract.set_created_at(None); + assert_eq!( + ContractReferenceRequirement::last_change_time(&contract), + Some(5_000) + ); + } + #[test] fn should_display_reference_targets() { let contract_id = Identifier::from([7u8; 32]); @@ -7649,11 +7834,35 @@ mod tests { DocumentPropertyReferenceTarget::Contract { contract_requirements: ContractReferenceRequirements { moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: None, + minimum_seconds_since_update: None, }, } .to_string(), "contract with elected moderation" ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: Some(604_800), + minimum_seconds_since_update: Some(86_400), + }, + } + .to_string(), + "contract with elected moderation at least 604800 seconds old unchanged for at least 86400 seconds" + ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: None, + minimum_age_seconds: Some(1), + minimum_seconds_since_update: None, + }, + } + .to_string(), + "contract at least 1 seconds old" + ); assert_eq!(DocumentPropertyReferenceTarget::Token.to_string(), "token"); assert_eq!( DocumentPropertyReferenceTarget::PermanentDocument { diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_contract_requirement_not_met_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_contract_requirement_not_met_error.rs index ed221ae81a5..3d6df7df369 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/referenced_contract_requirement_not_met_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_contract_requirement_not_met_error.rs @@ -22,7 +22,7 @@ use thiserror::Error; DecodeUntrusted, )] #[error( - "referenced contract {contract_id} for path {path} does not declare {field} {required}, which the reference requires" + "referenced contract {contract_id} for path {path} does not meet the reference's requirement {field} {required}" )] #[platform_serialize(unversioned)] pub struct ReferencedContractRequirementNotMetError { @@ -52,12 +52,14 @@ impl ReferencedContractRequirementNotMetError { &self.contract_id } - /// The `contractRequirements` key of the requirement, `moderation` for one + /// The `contractRequirements` key of the requirement: `moderation`, `minimumAgeSeconds` or + /// `minimumSecondsSinceUpdate` pub fn field(&self) -> &str { &self.field } - /// The value the reference requires, `elected` for one + /// The value the reference requires as the schema spells it, `elected` for a moderation + /// requirement, the number of seconds for a duration pub fn required(&self) -> &str { &self.required } diff --git a/packages/rs-dpp/src/validation/meta_validators/mod.rs b/packages/rs-dpp/src/validation/meta_validators/mod.rs index f02d0cc5add..01f257f877b 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -339,6 +339,50 @@ mod tests { } } + #[test] + fn should_accept_contract_requirements_on_a_contract_refers_to_in_v3_document_schema() { + for requirements in [ + json!({ "moderation": "elected" }), + json!({ "minimumAgeSeconds": 1 }), + json!({ "minimumAgeSeconds": 4294967295u64 }), + json!({ "minimumSecondsSinceUpdate": 86400 }), + json!({ "moderation": "elected", "minimumAgeSeconds": 604800, "minimumSecondsSinceUpdate": 86400 }), + ] { + let schema = document_schema_with_refers_to(json!({ + "type": "contract", + "contractRequirements": requirements + })); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_ok(), + "expected contractRequirements {requirements} to be valid" + ); + } + } + + #[test] + fn should_reject_malformed_contract_requirements_in_v3_document_schema() { + for refers_to in [ + json!({ "type": "contract", "contractRequirements": {} }), + json!({ "type": "contract", "contractRequirements": { "moderation": "appointed" } }), + json!({ "type": "contract", "contractRequirements": { "minimumAgeSeconds": 0 } }), + json!({ "type": "contract", "contractRequirements": { "minimumAgeSeconds": 4294967296u64 } }), + json!({ "type": "contract", "contractRequirements": { "minimumAgeSeconds": "3600" } }), + json!({ "type": "contract", "contractRequirements": { "minimumAgeSeconds": 1.5 } }), + json!({ "type": "contract", "contractRequirements": { "minimumSecondsSinceUpdate": 0 } }), + json!({ "type": "contract", "contractRequirements": { "minimumSecondsSinceUpdate": "60" } }), + json!({ "type": "contract", "contractRequirements": { "tokens": "any" } }), + json!({ "type": "identity", "contractRequirements": { "minimumAgeSeconds": 3600 } }), + ] { + let schema = document_schema_with_refers_to(refers_to.clone()); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_err(), + "expected refersTo {refers_to} to be invalid" + ); + } + } + #[test] fn should_accept_permanent_document_refers_to_in_v3_document_schema() { let schema = document_schema_with_refers_to(json!({ diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index dc0b2fec5a8..7c0f91bff2b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -304,16 +304,16 @@ fn validate_document_type_references_v0( None => false, Some(fetch_info) => { // The declaration's requirements are checked against the contract - // just fetched, so they cost no further read; the first unmet one - // refuses the write - if let Some(requirement) = - contract_requirements.first_unmet_by(&fetch_info.contract) + // just fetched and the block time, so they cost no further read; + // the first unmet one refuses the write + if let Some(requirement) = contract_requirements + .first_unmet_by(&fetch_info.contract, block_info.time_ms) { return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedContractRequirementNotMetError::new( Identifier::from(referenced_id), requirement.field().to_string(), - requirement.required().to_string(), + requirement.required(), path.to_string(), ) .into(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 382555a1187..ffafa84cb55 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -5303,6 +5303,20 @@ mod creation_tests { /// tests, and one that declares no moderation, so a reference to it is unmet. const REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_ID: &str = "9k3RE6kHNTsDmyXFwEPpiFQ3ipXfp5FuXGXpQ1rDHDJb"; + const REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-aged-contract-ref.json"; + /// The `id` of the aged-contract-reference fixture (`minimumAgeSeconds: 3600`): written to + /// state without a creation time, so a reference to it is unmet. + const REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_ID: &str = + "GbYWKJSr6P7fJqkAdSCNM5kuN2eBewAgYfrMJ22rfhrD"; + const REFERENCE_VALIDATION_UPDATED_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-updated-contract-ref.json"; + /// The block time the aged- and updated-contract-reference tests write the referring + /// document at + const AGED_REFERENCE_BLOCK_TIME_MS: u64 = 1_700_000_000_000; + /// The minimum age, and minimum time since the last update, the aged- and + /// updated-contract-reference fixtures declare, in milliseconds + const AGED_REFERENCE_MINIMUM_AGE_MS: u64 = 3_600_000; const REFERENCE_VALIDATION_TOKEN_REF_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-token-ref.json"; const REFERENCE_VALIDATION_OPTIONAL_CONTRACT_PATH: &str = @@ -5326,6 +5340,7 @@ mod creation_tests { { run_reference_validation_creation_with_setup_and_mutator( contract_path, + BlockInfo::default(), |_, _| Identifier::default(), |document, targets, _| mutator(document, targets), ) @@ -5334,9 +5349,10 @@ mod creation_tests { /// Like `run_reference_validation_creation_with_mutator`, with a `setup` step that writes /// whatever else the test needs into state before the contract, and hands the mutator an - /// id it produced (a contract's, say). + /// id it produced (a contract's, say). The document is written in a block of `block_info`. async fn run_reference_validation_creation_with_setup_and_mutator( contract_path: &str, + block_info: BlockInfo, setup: S, mutator: F, ) -> StateTransitionExecutionResult @@ -5434,7 +5450,7 @@ mod creation_tests { .process_raw_state_transitions( &[documents_batch_create_serialized_transition], &platform_state, - &BlockInfo::default(), + &block_info, &transaction, platform_version, false, @@ -5700,6 +5716,7 @@ mod creation_tests { async fn should_document_creation_succeed_when_referenced_contract_is_elected_moderated() { let result = run_reference_validation_creation_with_setup_and_mutator( REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), insert_elected_contract, |document, _, elected_contract_id| { document.set("refContractId", elected_contract_id.into()); @@ -5713,6 +5730,201 @@ mod creation_tests { ); } + /// A contract created at `created_at`, for a reference that requires a minimum age. It is + /// written to state directly, the way the fixtures are, with the creation time a contract + /// create transition would have recorded. + fn insert_contract_created_at( + created_at: Option, + ) -> impl FnOnce(&mut TempPlatform, &PlatformVersion) -> Identifier { + insert_contract_with_times(created_at, None) + } + + /// A contract created at `created_at` and last updated at `updated_at`, the times the + /// create and update transitions would have recorded, written to state directly. + fn insert_contract_with_times( + created_at: Option, + updated_at: Option, + ) -> impl FnOnce(&mut TempPlatform, &PlatformVersion) -> Identifier { + move |platform, _platform_version| { + use dpp::data_contract::accessors::v1::DataContractV1Setters; + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_CONTRACT_REF_CONTRACT_PATH, + Some([0xA6; 32]), + None, + Some(|contract: &mut DataContract| { + contract.set_created_at(created_at); + contract.set_updated_at(updated_at); + }), + None, + None, + ); + contract.id() + } + } + + fn aged_reference_block_info() -> BlockInfo { + BlockInfo { + time_ms: AGED_REFERENCE_BLOCK_TIME_MS, + ..Default::default() + } + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_contract_is_too_young() { + // Created one millisecond less than the minimum age before the block + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_contract_created_at(Some( + AGED_REFERENCE_BLOCK_TIME_MS - AGED_REFERENCE_MINIMUM_AGE_MS + 1, + )), + |document, _, young_contract_id| { + document.set("refContractId", young_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &Identifier::from([0xA6; 32]) + && e.field() == "minimumAgeSeconds" + && e.required() == "3600" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_contract_has_no_creation_time() { + // The fixture contract itself exists in state, written without a creation time, as a + // contract created before contracts recorded one would be: its age is unknown + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + |_, _| Identifier::default(), + |document, _, _| { + document.set("refContractId", existing_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &existing_contract_id + && e.field() == "minimumAgeSeconds" + && e.required() == "3600" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_contract_was_updated_too_recently() { + // Created long before the block, but updated one millisecond less than the minimum + // before it: the update restarts the clock + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_UPDATED_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_contract_with_times( + Some(AGED_REFERENCE_BLOCK_TIME_MS - 100 * AGED_REFERENCE_MINIMUM_AGE_MS), + Some(AGED_REFERENCE_BLOCK_TIME_MS - AGED_REFERENCE_MINIMUM_AGE_MS + 1), + ), + |document, _, updated_contract_id| { + document.set("refContractId", updated_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &Identifier::from([0xA6; 32]) + && e.field() == "minimumSecondsSinceUpdate" + && e.required() == "3600" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_referenced_contract_was_updated_long_enough_ago() + { + // Updated exactly the minimum before the block + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_UPDATED_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_contract_with_times( + Some(AGED_REFERENCE_BLOCK_TIME_MS - 100 * AGED_REFERENCE_MINIMUM_AGE_MS), + Some(AGED_REFERENCE_BLOCK_TIME_MS - AGED_REFERENCE_MINIMUM_AGE_MS), + ), + |document, _, updated_contract_id| { + document.set("refContractId", updated_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_never_updated_referenced_contract_is_old_enough() + { + // Never updated: the creation time is the last change + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_UPDATED_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_contract_created_at(Some( + AGED_REFERENCE_BLOCK_TIME_MS - AGED_REFERENCE_MINIMUM_AGE_MS, + )), + |document, _, old_contract_id| { + document.set("refContractId", old_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_referenced_contract_is_old_enough() { + // Created exactly the minimum age before the block + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_PATH, + aged_reference_block_info(), + insert_contract_created_at(Some( + AGED_REFERENCE_BLOCK_TIME_MS - AGED_REFERENCE_MINIMUM_AGE_MS, + )), + |document, _, old_contract_id| { + document.set("refContractId", old_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + #[tokio::test] async fn should_document_creation_succeed_with_nested_and_multiple_references() { let result = run_reference_validation_creation_with_mutator( diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-aged-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-aged-contract-ref.json new file mode 100644 index 00000000000..6e7e380ca8d --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-aged-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "GbYWKJSr6P7fJqkAdSCNM5kuN2eBewAgYfrMJ22rfhrD", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "refContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "contractRequirements": { + "minimumAgeSeconds": 3600 + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "refContractId" + ], + "indices": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-updated-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-updated-contract-ref.json new file mode 100644 index 00000000000..5c7d7490200 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-updated-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "6wUrQd8gVLTK7sHXLBXT5brjTxfMGjnSxw2Fddwr791g", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "refContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "contractRequirements": { + "minimumSecondsSinceUpdate": 3600 + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "refContractId" + ], + "indices": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 2dcdc60b4e3..530e2ff4eaa 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -526,14 +526,19 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// document id) for every resolution, where the shipped rule awarded the /// latest; DPNS contests ending from this version on follow the new rule. /// -/// 24. **Contract references may require elected moderation**: a `contract` -/// `refersTo` declaration may carry `contractRequirements`, what the referenced -/// contract must declare beyond existing, with `moderation: "elected"` as -/// the first requirement (meta-schema v3, `apply_property_reference` 0, +/// 24. **Contract references may require elected moderation, a minimum age or +/// a minimum time since the last update**: a `contract` `refersTo` +/// declaration may carry `contractRequirements`, what the referenced +/// contract must declare beyond existing, with `moderation: "elected"`, +/// `minimumAgeSeconds` (the contract's recorded creation time must be at +/// least that many seconds before the block time of the write) and +/// `minimumSecondsSinceUpdate` (the same of the later of its creation and +/// last update times; a contract without a recorded creation time never +/// meets either) as the requirements (meta-schema v3, `apply_property_reference` 0, /// `ContractReferenceRequirements` on `DocumentPropertyReferenceTarget::Contract`). -/// The document reference validation checks it against the contract it -/// fetched for the existence check, so it costs no further read, and -/// refuses an unmet requirement with +/// The document reference validation checks them against the contract it +/// fetched for the existence check and the block time, so they cost no +/// further read, and refuses the first unmet requirement with /// `ReferencedContractRequirementNotMetError` (40135). A changed /// `contractRequirements` is an incompatible schema change on update. /// diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index ae2be344027..00e207bef1d 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -51,9 +51,9 @@ pub enum DocumentReferenceErrorCodeWasm { /// reference; a `permanentDocument` reference is the one for a type /// declaring `canBeDeleted: false`. ReferencedDocumentTypeNotDeletable = 40131, - /// The referenced contract exists but does not declare what the - /// reference's `contractRequirements` require of it, elected moderation for - /// one. + /// The referenced contract exists but does not meet what the reference's + /// `contractRequirements` require of it: elected moderation, a minimum age + /// or a minimum time since its last update at the block time of the write. ReferencedContractRequirementNotMet = 40135, } diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index f359688bcf7..d5f8e8c411d 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -37,11 +37,19 @@ export type DocumentPropertyReferenceTarget = /** * What the referenced contract must declare beyond existing, checked * by consensus when the referring document is written against the - * contract fetched for the existence check: `moderation: 'elected'` - * requires an elected moderation team (code 40135 when unmet). - * Absent when the declaration carries no requirement. + * contract fetched for the existence check and the block time: + * `moderation: 'elected'` requires an elected moderation team, + * `minimumAgeSeconds` requires the contract's recorded creation time + * to be at least that many seconds before the block time of the write, + * and `minimumSecondsSinceUpdate` the same of the later of its creation + * and last update times (code 40135 when any is unmet). Absent when + * the declaration carries no requirement. */ - contractRequirements?: { moderation?: 'elected' }; + contractRequirements?: { + moderation?: 'elected'; + minimumAgeSeconds?: number; + minimumSecondsSinceUpdate?: number; + }; } | { type: 'token' } | { @@ -181,14 +189,32 @@ fn reference_to_js( } => { // Absent, not `{}`-valued, when the declaration requires nothing, // matching the schema's own omission. - if let Some(moderation) = contract_requirements.moderation { + if !contract_requirements.is_empty() { let fields = Object::new(); - set_field( - &fields, - "moderation", - &JsValue::from_str(moderation.as_str()), - path, - )?; + if let Some(moderation) = contract_requirements.moderation { + set_field( + &fields, + "moderation", + &JsValue::from_str(moderation.as_str()), + path, + )?; + } + if let Some(seconds) = contract_requirements.minimum_age_seconds { + set_field( + &fields, + "minimumAgeSeconds", + &JsValue::from_f64(f64::from(seconds)), + path, + )?; + } + if let Some(seconds) = contract_requirements.minimum_seconds_since_update { + set_field( + &fields, + "minimumSecondsSinceUpdate", + &JsValue::from_f64(f64::from(seconds)), + path, + )?; + } set_field(&object, "contractRequirements", &fields, path)?; } }