From e453d9e3e69f562d9c8b124d18aa0ee2887818a0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 22 Sep 2026 22:47:45 +0700 Subject: [PATCH 1/2] feat(platform)!: contract references may require the referenced contract's owner relation to the writer `contractRequirements` on a `contract` `refersTo` gains `owner`: `"self"` requires the referenced contract to be owned by the `$ownerId` of the referring document (the transition's owner), a write gate like the `$ownerId` property agreement of a document reference; `"other"` requires it not to be. The check runs against the contract already fetched for the existence check, so it costs no further read, and refuses the write with the existing `ReferencedContractRequirementNotMetError` (40135), field `owner`, required `self` or `other`. The requirements are now checked against a `ReferringWrite` (the writer's id and the block time) instead of the block time alone. Meta-schema v3, the parser, the wasm-dpp2 surface, the v14 changelog and the book are extended in place, protocol version 14 being unreleased. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/contract-moderation.md | 2 +- .../document/v3/document-meta.json | 8 +- .../class_methods/try_from_schema/mod.rs | 71 +++++- .../methods/validate_update/common/mod.rs | 5 + .../src/data_contract/document_type/mod.rs | 1 + .../document_type/property/mod.rs | 203 ++++++++++++++++-- ...nced_contract_requirement_not_met_error.rs | 6 +- .../src/validation/meta_validators/mod.rs | 6 + .../document_reference_validation/v0/mod.rs | 14 +- .../batch/tests/document/creation.rs | 153 ++++++++++++- ...ion-contract-owner-other-contract-ref.json | 38 ++++ ...tion-contract-owner-self-contract-ref.json | 38 ++++ .../rs-platform-version/src/version/v14.rs | 29 +-- packages/wasm-dpp2/src/consensus_error.rs | 3 +- .../data_contract/document_type_reference.rs | 8 +- 15 files changed, 535 insertions(+), 50 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-other-contract-ref.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-self-contract-ref.json diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 5ce4e5b6e56..3a7b95e5890 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"`; `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. +**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); `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); and `owner`, `"self"` requiring the referenced contract to be owned by the writer of the referring document (its `$ownerId`, a write gate like the `$ownerId` property agreement of a document reference) and `"other"` by anyone else (so a charter may forbid an owner from chartering its own team). 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 write itself (its owner and 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 54b86552727..36a4c391e40 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, 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)", + "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); owner \"self\" requires the contract to be owned by the writer of the referring document (its $ownerId), \"other\" by anyone else. An unmet requirement refuses the write (ReferencedContractRequirementNotMetError, 40135)", "type": "object", "properties": { "moderation": { @@ -150,6 +150,12 @@ "type": "integer", "minimum": 1, "maximum": 4294967295 + }, + "owner": { + "enum": [ + "self", + "other" + ] } }, "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 672b4feb1ae..f1be161e903 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 @@ -4,8 +4,9 @@ use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; use crate::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, - property_names, ContractReferenceModeration, ContractReferenceRequirements, DocumentProperty, - DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, + property_names, ContractReferenceModeration, ContractReferenceOwner, + ContractReferenceRequirements, DocumentProperty, DocumentPropertyReferenceTarget, + DocumentPropertyType, DocumentType, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; @@ -526,8 +527,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 (`moderation`), or a bound on it (`minimumAgeSeconds`), -/// at least one when the object is given at all. +/// contract with a closed set of values (`moderation`, `owner`), 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 { @@ -564,6 +565,18 @@ fn parse_contract_reference_requirements( fields.minimum_seconds_since_update = Some(parse_contract_reference_seconds(&field, value)?); } + property_names::OWNER => { + let name = value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "contract refersTo contractRequirements owner must be a string".to_string(), + ) + })?; + fields.owner = Some(ContractReferenceOwner::from_wire_name(name).ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements owner {name:?} is unknown, expected \"self\" or \"other\"" + )) + })?); + } other => { return Err(DataContractError::InvalidContractStructure(format!( "contract refersTo contractRequirements {other:?} is unknown" @@ -1118,6 +1131,7 @@ mod tests { moderation: Some(ContractReferenceModeration::Elected), minimum_age_seconds: None, minimum_seconds_since_update: None, + owner: None, }, } ) @@ -1137,6 +1151,7 @@ mod tests { moderation: None, minimum_age_seconds: Some(604_800), minimum_seconds_since_update: None, + owner: None, }, } ) @@ -1152,6 +1167,7 @@ mod tests { moderation: None, minimum_age_seconds: None, minimum_seconds_since_update: Some(86_400), + owner: None, }, } ) @@ -1171,6 +1187,45 @@ mod tests { moderation: Some(ContractReferenceModeration::Elected), minimum_age_seconds: Some(u32::MAX), minimum_seconds_since_update: Some(1), + owner: None, + }, + } + ) + ); + } + + #[test] + fn should_parse_contract_refers_to_requiring_an_owner_relation() { + for (name, owner) in [ + ("self", ContractReferenceOwner::Writer), + ("other", ContractReferenceOwner::Other), + ] { + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { "owner": name } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + owner: Some(owner), + ..Default::default() + }, + } + ) + ); + } + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { "moderation": "elected", "owner": "other" } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + owner: Some(ContractReferenceOwner::Other), + ..Default::default() }, } ) @@ -1221,6 +1276,14 @@ mod tests { json!({ "type": "contract", "contractRequirements": { "tokens": "any" } }), "is unknown", ), + ( + json!({ "type": "contract", "contractRequirements": { "owner": "anyone" } }), + "owner \"anyone\" is unknown, expected \"self\" or \"other\"", + ), + ( + json!({ "type": "contract", "contractRequirements": { "owner": true } }), + "owner must be a string", + ), ( json!({ "type": "identity", "contractRequirements": { "moderation": "elected" } }), "does not take contractRequirements", 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 fb1fbf67dc1..433a6f8dd1d 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 @@ -2021,6 +2021,11 @@ mod tests { platform_value!({ "type": "contract", "contractRequirements": { "minimumSecondsSinceUpdate": 61 } }), "/properties/toUserId/refersTo/contractRequirements/minimumSecondsSinceUpdate", ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "owner": "self" } }), + platform_value!({ "type": "contract", "contractRequirements": { "owner": "other" } }), + "/properties/toUserId/refersTo/contractRequirements/owner", + ), ] { 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 5d19f4f47f3..0347b09d60b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -111,6 +111,7 @@ pub(crate) mod property_names { pub const MODERATION: &str = "moderation"; pub const MINIMUM_AGE_SECONDS: &str = "minimumAgeSeconds"; pub const MINIMUM_SECONDS_SINCE_UPDATE: &str = "minimumSecondsSinceUpdate"; + pub const OWNER: &str = "owner"; 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 12ef6abbe3f..12a166d6d5f 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 @@ -94,9 +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 and the block time of the write, so a requirement costs no -/// further read. An unmet one refuses the write with `ReferencedContractRequirementNotMetError` -/// (40135). +/// fetched for the existence check and the write itself (its owner and block time), 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, )] @@ -116,6 +116,62 @@ pub struct ContractReferenceRequirements { /// recorded a creation time does not meet it. #[serde(default, skip_serializing_if = "Option::is_none")] pub minimum_seconds_since_update: Option, + /// Who must own the referenced contract, relative to the writer of the referring document. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, +} + +/// The write of a referring document, what a contract reference's requirements are checked +/// against beside the referenced contract itself. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct ReferringWrite { + /// The `$ownerId` of the referring document: the owner of the transition writing it. + pub owner_id: Identifier, + /// The time of the block writing it. + pub block_time_ms: TimestampMillis, +} + +/// Who a `contract` reference may require to own the referenced contract, relative to the +/// writer of the referring document. +#[derive( + Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Encode, Decode, DecodeUntrusted, +)] +pub enum ContractReferenceOwner { + /// The writer itself: the contract's owner is the `$ownerId` of the referring document, + /// a write gate like the `$ownerId` property agreement of a document reference. + #[serde(rename = "self")] + Writer, + /// Anyone but the writer. + #[serde(rename = "other")] + Other, +} + +impl ContractReferenceOwner { + /// The wire name, the value of `contractRequirements.owner`. + pub fn as_str(&self) -> &'static str { + match self { + ContractReferenceOwner::Writer => "self", + ContractReferenceOwner::Other => "other", + } + } + + /// The owner relation a wire name names, `None` for any other name. + pub fn from_wire_name(name: &str) -> Option { + match name { + "self" => Some(ContractReferenceOwner::Writer), + "other" => Some(ContractReferenceOwner::Other), + _ => None, + } + } + + /// Whether `contract`, owned as it is, meets this for a referring document owned by + /// `writer_id`. + pub fn is_met_by(&self, contract: &DataContract, writer_id: &Identifier) -> bool { + match self { + ContractReferenceOwner::Writer => contract.owner_id() == *writer_id, + ContractReferenceOwner::Other => contract.owner_id() != *writer_id, + } + } } /// The moderation a `contract` reference may require of the referenced contract. @@ -164,6 +220,7 @@ pub enum ContractReferenceRequirement { Moderation(ContractReferenceModeration), MinimumAgeSeconds(u32), MinimumSecondsSinceUpdate(u32), + Owner(ContractReferenceOwner), } impl ContractReferenceRequirement { @@ -177,6 +234,7 @@ impl ContractReferenceRequirement { ContractReferenceRequirement::MinimumSecondsSinceUpdate(_) => { property_names::MINIMUM_SECONDS_SINCE_UPDATE } + ContractReferenceRequirement::Owner(_) => property_names::OWNER, } } @@ -188,19 +246,27 @@ impl ContractReferenceRequirement { | ContractReferenceRequirement::MinimumSecondsSinceUpdate(seconds) => { seconds.to_string() } + ContractReferenceRequirement::Owner(owner) => owner.as_str().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 { + /// Whether `contract` meets this requirement for `write`, the write of the referring + /// document. + pub fn is_met_by(&self, contract: &DataContract, write: ReferringWrite) -> 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) + Self::minimum_age_is_met(contract.created_at(), *seconds, write.block_time_ms) } ContractReferenceRequirement::MinimumSecondsSinceUpdate(seconds) => { - Self::minimum_age_is_met(Self::last_change_time(contract), *seconds, block_time_ms) + Self::minimum_age_is_met( + Self::last_change_time(contract), + *seconds, + write.block_time_ms, + ) + } + ContractReferenceRequirement::Owner(owner) => { + owner.is_met_by(contract, &write.owner_id) } } } @@ -237,6 +303,7 @@ impl ContractReferenceRequirements { self.moderation.is_none() && self.minimum_age_seconds.is_none() && self.minimum_seconds_since_update.is_none() + && self.owner.is_none() } /// The requirements, in declaration order. @@ -254,17 +321,22 @@ impl ContractReferenceRequirements { .into_iter() .map(ContractReferenceRequirement::MinimumSecondsSinceUpdate), ) + .chain( + self.owner + .into_iter() + .map(ContractReferenceRequirement::Owner), + ) } - /// 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. + /// The first requirement `contract` does not meet for `write`, the write of the referring + /// document, `None` when it meets them all. pub fn first_unmet_by( &self, contract: &DataContract, - block_time_ms: TimestampMillis, + write: ReferringWrite, ) -> Option { self.requirements() - .find(|requirement| !requirement.is_met_by(contract, block_time_ms)) + .find(|requirement| !requirement.is_met_by(contract, write)) } } @@ -472,6 +544,11 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { if let Some(seconds) = contract_requirements.minimum_seconds_since_update { write!(f, " unchanged for at least {seconds} seconds")?; } + match contract_requirements.owner { + Some(ContractReferenceOwner::Writer) => write!(f, " owned by the writer")?, + Some(ContractReferenceOwner::Other) => write!(f, " not owned by the writer")?, + None => {} + } Ok(()) } DocumentPropertyReferenceTarget::Token => write!(f, "token"), @@ -7753,6 +7830,7 @@ mod tests { moderation: None, minimum_age_seconds: Some(3600), minimum_seconds_since_update: Some(60), + owner: None, }; assert_eq!( requirements.requirements().collect::>(), @@ -7779,6 +7857,83 @@ mod tests { ); } + #[test] + fn should_meet_an_owner_requirement_from_the_referenced_contract_owner_and_the_writer() { + use crate::tests::fixtures::get_dashpay_contract_fixture; + + let platform_version = PlatformVersion::latest(); + let contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + let owner_id = contract.owner_id(); + let someone_else = Identifier::from([0x42; 32]); + assert_ne!(owner_id, someone_else); + + assert!(ContractReferenceOwner::Writer.is_met_by(&contract, &owner_id)); + assert!(!ContractReferenceOwner::Writer.is_met_by(&contract, &someone_else)); + assert!(!ContractReferenceOwner::Other.is_met_by(&contract, &owner_id)); + assert!(ContractReferenceOwner::Other.is_met_by(&contract, &someone_else)); + + assert_eq!(ContractReferenceOwner::Writer.as_str(), "self"); + assert_eq!(ContractReferenceOwner::Other.as_str(), "other"); + assert_eq!( + ContractReferenceOwner::from_wire_name("self"), + Some(ContractReferenceOwner::Writer) + ); + assert_eq!( + ContractReferenceOwner::from_wire_name("other"), + Some(ContractReferenceOwner::Other) + ); + assert_eq!(ContractReferenceOwner::from_wire_name("owner"), None); + + let requirement = ContractReferenceRequirement::Owner(ContractReferenceOwner::Writer); + assert_eq!(requirement.field(), "owner"); + assert_eq!(requirement.required(), "self"); + assert_eq!( + ContractReferenceRequirement::Owner(ContractReferenceOwner::Other).required(), + "other" + ); + + // The first unmet requirement is reported in declaration order: an owner requirement + // is checked after the moderation and duration ones + let requirements = ContractReferenceRequirements { + owner: Some(ContractReferenceOwner::Other), + ..Default::default() + }; + assert_eq!( + requirements.requirements().collect::>(), + vec![ContractReferenceRequirement::Owner( + ContractReferenceOwner::Other + )] + ); + let by_owner = ReferringWrite { + owner_id, + block_time_ms: 0, + }; + let by_someone_else = ReferringWrite { + owner_id: someone_else, + block_time_ms: 0, + }; + assert_eq!( + requirements.first_unmet_by(&contract, by_owner), + Some(ContractReferenceRequirement::Owner( + ContractReferenceOwner::Other + )) + ); + assert_eq!( + requirements.first_unmet_by(&contract, by_someone_else), + None + ); + let both = ContractReferenceRequirements { + minimum_age_seconds: Some(1), + owner: Some(ContractReferenceOwner::Other), + ..Default::default() + }; + assert_eq!( + both.first_unmet_by(&contract, by_owner), + Some(ContractReferenceRequirement::MinimumAgeSeconds(1)) + ); + } + #[test] fn should_take_the_last_change_time_from_the_later_of_creation_and_update() { use crate::data_contract::accessors::v1::DataContractV1Setters; @@ -7836,6 +7991,7 @@ mod tests { moderation: Some(ContractReferenceModeration::Elected), minimum_age_seconds: None, minimum_seconds_since_update: None, + owner: None, }, } .to_string(), @@ -7847,6 +8003,7 @@ mod tests { moderation: Some(ContractReferenceModeration::Elected), minimum_age_seconds: Some(604_800), minimum_seconds_since_update: Some(86_400), + owner: None, }, } .to_string(), @@ -7858,11 +8015,33 @@ mod tests { moderation: None, minimum_age_seconds: Some(1), minimum_seconds_since_update: None, + owner: None, }, } .to_string(), "contract at least 1 seconds old" ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + owner: Some(ContractReferenceOwner::Writer), + ..Default::default() + }, + } + .to_string(), + "contract owned by the writer" + ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + owner: Some(ContractReferenceOwner::Other), + ..Default::default() + }, + } + .to_string(), + "contract with elected moderation not owned by the writer" + ); 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 3d6df7df369..2731f398acf 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,14 +52,14 @@ impl ReferencedContractRequirementNotMetError { &self.contract_id } - /// The `contractRequirements` key of the requirement: `moderation`, `minimumAgeSeconds` or - /// `minimumSecondsSinceUpdate` + /// The `contractRequirements` key of the requirement: `moderation`, `minimumAgeSeconds`, + /// `minimumSecondsSinceUpdate` or `owner` 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 duration + /// requirement, the number of seconds for a duration, `self` or `other` for an owner 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 01f257f877b..b47e2aa9430 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -347,6 +347,9 @@ mod tests { json!({ "minimumAgeSeconds": 4294967295u64 }), json!({ "minimumSecondsSinceUpdate": 86400 }), json!({ "moderation": "elected", "minimumAgeSeconds": 604800, "minimumSecondsSinceUpdate": 86400 }), + json!({ "owner": "self" }), + json!({ "owner": "other" }), + json!({ "moderation": "elected", "owner": "other" }), ] { let schema = document_schema_with_refers_to(json!({ "type": "contract", @@ -372,7 +375,10 @@ mod tests { json!({ "type": "contract", "contractRequirements": { "minimumSecondsSinceUpdate": 0 } }), json!({ "type": "contract", "contractRequirements": { "minimumSecondsSinceUpdate": "60" } }), json!({ "type": "contract", "contractRequirements": { "tokens": "any" } }), + json!({ "type": "contract", "contractRequirements": { "owner": "anyone" } }), + json!({ "type": "contract", "contractRequirements": { "owner": true } }), json!({ "type": "identity", "contractRequirements": { "minimumAgeSeconds": 3600 } }), + json!({ "type": "identity", "contractRequirements": { "owner": "self" } }), ] { let schema = document_schema_with_refers_to(refers_to.clone()); 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 7c0f91bff2b..6487aa68c26 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 @@ -12,7 +12,7 @@ use dpp::data_contract::document_type::accessors::{ use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::{ is_referring_system_agreement_property, DocumentPropertyReferenceTarget, - DocumentPropertyType, DocumentTypeRef, + DocumentPropertyType, DocumentTypeRef, ReferringWrite, }; use dpp::data_contract::DataContract; use dpp::document::property_names::{CREATOR_ID, OWNER_ID}; @@ -304,10 +304,14 @@ fn validate_document_type_references_v0( None => false, Some(fetch_info) => { // The declaration's requirements are checked against the 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) + // just fetched and the write itself (its owner and block time), so + // they cost no further read; the first unmet one refuses the write + let write = ReferringWrite { + owner_id, + block_time_ms: block_info.time_ms, + }; + if let Some(requirement) = + contract_requirements.first_unmet_by(&fetch_info.contract, write) { return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedContractRequirementNotMetError::new( 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 ffafa84cb55..8ff5f67ad16 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 @@ -5317,6 +5317,18 @@ mod creation_tests { /// 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_OWNER_SELF_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-self-contract-ref.json"; + /// The `id` of the self-owned-contract-reference fixture (`owner: "self"`): owned by an + /// identity that is not the writer, so a reference to it is unmet. + const REFERENCE_VALIDATION_OWNER_SELF_CONTRACT_REF_CONTRACT_ID: &str = + "Gb9ZqQQTAoV7JCUrZhsS4ssZozbE33Suunzhr7RVy2pD"; + const REFERENCE_VALIDATION_OWNER_OTHER_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-other-contract-ref.json"; + /// The `id` of the other-owned-contract-reference fixture (`owner: "other"`): owned by an + /// identity that is not the writer, so a reference to it is met. + const REFERENCE_VALIDATION_OWNER_OTHER_CONTRACT_REF_CONTRACT_ID: &str = + "EPHoATa8ifeWoviLaxTQRWfsJUHAQUCro6dEkwgjwog7"; 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 = @@ -5341,15 +5353,16 @@ mod creation_tests { run_reference_validation_creation_with_setup_and_mutator( contract_path, BlockInfo::default(), - |_, _| Identifier::default(), + |_, _, _| Identifier::default(), |document, targets, _| mutator(document, targets), ) .await } /// 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). The document is written in a block of `block_info`. + /// whatever else the test needs into state before the contract, given the targets already + /// in state (the writer's identity among them), and hands the mutator an 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, @@ -5357,7 +5370,11 @@ mod creation_tests { mutator: F, ) -> StateTransitionExecutionResult where - S: FnOnce(&mut TempPlatform, &PlatformVersion) -> Identifier, + S: FnOnce( + &mut TempPlatform, + &ReferenceTargets, + &PlatformVersion, + ) -> Identifier, F: FnOnce(&mut Document, &ReferenceTargets, Identifier), { let platform_version = PlatformVersion::latest(); @@ -5389,7 +5406,7 @@ mod creation_tests { token_id, }; - let setup_id = setup(&mut platform, platform_version); + let setup_id = setup(&mut platform, &targets, platform_version); let contract = setup_contract( &platform.drive, @@ -5623,6 +5640,7 @@ mod creation_tests { /// list behind it. fn insert_elected_contract( platform: &mut TempPlatform, + _targets: &ReferenceTargets, _platform_version: &PlatformVersion, ) -> Identifier { use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -5735,7 +5753,8 @@ mod creation_tests { /// create transition would have recorded. fn insert_contract_created_at( created_at: Option, - ) -> impl FnOnce(&mut TempPlatform, &PlatformVersion) -> Identifier { + ) -> impl FnOnce(&mut TempPlatform, &ReferenceTargets, &PlatformVersion) -> Identifier + { insert_contract_with_times(created_at, None) } @@ -5744,8 +5763,9 @@ mod creation_tests { fn insert_contract_with_times( created_at: Option, updated_at: Option, - ) -> impl FnOnce(&mut TempPlatform, &PlatformVersion) -> Identifier { - move |platform, _platform_version| { + ) -> impl FnOnce(&mut TempPlatform, &ReferenceTargets, &PlatformVersion) -> Identifier + { + move |platform, _targets, _platform_version| { use dpp::data_contract::accessors::v1::DataContractV1Setters; let contract = setup_contract( @@ -5811,7 +5831,7 @@ mod creation_tests { let result = run_reference_validation_creation_with_setup_and_mutator( REFERENCE_VALIDATION_AGED_CONTRACT_REF_CONTRACT_PATH, aged_reference_block_info(), - |_, _| Identifier::default(), + |_, _, _| Identifier::default(), |document, _, _| { document.set("refContractId", existing_contract_id.into()); }, @@ -5925,6 +5945,121 @@ mod creation_tests { ); } + /// A contract owned by the writer of the referring document, for a reference that requires + /// the referenced contract to be, or not to be, the writer's own. Written to state directly, + /// the way the fixtures are. + fn insert_contract_owned_by_writer( + platform: &mut TempPlatform, + targets: &ReferenceTargets, + _platform_version: &PlatformVersion, + ) -> Identifier { + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_CONTRACT_REF_CONTRACT_PATH, + Some([0xB1; 32]), + Some(targets.identity_id.to_buffer()), + None::, + None, + None, + ); + contract.id() + } + + #[tokio::test] + async fn should_document_creation_succeed_when_required_self_owned_contract_is_the_writers() { + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_OWNER_SELF_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_contract_owned_by_writer, + |document, _, own_contract_id| { + document.set("refContractId", own_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_self_owned_contract_is_someone_elses() { + // The fixture contract itself exists in state, owned by an identity that is not the + // writer + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_OWNER_SELF_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_OWNER_SELF_CONTRACT_REF_CONTRACT_PATH, + |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() == "owner" + && e.required() == "self" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_required_other_owned_contract_is_someone_elses() + { + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_OWNER_OTHER_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_OWNER_OTHER_CONTRACT_REF_CONTRACT_PATH, + |document, _| { + document.set("refContractId", existing_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_other_owned_contract_is_the_writers() { + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_OWNER_OTHER_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_contract_owned_by_writer, + |document, _, own_contract_id| { + document.set("refContractId", own_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &Identifier::from([0xB1; 32]) + && e.field() == "owner" + && e.required() == "other" + && e.path() == "refContractId" + ); + } + #[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-owner-other-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-other-contract-ref.json new file mode 100644 index 00000000000..dc6f2f31520 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-other-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "EPHoATa8ifeWoviLaxTQRWfsJUHAQUCro6dEkwgjwog7", + "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": { + "owner": "other" + } + } + }, + "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-owner-self-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-self-contract-ref.json new file mode 100644 index 00000000000..752519fde02 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-self-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "Gb9ZqQQTAoV7JCUrZhsS4ssZozbE33Suunzhr7RVy2pD", + "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": { + "owner": "self" + } + } + }, + "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 530e2ff4eaa..8e62ed67ac1 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -526,19 +526,22 @@ 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 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 -/// further read, and refuses the first unmet requirement with +/// 24. **Contract references may require elected moderation, a minimum age, a +/// minimum time since the last update or an owner relation to the +/// writer**: 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), `minimumSecondsSinceUpdate` (the +/// same of the later of its creation and last update times; a contract +/// without a recorded creation time never meets either) and `owner` +/// (`"self"`: the contract is owned by the `$ownerId` of the referring +/// document, `"other"`: by anyone else) 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 write itself (its owner and 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 00e207bef1d..dd326ac6101 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -53,7 +53,8 @@ pub enum DocumentReferenceErrorCodeWasm { ReferencedDocumentTypeNotDeletable = 40131, /// 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. + /// or a minimum time since its last update at the block time of the write, + /// or an owner relation to the writer of the referring document. 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 d5f8e8c411d..090510aa888 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -42,13 +42,16 @@ export type DocumentPropertyReferenceTarget = * `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 + * and last update times, and `owner: 'self'` requires the contract to + * be owned by the writer of the referring document (its `$ownerId`), + * `'other'` by anyone else (code 40135 when any is unmet). Absent when * the declaration carries no requirement. */ contractRequirements?: { moderation?: 'elected'; minimumAgeSeconds?: number; minimumSecondsSinceUpdate?: number; + owner?: 'self' | 'other'; }; } | { type: 'token' } @@ -215,6 +218,9 @@ fn reference_to_js( path, )?; } + if let Some(owner) = contract_requirements.owner { + set_field(&fields, "owner", &JsValue::from_str(owner.as_str()), path)?; + } set_field(&object, "contractRequirements", &fields, path)?; } } From f2ee2795e3e0d168fb0e52857677aab06794374f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 22 Sep 2026 22:54:32 +0700 Subject: [PATCH 2/2] feat(platform)!: contract references may require the referenced contract to be read-only, keep history or protect its owner `contractRequirements` on a `contract` `refersTo` gains three flags read from the referenced contract's config: `readonly: true` requires a read-only contract, one that can never be updated again; `keepsHistory: true` requires one keeping its history; `ownerProtected: true | false` requires an elected moderation declaration whose owner protection flag has that value, so a contract with no moderation or non-elected moderation meets neither value and the check implies elected moderation without the schema having to say so. Only `true` is meaningful for the first two, so the parser and the meta-schema (`const: true`) refuse `false`. An unmet flag refuses the write with the existing `ReferencedContractRequirementNotMetError` (40135), field the key and required `true` or `false`. Meta-schema v3, the parser, the wasm-dpp2 surface, the v14 changelog and the book are extended in place, protocol version 14 being unreleased. The drive-abci reference test helper for an elected contract takes the owner protection flag, and a new one writes a contract with an adjusted config. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/contract-moderation.md | 2 +- .../document/v3/document-meta.json | 11 +- .../class_methods/try_from_schema/mod.rs | 145 ++++++++- .../methods/validate_update/common/mod.rs | 15 + .../src/data_contract/document_type/mod.rs | 3 + .../document_type/property/mod.rs | 207 +++++++++++++ ...nced_contract_requirement_not_met_error.rs | 5 +- .../src/validation/meta_validators/mod.rs | 14 + .../batch/tests/document/creation.rs | 282 +++++++++++++++--- ...n-contract-keeps-history-contract-ref.json | 38 +++ ...contract-owner-protected-contract-ref.json | 38 +++ ...dation-contract-readonly-contract-ref.json | 38 +++ .../rs-platform-version/src/version/v14.rs | 28 +- packages/wasm-dpp2/src/consensus_error.rs | 4 +- .../data_contract/document_type_reference.rs | 20 +- 15 files changed, 792 insertions(+), 58 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-keeps-history-contract-ref.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-protected-contract-ref.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-readonly-contract-ref.json diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 3a7b95e5890..062229cf315 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"`; `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); `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); and `owner`, `"self"` requiring the referenced contract to be owned by the writer of the referring document (its `$ownerId`, a write gate like the `$ownerId` property agreement of a document reference) and `"other"` by anyone else (so a charter may forbid an owner from chartering its own team). 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 write itself (its owner and 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); `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); and `owner`, `"self"` requiring the referenced contract to be owned by the writer of the referring document (its `$ownerId`, a write gate like the `$ownerId` property agreement of a document reference) and `"other"` by anyone else (so a charter may forbid an owner from chartering its own team); `readonly: true`, requiring the referenced contract's config to be read-only, one that can never be updated again (which makes `minimumSecondsSinceUpdate` moot for the same target); `keepsHistory: true`, requiring its config to keep history (only `true` is declarable for either flag); and `ownerProtected`, requiring the contract's elected moderation declaration to protect the owner from the team (`true`) or to leave it unprotected (`false`), which implies elected moderation without the schema having to say so, a contract without an elected declaration meeting neither value. 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 write itself (its owner and 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 36a4c391e40..36b9bd759c0 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, and minimumSecondsSinceUpdate the later of its recorded creation and last update times (a contract without a recorded creation time never meets either); owner \"self\" requires the contract to be owned by the writer of the referring document (its $ownerId), \"other\" by anyone else. 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); owner \"self\" requires the contract to be owned by the writer of the referring document (its $ownerId), \"other\" by anyone else; readonly true requires the contract's config to be readonly (one that can never be updated again) and keepsHistory true its config to keep history, only true being declarable for either; ownerProtected requires the contract's elected moderation declaration to protect (true) or not protect (false) the contract owner from the team, a contract without elected moderation meeting neither. An unmet requirement refuses the write (ReferencedContractRequirementNotMetError, 40135)", "type": "object", "properties": { "moderation": { @@ -156,6 +156,15 @@ "self", "other" ] + }, + "readonly": { + "const": true + }, + "keepsHistory": { + "const": true + }, + "ownerProtected": { + "type": "boolean" } }, "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 f1be161e903..4c041013c32 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 @@ -527,8 +527,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 (`moderation`, `owner`), or a bound on it -/// (`minimumAgeSeconds`), at least one when the object is given at all. +/// contract with a closed set of values (`moderation`, `owner`, the config flags), 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 { @@ -577,6 +577,15 @@ fn parse_contract_reference_requirements( )) })?); } + property_names::READONLY => { + fields.readonly = Some(parse_contract_reference_true(&field, value)?); + } + property_names::KEEPS_HISTORY => { + fields.keeps_history = Some(parse_contract_reference_true(&field, value)?); + } + property_names::OWNER_PROTECTED => { + fields.owner_protected = Some(parse_contract_reference_bool(&field, value)?); + } other => { return Err(DataContractError::InvalidContractStructure(format!( "contract refersTo contractRequirements {other:?} is unknown" @@ -603,6 +612,28 @@ fn parse_contract_reference_seconds(field: &str, value: &Value) -> Result Result { + value.as_bool().ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements {field} must be a boolean" + )) + }) +} + +/// A flag requirement of a `contract` reference (`readonly`, `keepsHistory`): only `true` +/// requires anything, so `false` is refused rather than declared as a requirement that +/// requires nothing. +fn parse_contract_reference_true(field: &str, value: &Value) -> Result { + if parse_contract_reference_bool(field, value)? { + Ok(true) + } else { + Err(DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements {field} must be true" + ))) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1132,6 +1163,9 @@ mod tests { minimum_age_seconds: None, minimum_seconds_since_update: None, owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, }, } ) @@ -1152,6 +1186,9 @@ mod tests { minimum_age_seconds: Some(604_800), minimum_seconds_since_update: None, owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, }, } ) @@ -1168,6 +1205,9 @@ mod tests { minimum_age_seconds: None, minimum_seconds_since_update: Some(86_400), owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, }, } ) @@ -1188,6 +1228,9 @@ mod tests { minimum_age_seconds: Some(u32::MAX), minimum_seconds_since_update: Some(1), owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, }, } ) @@ -1232,6 +1275,104 @@ mod tests { ); } + #[test] + fn should_parse_contract_refers_to_requiring_config_flags() { + for (requirements, expected) in [ + ( + json!({ "readonly": true }), + ContractReferenceRequirements { + readonly: Some(true), + ..Default::default() + }, + ), + ( + json!({ "keepsHistory": true }), + ContractReferenceRequirements { + keeps_history: Some(true), + ..Default::default() + }, + ), + ( + json!({ "ownerProtected": true }), + ContractReferenceRequirements { + owner_protected: Some(true), + ..Default::default() + }, + ), + ( + json!({ "ownerProtected": false }), + ContractReferenceRequirements { + owner_protected: Some(false), + ..Default::default() + }, + ), + ( + json!({ "moderation": "elected", "readonly": true, "keepsHistory": true, "ownerProtected": false }), + ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + readonly: Some(true), + keeps_history: Some(true), + owner_protected: Some(false), + ..Default::default() + }, + ), + ] { + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": requirements + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: expected, + } + ), + "{requirements}" + ); + } + } + + #[test] + fn should_reject_a_config_flag_requirement_that_is_not_a_boolean_or_requires_nothing() { + for (field, value, fragment) in [ + ("readonly", json!(false), "readonly must be true"), + ("readonly", json!("true"), "readonly must be a boolean"), + ("readonly", json!(1), "readonly must be a boolean"), + ("keepsHistory", json!(false), "keepsHistory must be true"), + ( + "keepsHistory", + json!("true"), + "keepsHistory must be a boolean", + ), + ( + "ownerProtected", + json!("true"), + "ownerProtected must be a boolean", + ), + ( + "ownerProtected", + json!(0), + "ownerProtected must be a boolean", + ), + ( + "ownerProtected", + json!(null), + "ownerProtected must be a boolean", + ), + ] { + let refers_to = json!({ + "type": "contract", + "contractRequirements": { field: value } + }); + 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_a_duration_requirement_that_is_zero_negative_too_large_or_not_an_integer() { for field in ["minimumAgeSeconds", "minimumSecondsSinceUpdate"] { 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 433a6f8dd1d..8bbcf432ae1 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 @@ -2026,6 +2026,21 @@ mod tests { platform_value!({ "type": "contract", "contractRequirements": { "owner": "other" } }), "/properties/toUserId/refersTo/contractRequirements/owner", ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "ownerProtected": true } }), + platform_value!({ "type": "contract", "contractRequirements": { "ownerProtected": false } }), + "/properties/toUserId/refersTo/contractRequirements/ownerProtected", + ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "owner": "self" } }), + platform_value!({ "type": "contract", "contractRequirements": { "owner": "self", "readonly": true } }), + "/properties/toUserId/refersTo/contractRequirements/readonly", + ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "owner": "self", "keepsHistory": true } }), + platform_value!({ "type": "contract", "contractRequirements": { "owner": "self" } }), + "/properties/toUserId/refersTo/contractRequirements/keepsHistory", + ), ] { 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 0347b09d60b..3e39ec42e9c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -112,6 +112,9 @@ pub(crate) mod property_names { pub const MINIMUM_AGE_SECONDS: &str = "minimumAgeSeconds"; pub const MINIMUM_SECONDS_SINCE_UPDATE: &str = "minimumSecondsSinceUpdate"; pub const OWNER: &str = "owner"; + pub const READONLY: &str = "readonly"; + pub const KEEPS_HISTORY: &str = "keepsHistory"; + pub const OWNER_PROTECTED: &str = "ownerProtected"; 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 12a166d6d5f..3b6452fbf14 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 @@ -13,6 +13,7 @@ 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::v0::DataContractConfigGettersV0; use crate::data_contract::config::v1::DataContractConfigGettersV1; use crate::data_contract::config::v2::DataContractConfigGettersV2; use crate::data_contract::config::DataContractConfig; @@ -119,6 +120,20 @@ pub struct ContractReferenceRequirements { /// Who must own the referenced contract, relative to the writer of the referring document. #[serde(default, skip_serializing_if = "Option::is_none")] pub owner: Option, + /// Whether the referenced contract must be read-only (its config's `readonly`), a + /// contract that can never be updated again. Only `true` is declarable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub readonly: Option, + /// Whether the referenced contract must keep its history (its config's `keepsHistory`). + /// Only `true` is declarable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keeps_history: Option, + /// Whether the elected moderation declaration of the referenced contract must protect + /// (`true`), or must not protect (`false`), the contract owner from the team, its + /// `ownerProtected`. Either value implies elected moderation: a contract with no + /// moderation, or with moderation of another kind, meets neither. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner_protected: Option, } /// The write of a referring document, what a contract reference's requirements are checked @@ -221,6 +236,9 @@ pub enum ContractReferenceRequirement { MinimumAgeSeconds(u32), MinimumSecondsSinceUpdate(u32), Owner(ContractReferenceOwner), + Readonly(bool), + KeepsHistory(bool), + OwnerProtected(bool), } impl ContractReferenceRequirement { @@ -235,6 +253,9 @@ impl ContractReferenceRequirement { property_names::MINIMUM_SECONDS_SINCE_UPDATE } ContractReferenceRequirement::Owner(_) => property_names::OWNER, + ContractReferenceRequirement::Readonly(_) => property_names::READONLY, + ContractReferenceRequirement::KeepsHistory(_) => property_names::KEEPS_HISTORY, + ContractReferenceRequirement::OwnerProtected(_) => property_names::OWNER_PROTECTED, } } @@ -247,6 +268,9 @@ impl ContractReferenceRequirement { seconds.to_string() } ContractReferenceRequirement::Owner(owner) => owner.as_str().to_string(), + ContractReferenceRequirement::Readonly(flag) + | ContractReferenceRequirement::KeepsHistory(flag) + | ContractReferenceRequirement::OwnerProtected(flag) => flag.to_string(), } } @@ -268,6 +292,19 @@ impl ContractReferenceRequirement { ContractReferenceRequirement::Owner(owner) => { owner.is_met_by(contract, &write.owner_id) } + ContractReferenceRequirement::Readonly(required) => { + contract.config().readonly() == *required + } + ContractReferenceRequirement::KeepsHistory(required) => { + contract.config().keeps_history() == *required + } + // Either value needs an elected declaration to read the flag from: a contract + // without one meets neither + ContractReferenceRequirement::OwnerProtected(required) => contract + .config() + .moderation() + .and_then(|moderation| moderation.moderators.elected()) + .is_some_and(|elected| elected.owner_protected == *required), } } @@ -304,6 +341,9 @@ impl ContractReferenceRequirements { && self.minimum_age_seconds.is_none() && self.minimum_seconds_since_update.is_none() && self.owner.is_none() + && self.readonly.is_none() + && self.keeps_history.is_none() + && self.owner_protected.is_none() } /// The requirements, in declaration order. @@ -326,6 +366,21 @@ impl ContractReferenceRequirements { .into_iter() .map(ContractReferenceRequirement::Owner), ) + .chain( + self.readonly + .into_iter() + .map(ContractReferenceRequirement::Readonly), + ) + .chain( + self.keeps_history + .into_iter() + .map(ContractReferenceRequirement::KeepsHistory), + ) + .chain( + self.owner_protected + .into_iter() + .map(ContractReferenceRequirement::OwnerProtected), + ) } /// The first requirement `contract` does not meet for `write`, the write of the referring @@ -549,6 +604,17 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { Some(ContractReferenceOwner::Other) => write!(f, " not owned by the writer")?, None => {} } + if contract_requirements.readonly == Some(true) { + write!(f, " read-only")?; + } + if contract_requirements.keeps_history == Some(true) { + write!(f, " keeping history")?; + } + match contract_requirements.owner_protected { + Some(true) => write!(f, " with the owner protected")?, + Some(false) => write!(f, " with the owner unprotected")?, + None => {} + } Ok(()) } DocumentPropertyReferenceTarget::Token => write!(f, "token"), @@ -7831,6 +7897,9 @@ mod tests { minimum_age_seconds: Some(3600), minimum_seconds_since_update: Some(60), owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, }; assert_eq!( requirements.requirements().collect::>(), @@ -7934,6 +8003,112 @@ mod tests { ); } + #[test] + fn should_meet_a_config_flag_requirement_from_the_referenced_contract_config() { + use crate::data_contract::accessors::v0::DataContractV0Setters; + use crate::data_contract::config::moderation::{ + ContractModerationConfig, ElectedModerators, InterimModerators, ModerationAbility, + DEFAULT_ELECTION_WINDOW_SECONDS, + }; + use crate::data_contract::config::v0::DataContractConfigSettersV0; + use crate::tests::fixtures::get_dashpay_contract_fixture; + use std::collections::BTreeSet; + + let platform_version = PlatformVersion::latest(); + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + let write = ReferringWrite { + owner_id: Identifier::from([0x42; 32]), + block_time_ms: 0, + }; + let readonly = ContractReferenceRequirement::Readonly(true); + let keeps_history = ContractReferenceRequirement::KeepsHistory(true); + let protected = ContractReferenceRequirement::OwnerProtected(true); + let unprotected = ContractReferenceRequirement::OwnerProtected(false); + + // The fixture is neither read-only nor keeping history, and declares no moderation: + // it meets none of the flags, whichever value the owner protection requires + assert!(!readonly.is_met_by(&contract, write)); + assert!(!keeps_history.is_met_by(&contract, write)); + assert!(!protected.is_met_by(&contract, write)); + assert!(!unprotected.is_met_by(&contract, write)); + + let mut config = contract.config().clone(); + config.set_readonly(true); + config.set_keeps_history(true); + contract.set_config(config); + assert!(readonly.is_met_by(&contract, write)); + assert!(keeps_history.is_met_by(&contract, write)); + + // Appointed moderation still has no owner protection to read + contract.set_config(contract.config().clone().with_moderation(Some( + ContractModerationConfig { + banlist: true, + suspensions: false, + warnings: false, + moderators: ContractModerators::AppointedModerators(BTreeSet::from([ + Identifier::from([0x77; 32]), + ])), + }, + ))); + assert!(!protected.is_met_by(&contract, write)); + assert!(!unprotected.is_met_by(&contract, write)); + + for owner_protected in [true, false] { + contract.set_config(contract.config().clone().with_moderation(Some( + ContractModerationConfig { + banlist: true, + suspensions: false, + warnings: false, + moderators: ContractModerators::Elected(Box::new(ElectedModerators { + join_window: DEFAULT_ELECTION_WINDOW_SECONDS, + vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, + challenge_cool_down: 1_209_600, + moderated_document_types: BTreeMap::from([( + "profile".to_string(), + BTreeSet::from([ModerationAbility::Ban]), + )]), + interim: InterimModerators::ContractOwner, + owner_protected, + })), + }, + ))); + assert_eq!(protected.is_met_by(&contract, write), owner_protected); + assert_eq!(unprotected.is_met_by(&contract, write), !owner_protected); + } + + assert_eq!(readonly.field(), "readonly"); + assert_eq!(readonly.required(), "true"); + assert_eq!(keeps_history.field(), "keepsHistory"); + assert_eq!(keeps_history.required(), "true"); + assert_eq!(protected.field(), "ownerProtected"); + assert_eq!(protected.required(), "true"); + assert_eq!(unprotected.required(), "false"); + + // The contract is left read-only, keeping history and with its owner unprotected: + // requiring the protection is the one requirement it does not meet + let requirements = ContractReferenceRequirements { + readonly: Some(true), + keeps_history: Some(true), + owner_protected: Some(true), + ..Default::default() + }; + assert!(!requirements.is_empty()); + assert_eq!( + requirements.requirements().collect::>(), + vec![readonly, keeps_history, protected] + ); + assert_eq!( + requirements.first_unmet_by(&contract, write), + Some(protected) + ); + let met = ContractReferenceRequirements { + owner_protected: Some(false), + ..requirements + }; + assert_eq!(met.first_unmet_by(&contract, write), None); + } + #[test] fn should_take_the_last_change_time_from_the_later_of_creation_and_update() { use crate::data_contract::accessors::v1::DataContractV1Setters; @@ -7992,6 +8167,9 @@ mod tests { minimum_age_seconds: None, minimum_seconds_since_update: None, owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, }, } .to_string(), @@ -8004,6 +8182,9 @@ mod tests { minimum_age_seconds: Some(604_800), minimum_seconds_since_update: Some(86_400), owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, }, } .to_string(), @@ -8016,6 +8197,9 @@ mod tests { minimum_age_seconds: Some(1), minimum_seconds_since_update: None, owner: None, + readonly: None, + keeps_history: None, + owner_protected: None, }, } .to_string(), @@ -8042,6 +8226,29 @@ mod tests { .to_string(), "contract with elected moderation not owned by the writer" ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + readonly: Some(true), + keeps_history: Some(true), + owner_protected: Some(true), + ..Default::default() + }, + } + .to_string(), + "contract read-only keeping history with the owner protected" + ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + owner: Some(ContractReferenceOwner::Other), + owner_protected: Some(false), + ..Default::default() + }, + } + .to_string(), + "contract not owned by the writer with the owner unprotected" + ); 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 2731f398acf..ca08a93caaf 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 @@ -53,13 +53,14 @@ impl ReferencedContractRequirementNotMetError { } /// The `contractRequirements` key of the requirement: `moderation`, `minimumAgeSeconds`, - /// `minimumSecondsSinceUpdate` or `owner` + /// `minimumSecondsSinceUpdate`, `owner`, `readonly`, `keepsHistory` or `ownerProtected` 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 duration, `self` or `other` for an owner + /// requirement, the number of seconds for a duration, `self` or `other` for an owner, + /// `true` or `false` for a config flag 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 b47e2aa9430..4a6ebc1f0f5 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -350,6 +350,11 @@ mod tests { json!({ "owner": "self" }), json!({ "owner": "other" }), json!({ "moderation": "elected", "owner": "other" }), + json!({ "readonly": true }), + json!({ "keepsHistory": true }), + json!({ "ownerProtected": true }), + json!({ "ownerProtected": false }), + json!({ "moderation": "elected", "owner": "other", "readonly": true, "keepsHistory": true, "ownerProtected": false }), ] { let schema = document_schema_with_refers_to(json!({ "type": "contract", @@ -379,6 +384,15 @@ mod tests { json!({ "type": "contract", "contractRequirements": { "owner": true } }), json!({ "type": "identity", "contractRequirements": { "minimumAgeSeconds": 3600 } }), json!({ "type": "identity", "contractRequirements": { "owner": "self" } }), + json!({ "type": "contract", "contractRequirements": { "readonly": false } }), + json!({ "type": "contract", "contractRequirements": { "readonly": "true" } }), + json!({ "type": "contract", "contractRequirements": { "readonly": 1 } }), + json!({ "type": "contract", "contractRequirements": { "keepsHistory": false } }), + json!({ "type": "contract", "contractRequirements": { "keepsHistory": "true" } }), + json!({ "type": "contract", "contractRequirements": { "ownerProtected": "true" } }), + json!({ "type": "contract", "contractRequirements": { "ownerProtected": 1 } }), + json!({ "type": "contract", "contractRequirements": { "ownerProtected": null } }), + json!({ "type": "identity", "contractRequirements": { "readonly": true } }), ] { let schema = document_schema_with_refers_to(refers_to.clone()); 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 8ff5f67ad16..ec22d91e08f 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 @@ -16,6 +16,8 @@ mod creation_tests { use dpp::fee::fee_result::refunds::FeeRefunds; use dpp::fee::fee_result::FeeResult; use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::data_contract::config::v0::DataContractConfigSettersV0; + use dpp::data_contract::config::DataContractConfig; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::restricted_creation::CreationRestrictionMode; use dpp::document::Document; @@ -5329,6 +5331,24 @@ mod creation_tests { /// identity that is not the writer, so a reference to it is met. const REFERENCE_VALIDATION_OWNER_OTHER_CONTRACT_REF_CONTRACT_ID: &str = "EPHoATa8ifeWoviLaxTQRWfsJUHAQUCro6dEkwgjwog7"; + const REFERENCE_VALIDATION_READONLY_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-readonly-contract-ref.json"; + /// The `id` of the read-only-contract-reference fixture (`readonly: true`): not read-only + /// itself, so a reference to it is unmet. + const REFERENCE_VALIDATION_READONLY_CONTRACT_REF_CONTRACT_ID: &str = + "2FkduiNwoBct7PdxmCRYgLxckj4s8dhx7tXLUUpq74Qk"; + const REFERENCE_VALIDATION_KEEPS_HISTORY_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-keeps-history-contract-ref.json"; + /// The `id` of the history-keeping-contract-reference fixture (`keepsHistory: true`): not + /// keeping history itself, so a reference to it is unmet. + const REFERENCE_VALIDATION_KEEPS_HISTORY_CONTRACT_REF_CONTRACT_ID: &str = + "7gQQ8BdF9gem9YxBNLd3QW7AmiGYNTTuVLerkiJZ3JCx"; + const REFERENCE_VALIDATION_OWNER_PROTECTED_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-protected-contract-ref.json"; + /// The `id` of the owner-protected-contract-reference fixture (`ownerProtected: true`): + /// declaring no moderation at all, so a reference to it is unmet. + const REFERENCE_VALIDATION_OWNER_PROTECTED_CONTRACT_REF_CONTRACT_ID: &str = + "6BL67iVAJ5eiVne4qXJGNRGpVqB2b2TKiF1odQ7y2qjn"; 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 = @@ -5635,50 +5655,67 @@ mod creation_tests { ); } - /// A contract with an elected moderation team, for a reference that requires one. It is - /// written to state directly, the way the fixtures are, so the moderated type needs no - /// list behind it. + /// A contract with an elected moderation team, protecting its owner from the team or not, + /// for a reference that requires one. It is written to state directly, the way the + /// fixtures are, so the moderated type needs no list behind it. fn insert_elected_contract( - platform: &mut TempPlatform, - _targets: &ReferenceTargets, - _platform_version: &PlatformVersion, - ) -> Identifier { - use dpp::data_contract::accessors::v0::DataContractV0Getters; + owner_protected: bool, + ) -> impl FnOnce(&mut TempPlatform, &ReferenceTargets, &PlatformVersion) -> Identifier + { use dpp::data_contract::config::moderation::{ ContractModerationConfig, ContractModerators, ElectedModerators, InterimModerators, ModerationAbility, DEFAULT_ELECTION_WINDOW_SECONDS, }; use std::collections::{BTreeMap, BTreeSet}; - let contract = setup_contract( - &platform.drive, - REFERENCE_VALIDATION_CONTRACT_REF_CONTRACT_PATH, - Some([0xE1; 32]), - None, - Some(|contract: &mut DataContract| { - contract.set_config(contract.config().clone().with_moderation(Some( - ContractModerationConfig { - banlist: true, - suspensions: true, - warnings: false, - moderators: ContractModerators::Elected(Box::new(ElectedModerators { - join_window: DEFAULT_ELECTION_WINDOW_SECONDS, - vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, - challenge_cool_down: 1_209_600, - moderated_document_types: BTreeMap::from([( - "message".to_string(), - BTreeSet::from([ModerationAbility::Ban]), - )]), - interim: InterimModerators::ContractOwner, - owner_protected: false, - })), - }, - ))); - }), - None, - None, - ); - contract.id() + insert_contract_configured([0xE1; 32], move |config| { + *config = config + .clone() + .with_moderation(Some(ContractModerationConfig { + banlist: true, + suspensions: true, + warnings: false, + moderators: ContractModerators::Elected(Box::new(ElectedModerators { + join_window: DEFAULT_ELECTION_WINDOW_SECONDS, + vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, + challenge_cool_down: 1_209_600, + moderated_document_types: BTreeMap::from([( + "message".to_string(), + BTreeSet::from([ModerationAbility::Ban]), + )]), + interim: InterimModerators::ContractOwner, + owner_protected, + })), + })); + }) + } + + /// A contract with the id `contract_id` whose config `configure` adjusts, for a reference + /// that requires something of the config. Written to state directly, the way the fixtures + /// are. + fn insert_contract_configured( + contract_id: [u8; 32], + configure: impl FnOnce(&mut DataContractConfig), + ) -> impl FnOnce(&mut TempPlatform, &ReferenceTargets, &PlatformVersion) -> Identifier + { + move |platform, _targets, _platform_version| { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_CONTRACT_REF_CONTRACT_PATH, + Some(contract_id), + None, + Some(|contract: &mut DataContract| { + let mut config = contract.config().clone(); + configure(&mut config); + contract.set_config(config); + }), + None, + None, + ); + contract.id() + } } #[tokio::test] @@ -5735,7 +5772,7 @@ mod creation_tests { let result = run_reference_validation_creation_with_setup_and_mutator( REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_PATH, BlockInfo::default(), - insert_elected_contract, + insert_elected_contract(false), |document, _, elected_contract_id| { document.set("refContractId", elected_contract_id.into()); }, @@ -6060,6 +6097,175 @@ mod creation_tests { ); } + #[tokio::test] + async fn should_document_creation_succeed_when_required_readonly_contract_is_readonly() { + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_READONLY_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_contract_configured([0xC1; 32], |config| config.set_readonly(true)), + |document, _, readonly_contract_id| { + document.set("refContractId", readonly_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_readonly_contract_is_updatable() { + // The fixture contract itself exists in state and is not read-only + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_READONLY_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_READONLY_CONTRACT_REF_CONTRACT_PATH, + |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() == "readonly" + && e.required() == "true" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_required_history_keeping_contract_keeps_history() + { + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_KEEPS_HISTORY_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_contract_configured([0xC2; 32], |config| config.set_keeps_history(true)), + |document, _, history_contract_id| { + document.set("refContractId", history_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_history_keeping_contract_keeps_none() { + // The fixture contract itself exists in state and keeps no history + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_KEEPS_HISTORY_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_KEEPS_HISTORY_CONTRACT_REF_CONTRACT_PATH, + |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() == "keepsHistory" + && e.required() == "true" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_required_owner_protection_is_declared() { + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_OWNER_PROTECTED_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_elected_contract(true), + |document, _, elected_contract_id| { + document.set("refContractId", elected_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_owner_protection_is_not_declared() { + // Elected moderation whose declaration leaves the owner unprotected + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_OWNER_PROTECTED_CONTRACT_REF_CONTRACT_PATH, + BlockInfo::default(), + insert_elected_contract(false), + |document, _, elected_contract_id| { + document.set("refContractId", elected_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &Identifier::from([0xE1; 32]) + && e.field() == "ownerProtected" + && e.required() == "true" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_owner_protection_has_no_elected_moderation( + ) { + // The fixture contract itself exists in state and declares no moderation at all, so + // there is no owner protection flag to meet either value + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_OWNER_PROTECTED_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_OWNER_PROTECTED_CONTRACT_REF_CONTRACT_PATH, + |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() == "ownerProtected" + && e.required() == "true" + && e.path() == "refContractId" + ); + } + #[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-keeps-history-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-keeps-history-contract-ref.json new file mode 100644 index 00000000000..bad217dc0a4 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-keeps-history-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "7gQQ8BdF9gem9YxBNLd3QW7AmiGYNTTuVLerkiJZ3JCx", + "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": { + "keepsHistory": true + } + } + }, + "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-owner-protected-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-protected-contract-ref.json new file mode 100644 index 00000000000..84429f76050 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-protected-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "6BL67iVAJ5eiVne4qXJGNRGpVqB2b2TKiF1odQ7y2qjn", + "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": { + "ownerProtected": true + } + } + }, + "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-readonly-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-readonly-contract-ref.json new file mode 100644 index 00000000000..ca7a468e017 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-readonly-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "2FkduiNwoBct7PdxmCRYgLxckj4s8dhx7tXLUUpq74Qk", + "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": { + "readonly": true + } + } + }, + "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 8e62ed67ac1..6695feb58cf 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -527,17 +527,23 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// latest; DPNS contests ending from this version on follow the new rule. /// /// 24. **Contract references may require elected moderation, a minimum age, a -/// minimum time since the last update or an owner relation to the -/// writer**: 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), `minimumSecondsSinceUpdate` (the -/// same of the later of its creation and last update times; a contract -/// without a recorded creation time never meets either) and `owner` -/// (`"self"`: the contract is owned by the `$ownerId` of the referring -/// document, `"other"`: by anyone else) as the requirements (meta-schema -/// v3, `apply_property_reference` 0, `ContractReferenceRequirements` on +/// minimum time since the last update, an owner relation to the writer or +/// config flags of the referenced contract**: 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), +/// `minimumSecondsSinceUpdate` (the same of the later of its creation and +/// last update times; a contract without a recorded creation time never +/// meets either), `owner` (`"self"`: the contract is owned by the +/// `$ownerId` of the referring document, `"other"`: by anyone else), +/// `readonly: true` (its config is read-only, so it can never be updated +/// again), `keepsHistory: true` (its config keeps history) and +/// `ownerProtected` (its elected moderation declaration protects the owner +/// from the team, or does not, as the value says; a contract without +/// elected moderation meets neither value) 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 write itself (its owner and block time), so diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index dd326ac6101..d45db6c53c3 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -54,7 +54,9 @@ pub enum DocumentReferenceErrorCodeWasm { /// 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, - /// or an owner relation to the writer of the referring document. + /// an owner relation to the writer of the referring document, or a config + /// flag (read-only, keeping history, the owner protection of its elected + /// moderation declaration). 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 090510aa888..ca64c51d3c2 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -44,14 +44,21 @@ export type DocumentPropertyReferenceTarget = * and `minimumSecondsSinceUpdate` the same of the later of its creation * and last update times, and `owner: 'self'` requires the contract to * be owned by the writer of the referring document (its `$ownerId`), - * `'other'` by anyone else (code 40135 when any is unmet). Absent when - * the declaration carries no requirement. + * `'other'` by anyone else; `readonly: true` requires a read-only + * contract (one that can never be updated again), `keepsHistory: true` + * one keeping its history, and `ownerProtected` an elected moderation + * declaration whose owner protection flag has that value (code 40135 + * when any is unmet). Absent when the declaration carries no + * requirement. */ contractRequirements?: { moderation?: 'elected'; minimumAgeSeconds?: number; minimumSecondsSinceUpdate?: number; owner?: 'self' | 'other'; + readonly?: true; + keepsHistory?: true; + ownerProtected?: boolean; }; } | { type: 'token' } @@ -221,6 +228,15 @@ fn reference_to_js( if let Some(owner) = contract_requirements.owner { set_field(&fields, "owner", &JsValue::from_str(owner.as_str()), path)?; } + for (name, flag) in [ + ("readonly", contract_requirements.readonly), + ("keepsHistory", contract_requirements.keeps_history), + ("ownerProtected", contract_requirements.owner_protected), + ] { + if let Some(flag) = flag { + set_field(&fields, name, &JsValue::from_bool(flag), path)?; + } + } set_field(&object, "contractRequirements", &fields, path)?; } }