From 2aaa60c331cd7c18ddddfe14a04120b5adb6cfc1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 22 Sep 2026 22:09:47 +0700 Subject: [PATCH 1/2] feat(platform)!: contract references may require a minimum age of the referenced contract A `contract` refersTo declaration's `contractRequirements` gains `minimumAgeSeconds`: the referenced contract's recorded creation time plus that many seconds must not be after the block time of the write. It gives a contract's creation a delay before a charter can be posted against it, so a moderation team cannot be seated before anyone has seen the contract. A contract without a recorded creation time (the V0 format) never meets it. Checked in the document reference validation against the contract already fetched for the existence check, so it costs no further read; an unmet requirement refuses the write with the existing ReferencedContractRequirementNotMetError (40135), field `minimumAgeSeconds`, required = the number. Meta-schema v3 and the parser bound the value to 1..=4294967295; a changed value is an incompatible schema change on update. wasm-dpp2 reports it on the reference surface. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/contract-moderation.md | 2 +- .../document/v3/document-meta.json | 7 +- .../class_methods/try_from_schema/mod.rs | 76 +++++++++- .../methods/validate_update/common/mod.rs | 5 + .../src/data_contract/document_type/mod.rs | 1 + .../document_type/property/mod.rs | 143 ++++++++++++++++-- ...nced_contract_requirement_not_met_error.rs | 7 +- .../src/validation/meta_validators/mod.rs | 41 +++++ .../document_reference_validation/v0/mod.rs | 10 +- .../batch/tests/document/creation.rs | 128 +++++++++++++++- ...validation-contract-aged-contract-ref.json | 38 +++++ .../rs-platform-version/src/version/v14.rs | 17 ++- packages/wasm-dpp2/src/consensus_error.rs | 6 +- .../data_contract/document_type_reference.rs | 35 +++-- 14 files changed, 472 insertions(+), 44 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-aged-contract-ref.json diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 7eeabd3acaf..bf20e32c9f6 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"`, and `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; a contract created before contracts recorded their creation time never meets it). 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..c9510bf06a2 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,18 @@ "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 (a contract without a recorded creation time never meets it). An unmet requirement refuses the write (ReferencedContractRequirementNotMetError, 40135)", "type": "object", "properties": { "moderation": { "enum": [ "elected" ] + }, + "minimumAgeSeconds": { + "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..05842436705 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,21 @@ fn parse_contract_reference_requirements( )) })?); } + property_names::MINIMUM_AGE_SECONDS => { + let seconds: u32 = value.to_integer().map_err(|_| { + DataContractError::InvalidContractStructure( + "contract refersTo contractRequirements minimumAgeSeconds must be an integer from 1 to 4294967295" + .to_string(), + ) + })?; + if seconds == 0 { + return Err(DataContractError::InvalidContractStructure( + "contract refersTo contractRequirements minimumAgeSeconds must be at least 1" + .to_string(), + )); + } + fields.minimum_age_seconds = Some(seconds); + } other => { return Err(DataContractError::InvalidContractStructure(format!( "contract refersTo contractRequirements {other:?} is unknown" @@ -1092,12 +1108,70 @@ mod tests { DocumentPropertyReferenceTarget::Contract { contract_requirements: ContractReferenceRequirements { moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: None, + }, + } + ) + ); + } + + #[test] + fn should_parse_contract_refers_to_requiring_a_minimum_age() { + 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), + }, + } + ) + ); + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { + "moderation": "elected", + "minimumAgeSeconds": u32::MAX + } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: Some(u32::MAX), }, } ) ); } + #[test] + fn should_reject_a_minimum_age_that_is_zero_negative_too_large_or_not_an_integer() { + 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": { "minimumAgeSeconds": 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), + "{refers_to}: expected {fragment:?}, got {err}" + ); + } + } + #[test] fn should_reject_contract_requirements_that_are_empty_unknown_or_on_another_type() { for (refers_to, fragment) in [ 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..99f0dc94df4 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,11 @@ 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", + ), ] { 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..c0555f841d4 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,7 @@ 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 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..c1d4819aa97 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,11 @@ 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, } /// The moderation a `contract` reference may require of the referenced contract. @@ -149,6 +156,7 @@ impl ContractReferenceModeration { #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum ContractReferenceRequirement { Moderation(ContractReferenceModeration), + MinimumAgeSeconds(u32), } impl ContractReferenceRequirement { @@ -156,21 +164,52 @@ impl ContractReferenceRequirement { pub fn field(&self) -> &'static str { match self { ContractReferenceRequirement::Moderation(_) => property_names::MODERATION, + ContractReferenceRequirement::MinimumAgeSeconds(_) => { + property_names::MINIMUM_AGE_SECONDS + } } } /// 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().to_string(), + ContractReferenceRequirement::MinimumAgeSeconds(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.as_str(), + ContractReferenceRequirement::Moderation(moderation) => moderation.is_met_by(contract), + ContractReferenceRequirement::MinimumAgeSeconds(seconds) => { + Self::minimum_age_is_met(contract.created_at(), *seconds, block_time_ms) + } } } + + /// Whether a contract created at `created_at` is at least `minimum_age_seconds` old at + /// `block_time_ms`. A contract without a recorded creation time (one created before + /// contracts recorded it) is of unknown age and does not meet any minimum. + pub fn minimum_age_is_met( + created_at: Option, + minimum_age_seconds: u32, + block_time_ms: TimestampMillis, + ) -> bool { + let Some(created_at) = created_at else { + return false; + }; + let old_enough_at = created_at + .saturating_add(TimestampMillis::from(minimum_age_seconds).saturating_mul(1000)); + block_time_ms >= old_enough_at + } } impl ContractReferenceRequirements { /// Whether the declaration requires nothing beyond the contract's existence. pub fn is_empty(&self) -> bool { - self.moderation.is_none() + self.moderation.is_none() && self.minimum_age_seconds.is_none() } /// The requirements, in declaration order. @@ -178,13 +217,22 @@ impl ContractReferenceRequirements { self.moderation .into_iter() .map(ContractReferenceRequirement::Moderation) + .chain( + self.minimum_age_seconds + .into_iter() + .map(ContractReferenceRequirement::MinimumAgeSeconds), + ) } - /// 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 +434,9 @@ 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")?; + } Ok(()) } DocumentPropertyReferenceTarget::Token => write!(f, "token"), @@ -7630,6 +7681,57 @@ 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), + }; + assert_eq!( + requirements.requirements().collect::>(), + vec![ContractReferenceRequirement::MinimumAgeSeconds(3600)] + ); + assert_eq!( + ContractReferenceRequirement::MinimumAgeSeconds(3600).field(), + "minimumAgeSeconds" + ); + assert_eq!( + ContractReferenceRequirement::MinimumAgeSeconds(3600).required(), + "3600" + ); + } + #[test] fn should_display_reference_targets() { let contract_id = Identifier::from([7u8; 32]); @@ -7649,11 +7751,32 @@ mod tests { DocumentPropertyReferenceTarget::Contract { contract_requirements: ContractReferenceRequirements { moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: None, }, } .to_string(), "contract with elected moderation" ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: Some(604_800), + }, + } + .to_string(), + "contract with elected moderation at least 604800 seconds old" + ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: None, + minimum_age_seconds: Some(1), + }, + } + .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..2c9f20a9278 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,13 @@ impl ReferencedContractRequirementNotMetError { &self.contract_id } - /// The `contractRequirements` key of the requirement, `moderation` for one + /// The `contractRequirements` key of the requirement, `moderation` or `minimumAgeSeconds` 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 minimum age 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..4ca2247d845 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -339,6 +339,47 @@ 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!({ "moderation": "elected", "minimumAgeSeconds": 604800 }), + ] { + 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": { "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..026cf1feb42 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,16 @@ 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"; + /// The block time the aged-contract-reference tests write the referring document at + const AGED_REFERENCE_BLOCK_TIME_MS: u64 = 1_700_000_000_000; + /// The minimum age the aged-contract-reference fixture declares, 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 +5336,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 +5345,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 +5446,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 +5712,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 +5726,117 @@ 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 { + 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); + }), + 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_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-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 2dcdc60b4e3..20015c6f2be 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -526,14 +526,17 @@ 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 or a minimum age**: +/// a `contract` `refersTo` declaration may carry `contractRequirements`, +/// what the referenced contract must declare beyond existing, with +/// `moderation: "elected"` and `minimumAgeSeconds` (the contract's recorded +/// creation time must be at least that many seconds before the block time +/// of the write; a contract without a recorded creation time never meets +/// it) 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..345bdc9883d 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, or a minimum + /// age 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..4ef9cf1f2b8 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,14 @@ 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, and + * `minimumAgeSeconds` requires the contract's recorded creation time + * to be at least that many seconds before the block time of the write + * (code 40135 when either is unmet). Absent when the declaration + * carries no requirement. */ - contractRequirements?: { moderation?: 'elected' }; + contractRequirements?: { moderation?: 'elected'; minimumAgeSeconds?: number }; } | { type: 'token' } | { @@ -181,14 +184,24 @@ 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, + )?; + } set_field(&object, "contractRequirements", &fields, path)?; } } From 9274220ffcc10efd33d1a9ad27fdb46d5fbd5ac2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 22 Sep 2026 22:23:11 +0700 Subject: [PATCH 2/2] feat(platform)!: contract references may require a minimum time since the referenced contract's last update `contractRequirements` gains `minimumSecondsSinceUpdate`, measured from the later of the referenced contract's recorded creation and last update times to the block time of the write. It closes the gap the creation delay leaves: an old contract updated to declare elected moderation would otherwise take its owner's charter in the next block. Any update restarts the clock; a contract never updated counts from its creation; one without a recorded creation time never meets it. Same declaration bounds (1..=u32::MAX), the same 40135 error with the field naming the requirement, and the same wasm-dpp2 surface. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/contract-moderation.md | 2 +- .../document/v3/document-meta.json | 7 +- .../class_methods/try_from_schema/mod.rs | 96 +++++++++------ .../methods/validate_update/common/mod.rs | 5 + .../src/data_contract/document_type/mod.rs | 1 + .../document_type/property/mod.rs | 110 ++++++++++++++++-- ...nced_contract_requirement_not_met_error.rs | 5 +- .../src/validation/meta_validators/mod.rs | 5 +- .../batch/tests/document/creation.rs | 92 ++++++++++++++- ...idation-contract-updated-contract-ref.json | 38 ++++++ .../rs-platform-version/src/version/v14.rs | 16 +-- packages/wasm-dpp2/src/consensus_error.rs | 4 +- .../data_contract/document_type_reference.rs | 23 +++- 13 files changed, 338 insertions(+), 66 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-updated-contract-ref.json diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index bf20e32c9f6..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 or a bound: `moderation: "elected"`, and `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; a contract created before contracts recorded their creation time never meets it). 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. +**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 c9510bf06a2..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,7 +133,7 @@ "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 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 (a contract without a recorded creation time never meets it). 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": { @@ -145,6 +145,11 @@ "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 05842436705..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 @@ -558,19 +558,11 @@ fn parse_contract_reference_requirements( })?); } property_names::MINIMUM_AGE_SECONDS => { - let seconds: u32 = value.to_integer().map_err(|_| { - DataContractError::InvalidContractStructure( - "contract refersTo contractRequirements minimumAgeSeconds must be an integer from 1 to 4294967295" - .to_string(), - ) - })?; - if seconds == 0 { - return Err(DataContractError::InvalidContractStructure( - "contract refersTo contractRequirements minimumAgeSeconds must be at least 1" - .to_string(), - )); - } - fields.minimum_age_seconds = Some(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!( @@ -582,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::*; @@ -1109,6 +1117,7 @@ mod tests { contract_requirements: ContractReferenceRequirements { moderation: Some(ContractReferenceModeration::Elected), minimum_age_seconds: None, + minimum_seconds_since_update: None, }, } ) @@ -1116,7 +1125,7 @@ mod tests { } #[test] - fn should_parse_contract_refers_to_requiring_a_minimum_age() { + fn should_parse_contract_refers_to_requiring_a_minimum_age_or_time_since_update() { assert_eq!( contract_reference_target(json!({ "type": "contract", @@ -1127,6 +1136,22 @@ mod tests { 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), }, } ) @@ -1136,7 +1161,8 @@ mod tests { "type": "contract", "contractRequirements": { "moderation": "elected", - "minimumAgeSeconds": u32::MAX + "minimumAgeSeconds": u32::MAX, + "minimumSecondsSinceUpdate": 1 } })), DocumentPropertyType::IdentifierWithReference( @@ -1144,6 +1170,7 @@ mod tests { contract_requirements: ContractReferenceRequirements { moderation: Some(ContractReferenceModeration::Elected), minimum_age_seconds: Some(u32::MAX), + minimum_seconds_since_update: Some(1), }, } ) @@ -1151,24 +1178,27 @@ mod tests { } #[test] - fn should_reject_a_minimum_age_that_is_zero_negative_too_large_or_not_an_integer() { - 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": { "minimumAgeSeconds": 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), - "{refers_to}: expected {fragment:?}, got {err}" - ); + 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}" + ); + } } } 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 99f0dc94df4..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 @@ -2016,6 +2016,11 @@ mod tests { 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 c0555f841d4..5d19f4f47f3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -110,6 +110,7 @@ pub(crate) mod property_names { 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 c1d4819aa97..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 @@ -110,6 +110,12 @@ pub struct ContractReferenceRequirements { /// 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. @@ -157,6 +163,7 @@ impl ContractReferenceModeration { pub enum ContractReferenceRequirement { Moderation(ContractReferenceModeration), MinimumAgeSeconds(u32), + MinimumSecondsSinceUpdate(u32), } impl ContractReferenceRequirement { @@ -167,6 +174,9 @@ impl ContractReferenceRequirement { ContractReferenceRequirement::MinimumAgeSeconds(_) => { property_names::MINIMUM_AGE_SECONDS } + ContractReferenceRequirement::MinimumSecondsSinceUpdate(_) => { + property_names::MINIMUM_SECONDS_SINCE_UPDATE + } } } @@ -174,7 +184,10 @@ impl ContractReferenceRequirement { pub fn required(&self) -> String { match self { ContractReferenceRequirement::Moderation(moderation) => moderation.as_str().to_string(), - ContractReferenceRequirement::MinimumAgeSeconds(seconds) => seconds.to_string(), + ContractReferenceRequirement::MinimumAgeSeconds(seconds) + | ContractReferenceRequirement::MinimumSecondsSinceUpdate(seconds) => { + seconds.to_string() + } } } @@ -186,30 +199,44 @@ impl ContractReferenceRequirement { 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 a contract created at `created_at` is at least `minimum_age_seconds` old at - /// `block_time_ms`. A contract without a recorded creation time (one created before - /// contracts recorded it) is of unknown age and does not meet any minimum. + /// 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( - created_at: Option, - minimum_age_seconds: u32, + since: Option, + minimum_seconds: u32, block_time_ms: TimestampMillis, ) -> bool { - let Some(created_at) = created_at else { + let Some(since) = since else { return false; }; - let old_enough_at = created_at - .saturating_add(TimestampMillis::from(minimum_age_seconds).saturating_mul(1000)); + 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), + } + } } 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.moderation.is_none() + && self.minimum_age_seconds.is_none() + && self.minimum_seconds_since_update.is_none() } /// The requirements, in declaration order. @@ -222,6 +249,11 @@ impl ContractReferenceRequirements { .into_iter() .map(ContractReferenceRequirement::MinimumAgeSeconds), ) + .chain( + self.minimum_seconds_since_update + .into_iter() + .map(ContractReferenceRequirement::MinimumSecondsSinceUpdate), + ) } /// The first requirement `contract` does not meet at `block_time_ms`, the time of the @@ -437,6 +469,9 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { 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"), @@ -7717,10 +7752,14 @@ mod tests { 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)] + vec![ + ContractReferenceRequirement::MinimumAgeSeconds(3600), + ContractReferenceRequirement::MinimumSecondsSinceUpdate(60) + ] ); assert_eq!( ContractReferenceRequirement::MinimumAgeSeconds(3600).field(), @@ -7730,6 +7769,50 @@ mod tests { 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] @@ -7752,6 +7835,7 @@ mod tests { contract_requirements: ContractReferenceRequirements { moderation: Some(ContractReferenceModeration::Elected), minimum_age_seconds: None, + minimum_seconds_since_update: None, }, } .to_string(), @@ -7762,16 +7846,18 @@ mod tests { 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" + "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(), 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 2c9f20a9278..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 @@ -52,13 +52,14 @@ impl ReferencedContractRequirementNotMetError { &self.contract_id } - /// The `contractRequirements` key of the requirement, `moderation` or `minimumAgeSeconds` + /// The `contractRequirements` key of the requirement: `moderation`, `minimumAgeSeconds` or + /// `minimumSecondsSinceUpdate` pub fn field(&self) -> &str { &self.field } /// The value the reference requires as the schema spells it, `elected` for a moderation - /// requirement, the number of seconds for a minimum age + /// 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 4ca2247d845..01f257f877b 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -345,7 +345,8 @@ mod tests { json!({ "moderation": "elected" }), json!({ "minimumAgeSeconds": 1 }), json!({ "minimumAgeSeconds": 4294967295u64 }), - json!({ "moderation": "elected", "minimumAgeSeconds": 604800 }), + json!({ "minimumSecondsSinceUpdate": 86400 }), + json!({ "moderation": "elected", "minimumAgeSeconds": 604800, "minimumSecondsSinceUpdate": 86400 }), ] { let schema = document_schema_with_refers_to(json!({ "type": "contract", @@ -368,6 +369,8 @@ mod tests { 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 } }), ] { 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 026cf1feb42..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 @@ -5309,9 +5309,13 @@ mod creation_tests { /// state without a creation time, so a reference to it is unmet. const REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_ID: &str = "GbYWKJSr6P7fJqkAdSCNM5kuN2eBewAgYfrMJ22rfhrD"; - /// The block time the aged-contract-reference tests write the referring document at + 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 the aged-contract-reference fixture declares, in milliseconds + /// 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"; @@ -5731,6 +5735,15 @@ mod creation_tests { /// 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; @@ -5742,6 +5755,7 @@ mod creation_tests { None, Some(|contract: &mut DataContract| { contract.set_created_at(created_at); + contract.set_updated_at(updated_at); }), None, None, @@ -5816,6 +5830,80 @@ mod creation_tests { ); } + #[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 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 20015c6f2be..530e2ff4eaa 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -526,13 +526,15 @@ 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 or a minimum age**: -/// a `contract` `refersTo` declaration may carry `contractRequirements`, -/// what the referenced contract must declare beyond existing, with -/// `moderation: "elected"` and `minimumAgeSeconds` (the contract's recorded -/// creation time must be at least that many seconds before the block time -/// of the write; a contract without a recorded creation time never meets -/// it) as the requirements (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 them against the contract it /// fetched for the existence check and the block time, so they cost no diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 345bdc9883d..00e207bef1d 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -52,8 +52,8 @@ pub enum DocumentReferenceErrorCodeWasm { /// declaring `canBeDeleted: false`. ReferencedDocumentTypeNotDeletable = 40131, /// The referenced contract exists but does not meet what the reference's - /// `contractRequirements` require of it: elected moderation, or a minimum - /// age at the block time of the write. + /// `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 4ef9cf1f2b8..d5f8e8c411d 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -38,13 +38,18 @@ 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 and the block time: - * `moderation: 'elected'` requires an elected moderation team, and + * `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 - * (code 40135 when either is unmet). Absent when the declaration - * carries no requirement. + * 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'; minimumAgeSeconds?: number }; + contractRequirements?: { + moderation?: 'elected'; + minimumAgeSeconds?: number; + minimumSecondsSinceUpdate?: number; + }; } | { type: 'token' } | { @@ -202,6 +207,14 @@ fn reference_to_js( 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)?; } }