From d20acdcc782c3ac01ed6da27891e8c1fbf772f6c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 22:29:04 +0700 Subject: [PATCH 1/2] fix(platform)!: drop transient values on replace and refuse declarations that read them (PV14) A transient property is judged on the transition and never stored, but: a replace stored whatever it carried (only a create dropped the values); an index, a lookup's index, a propertyAgreement's referenced side and a key reference's identity could all read a transient value no stored document holds; transient entries naming nested paths, system or unknown properties were accepted and not dropped; and changing the transient list on update failed as an unsupported keyword, an internal error. - document_from_replace_transition_action 1 drops transient values by top-level name, as the create action does - parser generation 3 (full validation) refuses a transient entry that is not a top-level property and an index reading a transient property - referenced_side_error refuses a lookup index keyed by one - data_contract_reference_validation 0 refuses a transient referenced agreement property and a stored key id paired with a transient identity - validate_schema_compatibility 1 freezes the transient list Every generation touched is only selected by, or only reached from, protocol version 14, which is unreleased. A census of all mainnet and testnet contracts found transient only on immutable DPNS-shaped domain types, which none of the new rules refuse. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 20 ++ .../document/v3/document-meta.json | 3 +- .../class_methods/try_from_schema/v3/mod.rs | 67 ++++++- .../try_from_schema/v3/transient_tests.rs | 171 ++++++++++++++++++ .../document_type/property/mod.rs | 2 +- .../property/reference_lookup.rs | 46 ++++- .../validate_schema_compatibility/v1/mod.rs | 91 +++++++++- .../v0/mod.rs | 61 ++++++- .../data_contract_create/mod.rs | 82 +++++++++ .../data_contract_update/mod.rs | 82 +++++++++ ...ntract-agreement-transient-referenced.json | 57 ++++++ ...ontract-agreement-transient-referring.json | 57 ++++++ ...y-key-registration-transient-identity.json | 35 ++++ ...ntity-property-key-transient-identity.json | 42 +++++ ...-identity-property-key-transient-pair.json | 43 +++++ .../v1/mod.rs | 44 ++++- .../state_transition_action/batch/tests.rs | 54 ++++++ .../rs-platform-version/src/version/v14.rs | 29 +++ 18 files changed, 968 insertions(+), 18 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/transient_tests.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referenced.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referring.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-transient-identity.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-identity.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-pair.json diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 4e6115f5b28..a0ad1d78c67 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -466,6 +466,26 @@ Enforcement lives in the replace action's state validation (generation 1). The a In Rust the lists are `DocumentTypeV2Getters::immutable_fields()` and `immutable_fields_allow_setting()`. Earlier document type generations return empty sets. +## Transient Properties + +The doctype-level `transient` keyword lists top-level properties whose values are validated on the transition but never stored. DPNS uses it for the `domain`'s `preorderSalt`: the write proves the salted preorder, and the salt is then dropped. + +```json +"transient": ["preorderSalt"] +``` + +A create drops the listed values before its document is built. Before protocol version 14 a replace stored whatever it carried, so a replaced document kept values its create had dropped; from protocol version 14 a replace drops them the same way (`document_from_replace_transition_action` 1). Values are dropped by top-level name, so a leaf of a transient object goes with the object. + +Because no stored document carries a transient value, a rule that reads a stored value refuses a transient one, judged by the property's path and every object around it (`is_transient`). From protocol version 14, at registration: + +- Every `transient` entry names a top-level property. A nested path, a system property or an undeclared name would mark a property transient in the parsed type while its value was still stored. +- No index reads a transient property. Every document would sit in the index's null branch, so a query by the value would find nothing and a unique index would enforce nothing. +- A `refersTo` lookup reads no transient property to assemble its key, and its index keys documents by none. A `propertyAgreement` names none on its referenced side. The referring side may be transient: it is judged on the transition, a write gate like the writer's `$ownerId`. +- A key reference does not store its key id with a transient identity, whichever side declares it (`identityProperty` on the key id, `keyIdProperty` on the identity): the key id alone names no key. +- `encryptedFor` names no transient recipient or key id. + +The list cannot change on contract update: it decides which values stored documents carry and how every property is encoded (a transient property takes a presence byte even when required). From protocol version 14 any change is an incompatible schema change; before it, the schema compatibility check failed on the keyword as unsupported, an internal error. + ## Typed Arrays Up to protocol version 13 a `type: "array"` property had to be a byte array (`byteArray: true`). Protocol version 14 adds typed arrays: a list whose `items` schema says what every element is. 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 4304edb3e5b..c01dce4d65d 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 @@ -1727,7 +1727,8 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "Names of top-level properties whose values are validated on the transition but never stored: a create, and from protocol version 14 a replace, drops them before the document is written. From protocol version 14 every entry must name a top-level property (list the object around a nested one); no index may read a transient property or one inside a transient object; a refersTo lookup may not read one on either side, a propertyAgreement may not name one on its referenced side, and a stored key id may not pair with a transient identity; encryptedFor may not name one. Adding, removing or changing an entry on contract update is an incompatible schema change." }, "immutable": { "type": "array", diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 3899b9665b0..cd9a34b13f0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -29,7 +29,8 @@ use crate::data_contract::document_type::index::IndexGrammarAdmissions; use crate::data_contract::document_type::property::DocumentPropertyType; #[cfg(feature = "validation")] use crate::data_contract::document_type::property::{ - DocumentPropertyReferenceTarget, PropertyReference, ReferenceHolder, ReferenceOperands, + is_transient, DocumentPropertyReferenceTarget, PropertyReference, ReferenceHolder, + ReferenceOperands, }; use crate::data_contract::document_type::property_names; use crate::data_contract::document_type::reference_lookup::owner_can_change; @@ -512,11 +513,73 @@ fn try_from_schema_generation_3( validate_reference_expressions(&v2, data_contract_id, name, platform_version)?; validate_reference_count(&v2, name, platform_version)?; validate_no_immutable_deletable_element_references(&v2, name)?; + validate_transient_fields(&v2, name)?; + validate_no_transient_index_properties(&v2, name)?; } Ok(v2) } +/// Every entry of the `transient` list names a top-level property of the +/// document type. Drive drops transient values by top-level name before a +/// document is stored, so an entry naming a nested path, a system property or +/// nothing at all would mark a property transient in the parsed type and +/// still leave its value stored: list the object around a nested property. +/// +/// Full validation only: a contract registered before this version was never +/// held to it, and a stored contract must stay readable. +#[cfg(feature = "validation")] +fn validate_transient_fields( + document_type: &DocumentTypeV2, + name: &str, +) -> Result<(), ProtocolError> { + match document_type + .transient_fields + .iter() + .find(|field| !document_type.properties.contains_key(*field)) + { + Some(field) => Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{name}\" lists \"{field}\" as transient, but it is not a \ + top-level property of the document type: transient values are dropped by \ + top-level name, so list the object around a nested property" + )), + )), + None => Ok(()), + } +} + +/// No index reads a transient property or a property inside a transient +/// object. Its value is never stored, so every document would sit in the +/// index's null branch: a query by the value finds nothing, and a unique index +/// enforces nothing, since a create is checked against stored entries, none +/// of which holds the value. +/// +/// Full validation only, like [`validate_transient_fields`]. +#[cfg(feature = "validation")] +fn validate_no_transient_index_properties( + document_type: &DocumentTypeV2, + name: &str, +) -> Result<(), ProtocolError> { + for index in document_type.indices.values() { + if let Some(property) = index + .properties + .iter() + .find(|property| is_transient(DocumentTypeRef::V2(document_type), &property.name)) + { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "index \"{}\" of document type \"{name}\" reads \"{}\", which is transient \ + or inside a transient object: its value is never stored, so the index \ + would never hold it", + index.name, property.name + )), + )); + } + } + Ok(()) +} + /// Every typed array property's `maxItems` (which the parse requires) is at /// most `SystemLimits::max_typed_array_items`, so its worst-case encoded /// size stays small. Read off the flattened properties, which reach a typed @@ -825,6 +888,8 @@ mod reference_lookup_tests; #[cfg(all(test, feature = "validation"))] mod reference_test_helpers; #[cfg(all(test, feature = "validation"))] +mod transient_tests; +#[cfg(all(test, feature = "validation"))] mod typed_array_reference_tests; #[cfg(all(test, feature = "validation"))] mod typed_array_test_helpers; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/transient_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/transient_tests.rs new file mode 100644 index 00000000000..b097d681611 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/transient_tests.rs @@ -0,0 +1,171 @@ +//! The `transient` doctype keyword under generation 3: every entry names a +//! top-level property, and no index reads a transient value. Both are +//! registration lints (`full_validation` only), so a stored contract stays +//! readable, and generation 2 (protocol version 13) keeps accepting both. + +use super::*; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::errors::DataContractError; +use platform_value::platform_value; + +fn parse_dispatched( + schema: Value, + platform_version: &PlatformVersion, + full_validation: bool, +) -> Result { + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "note", + schema, + None, + &BTreeMap::new(), + &config, + full_validation, + &mut vec![], + platform_version, + ) +} + +/// A `note` type with a short `code`, a long `body` and a required `meta` +/// object around a required `tag`, with `extra` set on top. +fn note_schema_with(extra: Value) -> Value { + let mut schema = platform_value!({ + "type": "object", + "properties": { + "code": { "type": "string", "maxLength": 32, "position": 0 }, + "body": { "type": "string", "maxLength": 500, "position": 1 }, + "meta": { + "type": "object", + "position": 2, + "properties": { + "tag": { "type": "string", "maxLength": 30, "position": 0 } + }, + "required": ["tag"], + "additionalProperties": false + } + }, + "required": ["code", "meta"], + "additionalProperties": false + }); + if let Value::Map(entries) = extra { + for (key, value) in entries { + let key = key.to_text().expect("a text key"); + schema.set_value(&key, value).expect("doctype key applies"); + } + } + schema +} + +fn expect_structure_error(result: Result, needle: &str) { + let message = match result { + Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure( + message, + ))) => message, + Err(ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message), + )) => message, + other => { + panic!("expected InvalidContractStructure containing {needle:?}, got {other:?}") + } + }, + Err(other) => { + panic!("expected InvalidContractStructure containing {needle:?}, got {other}") + } + Ok(parsed) => { + panic!("expected rejection containing {needle:?}, but the schema parsed: {parsed:?}") + } + }; + assert!( + message.contains(needle), + "expected structure error containing {needle:?}, got: {message}" + ); +} + +#[test] +fn should_accept_transient_top_level_properties_and_objects() { + for transient in [ + platform_value!(["body"]), + platform_value!(["meta"]), + platform_value!(["code", "body", "meta"]), + ] { + let schema = note_schema_with(platform_value!({ "transient": transient.clone() })); + parse_dispatched(schema, PlatformVersion::latest(), true) + .unwrap_or_else(|e| panic!("{transient:?} should register: {e}")); + } +} + +/// Drive drops transient values by top-level name, so an entry naming +/// anything else would be flagged transient and still stored. +#[test] +fn should_refuse_a_transient_entry_that_is_not_a_top_level_property() { + for entry in ["meta.tag", "ghost", "$ownerId"] { + let schema = note_schema_with(platform_value!({ "transient": [entry] })); + expect_structure_error( + parse_dispatched(schema.clone(), PlatformVersion::latest(), true), + &format!( + "document type \"note\" lists \"{entry}\" as transient, but it is not a \ + top-level property of the document type" + ), + ); + + // A stored contract is parsed without full validation and stays readable + parse_dispatched(schema.clone(), PlatformVersion::latest(), false) + .unwrap_or_else(|e| panic!("{entry}: the stored path should parse: {e}")); + + // Generation 2 predates the rule + let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13"); + parse_dispatched(schema, platform_version_13, true) + .unwrap_or_else(|e| panic!("{entry}: protocol version 13 should accept it: {e}")); + } +} + +/// A transient value is never stored, so every document would sit in the +/// index's null branch and a unique index would enforce nothing. +#[test] +fn should_refuse_an_index_reading_a_transient_property_or_one_inside_a_transient_object() { + for (transient, index_property, unique) in [ + ("code", "code", true), + ("code", "code", false), + ("meta", "meta.tag", true), + ] { + let index_properties = Value::Array(vec![Value::Map(vec![( + Value::Text(index_property.to_string()), + Value::Text("asc".to_string()), + )])]); + let schema = note_schema_with(platform_value!({ + "transient": [transient], + "indices": [{ + "name": "byValue", + "properties": index_properties, + "unique": unique + }] + })); + expect_structure_error( + parse_dispatched(schema.clone(), PlatformVersion::latest(), true), + &format!( + "index \"byValue\" of document type \"note\" reads \"{index_property}\", which \ + is transient or inside a transient object" + ), + ); + parse_dispatched(schema.clone(), PlatformVersion::latest(), false) + .unwrap_or_else(|e| panic!("{index_property}: the stored path should parse: {e}")); + let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13"); + parse_dispatched(schema, platform_version_13, true).unwrap_or_else(|e| { + panic!("{index_property}: protocol version 13 should accept it: {e}") + }); + } + + // The same index over a stored property, beside a transient one, registers + let schema = note_schema_with(platform_value!({ + "transient": ["body"], + "indices": [{ "name": "byValue", "properties": [{ "meta.tag": "asc" }], "unique": true }] + })); + parse_dispatched(schema, PlatformVersion::latest(), true) + .expect("an index over a stored property registers"); +} 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 f0253041799..f64804b1009 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 @@ -1195,7 +1195,7 @@ pub fn is_referring_system_agreement_property(name: &str) -> bool { /// around it, is transient: either way its value is never stored. /// `transient_fields()` holds the paths as declared, so a leaf of a transient /// object is found only through the object's path, a prefix of its own. -pub(crate) fn is_transient(document_type: DocumentTypeRef, path: &str) -> bool { +pub fn is_transient(document_type: DocumentTypeRef, path: &str) -> bool { let transient_fields = document_type.transient_fields(); path.match_indices('.') .map(|(end, _)| &path[..end]) diff --git a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs index 8a495152454..236cb929b42 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs @@ -223,7 +223,8 @@ impl DocumentReferenceLookup { /// index must exist and be unique, so the key finds at most one document; /// it may not bucket a timestamp (`timeRange`), since its first key part /// is then a bucket start no referring value names; the referenced type - /// may not be `indexOnly`; `keys` must map every property of the index + /// may not be `indexOnly`; no index property may be transient, a value + /// no stored document holds; `keys` must map every property of the index /// exactly once and nothing else; and each source must hold the same kind /// of value as the index property it fills, or no document could ever /// match. The key must also stay with the document it found, see @@ -261,6 +262,22 @@ impl DocumentReferenceLookup { referenced.name() )); } + // A transient value is never stored, so no document would ever sit in + // the index under a key naming it + if let Some(transient) = index + .properties + .iter() + .find(|property| is_transient(referenced, &property.name)) + { + return Some(format!( + "index \"{}\" of \"{}\" keys documents by \"{}\", which is transient or inside \ + a transient object: its value is never stored, so the lookup could never find \ + a document", + self.index, + referenced.name(), + transient.name + )); + } if let Some(missing) = index .properties .iter() @@ -695,6 +712,33 @@ mod tests { ); } + /// A registration refuses an index over a transient property, but a type + /// parsed without full validation (a stored contract) still reaches the + /// lookup's own check, which must never find a document through it. + #[test] + fn should_refuse_a_lookup_into_an_index_reading_a_transient_property() { + let lookup = lookup( + "bySubmittedCharter", + &[ + ("submittedCharterId", "submittedCharterId"), + ("$ownerId", "."), + ], + ); + let referenced = join_request_with(platform_value!({ + "transient": ["submittedCharterId"] + })); + let error = lookup + .referenced_side_error(elected_charter().as_ref(), referenced.as_ref()) + .expect("an index over a transient property should be refused"); + assert!( + error.contains( + "index \"bySubmittedCharter\" of \"joinRequest\" keys documents by \ + \"submittedCharterId\", which is transient or inside a transient object" + ), + "{error}" + ); + } + /// A key part read from the writer would move with a transfer or a /// purchase of the referring document, which re-validates nothing, so /// only a type that can do neither may read it. diff --git a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs index da34f2b3daa..e2e09dacee1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs @@ -32,7 +32,8 @@ //! //! The top-level `ownerRefersTo` and `creatorRefersTo` keys (protocol version //! 14) get the frozen rule of the property `refersTo`, so any change to them -//! is an incompatible schema change. +//! is an incompatible schema change. So does the top-level `transient` list, +//! which generation 0 fails on as an unsupported keyword. use crate::data_contract::document_type::schema::IncompatibleJsonSchemaOperation; use crate::data_contract::errors::{DataContractError, JsonSchemaError}; @@ -68,8 +69,14 @@ static OPTIONS: Lazy = Lazy::new(|| { // also reads, because no earlier protocol version knows the keywords. // Without a `refersTo` rule to copy, a diff under either fails as an // unsupported keyword, an error rather than a panic. + // The top-level `transient` list gets the same frozen rule: it decides + // which values a stored document carries and how each property is encoded + // (a transient one takes a presence byte even when required), so documents + // written under one list could not be read under another. Without a rule + // the differ fails on any change to it as an unsupported keyword, an + // internal error instead of an incompatible schema change. let refers_to_rule = KEYWORD_COMPATIBILITY_RULES.get("refersTo"); - let doctype_refers_to_rules = ["ownerRefersTo", "creatorRefersTo"] + let frozen_doctype_rules = ["ownerRefersTo", "creatorRefersTo", "transient"] .into_iter() .filter_map(|keyword| refers_to_rule.map(|rule| (keyword, rule.clone()))); @@ -77,7 +84,7 @@ static OPTIONS: Lazy = Lazy::new(|| { override_rules: CompatibilityRulesCollection::from_iter( [("required", required_rule)] .into_iter() - .chain(doctype_refers_to_rules), + .chain(frozen_doctype_rules), ), } }); @@ -347,4 +354,82 @@ mod tests { )) if message == "schema keyword 'indices' at path '/indices/0/unique' is not supported" ); } + + fn with_transient(transient: Option) -> serde_json::Value { + let mut schema = json!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0}, + "b": {"type": "string", "position": 1}, + }, + "additionalProperties": false, + }); + if let Some(transient) = transient { + schema["transient"] = transient; + } + schema + } + + /// Stored documents are encoded by the transient list (a transient + /// property takes a presence byte), so no change to it is compatible. + #[test] + fn should_report_every_transient_list_change_as_incompatible() { + let platform_version = PlatformVersion::latest(); + for (original, new, change_name, change_path) in [ + (None, Some(json!(["a"])), "add", "/transient"), + (Some(json!(["a"])), None, "remove", "/transient"), + ( + Some(json!(["a"])), + Some(json!(["a", "b"])), + "add", + "/transient/1", + ), + ( + Some(json!(["a"])), + Some(json!(["b"])), + "replace", + "/transient/0", + ), + ] { + let result = validate_schema_compatibility( + &with_transient(original.clone()), + &with_transient(new.clone()), + platform_version, + ) + .expect("a transient change is judged, not an unsupported keyword"); + assert_matches!( + result.errors.as_slice(), + [change] if change.name == change_name && change.path == change_path, + "{original:?} -> {new:?}" + ); + } + + // An unchanged list is no change at all + let unchanged = with_transient(Some(json!(["a"]))); + assert!( + validate_schema_compatibility(&unchanged, &unchanged, platform_version) + .expect("an unchanged schema is judged") + .is_valid() + ); + } + + // Replay-safety pin: protocol version 13 dispatches to v0, where a + // `/transient` diff still hits the unsupported-keyword hard error. + #[test] + fn v0_should_error_on_transient_diff() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13 must exist"); + let error = validate_schema_compatibility( + &with_transient(Some(json!(["a"]))), + &with_transient(Some(json!(["a", "b"]))), + platform_version, + ) + .expect_err("a transient diff must hard-error under v0"); + + assert_matches!( + error, + ProtocolError::DataContractError(DataContractError::JsonSchema( + JsonSchemaError::SchemaCompatibilityValidationError(message) + )) if message == "schema keyword 'transient' at path '/transient/1' is not supported" + ); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs index e2498195789..9ebbb6ea7f5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs @@ -2,7 +2,7 @@ use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::{ - is_referenced_system_agreement_property, is_referring_system_agreement_property, + is_referenced_system_agreement_property, is_referring_system_agreement_property, is_transient, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, DocumentTypeRef, KeyReferenceIdentityProperty, PropertyReference, ReferenceHolder, @@ -39,6 +39,19 @@ fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { a.value_kind() == b.value_kind() } +/// Whether a key reference pairing the key id at `key_id_path` with the +/// identity at `identity_path` of `document_type` would store the key id +/// without its identity: the identity transient (or inside a transient +/// object) while the key id is not. A stored key id alone names no key. With +/// both transient, or the key id alone, nothing unreadable is stored. +fn stores_key_id_without_identity( + document_type: DocumentTypeRef, + key_id_path: &str, + identity_path: &str, +) -> bool { + is_transient(document_type, identity_path) && !is_transient(document_type, key_id_path) +} + /// Checks every reference declaration of the given contract that carries /// declaration content. /// @@ -55,7 +68,10 @@ fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { /// billed. /// /// `identityPublicKey`: the declared key id property must exist in the same -/// document type and be an integer. +/// document type and be an integer. On either side of a key reference, a +/// stored key id may not pair with a transient identity, which would leave it +/// naming no key; an agreement's referenced property may not be transient, +/// since no stored document carries its value. /// /// A declaration on the `items` of a typed array of identifiers holds for /// every element and is checked once, exactly as a single reference's: the @@ -194,6 +210,17 @@ pub(super) fn validate_data_contract_references_v0( } Some(_) => {} } + if stores_key_id_without_identity( + document_type.as_ref(), + path, + identity_path, + ) { + return Ok(invalid(&format!( + "the key id is stored but the identity property \ + {identity_path} is transient or inside a transient object: \ + a reader could not tell whose key it is" + ))); + } } } continue; @@ -306,7 +333,25 @@ fn validate_reference_target_declaration_v0( .into(), )); } - Some(_) => return Ok(SimpleConsensusValidationResult::new()), + Some(_) => { + let stored_without_identity = reference_property.is_some_and(|identity_path| { + stores_key_id_without_identity(document_type, key_id_property, identity_path) + }); + if stored_without_identity { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), + declaration_path, + "the key id is stored but the identity property carrying the \ + reference is transient or inside a transient object: a reader \ + could not tell whose key it is" + .to_string(), + ) + .into(), + )); + } + return Ok(SimpleConsensusValidationResult::new()); + } } } @@ -532,6 +577,16 @@ fn validate_reference_target_declaration_v0( "the referenced document type does not define the referenced property", )); }; + // No stored document carries a transient value, so a referring + // document could only agree by omitting its own side. The referring + // side may be transient: it is judged on the transition, a write + // gate like the writer's `$ownerId`. + if is_transient(referenced_document_type, referenced_property) { + return Ok(invalid( + "the referenced property is transient or inside a transient object: no \ + stored document carries its value, so none could be agreed with", + )); + } if matches!(referring_type, DocumentPropertyType::Object(_)) || matches!(referenced.property_type, DocumentPropertyType::Object(_)) { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs index 97846cfb16a..96739f47201 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs @@ -5964,6 +5964,42 @@ mod tests { ); } + /// No stored document carries a transient value, so an agreement with + /// one on the referenced side could only hold for a referring document + /// omitting its own side, and a required one never. + #[tokio::test] + async fn should_reject_agreement_on_a_transient_referenced_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referenced.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(error) + ), + .. + } if error.reason().contains("the referenced property is transient") + ); + } + + /// The referring side is judged on the transition, so a transient one is + /// a write gate and registers. + #[tokio::test] + async fn should_register_agreement_on_a_transient_referring_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referring.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + /// `refersTo` on the items of a typed array registers with every target /// the fixture uses: permanent and deletable document elements, an /// agreement keyed by the writer and one on a schema property. @@ -6288,6 +6324,52 @@ mod tests { ); } + /// A stored key id whose identity is transient names no key a reader + /// could find, whichever side declares the pair: `identityProperty` on + /// the key id, or `keyIdProperty` on the identity. + #[tokio::test] + async fn should_reject_a_stored_key_id_paired_with_a_transient_identity() { + for (fixture, key_id_property) in [ + ( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-identity.json", + "recipientKeyId", + ), + ( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-transient-identity.json", + "toKeyIndex", + ), + ] { + let result = run_contract_create(fixture).await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedKeyIdPropertyInvalidError(e) + ), + .. + } if e.key_id_property() == key_id_property + && e.message().contains("transient or inside a transient object"), + "{fixture}" + ); + } + } + + /// With the key id transient too, nothing unreadable is stored: the + /// pair is judged on the transition alone. + #[tokio::test] + async fn should_register_a_key_reference_whose_key_id_and_identity_are_both_transient() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-pair.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + #[tokio::test] async fn should_reject_key_reference_naming_an_identity_property_with_its_own_key_reference( ) { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index da6ac9a75d6..dccfc87c91a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -355,6 +355,88 @@ mod tests { assert_state_consensus_errors!(result, DataContractIsReadonlyError, 1); } + /// Stored documents are encoded by the `transient` list, so an update + /// may not change it. The schema compatibility check had no rule for + /// the keyword and failed with an internal error, dropping the + /// transition unpaid; it now reports an incompatible schema change. + #[test] + pub fn should_refuse_an_update_changing_the_transient_list_as_an_incompatible_schema() { + use dpp::consensus::basic::BasicError; + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + + let platform_version = PlatformVersion::latest(); + let TestData { + mut data_contract, + platform, + } = setup_test(); + apply_contract(&platform, &data_contract, Default::default()); + + let mut updated_document = data_contract + .document_type_for_name("niceDocument") + .expect("the fixture's niceDocument") + .schema() + .clone(); + updated_document + .set_value("transient", platform_value!(["name"])) + .expect("the transient list sets"); + + data_contract.increment_version(); + data_contract + .set_document_schema( + "niceDocument", + updated_document, + true, + &mut vec![], + platform_version, + ) + .expect("to be able to set document schema"); + + let state_transition = DataContractUpdateTransitionV0 { + identity_contract_nonce: 1, + data_contract: DataContractInSerializationFormat::try_from_platform_versioned( + data_contract, + platform_version, + ) + .expect("to be able to convert data contract to serialization format"), + user_fee_increase: 0, + signature: BinaryData::new(vec![0; 65]), + signature_public_key_id: 0, + }; + + let state = platform.state.load(); + + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("expected a platform version"); + + let result = DataContractUpdateTransition::V0(state_transition) + .validate_state( + None, + &platform_ref, + ValidationMode::Validator, + &BlockInfo::default(), + &mut execution_context, + None, + ) + .expect("a transient change is a consensus error, not an internal one"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.document_type_name() == "niceDocument" + && e.operation() == "add" + && e.property_path() == "/transient" + ); + } + #[test] pub fn should_keep_history_if_contract_config_keeps_history_is_true() { let TestData { diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referenced.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referenced.json new file mode 100644 index 00000000000..7a8ff5588d9 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referenced.json @@ -0,0 +1,57 @@ +{ + "$formatVersion": "1", + "id": "6Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVe", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "canBeDeleted": false, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + }, + "topic": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false, + "transient": [ + "topic" + ] + }, + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "noteId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "note", + "propertyAgreement": { + "topic": "topic" + } + } + }, + "topic": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referring.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referring.json new file mode 100644 index 00000000000..e7f13f07cfe --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referring.json @@ -0,0 +1,57 @@ +{ + "$formatVersion": "1", + "id": "6Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVe", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "canBeDeleted": false, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + }, + "topic": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + }, + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "noteId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "note", + "propertyAgreement": { + "topic": "topic" + } + } + }, + "topic": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false, + "transient": [ + "topic" + ] + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-transient-identity.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-transient-identity.json new file mode 100644 index 00000000000..c25571076a2 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-transient-identity.json @@ -0,0 +1,35 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "toKeyIndex" + } + }, + "toKeyIndex": { + "type": "integer", + "position": 1, + "minimum": 0 + } + }, + "required": [], + "additionalProperties": false, + "transient": [ + "toUserId" + ] + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-identity.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-identity.json new file mode 100644 index 00000000000..b7766a7429a --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-identity.json @@ -0,0 +1,42 @@ +{ + "$formatVersion": "1", + "id": "F2rQbPN8ub3UN19qTQh86Wx8LWsAkHFJe39j4QJyxYVG", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 1, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "toUserId" + } + }, + "note": { + "type": "string", + "position": 2, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false, + "transient": [ + "toUserId" + ] + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-pair.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-pair.json new file mode 100644 index 00000000000..9edf94ff89c --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-pair.json @@ -0,0 +1,43 @@ +{ + "$formatVersion": "1", + "id": "F2rQbPN8ub3UN19qTQh86Wx8LWsAkHFJe39j4QJyxYVG", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 1, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "toUserId" + } + }, + "note": { + "type": "string", + "position": 2, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false, + "transient": [ + "toUserId", + "recipientKeyId" + ] + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v1/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v1/mod.rs index 9b1b631e1fc..7b55966cee7 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v1/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v1/mod.rs @@ -1,12 +1,22 @@ //! Generation 1 of the replace-action → `Document` conversion: generation 0 -//! plus the contract-version stamp. A replace re-supplies the full document -//! contents, so the document is re-stamped with the current contract -//! version. This generation must only be selected by platform versions -//! whose document serialization format writes the stamp (format 3, protocol -//! v14+); the version table pairs the two. +//! plus the contract-version stamp, without the transient values. A replace +//! re-supplies the full document contents, so the document is re-stamped with +//! the current contract version. This generation must only be selected by +//! platform versions whose document serialization format writes the stamp +//! (format 3, protocol v14+); the version table pairs the two. +//! +//! A transient property is judged on the transition and never stored: a +//! create drops its value before the document is built. Generation 0 stored +//! whatever a replace carried, so a replaced document kept the values its +//! create had dropped. Generation 1 drops them the same way, by top-level +//! name. Edited in place: protocol version 14, the only one selecting this +//! generation, is unreleased. + +use std::collections::BTreeSet; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::document::{Document, DocumentV0Setters}; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; use dpp::platform_value::Identifier; use dpp::version::PlatformVersion; use dpp::ProtocolError; @@ -17,7 +27,8 @@ use super::{DocumentFromReplaceTransitionActionV0, DocumentReplaceTransitionActi /// document from replace transition v1 pub trait DocumentFromReplaceTransitionActionV1 { /// Attempts to create a new `Document` from the given `DocumentReplaceTransitionAction` - /// reference and `owner_id`, re-stamped with the current contract version. + /// reference and `owner_id`, re-stamped with the current contract version and + /// without the document type's transient values. fn try_from_replace_transition_action_v1( value: &DocumentReplaceTransitionActionV0, owner_id: Identifier, @@ -26,7 +37,8 @@ pub trait DocumentFromReplaceTransitionActionV1 { where Self: Sized; /// Attempts to create a new `Document` from the given `DocumentReplaceTransitionAction` - /// instance and `owner_id`, re-stamped with the current contract version. + /// instance and `owner_id`, re-stamped with the current contract version and + /// without the document type's transient values. fn try_from_owned_replace_transition_action_v1( value: DocumentReplaceTransitionActionV0, owner_id: Identifier, @@ -46,6 +58,10 @@ impl DocumentFromReplaceTransitionActionV1 for Document { let mut document = Self::try_from_replace_transition_action_v0(value, owner_id, platform_version)?; document.set_contract_version(Some(contract_version)); + drop_transient_values( + &mut document, + value.base.document_type()?.transient_fields(), + ); Ok(document) } @@ -55,9 +71,21 @@ impl DocumentFromReplaceTransitionActionV1 for Document { platform_version: &PlatformVersion, ) -> Result { let contract_version = value.base.data_contract_fetch_info_ref().contract.version(); + let transient_fields = value.base.document_type()?.transient_fields().clone(); let mut document = Self::try_from_owned_replace_transition_action_v0(value, owner_id, platform_version)?; document.set_contract_version(Some(contract_version)); + drop_transient_values(&mut document, &transient_fields); Ok(document) } } + +/// Drops the values of `transient_fields` from `document`, as the create action +/// drops them from its data. +fn drop_transient_values(document: &mut Document, transient_fields: &BTreeSet) { + if !transient_fields.is_empty() { + document + .properties_mut() + .retain(|key, _| !transient_fields.contains(key)); + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/tests.rs b/packages/rs-drive/src/state_transition_action/batch/tests.rs index a4ec5149de0..3334430a0de 100644 --- a/packages/rs-drive/src/state_transition_action/batch/tests.rs +++ b/packages/rs-drive/src/state_transition_action/batch/tests.rs @@ -3055,6 +3055,60 @@ fn should_stamp_fetched_contract_version_on_replace_conversion() { assert_eq!(owned.contract_version(), Some(STAMP_TEST_CONTRACT_VERSION)); } +/// A create drops a transient value (the DPNS `domain`'s `preorderSalt`) +/// before its document is stored. From protocol version 14 a replace drops it +/// too; protocol version 13 stored whatever the replace carried. +#[test] +fn should_drop_transient_values_on_replace_conversion_from_protocol_version_14() { + let owner_id = Identifier::from([0xDD; 32]); + let data = BTreeMap::from([ + ("label".to_string(), Value::Text("alice".to_string())), + ("preorderSalt".to_string(), Value::Bytes32([7; 32])), + ]); + let platform_version_13 = PlatformVersion::get(13).expect("expected protocol version 13"); + + for (platform_version, replace_keeps_salt) in [ + (PlatformVersion::latest(), false), + (platform_version_13, true), + ] { + let mut replace = stamp_test_replace_action(platform_version.protocol_version); + let DocumentReplaceTransitionAction::V0(replace_v0) = &mut replace; + replace_v0.data = data.clone(); + let mut create = stamp_test_create_action(platform_version.protocol_version); + let DocumentCreateTransitionAction::V0(create_v0) = &mut create; + create_v0.data = data.clone(); + + let replaced = [ + Document::try_from_replace_transition_action(&replace, owner_id, platform_version) + .expect("borrowed replace conversion"), + Document::try_from_owned_replace_transition_action(replace, owner_id, platform_version) + .expect("owned replace conversion"), + ]; + let created = [ + Document::try_from_create_transition_action(&create, owner_id, platform_version) + .expect("borrowed create conversion"), + Document::try_from_owned_create_transition_action(create, owner_id, platform_version) + .expect("owned create conversion"), + ]; + for (document, keeps_salt) in replaced + .iter() + .map(|document| (document, replace_keeps_salt)) + .chain(created.iter().map(|document| (document, false))) + { + assert_eq!( + document.properties().contains_key("preorderSalt"), + keeps_salt, + "protocol version {}", + platform_version.protocol_version + ); + assert_eq!( + document.properties().get("label"), + Some(&Value::Text("alice".to_string())) + ); + } + } +} + // ============================================================ // 24. Gas payer resolution (protocol version 14) // ============================================================ diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 6db487a73b2..934d42b7bae 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -852,6 +852,35 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// rules, never on a transfer or a purchase, and frozen on update the same /// way. /// +/// 35. **Transient properties are never stored**: a transient property is +/// judged on the transition and dropped before its document is stored. +/// Up to v13 only a create dropped it and a replace stored whatever it +/// carried; `document_from_replace_transition_action` 1 (paired with the +/// contract-version stamp, edited in place, only selected by this +/// version) drops the transient values of a replace by top-level name as +/// a create does. The rules that read a stored value refuse a transient +/// one, by the property's path and every enclosing object's +/// (`is_transient`): at registration (parser generation 3 under full +/// validation) every `transient` entry must name a top-level property, +/// since Drive drops values by top-level name, and no index may read a +/// transient property, which every document would leave in the index's +/// null branch; a lookup's referenced side refuses such an index too +/// (`referenced_side_error`); the contract reference validation +/// (`data_contract_reference_validation` 0, extended in place, only +/// reached from this version) refuses a `propertyAgreement` whose +/// referenced property is transient, which no stored document carries, +/// and a key reference that stores the key id while its identity is +/// transient, in either form (`identityProperty` on the key id, +/// `keyIdProperty` on the identity). A transient referring side of an +/// agreement stays allowed: it is a write gate, judged on the +/// transition. Changing the `transient` list on contract update was an +/// unsupported keyword to the schema compatibility check, an internal +/// error that dropped the transition unpaid; `validate_schema_compatibility` +/// 1 freezes it as it freezes `refersTo`, an incompatible schema change. +/// A census of every mainnet and testnet contract (2026-09-23) found +/// `transient` only on DPNS-shaped `domain` types, which are immutable, +/// index no transient property and list top-level properties only. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's From d17a1ae3a4d19cd3a07f49745c09752faa823269 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 22:54:42 +0700 Subject: [PATCH 2/2] fix(platform): transient review fixes (PV14) - the frozen transient list is compared as a set: sorted and deduplicated before the schema diff, so a reordering is no change - one drop_transient_values helper for the create action (pure refactor) and the replace conversion, which now drops before moving the data instead of cloning the transient set - end-to-end replace test through process_raw_state_transitions at protocol versions 14 and 13 - drive-abci fixtures for a transient object around an agreement's referenced property or a key reference's identity, and the keyIdProperty pair - inertness comments at the in-place edits of data_contract_reference_validation 0, a should_ test name, shared v3 test helpers, a hint only for dotted transient entries, and the update limitation documented at the parser check Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 2 +- .../try_from_schema/v3/immutable_tests.rs | 7 +- .../class_methods/try_from_schema/v3/mod.rs | 27 ++- .../try_from_schema/v3/transient_tests.rs | 58 +----- .../validate_schema_compatibility/v1/mod.rs | 57 ++++-- .../batch/tests/document/replacement.rs | 165 ++++++++++++++++++ .../v0/mod.rs | 10 +- .../data_contract_create/mod.rs | 53 +++--- .../data_contract_update/mod.rs | 4 +- ...agreement-transient-referenced-object.json | 69 ++++++++ ...ntity-key-registration-transient-pair.json | 36 ++++ ...dentity-property-key-transient-object.json | 49 ++++++ .../transient/transient-note-contract.json | 27 +++ .../v0/mod.rs | 13 +- .../v1/mod.rs | 25 +-- .../document_transition/mod.rs | 15 ++ .../rs-platform-version/src/version/v14.rs | 4 +- 17 files changed, 489 insertions(+), 132 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referenced-object.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-transient-pair.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-object.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/transient/transient-note-contract.json diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index a0ad1d78c67..286b0c6f3b6 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -484,7 +484,7 @@ Because no stored document carries a transient value, a rule that reads a stored - A key reference does not store its key id with a transient identity, whichever side declares it (`identityProperty` on the key id, `keyIdProperty` on the identity): the key id alone names no key. - `encryptedFor` names no transient recipient or key id. -The list cannot change on contract update: it decides which values stored documents carry and how every property is encoded (a transient property takes a presence byte even when required). From protocol version 14 any change is an incompatible schema change; before it, the schema compatibility check failed on the keyword as unsupported, an internal error. +The list cannot change on contract update: it decides which values stored documents carry and how every property is encoded (a transient property takes a presence byte even when required). From protocol version 14 any change to the names it lists is an incompatible schema change (the list is compared as a set, so reordering or repeating a name is no change); before it, the schema compatibility check failed on the keyword as unsupported, an internal error. ## Typed Arrays diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/immutable_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/immutable_tests.rs index f2e65b14608..cdcb6bf2e3a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/immutable_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/immutable_tests.rs @@ -48,7 +48,7 @@ fn parse_with( /// Parse through the real dispatcher, which picks the parser generation out /// of the platform version's `try_from_schema` table value (generation 2 at /// PV13, generation 3 at PV14). -fn parse_dispatched( +pub(super) fn parse_dispatched( schema: Value, platform_version: &PlatformVersion, full_validation: bool, @@ -108,7 +108,10 @@ fn names(entries: &[&str]) -> BTreeSet { /// The lints surface as `InvalidContractStructure` either directly or, with /// the `validation` feature on, wrapped as the basic `ContractError`. -fn expect_structure_error(result: Result, needle: &str) { +pub(super) fn expect_structure_error( + result: Result, + needle: &str, +) { let message = match result { Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure( message, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index cd9a34b13f0..7c21126c950 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -527,7 +527,11 @@ fn try_from_schema_generation_3( /// still leave its value stored: list the object around a nested property. /// /// Full validation only: a contract registered before this version was never -/// held to it, and a stored contract must stay readable. +/// held to it, and a stored contract must stay readable. An update re-parses +/// the whole contract under full validation, and neither the list nor an index +/// can change on update, so a contract registered earlier with such a shape +/// could no longer be updated; a census of every mainnet and testnet contract +/// (2026-09-23) found none, as for the word-character names rule. #[cfg(feature = "validation")] fn validate_transient_fields( document_type: &DocumentTypeV2, @@ -538,13 +542,20 @@ fn validate_transient_fields( .iter() .find(|field| !document_type.properties.contains_key(*field)) { - Some(field) => Err(consensus_or_protocol_data_contract_error( - DataContractError::InvalidContractStructure(format!( - "document type \"{name}\" lists \"{field}\" as transient, but it is not a \ - top-level property of the document type: transient values are dropped by \ - top-level name, so list the object around a nested property" - )), - )), + Some(field) => { + let hint = if field.contains('.') && !field.starts_with('$') { + ": transient values are dropped by top-level name, so list the object around \ + a nested property" + } else { + "" + }; + Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{name}\" lists \"{field}\" as transient, but it is not a \ + top-level property of the document type{hint}" + )), + )) + } None => Ok(()), } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/transient_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/transient_tests.rs index b097d681611..37a923669ba 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/transient_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/transient_tests.rs @@ -3,35 +3,11 @@ //! registration lints (`full_validation` only), so a stored contract stays //! readable, and generation 2 (protocol version 13) keeps accepting both. +use super::immutable_tests::{expect_structure_error, parse_dispatched}; use super::*; -use crate::consensus::basic::BasicError; -use crate::consensus::ConsensusError; -use crate::data_contract::errors::DataContractError; use platform_value::platform_value; -fn parse_dispatched( - schema: Value, - platform_version: &PlatformVersion, - full_validation: bool, -) -> Result { - let config = DataContractConfig::default_for_version(platform_version) - .expect("default config available on this platform version"); - DocumentType::try_from_schema( - Identifier::new([1; 32]), - 1, - config.version(), - "note", - schema, - None, - &BTreeMap::new(), - &config, - full_validation, - &mut vec![], - platform_version, - ) -} - -/// A `note` type with a short `code`, a long `body` and a required `meta` +/// A type (parsed as `post`) with a short `code`, a long `body` and a required `meta` /// object around a required `tag`, with `extra` set on top. fn note_schema_with(extra: Value) -> Value { let mut schema = platform_value!({ @@ -61,32 +37,6 @@ fn note_schema_with(extra: Value) -> Value { schema } -fn expect_structure_error(result: Result, needle: &str) { - let message = match result { - Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure( - message, - ))) => message, - Err(ProtocolError::ConsensusError(boxed)) => match *boxed { - ConsensusError::BasicError(BasicError::ContractError( - DataContractError::InvalidContractStructure(message), - )) => message, - other => { - panic!("expected InvalidContractStructure containing {needle:?}, got {other:?}") - } - }, - Err(other) => { - panic!("expected InvalidContractStructure containing {needle:?}, got {other}") - } - Ok(parsed) => { - panic!("expected rejection containing {needle:?}, but the schema parsed: {parsed:?}") - } - }; - assert!( - message.contains(needle), - "expected structure error containing {needle:?}, got: {message}" - ); -} - #[test] fn should_accept_transient_top_level_properties_and_objects() { for transient in [ @@ -109,7 +59,7 @@ fn should_refuse_a_transient_entry_that_is_not_a_top_level_property() { expect_structure_error( parse_dispatched(schema.clone(), PlatformVersion::latest(), true), &format!( - "document type \"note\" lists \"{entry}\" as transient, but it is not a \ + "document type \"post\" lists \"{entry}\" as transient, but it is not a \ top-level property of the document type" ), ); @@ -149,7 +99,7 @@ fn should_refuse_an_index_reading_a_transient_property_or_one_inside_a_transient expect_structure_error( parse_dispatched(schema.clone(), PlatformVersion::latest(), true), &format!( - "index \"byValue\" of document type \"note\" reads \"{index_property}\", which \ + "index \"byValue\" of document type \"post\" reads \"{index_property}\", which \ is transient or inside a transient object" ), ); diff --git a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs index e2e09dacee1..4445b34e5a4 100644 --- a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs @@ -33,8 +33,10 @@ //! The top-level `ownerRefersTo` and `creatorRefersTo` keys (protocol version //! 14) get the frozen rule of the property `refersTo`, so any change to them //! is an incompatible schema change. So does the top-level `transient` list, -//! which generation 0 fails on as an unsupported keyword. +//! which generation 0 fails on as an unsupported keyword, compared as the set +//! of names the parse reads: sorted and deduplicated before the diff. +use crate::data_contract::document_type::property_names::TRANSIENT; use crate::data_contract::document_type::schema::IncompatibleJsonSchemaOperation; use crate::data_contract::errors::{DataContractError, JsonSchemaError}; use crate::data_contract::JsonValue; @@ -74,9 +76,11 @@ static OPTIONS: Lazy = Lazy::new(|| { // (a transient one takes a presence byte even when required), so documents // written under one list could not be read under another. Without a rule // the differ fails on any change to it as an unsupported keyword, an - // internal error instead of an incompatible schema change. + // internal error instead of an incompatible schema change. The parse reads + // the list as a set, so it is sorted and deduplicated before the diff + // ([`prepared_for_diff`]): only a changed set of names is a change. let refers_to_rule = KEYWORD_COMPATIBILITY_RULES.get("refersTo"); - let frozen_doctype_rules = ["ownerRefersTo", "creatorRefersTo", "transient"] + let frozen_doctype_rules = ["ownerRefersTo", "creatorRefersTo", TRANSIENT] .into_iter() .filter_map(|keyword| refers_to_rule.map(|rule| (keyword, rule.clone()))); @@ -101,23 +105,30 @@ static OPTIONS: Lazy = Lazy::new(|| { const TOP_LEVEL_VALIDATED_KEYS: [&str; 4] = ["indices", "required", "immutable", "immutableAllowSetting"]; -/// Strips [`TOP_LEVEL_VALIDATED_KEYS`] from a document type schema before it -/// is diffed. Only the document type's own top-level keys are removed; a -/// nested object property's `required` array lives under +/// Prepares a document type schema to be diffed: strips +/// [`TOP_LEVEL_VALIDATED_KEYS`], and sorts and deduplicates the top-level +/// `transient` list, which the parse reads as a set, so reordering or +/// repeating names is no change. Only the document type's own top-level keys +/// are touched; a nested object property's `required` array lives under /// `/properties//required` and stays governed by the differ's frozen -/// `required` rule, as do properties named `indices`, `required` or -/// `immutable`. -fn without_top_level_validated_keys(schema: &JsonValue) -> Cow<'_, JsonValue> { +/// `required` rule, as do properties named `indices`, `required`, +/// `immutable` or `transient`. +fn prepared_for_diff(schema: &JsonValue) -> Cow<'_, JsonValue> { match schema { JsonValue::Object(map) - if TOP_LEVEL_VALIDATED_KEYS - .iter() - .any(|key| map.contains_key(*key)) => + if map.contains_key(TRANSIENT) + || TOP_LEVEL_VALIDATED_KEYS + .iter() + .any(|key| map.contains_key(*key)) => { let mut map = map.clone(); for key in TOP_LEVEL_VALIDATED_KEYS { map.remove(key); } + if let Some(JsonValue::Array(names)) = map.get_mut(TRANSIENT) { + names.sort_by(|a, b| a.as_str().cmp(&b.as_str())); + names.dedup(); + } Cow::Owned(JsonValue::Object(map)) } _ => Cow::Borrowed(schema), @@ -137,8 +148,8 @@ pub(super) fn validate_schema_compatibility_v1( original_schema: &JsonValue, new_schema: &JsonValue, ) -> Result, ProtocolError> { - let original_schema = without_top_level_validated_keys(original_schema); - let new_schema = without_top_level_validated_keys(new_schema); + let original_schema = prepared_for_diff(original_schema); + let new_schema = prepared_for_diff(new_schema); validate_schemas_compatibility(&original_schema, &new_schema, OPTIONS.deref()) .map(|result| { @@ -413,10 +424,26 @@ mod tests { ); } + /// The parse reads the list as a set, so reordering or repeating names + /// changes nothing a stored document depends on. + #[test] + fn should_accept_a_reordered_or_repeated_transient_list() { + let platform_version = PlatformVersion::latest(); + for new in [json!(["b", "a"]), json!(["a", "b", "a"])] { + let result = validate_schema_compatibility( + &with_transient(Some(json!(["a", "b"]))), + &with_transient(Some(new.clone())), + platform_version, + ) + .expect("a transient change is judged, not an unsupported keyword"); + assert!(result.is_valid(), "{new:?}: {:?}", result.errors); + } + } + // Replay-safety pin: protocol version 13 dispatches to v0, where a // `/transient` diff still hits the unsupported-keyword hard error. #[test] - fn v0_should_error_on_transient_diff() { + fn should_hard_error_on_a_transient_diff_at_protocol_version_13() { let platform_version = PlatformVersion::get(13).expect("protocol version 13 must exist"); let error = validate_schema_compatibility( &with_transient(Some(json!(["a"]))), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index 13c73623208..8dae0c2d0a4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -549,6 +549,171 @@ mod replacement_tests { .await; } + const TRANSIENT_NOTE_CONTRACT_PATH: &str = + "tests/supporting_files/contract/transient/transient-note-contract.json"; + + /// A transient value is judged on the transition and dropped before its + /// document is stored; from protocol version 14 a replace drops it too. + #[tokio::test] + async fn should_store_a_replaced_document_without_its_transient_values() { + assert_eq!( + run_replace_carrying_a_transient_value(PlatformVersion::latest()).await, + None + ); + } + + /// Protocol version 13 stored whatever a replace carried, transient values + /// included: pinned so its chain history stays reproducible. + #[tokio::test] + async fn should_store_the_transient_values_of_a_replace_at_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + assert_eq!( + run_replace_carrying_a_transient_value(platform_version).await, + Some(Value::Text("y".to_string())) + ); + } + + /// Creates a `note` whose transient `code` is `x`, checks the stored + /// document has no `code`, replaces it with `code` `y`, and returns the + /// `code` the stored document holds after the replace. + async fn run_replace_carrying_a_transient_value( + platform_version: &PlatformVersion, + ) -> Option { + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(platform_version.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(433); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + + let contract = setup_contract( + &platform.drive, + TRANSIENT_NOTE_CONTRACT_PATH, + None, + None, + None::, + None, + Some(platform_version), + ); + let note = contract + .document_type_for_name("note") + .expect("expected a note document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = note + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document + .set_id_for_creation(note, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + document.set("body", "a".into()); + document.set("code", "x".into()); + let document_id = document.id(); + + let stored_code = || { + let query = + DriveDocumentQuery::new_primary_key_single_item_query(&contract, note, document_id); + platform + .drive + .query_documents( + query, + None, + false, + None, + Some(platform_version.protocol_version), + ) + .expect("expected to query the note") + .documents_owned() + .pop() + .expect("expected the stored note") + .properties() + .get("code") + .cloned() + }; + let process_and_commit = |transition: Vec| { + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_eq!(processing_result.valid_count(), 1); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + }; + + let create = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + note, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + process_and_commit( + create + .serialize_to_bytes() + .expect("expected serialized create"), + ); + assert_eq!( + stored_code(), + None, + "a create never stores a transient value" + ); + + document.increment_revision().unwrap(); + document.set("body", "b".into()); + document.set("code", "y".into()); + let replace = BatchTransition::new_document_replacement_transition_from_document( + document, + note, + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + process_and_commit( + replace + .serialize_to_bytes() + .expect("expected serialized replace"), + ); + + stored_code() + } + async fn run_document_replace_on_document_type_that_is_mutable_at_protocol_version( protocol_version: dpp::version::ProtocolVersion, expected_processing_fee: dpp::fee::Credits, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs index 9ebbb6ea7f5..723c23f032f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs @@ -210,6 +210,9 @@ pub(super) fn validate_data_contract_references_v0( } Some(_) => {} } + // In place: inert before protocol version 14, which + // alone reaches this module (contract create and + // update state validation 1) if stores_key_id_without_identity( document_type.as_ref(), path, @@ -334,6 +337,9 @@ fn validate_reference_target_declaration_v0( )); } Some(_) => { + // In place: inert before protocol version 14, which alone + // reaches this module (contract create and update state + // validation 1) let stored_without_identity = reference_property.is_some_and(|identity_path| { stores_key_id_without_identity(document_type, key_id_property, identity_path) }); @@ -580,7 +586,9 @@ fn validate_reference_target_declaration_v0( // No stored document carries a transient value, so a referring // document could only agree by omitting its own side. The referring // side may be transient: it is judged on the transition, a write - // gate like the writer's `$ownerId`. + // gate like the writer's `$ownerId`. In place: inert before protocol + // version 14, which alone reaches this module (contract create and + // update state validation 1). if is_transient(referenced_document_type, referenced_property) { return Ok(invalid( "the referenced property is transient or inside a transient object: no \ diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs index 96739f47201..0ba0e6394f5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs @@ -5966,23 +5966,27 @@ mod tests { /// No stored document carries a transient value, so an agreement with /// one on the referenced side could only hold for a referring document - /// omitting its own side, and a required one never. + /// omitting its own side, and a required one never. A property inside a + /// transient object is dropped with the object. #[tokio::test] async fn should_reject_agreement_on_a_transient_referenced_property() { - let result = run_contract_create( + for fixture in [ "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referenced.json", - ) - .await; + "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referenced-object.json", + ] { + let result = run_contract_create(fixture).await; - assert_matches!( - result, - StateTransitionExecutionResult::PaidConsensusError { - error: ConsensusError::StateError( - StateError::ReferencedDocumentPropertyAgreementInvalidError(error) - ), - .. - } if error.reason().contains("the referenced property is transient") - ); + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(error) + ), + .. + } if error.reason().contains("the referenced property is transient"), + "{fixture}" + ); + } } /// The referring side is judged on the transition, so a transient one is @@ -6338,6 +6342,10 @@ mod tests { "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-transient-identity.json", "toKeyIndex", ), + ( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-object.json", + "recipientKeyId", + ), ] { let result = run_contract_create(fixture).await; @@ -6356,18 +6364,21 @@ mod tests { } /// With the key id transient too, nothing unreadable is stored: the - /// pair is judged on the transition alone. + /// pair is judged on the transition alone, in either form. #[tokio::test] async fn should_register_a_key_reference_whose_key_id_and_identity_are_both_transient() { - let result = run_contract_create( + for fixture in [ "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-pair.json", - ) - .await; + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-transient-pair.json", + ] { + let result = run_contract_create(fixture).await; - assert_matches!( - result, - StateTransitionExecutionResult::SuccessfulExecution { .. } - ); + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "{fixture}" + ); + } } #[tokio::test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index dccfc87c91a..054c06620c4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -263,6 +263,7 @@ mod tests { use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; use dpp::data_contract::config::v0::DataContractConfigSettersV0; + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::schema::DataContractSchemaMethodsV0; use dpp::data_contract::serialized_version::DataContractInSerializationFormat; @@ -361,9 +362,6 @@ mod tests { /// transition unpaid; it now reports an incompatible schema change. #[test] pub fn should_refuse_an_update_changing_the_transient_list_as_an_incompatible_schema() { - use dpp::consensus::basic::BasicError; - use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; - let platform_version = PlatformVersion::latest(); let TestData { mut data_contract, diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referenced-object.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referenced-object.json new file mode 100644 index 00000000000..ec312edb441 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-transient-referenced-object.json @@ -0,0 +1,69 @@ +{ + "$formatVersion": "1", + "id": "6Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVe", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "canBeDeleted": false, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + }, + "topic": { + "type": "string", + "position": 1, + "maxLength": 63 + }, + "meta": { + "type": "object", + "position": 2, + "properties": { + "topic": { + "type": "string", + "position": 0, + "maxLength": 63 + } + }, + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false, + "transient": [ + "meta" + ] + }, + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "noteId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "note", + "propertyAgreement": { + "topic": "meta.topic" + } + } + }, + "topic": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-transient-pair.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-transient-pair.json new file mode 100644 index 00000000000..e42f63e1827 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-transient-pair.json @@ -0,0 +1,36 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "toKeyIndex" + } + }, + "toKeyIndex": { + "type": "integer", + "position": 1, + "minimum": 0 + } + }, + "required": [], + "additionalProperties": false, + "transient": [ + "toUserId", + "toKeyIndex" + ] + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-object.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-object.json new file mode 100644 index 00000000000..cecbc55f66b --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key-transient-object.json @@ -0,0 +1,49 @@ +{ + "$formatVersion": "1", + "id": "F2rQbPN8ub3UN19qTQh86Wx8LWsAkHFJe39j4QJyxYVG", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 1, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "meta.toUserId" + } + }, + "note": { + "type": "string", + "position": 2, + "maxLength": 64 + }, + "meta": { + "type": "object", + "position": 0, + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false, + "transient": [ + "meta" + ] + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/transient/transient-note-contract.json b/packages/rs-drive-abci/tests/supporting_files/contract/transient/transient-note-contract.json new file mode 100644 index 00000000000..cea39eb9d1d --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/transient/transient-note-contract.json @@ -0,0 +1,27 @@ +{ + "$formatVersion": "1", + "id": "6Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVe", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "documentsMutable": true, + "properties": { + "body": { + "type": "string", + "position": 0, + "maxLength": 63 + }, + "code": { + "type": "string", + "position": 1, + "maxLength": 32 + } + }, + "required": ["body"], + "transient": ["code"], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs index 73193343490..d119adcf799 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs @@ -19,6 +19,7 @@ use dpp::document::property_names::{ use dpp::fee::Credits; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionV0}; +use crate::state_transition_action::batch::batched_transition::document_transition::drop_transient_values; use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; use dpp::version::PlatformVersion; @@ -148,11 +149,7 @@ impl DocumentFromCreateTransitionActionV0 for Document { let required_fields = document_type.required_fields(); - let transient_fields = document_type.transient_fields(); - - if !transient_fields.is_empty() { - data.retain(|key, _| !transient_fields.contains(key)); - } + drop_transient_values(&mut data, document_type.transient_fields()); let creator_id = if document_type.should_use_creator_id( data_contract.contract.system_version_type(), @@ -281,11 +278,7 @@ impl DocumentFromCreateTransitionActionV0 for Document { let required_fields = document_type.required_fields(); - let transient_fields = document_type.transient_fields(); - - if !transient_fields.is_empty() { - data.retain(|key, _| !transient_fields.contains(key)); - } + drop_transient_values(&mut data, document_type.transient_fields()); let creator_id = if document_type.should_use_creator_id( data_contract.contract.system_version_type(), diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v1/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v1/mod.rs index 7b55966cee7..d96a082666b 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v1/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v1/mod.rs @@ -12,8 +12,6 @@ //! name. Edited in place: protocol version 14, the only one selecting this //! generation, is unreleased. -use std::collections::BTreeSet; - use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; @@ -22,6 +20,7 @@ use dpp::version::PlatformVersion; use dpp::ProtocolError; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; +use crate::state_transition_action::batch::batched_transition::document_transition::drop_transient_values; use super::{DocumentFromReplaceTransitionActionV0, DocumentReplaceTransitionActionV0}; /// document from replace transition v1 @@ -59,33 +58,27 @@ impl DocumentFromReplaceTransitionActionV1 for Document { Self::try_from_replace_transition_action_v0(value, owner_id, platform_version)?; document.set_contract_version(Some(contract_version)); drop_transient_values( - &mut document, + document.properties_mut(), value.base.document_type()?.transient_fields(), ); Ok(document) } fn try_from_owned_replace_transition_action_v1( - value: DocumentReplaceTransitionActionV0, + mut value: DocumentReplaceTransitionActionV0, owner_id: Identifier, platform_version: &PlatformVersion, ) -> Result { let contract_version = value.base.data_contract_fetch_info_ref().contract.version(); - let transient_fields = value.base.document_type()?.transient_fields().clone(); + // Dropped from the action's own data before it moves into the document, + // as the create action does + drop_transient_values( + &mut value.data, + value.base.document_type()?.transient_fields(), + ); let mut document = Self::try_from_owned_replace_transition_action_v0(value, owner_id, platform_version)?; document.set_contract_version(Some(contract_version)); - drop_transient_values(&mut document, &transient_fields); Ok(document) } } - -/// Drops the values of `transient_fields` from `document`, as the create action -/// drops them from its data. -fn drop_transient_values(document: &mut Document, transient_fields: &BTreeSet) { - if !transient_fields.is_empty() { - document - .properties_mut() - .retain(|key, _| !transient_fields.contains(key)); - } -} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/mod.rs index 5798ea922d9..cd98d558509 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/mod.rs @@ -19,6 +19,8 @@ pub mod document_update_price_transition_action; pub use dpp::state_transition::batch_transition::batched_transition::document_transition_action_type::DocumentTransitionActionType; use derive_more::From; +use dpp::platform_value::Value; +use std::collections::{BTreeMap, BTreeSet}; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionAction; use crate::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0}; use crate::state_transition_action::batch::batched_transition::document_transition::document_delete_transition_action::DocumentDeleteTransitionAction; @@ -94,3 +96,16 @@ impl DocumentTransitionAction { } } } + +/// Drops the values of `transient_fields` from a document's `data`, by +/// top-level name: a transient property is judged on the transition and never +/// stored. The create action and, from protocol version 14, the replace action +/// both go through here, so they store the same data. +pub(crate) fn drop_transient_values( + data: &mut BTreeMap, + transient_fields: &BTreeSet, +) { + if !transient_fields.is_empty() { + data.retain(|key, _| !transient_fields.contains(key)); + } +} diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 934d42b7bae..c2b076d04eb 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -876,7 +876,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// transition. Changing the `transient` list on contract update was an /// unsupported keyword to the schema compatibility check, an internal /// error that dropped the transition unpaid; `validate_schema_compatibility` -/// 1 freezes it as it freezes `refersTo`, an incompatible schema change. +/// 1 freezes the set of names it lists (sorted and deduplicated before +/// the diff, so a reordering is no change) as it freezes `refersTo`, an +/// incompatible schema change. /// A census of every mainnet and testnet contract (2026-09-23) found /// `transient` only on DPNS-shaped `domain` types, which are immutable, /// index no transient property and list top-level properties only.