diff --git a/AGENTS.md b/AGENTS.md index 8b235bc094a..316d53322ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Platform uses data contracts to define application data schemas: Use LF, UTF‑8, and a final newline. - JS/TS: ESLint (Airbnb/TypeScript rules via package configs). Use camelCase for variables/functions, PascalCase for classes; prefer kebab-case filenames within JS packages. - Rust: Follow rustfmt defaults; keep code clippy-clean. Modules `snake_case`, types `PascalCase`, constants `SCREAMING_SNAKE_CASE`. -- Rust architecture rules live in The Dash Platform Book (`book/`). Read [book/src/contributing/coding-conventions.md](book/src/contributing/coding-conventions.md) before changing versioned behaviour, validation, errors, fees, or limits; it states each rule, why it exists, and links to the chapter with the mechanics. Key rules: shipped `vN` modules are frozen and new behaviour is a new `vN` selected only by the unreleased protocol version's tables; numbers go in `SystemLimits`, fees in named `FEE_VERSION*` schedules; `platform_version` is the last parameter; no `unwrap`/`expect` on block-execution paths (a panic halts the chain); imports at the top, no inline `crate::` paths; latest-generation tests use `PlatformVersion::latest()`. +- Rust architecture rules live in The Dash Platform Book (`book/`). Read [book/src/contributing/coding-conventions.md](book/src/contributing/coding-conventions.md) before changing versioned behaviour, validation, errors, fees, or limits; it states each rule, why it exists, and links to the chapter with the mechanics. Key rules: new behaviour is a new `vN` selected only by the unreleased protocol version's tables, and a shipped `vN` is edited in place only when the edit provably cannot modify consensus there, with the reason in the PR description's "In-place changes to shipped generations" section; numbers go in `SystemLimits`, fees in named `FEE_VERSION*` schedules; `platform_version` is the last parameter; no `unwrap`/`expect` on block-execution paths (a panic halts the chain); imports at the top, no inline `crate::` paths; latest-generation tests use `PlatformVersion::latest()`. ## Testing Guidelines - Unit/integration tests live alongside each package (e.g., `packages//tests`). E2E lives in `packages/platform-test-suite`. diff --git a/book/src/contributing/coding-conventions.md b/book/src/contributing/coding-conventions.md index 65d7ae9c579..1767435c069 100644 --- a/book/src/contributing/coding-conventions.md +++ b/book/src/contributing/coding-conventions.md @@ -72,25 +72,38 @@ The mechanics of `PlatformVersion`, feature version tables, and the dispatcher shape are covered in the [Versioning](../versioning/platform-version.md) chapters. The rules here are about what to do with those mechanics. -### Shipped generations are frozen +### Shipped generations are frozen unless the change cannot modify consensus A behaviour change to a versioned method means a new `vN` module selected only -by the tables of the unreleased protocol version. It never means editing a -shipped `vN`, and that includes "harmless" edits: threading a new parameter -through it, adding a version-table check inside it, or computing a gate that is -always false for old versions. Inside the new generation the capability is a -constant fact (`Index::try_from_value_map(map, true)`), not a runtime check. - -Why: replay safety becomes structural instead of something a reviewer has to -prove about a diff. A dead version check inside `v1` misleads the next reader -into thinking `v1` can take that path. Shipped files should stay byte-identical -to what shipped. - -How: copy the previous generation into the new module, make the change there, -move the tests that exercise the new behaviour into the new module, and bump -the method's number in the new protocol version's tables only. Duplication -between generations is the accepted cost; it is cheaper than a drift-prone -flag. +by the tables of the unreleased protocol version. A shipped `vN` may be edited +in place only when we are sure the edit cannot modify consensus at any protocol +version that selects it: the new code is unreachable there by construction +(the data it acts on cannot exist under those versions, such as a keyword every +one of their meta-schemas refuses and their parser ignores, judged through a +dpp method whose own gate is `None` there), or the edit is a pure refactor with +identical output. "Probably inert" is not enough. If the argument takes more +than a sentence, or rests on a runtime check inside the shipped module, add a +generation instead. Inside a new generation the capability is a constant fact +(`Index::try_from_value_map(map, true)`), not a runtime check. + +Why: replay safety is structural when a shipped file stays byte-identical, and +becomes a proof the reviewer has to check the moment it does not. An in-place +edit is acceptable when that proof is short and written down; a dead version +check inside `v1` that misleads the next reader into thinking `v1` can take +that path is not. + +How, new generation: copy the previous generation into the new module, make +the change there, move the tests that exercise the new behaviour into the new +module, and bump the method's number in the new protocol version's tables +only. Duplication between generations is the accepted cost; it is cheaper than +a drift-prone flag. + +How, in place: make the edit, leave a comment at the edited lines naming why +they are inert for every protocol version that selects the module, and give +the pull request description an "In-place changes to shipped generations" +section that lists each edited generation, the protocol versions that select +it, and the reason consensus cannot change there. Reviewers read that section +first. ### Table versions follow protocol-version boundaries, not PRs @@ -472,6 +485,17 @@ now does. `PlatformVersion::latest()`; pin `vN/`'s tests to `PlatformVersion::get(n)`. 5. Add a test that runs both versions through the dispatcher. +**Editing a shipped generation in place** + +1. Only when the edit cannot modify consensus at any protocol version that + selects the module: unreachable by construction there, or output-identical. +2. Comment the edited lines with why they are inert for those versions. +3. Add a test that runs the module at the last shipped protocol version and + shows the outcome unchanged. +4. Add an "In-place changes to shipped generations" section to the pull + request description: each edited generation, the versions that select it, + and the reason consensus cannot change there. + **Changing a limit or a fee** 1. A number: add or update the `SystemLimits` (or `*_constants`) field, backfill diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index d2909cb6bfb..d9dda2a3d10 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -332,6 +332,38 @@ The array is stored inline in the document, like any other property: a varint el In Rust a typed array parses to `DocumentPropertyType::TypedArray(TypedArrayProperty)`, whose `item_type` is the `DocumentPropertyType` the `items` schema parses to as a property schema (`try_from_value_map` with the contract's parsing options). The parse is the versioned `parse_typed_array` (`None` before protocol version 14, where an array that is not a byte array is refused as it always was). The older `DocumentPropertyType::Array` variant, whose elements are an `ArrayItemType` in their own length-prefixed encoding, is never produced by the parser. +## Distinct Identifier Properties + +Protocol version 14 adds the property-level `distinctFrom` keyword, a pure structure rule on identifier properties: the property's value must differ from the value of a named property of the same document, or from the document's `$ownerId`. It sits next to the reference keywords (`refersTo` and its `propertyAgreement`, which bind a property to another document's values) but reads nothing beyond the transition being written. + +```json +"delegateId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId", + "position": 0 +}, +"backupId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "delegateId", + "position": 1 +} +``` + +The value is `"$ownerId"` or the dotted path of another property of the document type (`"meta.reviewerId"` for a nested one). The parser (generation 3, meta-schema v3) checks the declaration when a contract enters the chain, on registration and on update: + +- The keyword is only allowed on identifier properties, enforced by the same dependent schema shape that restricts `refersTo`. +- A named property must exist on the document type, must itself be an identifier (the only kind the value can be compared with), and must not be the declaring property. `$ownerId` needs no check; no other system property is accepted. +- On contract update a changed, added or removed `distinctFrom` is an incompatible schema change, like a changed `refersTo`. +- A typed array of identifiers declares it on its `items`, and every element must then differ from the named value; the declaration is refused on the array itself and on elements of any other type. + +Enforcement lives in the structure validation of the document create and replace actions (create structure generation 1, introduced at protocol version 14, and replace structure generation 0, extended in place: the call is inert before 14, where no property can carry the keyword), after the schema validation of the document's properties, so every value compared is already a 32-byte identifier. The check reads the transition's data and the owner id it carries and never touches Drive; the declaring properties come from a list the parser built (`distinct_from_fields`), so a type without declarations costs nothing. An equal pair fails the write with `DocumentPropertyNotDistinctError` (basic code 10419), which names the document type, the property and what it collided with. When the named property is absent from the document there is nothing to differ from, so the rule passes. + +A transfer or purchase changes `$ownerId` without touching the data, so the transfer and purchase structure validations (generation 0, extended in place: the call is inert before protocol version 14, where no property can carry the keyword) judge the stored document against its new owner: a transfer to, or a purchase by, the identity a `$ownerId`-distinct property names is refused with the same error. Price updates change neither owner nor data and are not judged. + +In Rust the declaration is `DocumentProperty::distinct_from` (`Option`, absent on every property parsed before protocol version 14), the document check is `DocumentTypeV0Methods::validate_distinct_from_properties`, and `DistinctFrom::violation` judges one value on its own, which is how the elements of a typed array are judged one by one. + ## Rules and Guidelines **Do:** diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 4b9a1c34d97..8ac4cf33e7b 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -53,7 +53,7 @@ Error codes are organized into ranges that correspond to error categories and su | 10200-10277 | Data Contract | `DataContractMaxDepthExceedError` (10200), `DuplicateIndexError` (10201), `InvalidDataContractIdError` (10204), `DataContractInvalidRequiredFieldsUpdateError` (10276), `PreProgrammedDistributionAmountOverLimitError` (10277) | | 10350-10359 | Groups | `GroupPositionDoesNotExistError` (10350), `GroupExceedsMaxMembersError` (10354) | | 10360-10367 | Contract Groups | `ContractGroupMembershipsOverLimitError` (10360), `InvalidContractGroupAdminsError` (10364), `InvalidContractGroupDescriptionLengthError` (10367); 10365 unassigned | -| 10400-10418 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401) | +| 10400-10419 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401), `DocumentPropertyNotDistinctError` (10419) | | 10450-10460 | Tokens | `InvalidTokenIdError` (10450), `TokenTransferToOurselfError` (10456) | | 10500-10535 | Identity | `DuplicatedIdentityPublicKeyBasicError` (10500), `InvalidIdentityPublicKeyDataError` (10511) | | 10600-10603 | State Transition | `InvalidStateTransitionTypeError` (10600), `StateTransitionMaxSizeExceededError` (10602) | diff --git a/book/src/versioning/versioned-dispatch.md b/book/src/versioning/versioned-dispatch.md index 23505526aac..ba303a9a835 100644 --- a/book/src/versioning/versioned-dispatch.md +++ b/book/src/versioning/versioned-dispatch.md @@ -225,14 +225,16 @@ method, never the versioned implementation directly. The layout is the versioning contract made physical, and three rules follow from it: -- **One directory per generation, always.** A behaviour change to a versioned - method is a new `v1/` (or `v2/`, ...) directory with its own `mod.rs`, plus - a new match arm. It is never an edit inside `v0/`. That includes edits that - look harmless: threading a new parameter through `v0`, adding an - `if platform_version.protocol_version >= 14` inside it, or computing a - version-table gate that is always false for old versions. A shipped `vN/` - stays byte-identical to what shipped, so a reviewer never has to prove that - an in-place diff is inert for old blocks. +- **One directory per generation.** A behaviour change to a versioned method + is a new `v1/` (or `v2/`, ...) directory with its own `mod.rs`, plus a new + match arm. An edit inside a shipped `v0/` is allowed only when it cannot + modify consensus at any protocol version that selects `v0/`, because the + code it adds is unreachable there by construction or its output is + identical; the edited lines say why, and the pull request description + carries an "In-place changes to shipped generations" section (see the + [coding conventions](../contributing/coding-conventions.md)). An + `if platform_version.protocol_version >= 14` inside `v0/` is not that: it is + a runtime check the reader has to trust, so it gets a generation. - **Inside a generation, a capability is a constant fact, not a check.** If `v1` admits a new keyword, `v1` admits it unconditionally (`Index::try_from_value_map(map, true)`). The decision of whether the 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 a71689d814e..3bc68d67601 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 @@ -315,6 +315,12 @@ "type": "integer", "minimum": 0 }, + "distinctFrom": { + "description": "Only on identifier properties: the value must differ from the value of the named property of the same document (a dotted path for a nested one) or, as \"$ownerId\", from the document's owner. Checked on document create and replace against the transition alone; an equal pair is refused (DocumentPropertyNotDistinctError, 10419). When the named property is absent from the document there is nothing to differ from, so the rule passes. A transfer to, or a purchase by, the identity a $ownerId declaration names is refused the same way, judged against the stored document. The named property must exist on the document type, be an identifier, and not be the declaring property itself. On a typed array of identifiers the keyword goes on the items and applies to every element. Available from protocol version 14.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, "requiredSince": { "type": "integer", "minimum": 1, @@ -385,6 +391,33 @@ "maxLength" ] }, + "distinctFrom": { + "description": "distinctFrom is only allowed on identifier properties", + "properties": { + "type": { + "const": "array" + }, + "byteArray": { + "const": true + }, + "contentMediaType": { + "const": "application/x.dash.dpp.identifier" + }, + "minItems": { + "const": 32 + }, + "maxItems": { + "const": 32 + } + }, + "required": [ + "type", + "byteArray", + "contentMediaType", + "minItems", + "maxItems" + ] + }, "refersTo": { "description": "refersTo is only allowed on identifier properties", "properties": { @@ -588,6 +621,12 @@ "byteArray": { "type": "boolean", "const": true + }, + "distinctFrom": { + "description": "Only on identifier elements: every element must differ from the value of the named property of the same document (a dotted path for a nested one) or, as \"$ownerId\", from the document's owner, exactly as distinctFrom on an identifier property. The declaration belongs on the items, not on the array. Available from protocol version 14.", + "type": "string", + "minLength": 1, + "maxLength": 256 } }, "required": [ @@ -595,6 +634,33 @@ ], "additionalProperties": false, "dependentSchemas": { + "distinctFrom": { + "description": "distinctFrom is only allowed on identifier elements", + "properties": { + "type": { + "const": "array" + }, + "byteArray": { + "const": true + }, + "contentMediaType": { + "const": "application/x.dash.dpp.identifier" + }, + "minItems": { + "const": 32 + }, + "maxItems": { + "const": 32 + } + }, + "required": [ + "type", + "byteArray", + "contentMediaType", + "minItems", + "maxItems" + ] + }, "byteArray": { "description": "should be used only with array type", "properties": { diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs index 589b295f98c..978fc4af0dd 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs @@ -1013,6 +1013,14 @@ impl DocumentTypeV2Getters for DocumentType { } } + fn distinct_from_fields(&self) -> &[String] { + match self { + DocumentType::V0(_) => &[], + DocumentType::V1(_) => &[], + DocumentType::V2(v2) => v2.distinct_from_fields(), + } + } + fn immutable_fields(&self) -> &BTreeSet { match self { DocumentType::V0(_) => &NO_IMMUTABLE_FIELDS, @@ -1138,6 +1146,14 @@ impl DocumentTypeV2Getters for DocumentTypeRef<'_> { } } + fn distinct_from_fields(&self) -> &[String] { + match self { + DocumentTypeRef::V0(_) => &[], + DocumentTypeRef::V1(_) => &[], + DocumentTypeRef::V2(v2) => v2.distinct_from_fields(), + } + } + fn immutable_fields(&self) -> &BTreeSet { match self { DocumentTypeRef::V0(_) => &NO_IMMUTABLE_FIELDS, @@ -1229,6 +1245,14 @@ impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> { } } + fn distinct_from_fields(&self) -> &[String] { + match self { + DocumentTypeMutRef::V0(_) => &[], + DocumentTypeMutRef::V1(_) => &[], + DocumentTypeMutRef::V2(v2) => v2.distinct_from_fields(), + } + } + fn immutable_fields(&self) -> &BTreeSet { match self { DocumentTypeMutRef::V0(_) => &NO_IMMUTABLE_FIELDS, diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs index 063ffe3fd54..37308e76a8d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs @@ -59,6 +59,11 @@ pub trait DocumentTypeV2Getters { /// mutable, where every property is already immutable. fn immutable_fields(&self) -> &BTreeSet; + /// The dotted paths of the properties that declare `distinctFrom` + /// (protocol version 14), in schema order. Empty on generations that + /// predate the keyword. + fn distinct_from_fields(&self) -> &[String]; + /// The subset of [`Self::immutable_fields`] a replace may still set while /// the stored document has no value for them (the /// `immutableAllowSetting` keyword, protocol version 14). Once present diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 145e3f62bce..9b4be2ec681 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -803,6 +803,16 @@ fn parse_document_properties( .map_err(consensus_or_protocol_data_contract_error)?; } + // Every property is in the flattened map now, so a `distinctFrom` target + // can be resolved against its siblings. Gated on the same version that + // parsed the declarations, so the two halves of the rule move together. + super::validate_distinct_from_targets( + &flattened_document_properties, + ctx.name, + ctx.platform_version, + ) + .map_err(consensus_or_protocol_data_contract_error)?; + Ok(ParsedProperties { flattened_document_properties, document_properties, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 25226361b1a..3002f4b2618 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -6,7 +6,7 @@ use crate::data_contract::document_type::v1::DocumentTypeV1; use crate::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, property_names, ContractReferenceModeration, ContractReferenceOwner, - ContractReferenceRequirements, DocumentProperty, DocumentPropertyReferenceTarget, + ContractReferenceRequirements, DistinctFrom, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentType, }; use crate::data_contract::errors::DataContractError; @@ -191,6 +191,8 @@ fn insert_values( property_type => { let property_type = apply_property_reference(&inner_properties, property_type, platform_version)?; + let distinct_from = + apply_distinct_from(&inner_properties, &property_type, platform_version)?; document_properties.insert( prefixed_property_key, DocumentProperty { @@ -198,6 +200,7 @@ fn insert_values( required: is_required, transient: is_transient, required_since, + distinct_from, }, ); } @@ -319,6 +322,7 @@ fn insert_values_nested( let property_type = apply_property_reference(&inner_properties, property_type, platform_version)?; + let distinct_from = apply_distinct_from(&inner_properties, &property_type, platform_version)?; document_properties.insert( property_key, @@ -327,12 +331,187 @@ fn insert_values_nested( required: is_required, transient: is_transient, required_since, + distinct_from, }, ); Ok(()) } +/// Reads a `distinctFrom` declaration off an identifier property, or off the +/// `items` of a typed array of identifiers: what its value (every element's +/// value) must differ from, the document's `$ownerId` or another property of +/// the same document type. Non-identifier properties cannot carry it. +/// +/// Versioned on `apply_distinct_from` in the platform version's document type +/// schema versions. `None` selects the behavior of the versions that predate +/// the keyword: it is ignored entirely, so their parses stay byte-for-byte +/// identical to what they always produced. +/// +/// The named property is checked against the rest of the document type once +/// every property is parsed, by [`validate_distinct_from_targets`]. +fn apply_distinct_from( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Result, DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_distinct_from + { + None => Ok(None), + Some(0) => apply_distinct_from_v0(inner_properties, property_type), + Some(version) => Err(DataContractError::Unsupported(format!( + "apply_distinct_from version {version} is not supported" + ))), + } +} + +fn apply_distinct_from_v0( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, +) -> Result, DataContractError> { + // A typed array carries the declaration on its `items`: every element must + // differ from the named value, so the elements must be identifiers + if let DocumentPropertyType::TypedArray(typed_array) = property_type { + if inner_properties.contains_key(property_names::DISTINCT_FROM) { + return Err(DataContractError::InvalidContractStructure( + "distinctFrom on a typed array belongs on its items, where it applies to every \ + element" + .to_string(), + )); + } + let items_map = match inner_properties.get(property_names::ITEMS) { + Some(items) => items.to_btree_ref_string_map()?, + None => return Ok(None), + }; + let Some(distinct_from_value) = items_map.get(property_names::DISTINCT_FROM) else { + return Ok(None); + }; + if !matches!( + *typed_array.item_type, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(DataContractError::InvalidContractStructure( + "distinctFrom is only allowed on identifier elements of a typed array".to_string(), + )); + } + let name = distinct_from_value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "distinctFrom must be a string naming $ownerId or a property of the same \ + document type" + .to_string(), + ) + })?; + return DistinctFrom::from_wire_name(name).map(Some); + } + + let Some(distinct_from_value) = inner_properties.get(property_names::DISTINCT_FROM) else { + return Ok(None); + }; + + if !matches!( + property_type, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(DataContractError::InvalidContractStructure( + "distinctFrom is only allowed on identifier properties".to_string(), + )); + } + + let name = distinct_from_value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "distinctFrom must be a string naming $ownerId or a property of the same document \ + type" + .to_string(), + ) + })?; + + DistinctFrom::from_wire_name(name).map(Some) +} + +/// Checks every `distinctFrom` declaration of a document type against the +/// rest of its properties, once they are all parsed: a named property must +/// exist, must be an identifier (the only kind the value can be compared +/// with), and must not be the declaring property itself. `$ownerId` needs no +/// check, every document has one. +/// +/// Runs on every parse, validating or not: the rule is a property of the +/// document type, and the write-time check reads the target through the +/// same flattened map, so a target that does not resolve here could never +/// be judged there. +/// +/// Versioned on `apply_distinct_from`, the version that parsed the +/// declarations: `None` predates the keyword, so there is nothing to check. +fn validate_distinct_from_targets( + flattened_properties: &IndexMap, + document_type_name: &str, + platform_version: &PlatformVersion, +) -> Result<(), DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_distinct_from + { + None => Ok(()), + Some(0) => validate_distinct_from_targets_v0(flattened_properties, document_type_name), + Some(version) => Err(DataContractError::Unsupported(format!( + "validate_distinct_from_targets version {version} is not supported" + ))), + } +} + +fn validate_distinct_from_targets_v0( + flattened_properties: &IndexMap, + document_type_name: &str, +) -> Result<(), DataContractError> { + for (path, property) in flattened_properties { + let Some(DistinctFrom::Property(target)) = &property.distinct_from else { + continue; + }; + if target == path { + return Err(DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" declares distinctFrom \ + itself: name $ownerId or another identifier property of the document type" + ))); + } + let Some(target_property) = flattened_properties.get(target) else { + // Objects are not in the flattened map, only their members are + let names_an_object = flattened_properties.keys().any(|key| { + key.len() > target.len() + && key.starts_with(target) + && key.as_bytes()[target.len()] == b'.' + }); + if names_an_object { + return Err(DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" declares distinctFrom \ + \"{target}\", which is an object, not an identifier property: name one of \ + its identifier members" + ))); + } + return Err(DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" declares distinctFrom \ + \"{target}\", but the document type has no property at that path" + ))); + }; + if !matches!( + target_property.property_type, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" declares distinctFrom \ + \"{target}\", which is not an identifier property" + ))); + } + } + Ok(()) +} + /// Folds a `refersTo` declaration into the property type: an identifier property /// with `refersTo` becomes `IdentifierWithReference(target)`. Non-identifier /// properties cannot carry `refersTo`. @@ -650,9 +829,20 @@ fn parse_contract_reference_true(field: &str, value: &Value) -> Result) -> serde_json::Value { + let mut property = json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position + }); + if let Some(distinct_from) = distinct_from { + property["distinctFrom"] = json!(distinct_from); + } + property + } + + /// A document type with `delegateId` declaring `distinct_from`, next to the + /// identifier `toUserId`, the string `note` and the nested object `meta` + /// holding the identifier `meta.reviewerId` and the string `meta.tag`. + fn distinct_from_schema(distinct_from: Option<&str>) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "delegateId": identifier_property(0, distinct_from), + "toUserId": identifier_property(1, None), + "note": {"type": "string", "maxLength": 32, "position": 2}, + "meta": { + "type": "object", + "position": 3, + "properties": { + "reviewerId": identifier_property(0, None), + "tag": {"type": "string", "maxLength": 32, "position": 1} + }, + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }) + } + + fn distinct_from_of(document_type: &DocumentType, path: &str) -> Option { + document_type + .as_ref() + .flattened_properties() + .get(path) + .expect("property should be present") + .distinct_from + .clone() + } + + #[test] + fn should_parse_distinct_from_owner_id() { + for full_validation in [true, false] { + let document_type = if full_validation { + try_document_type_from_schema_full_validation(distinct_from_schema(Some( + "$ownerId", + ))) + } else { + try_document_type_from_schema(distinct_from_schema(Some("$ownerId"))) + } + .expect("should parse"); + + assert_eq!( + distinct_from_of(&document_type, "delegateId"), + Some(DistinctFrom::OwnerId), + "full_validation: {full_validation}" + ); + assert_eq!(distinct_from_of(&document_type, "toUserId"), None); + } + } + + #[test] + fn should_parse_distinct_from_property_path_on_top_level_and_nested_properties() { + let document_type = try_document_type_from_schema_full_validation(json!({ + "type": "object", + "properties": { + "delegateId": identifier_property(0, Some("meta.reviewerId")), + "meta": { + "type": "object", + "position": 1, + "properties": { + "reviewerId": identifier_property(0, Some("delegateId")), + "tag": {"type": "string", "maxLength": 32, "position": 1} + }, + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + })) + .expect("should parse"); + + assert_eq!( + distinct_from_of(&document_type, "delegateId"), + Some(DistinctFrom::Property("meta.reviewerId".to_string())) + ); + assert_eq!( + distinct_from_of(&document_type, "meta.reviewerId"), + Some(DistinctFrom::Property("delegateId".to_string())) + ); + } + + #[test] + fn should_reject_distinct_from_on_a_non_identifier_property() { + let schema = json!({ + "type": "object", + "properties": { + "note": {"type": "string", "maxLength": 32, "position": 0, "distinctFrom": "$ownerId"}, + "toUserId": identifier_property(1, None) + }, + "required": [], + "additionalProperties": false + }); + + // Without the meta-schema the parser refuses it itself + let err = try_document_type_from_schema(schema.clone()).expect_err("should be refused"); + assert!( + err.to_string() + .contains("distinctFrom is only allowed on identifier properties"), + "got {err}" + ); + + // With it the meta-schema's dependent schema refuses it first, before + // the parser gets to run, so the parser's message must not be the one + let err = try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse it"); + assert!( + !err.to_string() + .contains("distinctFrom is only allowed on identifier properties"), + "the meta-schema, not the parser, must refuse it: {err}" + ); + } + + #[test] + fn should_reject_distinct_from_naming_a_property_that_does_not_exist() { + for target in ["missing", "meta.missing", "note.deeper"] { + let err = try_document_type_from_schema(distinct_from_schema(Some(target))) + .expect_err("should be refused"); + assert!( + err.to_string() + .contains(&format!("declares distinctFrom \"{target}\", but the document type has no property at that path")), + "{target}: got {err}" + ); + } + } + + #[test] + fn should_reject_distinct_from_naming_a_non_identifier_property() { + for target in ["note", "meta.tag", "meta"] { + let err = try_document_type_from_schema(distinct_from_schema(Some(target))) + .expect_err("should be refused"); + // An object is not in the flattened map at all; its members are + let expected = if target == "meta" { + "which is an object, not an identifier property".to_string() + } else { + format!("declares distinctFrom \"{target}\", which is not an identifier property") + }; + assert!(err.to_string().contains(&expected), "{target}: got {err}"); + } + } + + #[test] + fn should_reject_distinct_from_naming_itself() { + let err = try_document_type_from_schema(distinct_from_schema(Some("delegateId"))) + .expect_err("should be refused"); + assert!( + err.to_string().contains("declares distinctFrom itself"), + "got {err}" + ); + } + + #[test] + fn should_reject_distinct_from_naming_a_system_property_other_than_owner_id() { + for (target, fragment) in [ + ("$id", "not system property \"$id\""), + ("$creatorId", "not system property \"$creatorId\""), + ("", "between 1 and 256 characters"), + ] { + let err = try_document_type_from_schema(distinct_from_schema(Some(target))) + .expect_err("should be refused"); + assert!(err.to_string().contains(fragment), "{target:?}: got {err}"); + } + } + + #[test] + fn should_reject_distinct_from_that_is_not_a_string() { + let mut schema = distinct_from_schema(None); + schema["properties"]["delegateId"]["distinctFrom"] = json!(["$ownerId"]); + let err = try_document_type_from_schema(schema).expect_err("should be refused"); + assert!( + err.to_string().contains("distinctFrom must be a string"), + "got {err}" + ); + } + + #[test] + fn should_refuse_distinct_from_below_protocol_version_14_under_full_validation() { + // Meta-schema v2 (protocol version 13) knows no such keyword, so a + // registering parse refuses it. + let platform_version = PlatformVersion::get(13).expect("platform version 13 should exist"); + let config = + DataContractConfig::default_for_version(platform_version).expect("config should build"); + let value = platform_value::to_value(distinct_from_schema(Some("$ownerId"))) + .expect("schema should convert"); + + DocumentType::try_from_schema( + Identifier::random(), + 0, + config.version(), + "msg", + value, + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + ) + .expect_err("protocol version 13 should refuse the keyword"); + } + + #[test] + fn should_ignore_distinct_from_below_protocol_version_14_without_full_validation() { + // Platform versions whose tables carry `apply_distinct_from: None` + // predate the keyword: without the meta-schema they must ignore it and + // keep producing the plain property they always produced. + let platform_version = PlatformVersion::get(13).expect("platform version 13 should exist"); + + let document_type = try_document_type_from_schema_on_version( + distinct_from_schema(Some("$ownerId")), + platform_version, + ) + .expect("should parse"); + + assert_eq!(distinct_from_of(&document_type, "delegateId"), None); + } + + #[test] + fn should_accept_distinct_from_at_protocol_version_14() { + let platform_version = PlatformVersion::get(14).expect("platform version 14 should exist"); + + let document_type = try_document_type_from_schema_on_version( + distinct_from_schema(Some("toUserId")), + platform_version, + ) + .expect("should parse"); + + assert_eq!( + distinct_from_of(&document_type, "delegateId"), + Some(DistinctFrom::Property("toUserId".to_string())) + ); + } + + #[test] + fn should_parse_distinct_from_next_to_refers_to_and_judge_the_referencing_property() { + let mut schema = distinct_from_schema(Some("toUserId")); + schema["properties"]["delegateId"]["refersTo"] = json!({ "type": "identity" }); + let document_type = + try_document_type_from_schema_full_validation(schema).expect("should parse"); + + let property = document_type + .as_ref() + .flattened_properties() + .get("delegateId") + .expect("property should be present") + .clone(); + assert_eq!( + property.property_type, + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Identity + ) + ); + assert_eq!( + property.distinct_from, + Some(DistinctFrom::Property("toUserId".to_string())) + ); + + let data = BTreeMap::from([ + ("delegateId".to_string(), Value::Identifier([1; 32])), + ("toUserId".to_string(), Value::Identifier([1; 32])), + ]); + let result = document_type + .as_ref() + .validate_distinct_from_properties( + &data, + Identifier::from([2; 32]), + PlatformVersion::latest(), + ) + .expect("the check should run"); + assert!( + !result.is_valid(), + "a referencing property is judged like any other identifier" + ); + } + + #[test] + fn should_not_judge_distinct_from_before_protocol_version_14() { + // A document type parsed at 14 carries the declaration; judged through + // the dispatcher at 13, whose table has no `validate_distinct_from`, + // nothing is checked, as no property parsed there could declare it. + let document_type = try_document_type_from_schema(distinct_from_schema(Some("$ownerId"))) + .expect("should parse"); + let owner_id = Identifier::from([1; 32]); + let data = BTreeMap::from([("delegateId".to_string(), Value::Identifier([1; 32]))]); + + let before = document_type + .as_ref() + .validate_distinct_from_properties( + &data, + owner_id, + PlatformVersion::get(13).expect("platform version 13 should exist"), + ) + .expect("the check should run"); + assert!(before.is_valid(), "{:?}", before.errors); + + let at = document_type + .as_ref() + .validate_distinct_from_properties(&data, owner_id, PlatformVersion::latest()) + .expect("the check should run"); + assert!(!at.is_valid()); + } + + /// A typed array of identifiers whose items declare `distinct_from`. + fn identifier_array_property(position: u32, distinct_from: Option<&str>) -> serde_json::Value { + let mut items = json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }); + if let Some(distinct_from) = distinct_from { + items["distinctFrom"] = json!(distinct_from); + } + json!({ "type": "array", "maxItems": 8, "items": items, "position": position }) + } + + #[test] + fn should_parse_distinct_from_on_the_items_of_an_identifier_array_and_judge_every_element() { + let mut schema = distinct_from_schema(None); + schema["properties"]["members"] = identifier_array_property(4, Some("$ownerId")); + let document_type = + try_document_type_from_schema_full_validation(schema).expect("should parse"); + + assert_eq!( + distinct_from_of(&document_type, "members"), + Some(DistinctFrom::OwnerId) + ); + + let owner_id = Identifier::from([1; 32]); + let judge = |members: Vec| { + let data = BTreeMap::from([("members".to_string(), Value::Array(members))]); + document_type + .as_ref() + .validate_distinct_from_properties(&data, owner_id, PlatformVersion::latest()) + .expect("the check should run") + }; + + assert!(judge(vec![]).is_valid()); + assert!(judge(vec![Value::Identifier([2; 32]), Value::Identifier([3; 32])]).is_valid()); + let refused = judge(vec![Value::Identifier([2; 32]), Value::Identifier([1; 32])]); + assert_matches!( + refused.errors.as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertyNotDistinctError(e))] + if e.property() == "members" && e.distinct_from() == "$ownerId" + ); + } + + #[test] + fn should_reject_distinct_from_on_the_array_itself_or_on_non_identifier_items() { + for (property, fragment) in [ + ( + json!({ + "type": "array", "maxItems": 8, "position": 4, "distinctFrom": "$ownerId", + "items": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + } + }), + "belongs on its items", + ), + ( + json!({ + "type": "array", "maxItems": 8, "position": 4, + "items": { "type": "integer", "distinctFrom": "$ownerId" } + }), + "only allowed on identifier elements", + ), + ] { + let mut schema = distinct_from_schema(None); + schema["properties"]["members"] = property; + let err = try_document_type_from_schema(schema.clone()).expect_err("should be refused"); + assert!(err.to_string().contains(fragment), "got {err}"); + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse it too"); + } + } + + #[test] + fn should_round_trip_a_contract_through_platform_serialization_with_and_without_distinct_from() + { + let platform_version = PlatformVersion::latest(); + + for distinct_from in [None, Some("$ownerId"), Some("toUserId")] { + let contract_value = platform_value::to_value(json!({ + "$formatVersion": "1", + "id": Identifier::from([7u8; 32]).to_string(Encoding::Base58), + "ownerId": Identifier::from([8u8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { + "message": distinct_from_schema(distinct_from) + } + })) + .expect("contract should convert"); + let contract = DataContract::from_value(contract_value, true, platform_version) + .expect("the contract should parse"); + + let bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract should serialize"); + let recovered = + DataContract::versioned_deserialize_untrusted(&bytes, false, platform_version) + .expect("the contract should deserialize"); + + assert_eq!(contract, recovered, "distinctFrom {distinct_from:?}"); + let expected = distinct_from + .map(|name| DistinctFrom::from_wire_name(name).expect("a valid declaration")); + let message_type = recovered + .document_type_for_name("message") + .expect("the message type") + .to_owned_document_type(); + assert_eq!(distinct_from_of(&message_type, "delegateId"), expected); + } + } + // ================================================================ // requiredSince // ================================================================ diff --git a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs index dccb6e19d8b..ab025b6925e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs @@ -186,6 +186,7 @@ mod tests { ), required: true, required_since: None, + distinct_from: None, transient: false, } } @@ -199,6 +200,7 @@ mod tests { }), required: true, required_since: None, + distinct_from: None, transient: false, } } @@ -208,6 +210,7 @@ mod tests { property_type: DocumentPropertyType::Identifier, required: true, required_since: None, + distinct_from: None, transient: false, } } diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index a9cac78317a..1a95b2468c7 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -9,10 +9,13 @@ use crate::data_contract::document_type::index_level::IndexLevel; use crate::document::Document; use crate::document::INITIAL_REVISION; use crate::prelude::{BlockHeight, CoreBlockHeight, Revision}; +use crate::validation::SimpleConsensusValidationResult; use crate::version::PlatformVersion; use crate::ProtocolError; -use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; use crate::data_contract::document_type::methods::versioned_methods::DocumentTypeV0MethodsVersioned; use crate::fee::Credits; use crate::voting::vote_polls::VotePoll; @@ -463,6 +466,43 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe } } + /// Judges the `distinctFrom` declarations of the document type against a document's + /// `data` and the id of the identity writing it: an identifier property whose value + /// equals the named property of the same document, or the writer's `$ownerId`, fails + /// with `DocumentPropertyNotDistinctError` (10419). A declaration whose named property + /// is absent from `data` passes. Reads the transition alone, so it runs in the structure + /// stage of document create and replace, and of transfer and purchase against the + /// stored document and its new owner. + /// + /// `None` in the version table (protocol versions before 14) selects the behavior of + /// the versions that predate the keyword: nothing is checked, as no parsed property + /// carries a declaration there. + fn validate_distinct_from_properties( + &self, + data: &BTreeMap, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result + where + Self: DocumentTypeV2Getters, + { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .validate_distinct_from + { + None => Ok(SimpleConsensusValidationResult::default()), + Some(0) => Ok(self.validate_distinct_from_properties_v0(data, owner_id)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "validate_distinct_from_properties".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + fn sanitize_document_properties(&self, properties: &mut BTreeMap) { // Iterate through each property in the document for (field_name, field_value) in properties.iter_mut() { diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs index 8bbcf432ae1..2533d39f571 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -2060,6 +2060,106 @@ mod tests { ); } } + + /// `toUserId` and `delegateId`, two identifier properties, with `distinctFrom` on + /// `delegateId` as given. + fn distinct_from_document_type( + distinct_from: Option<&str>, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut delegate_id = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1 + }); + if let Some(distinct_from) = distinct_from { + delegate_id + .insert("distinctFrom".to_string(), distinct_from.into()) + .expect("should insert distinctFrom"); + } + + let schema = platform_value!({ + "type": "object", + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "delegateId": delegate_id + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + #[test] + fn should_return_invalid_result_when_distinct_from_is_added_changed_or_removed() { + let platform_version = PlatformVersion::latest(); + + for (old_distinct_from, new_distinct_from) in [ + (None, Some("$ownerId")), + (Some("$ownerId"), Some("toUserId")), + (Some("toUserId"), None), + ] { + let old_document_type = + distinct_from_document_type(old_distinct_from, platform_version); + let new_document_type = + distinct_from_document_type(new_distinct_from, platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == "/properties/delegateId/distinctFrom", + "{old_distinct_from:?} -> {new_distinct_from:?}" + ); + } + } + + #[test] + fn should_return_valid_result_when_distinct_from_is_unchanged() { + let platform_version = PlatformVersion::latest(); + + let old_document_type = distinct_from_document_type(Some("$ownerId"), platform_version); + let new_document_type = distinct_from_document_type(Some("$ownerId"), platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert!(result.is_valid(), "{:?}", result.errors); + } } mod validate_byte_array_encoding { diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs index c1b95d40cdc..bf5194d7b64 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs @@ -1,4 +1,6 @@ -use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; @@ -17,6 +19,7 @@ use crate::document::{Document, DocumentV0, DocumentV0Getters, INITIAL_REVISION} use crate::fee::Credits; use crate::identity::TimestampMillis; use crate::prelude::{BlockHeight, CoreBlockHeight}; +use crate::validation::SimpleConsensusValidationResult; use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; use crate::voting::vote_polls::VotePoll; use crate::ProtocolError; @@ -774,6 +777,50 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa } } } + + /// `validate_distinct_from_properties` version 0: every property of the document type + /// that declares `distinctFrom` and has a value in `data` is compared with what it + /// must differ from, the document's `owner_id` or the named property, and the first + /// equal pair is reported. The declaring properties are read from the list the parser + /// built, so a type without declarations costs nothing. Each value is judged by + /// `DistinctFrom::violation`, so an array item can be judged by the same rule with + /// the item's value. + fn validate_distinct_from_properties_v0( + &self, + data: &BTreeMap, + owner_id: Identifier, + ) -> SimpleConsensusValidationResult + where + Self: DocumentTypeV2Getters, + { + for path in self.distinct_from_fields() { + let Some(property) = self.flattened_properties().get(path) else { + continue; + }; + let Some(distinct_from) = property.distinct_from.as_ref() else { + continue; + }; + // A lookup error (an intermediate that is not an object) is refused by the + // schema validation that precedes this check, so it reads as absent here. + let Ok(Some(value)) = data.get_optional_at_path(path) else { + continue; + }; + // A typed array declares on its items: every element is judged on its own + let values: &[Value] = match (&property.property_type, value) { + (DocumentPropertyType::TypedArray(_), Value::Array(elements)) => elements, + (DocumentPropertyType::TypedArray(_), _) => continue, + _ => std::slice::from_ref(value), + }; + for value in values { + if let Some(error) = + distinct_from.violation(self.name(), path, value, data, owner_id) + { + return SimpleConsensusValidationResult::new_with_error(error.into()); + } + } + } + SimpleConsensusValidationResult::default() + } } impl DocumentTypeV0MethodsVersioned for DocumentTypeV0 {} diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index f7f8def6e26..d445e0a2b68 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -105,6 +105,7 @@ pub(crate) mod property_names { pub const ENCRYPTION_KEY_REQUIREMENTS: &str = "encryptionKeyReqs"; pub const DECRYPTION_KEY_REQUIREMENTS: &str = "decryptionKeyReqs"; pub const REFERS_TO: &str = "refersTo"; + pub const DISTINCT_FROM: &str = "distinctFrom"; pub const CONTRACT_ID: &str = "contractId"; pub const DOCUMENT_TYPE: &str = "documentType"; pub const KEY_ID_PROPERTY: &str = "keyIdProperty"; 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 63a11b62669..431ca67b432 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 @@ -10,6 +10,7 @@ use platform_serialization_derive::{ }; use crate::consensus::basic::decode::DecodingError; +use crate::consensus::basic::document::DocumentPropertyNotDistinctError; use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::accessors::v1::DataContractV1Getters; use crate::data_contract::config::v0::DataContractConfigGettersV0; @@ -26,7 +27,7 @@ use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use indexmap::IndexMap; use integer_encoding::{VarInt, VarIntReader}; use itertools::Itertools; -use platform_value::btreemap_extensions::BTreeValueMapHelper; +use platform_value::btreemap_extensions::{BTreeValueMapHelper, BTreeValueMapPathHelper}; use platform_value::{Identifier, Value}; use platform_version::version::PlatformVersion; use rand::distributions::{Alphanumeric, Standard}; @@ -52,6 +53,114 @@ pub struct DocumentProperty { /// for optional properties. Only ever `Some` when `required` is true. #[serde(skip_serializing_if = "Option::is_none")] pub required_since: Option, + /// What this identifier property's value must differ from (`distinctFrom`): + /// the document's `$ownerId` or another identifier property of the same + /// document type. `None` for every property that declares nothing, which + /// is every property parsed before protocol version 14. + #[serde(skip_serializing_if = "Option::is_none")] + pub distinct_from: Option, +} + +/// What a `distinctFrom` identifier property must differ from. +/// +/// Declared as `"distinctFrom": "$ownerId"` or `"distinctFrom": ""` +/// on an identifier property, or on the `items` of a typed array of identifiers, where it +/// binds every element (meta-schema v3, protocol version 14). A pure structure rule: +/// consensus compares the property's value with the named one when the document is created +/// or replaced, and refuses an equal pair with `DocumentPropertyNotDistinctError` (10419). +/// When the named property is absent from the document there is nothing to differ from, +/// so the rule passes. A transfer to, or a purchase by, the identity an `$ownerId` +/// declaration names is refused the same way, judged against the stored document. The +/// target is checked at contract registration and update: it must +/// be `$ownerId` or an existing identifier property of the same document type other than +/// the declaring one. +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +#[serde(into = "String")] +pub enum DistinctFrom { + /// The document's `$ownerId`, which the write transition carries. + OwnerId, + /// The dotted path of another identifier property of the same document type. + Property(String), +} + +impl DistinctFrom { + /// The declaration a wire name spells: `$ownerId` or a property path. Any other + /// `$`-prefixed name is refused, since no other system property is an identifier the + /// rule could compare against. + pub fn from_wire_name(name: &str) -> Result { + if name == OWNER_ID { + return Ok(DistinctFrom::OwnerId); + } + if name.starts_with('$') { + return Err(DataContractError::InvalidContractStructure(format!( + "distinctFrom must name \"{OWNER_ID}\" or a property of the same document type, \ + not system property \"{name}\"" + ))); + } + if name.is_empty() || name.len() > 256 { + return Err(DataContractError::InvalidContractStructure( + "distinctFrom property paths must be between 1 and 256 characters".to_string(), + )); + } + Ok(DistinctFrom::Property(name.to_string())) + } + + /// The wire name, as the schema spells it. + pub fn as_str(&self) -> &str { + match self { + DistinctFrom::OwnerId => OWNER_ID, + DistinctFrom::Property(path) => path.as_str(), + } + } + + /// The collision this declaration finds for one value: the error to refuse the write + /// with when `value`, the declaring property's own value, equals what it must differ + /// from, and `None` when the two differ or when the named property is absent from + /// `data` (there is nothing to differ from). `value` is passed on its own rather than + /// read from `data` so that an array item can be judged by the same rule with the + /// item's value; `path` is the declaring property's dotted path, for the error. + /// + /// A value on either side that is not a 32-byte identifier cannot collide: the schema + /// validation that precedes this check refuses such a document on its own. + pub fn violation( + &self, + document_type_name: &str, + path: &str, + value: &Value, + data: &BTreeMap, + owner_id: Identifier, + ) -> Option { + let Ok(value) = value.to_identifier() else { + return None; + }; + let other = match self { + DistinctFrom::OwnerId => owner_id, + DistinctFrom::Property(target) => { + // A lookup error (an intermediate that is not an object) is the same as + // absence here: the schema forbids the shape, so nothing to compare against. + let Ok(Some(other)) = data.get_optional_at_path(target) else { + return None; + }; + let Ok(other) = other.to_identifier() else { + return None; + }; + other + } + }; + (value == other).then(|| { + DocumentPropertyNotDistinctError::new( + document_type_name.to_string(), + path.to_string(), + self.as_str().to_string(), + ) + }) + } +} + +impl From for String { + fn from(distinct_from: DistinctFrom) -> Self { + distinct_from.as_str().to_string() + } } impl DocumentProperty { @@ -3498,6 +3607,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); sub_fields.insert( @@ -3507,6 +3617,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -6196,6 +6307,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); inner_fields.insert( @@ -6205,6 +6317,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6256,6 +6369,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6276,6 +6390,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); inner_fields.insert( @@ -6285,6 +6400,7 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6718,6 +6834,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6753,6 +6870,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); sub_fields.insert( @@ -6762,6 +6880,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -6780,6 +6899,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); sub_fields.insert( @@ -6789,6 +6909,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -7081,6 +7202,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); sub_fields.insert( @@ -7090,6 +7212,7 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -7150,6 +7273,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); sub_fields.insert( @@ -7159,6 +7283,7 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -7245,6 +7370,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); sub_fields.insert( @@ -7254,6 +7380,7 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -7520,6 +7647,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7549,6 +7677,7 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, }, ); // Second field is required @@ -7559,6 +7688,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7675,6 +7805,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7693,6 +7824,7 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7999,6 +8131,7 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -8262,6 +8395,7 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, }; let value = serde_json::to_value(&property).expect("serialization should succeed"); diff --git a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs index e36163d2979..d659e4d1e5e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs @@ -198,6 +198,7 @@ impl DocumentTypeV0 { required, transient: false, required_since: None, + distinct_from: None, } }; @@ -590,6 +591,7 @@ impl DocumentTypeV0 { required, transient: false, required_since: None, + distinct_from: None, } }; diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs index 800e9ee59ea..13b2ed0ac4c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs @@ -249,6 +249,10 @@ impl DocumentTypeV2Getters for DocumentTypeV2 { &self.immutable_fields } + fn distinct_from_fields(&self) -> &[String] { + &self.distinct_from_fields + } + fn immutable_fields_allow_setting(&self) -> &BTreeSet { &self.immutable_fields_allow_setting } diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 6c5213eaf44..9421b62dc17 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -55,6 +55,10 @@ pub struct DocumentTypeV2 { /// keyword, protocol version 14). Once present they are frozen like the /// rest of the list. Every entry is also in `immutable_fields`. pub(in crate::data_contract) immutable_fields_allow_setting: BTreeSet, + /// The dotted paths of the properties that declare `distinctFrom` + /// (protocol version 14), in schema order, so a document write finds + /// them without walking every property. Empty on every pre-PV14 contract. + pub(in crate::data_contract) distinct_from_fields: Vec, /// On an indexOnly type, the top-level properties stored in every entry's /// value after the row commitment (the `entryPayload` keyword), in name /// order. Empty on every other type and on every pre-PV14 contract. @@ -176,8 +180,21 @@ impl crate::data_contract::document_type::accessors::DocumentTypeV1Setters for D } } +/// The dotted paths of the properties that declare `distinctFrom`, in the +/// flattened map's (schema) order. +fn distinct_from_fields_of( + flattened_properties: &IndexMap, +) -> Vec { + flattened_properties + .iter() + .filter(|(_, property)| property.distinct_from.is_some()) + .map(|(path, _)| path.clone()) + .collect() +} + impl From for DocumentTypeV2 { fn from(value: DocumentTypeV0) -> Self { + let distinct_from_fields = distinct_from_fields_of(&value.flattened_properties); DocumentTypeV2 { name: value.name, schema: value.schema, @@ -191,6 +208,7 @@ impl From for DocumentTypeV2 { transient_fields: value.transient_fields, immutable_fields: BTreeSet::new(), immutable_fields_allow_setting: BTreeSet::new(), + distinct_from_fields, entry_payload: BTreeSet::new(), documents_keep_history: value.documents_keep_history, documents_keep_transfer_history: value.documents_keep_transfer_history, @@ -224,6 +242,7 @@ impl From for DocumentTypeV2 { impl From for DocumentTypeV2 { fn from(value: DocumentTypeV1) -> Self { + let distinct_from_fields = distinct_from_fields_of(&value.flattened_properties); DocumentTypeV2 { name: value.name, schema: value.schema, @@ -237,6 +256,7 @@ impl From for DocumentTypeV2 { transient_fields: value.transient_fields, immutable_fields: BTreeSet::new(), immutable_fields_allow_setting: BTreeSet::new(), + distinct_from_fields, entry_payload: BTreeSet::new(), documents_keep_history: value.documents_keep_history, documents_keep_transfer_history: value.documents_keep_transfer_history, diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index 3eb229a6ebf..82cfff715b3 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -54,13 +54,13 @@ use crate::consensus::basic::decode::{ use crate::consensus::basic::document::{ ContestedDocumentsTemporarilyNotAllowedError, DataContractNotPresentError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, - DocumentTransitionsAreAbsentError, DuplicateDocumentTransitionsWithIdsError, - DuplicateDocumentTransitionsWithIndicesError, InconsistentCompoundIndexDataError, - InvalidDocumentTransitionActionError, InvalidDocumentTransitionIdError, - InvalidDocumentTypeError, MaxDocumentsTransitionsExceededError, - MissingDataContractIdBasicError, MissingDocumentTransitionActionError, - MissingDocumentTransitionTypeError, MissingDocumentTypeError, - MissingPositionsInDocumentTypePropertiesError, NonceOutOfBoundsError, + DocumentPropertyNotDistinctError, DocumentTransitionsAreAbsentError, + DuplicateDocumentTransitionsWithIdsError, DuplicateDocumentTransitionsWithIndicesError, + InconsistentCompoundIndexDataError, InvalidDocumentTransitionActionError, + InvalidDocumentTransitionIdError, InvalidDocumentTypeError, + MaxDocumentsTransitionsExceededError, MissingDataContractIdBasicError, + MissingDocumentTransitionActionError, MissingDocumentTransitionTypeError, + MissingDocumentTypeError, MissingPositionsInDocumentTypePropertiesError, NonceOutOfBoundsError, }; use crate::consensus::basic::identity::ContractGroupBoundKeyNotAllowedInShieldedIdentityCreationError; use crate::consensus::basic::identity::IdentityKeyLimitsUpdateEmptyError; @@ -805,6 +805,9 @@ pub enum BasicError { // Documents cited by a contract moderation reason (protocol version 14). #[error(transparent)] InvalidContractModerationReasonDocumentsError(InvalidContractModerationReasonDocumentsError), + + #[error(transparent)] + DocumentPropertyNotDistinctError(DocumentPropertyNotDistinctError), } impl From for ConsensusError { @@ -882,13 +885,24 @@ mod tests { )), 192 ); - // Documents cited by a contract moderation reason (protocol version 14): the tail of - // the enum. + // Documents cited by a contract moderation reason (protocol version 14). assert_eq!( discriminant_of(BasicError::InvalidContractModerationReasonDocumentsError( InvalidContractModerationReasonDocumentsError::new("x".to_string()) )), 193 ); + // A `distinctFrom` identifier property equal to what it must differ from (protocol + // version 14): the tail of the enum. + assert_eq!( + discriminant_of(BasicError::DocumentPropertyNotDistinctError( + DocumentPropertyNotDistinctError::new( + "post".to_string(), + "delegateId".to_string(), + "$ownerId".to_string(), + ) + )), + 194 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/document_property_not_distinct_error.rs b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_not_distinct_error.rs new file mode 100644 index 00000000000..2c3470f1a00 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_not_distinct_error.rs @@ -0,0 +1,73 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A `distinctFrom` identifier property of the written document equals the value it must +/// differ from: the document's `$ownerId`, or the named property of the same document. +/// +/// A pure structure check on document create and replace (protocol version 14): it reads +/// the transition alone, so it is a basic error, not a state one. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Document type \"{document_type_name}\" property \"{property}\" must differ from \ + \"{distinct_from}\", but the two values are equal" +)] +#[platform_serialize(unversioned)] +pub struct DocumentPropertyNotDistinctError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_type_name: String, + /// Dotted path of the declaring property within the document type. + property: String, + /// What the declaration named: `$ownerId` or the dotted path of the property whose + /// value collided. + distinct_from: String, +} + +impl DocumentPropertyNotDistinctError { + pub fn new(document_type_name: String, property: String, distinct_from: String) -> Self { + Self { + document_type_name, + property, + distinct_from, + } + } + + pub fn document_type_name(&self) -> &str { + &self.document_type_name + } + + pub fn property(&self) -> &str { + &self.property + } + + pub fn distinct_from(&self) -> &str { + &self.distinct_from + } +} + +impl From for ConsensusError { + fn from(err: DocumentPropertyNotDistinctError) -> Self { + Self::BasicError(BasicError::DocumentPropertyNotDistinctError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs index f40fc9c3044..f784691f447 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs @@ -2,6 +2,7 @@ mod contested_documents_temporarily_not_allowed_error; mod data_contract_not_present_error; mod document_creation_not_allowed_error; mod document_field_max_size_exceeded_error; +mod document_property_not_distinct_error; mod document_transitions_are_absent_error; mod duplicate_document_transitions_with_ids_error; mod duplicate_document_transitions_with_indices_error; @@ -21,6 +22,7 @@ pub use contested_documents_temporarily_not_allowed_error::*; pub use data_contract_not_present_error::*; pub use document_creation_not_allowed_error::*; pub use document_field_max_size_exceeded_error::*; +pub use document_property_not_distinct_error::*; pub use document_transitions_are_absent_error::*; pub use duplicate_document_transitions_with_ids_error::*; pub use duplicate_document_transitions_with_indices_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 8048f541b18..e6de0db7561 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -166,6 +166,7 @@ impl ErrorWithCode for BasicError { Self::DocumentCreationNotAllowedError(_) => 10416, Self::DocumentFieldMaxSizeExceededError(_) => 10417, Self::ContestedDocumentsTemporarilyNotAllowedError(_) => 10418, + Self::DocumentPropertyNotDistinctError(_) => 10419, // Token Errors: 10450-10499 Self::InvalidTokenIdError(_) => 10450, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs index f7bb13051c1..234e5e0cbd8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs @@ -149,9 +149,22 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans } // Validate user defined properties - data_contract + let result = data_contract .validate_document_properties(document_type_name, self.data().into(), platform_version) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + + // -->> Introduced in V1 <<-- + // A `distinctFrom` identifier property must differ from the named sibling + // property or from the writer's `$ownerId`. Both are on the transition, so + // this is a structure check; it runs after the schema validation above so + // every value it compares is already known to be a 32-byte identifier. + document_type + .validate_distinct_from_properties(self.data(), owner_id, platform_version) .map_err(Error::Protocol) + // -->> End Introduced in V1 <<-- } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs index 7c65043d3e5..74ef284a7f9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs @@ -1,6 +1,7 @@ use dpp::consensus::basic::document::{InvalidDocumentTransitionActionError, InvalidDocumentTypeError}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::document::DocumentV0Getters; use dpp::nft::TradeMode; use dpp::validation::SimpleConsensusValidationResult; @@ -18,7 +19,7 @@ pub(in crate::execution::validation::state_transition::state_transitions::batch: impl DocumentPurchaseTransitionActionStructureValidationV0 for DocumentPurchaseTransitionAction { fn validate_structure_v0( &self, - _platform_version: &PlatformVersion, + platform_version: &PlatformVersion, ) -> Result { let contract_fetch_info = self.base().data_contract_fetch_info(); let data_contract = &contract_fetch_info.contract; @@ -47,15 +48,29 @@ impl DocumentPurchaseTransitionActionStructureValidationV0 for DocumentPurchaseT } if document_type.trade_mode() != TradeMode::DirectPurchase { - Ok(SimpleConsensusValidationResult::new_with_error( + return Ok(SimpleConsensusValidationResult::new_with_error( InvalidDocumentTransitionActionError::new(format!( "{} trade mode is not direct purchase but we are trying to purchase directly", document_type_name )) .into(), - )) - } else { - Ok(SimpleConsensusValidationResult::default()) + )); } + + // Added in place at protocol version 14, inert for every earlier version this + // generation serves: their meta-schemas refuse `distinctFrom`, their parser ignores + // it (`apply_distinct_from` is `None`), and `validate_distinct_from` is `None` there, + // so the call sees no declaration and returns an empty result. From 14, the + // document changes owner and a `distinctFrom: $ownerId` property of the stored + // document must differ from the new owner, which the action already carries on the + // document. The data was schema-validated when it was written, so every value + // compared is a 32-byte identifier. + document_type + .validate_distinct_from_properties( + self.document().properties(), + self.document().owner_id(), + platform_version, + ) + .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs index 582db214f0d..43ea70a0dd0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs @@ -1,6 +1,8 @@ use dpp::consensus::basic::document::{InvalidDocumentTransitionActionError, InvalidDocumentTypeError}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::identifier::Identifier; use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; use dpp::validation::SimpleConsensusValidationResult; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; @@ -11,12 +13,14 @@ use crate::error::Error; pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait DocumentReplaceTransitionActionStructureValidationV0 { fn validate_structure_v0( &self, + owner_id: Identifier, platform_version: &PlatformVersion, ) -> Result; } impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTransitionAction { fn validate_structure_v0( &self, + owner_id: Identifier, platform_version: &PlatformVersion, ) -> Result { let contract_fetch_info = self.base().data_contract_fetch_info(); @@ -44,8 +48,22 @@ impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTra // Validate user defined properties - data_contract + let result = data_contract .validate_document_properties(document_type_name, self.data().into(), platform_version) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + + // Added in place at protocol version 14, inert for every earlier version this + // generation serves: their meta-schemas refuse `distinctFrom`, their parser ignores + // it (`apply_distinct_from` is `None`), and `validate_distinct_from` is `None` there, + // so the call sees no declaration and returns an empty result. From 14, a + // `distinctFrom` identifier property must differ from the named sibling property or + // from the writer's `$ownerId`; both are on the transition, and the schema + // validation above already made every value compared a 32-byte identifier. + document_type + .validate_distinct_from_properties(self.data(), owner_id, platform_version) .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/mod.rs index 9f964c616ef..395d5bbc538 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/mod.rs @@ -20,6 +20,7 @@ mod state_v1; pub trait DocumentReplaceTransitionActionValidation { fn validate_structure( &self, + owner_id: Identifier, platform_version: &PlatformVersion, ) -> Result; @@ -37,6 +38,7 @@ pub trait DocumentReplaceTransitionActionValidation { impl DocumentReplaceTransitionActionValidation for DocumentReplaceTransitionAction { fn validate_structure( &self, + owner_id: Identifier, platform_version: &PlatformVersion, ) -> Result { match platform_version @@ -46,7 +48,7 @@ impl DocumentReplaceTransitionActionValidation for DocumentReplaceTransitionActi .batch_state_transition .document_replace_transition_structure_validation { - 0 => self.validate_structure_v0(platform_version), + 0 => self.validate_structure_v0(owner_id, platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "DocumentReplaceTransitionAction::validate_structure".to_string(), known_versions: vec![0], diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs index c8c27c46303..d60f9d0e901 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs @@ -1,6 +1,8 @@ use dpp::consensus::basic::document::{InvalidDocumentTransitionActionError, InvalidDocumentTypeError}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::document::DocumentV0Getters; use dpp::validation::SimpleConsensusValidationResult; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::{DocumentTransferTransitionAction, DocumentTransferTransitionActionAccessorsV0}; @@ -16,7 +18,7 @@ pub(in crate::execution::validation::state_transition::state_transitions::batch: impl DocumentTransferTransitionActionStructureValidationV0 for DocumentTransferTransitionAction { fn validate_structure_v0( &self, - _platform_version: &PlatformVersion, + platform_version: &PlatformVersion, ) -> Result { let contract_fetch_info = self.base().data_contract_fetch_info(); let data_contract = &contract_fetch_info.contract; @@ -32,15 +34,29 @@ impl DocumentTransferTransitionActionStructureValidationV0 for DocumentTransferT }; if !document_type.documents_transferable().is_transferable() { - Ok(SimpleConsensusValidationResult::new_with_error( + return Ok(SimpleConsensusValidationResult::new_with_error( InvalidDocumentTransitionActionError::new(format!( "{} is not a transferable document type", document_type_name )) .into(), - )) - } else { - Ok(SimpleConsensusValidationResult::default()) + )); } + + // Added in place at protocol version 14, inert for every earlier version this + // generation serves: their meta-schemas refuse `distinctFrom`, their parser ignores + // it (`apply_distinct_from` is `None`), and `validate_distinct_from` is `None` there, + // so the call sees no declaration and returns an empty result. From 14, the + // document changes owner and a `distinctFrom: $ownerId` property of the stored + // document must differ from the new owner, which the action already carries on the + // document. The data was schema-validated when it was written, so every value + // compared is a 32-byte identifier. + document_type + .validate_distinct_from_properties( + self.document().properties(), + self.document().owner_id(), + platform_version, + ) + .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/mod.rs index 773bf41d016..29bef5d5f95 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/mod.rs @@ -42,7 +42,7 @@ impl DocumentUpdatePriceTransitionActionValidation for DocumentUpdatePriceTransi .validation_and_processing .state_transitions .batch_state_transition - .document_transfer_transition_structure_validation + .document_update_price_transition_structure_validation { 0 => self.validate_structure_v0(platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v0/mod.rs index 0eafc05f797..33b6a0aa94b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v0/mod.rs @@ -153,7 +153,10 @@ impl DocumentsBatchStateTransitionStructureValidationV0 for BatchTransition { } } DocumentTransitionAction::ReplaceAction(replace_action) => { - let result = replace_action.validate_structure(platform_version)?; + // The owner id feeds the `distinctFrom` judgement added to replace + // structure v0 in place; it is inert before protocol version 14 + let result = + replace_action.validate_structure(identity.id, platform_version)?; if !result.is_valid() { let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(replace_action.base(), self.owner_id(), self.user_fee_increase()), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs index f0e9e5faa6c..f3f00431ddc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs @@ -283,7 +283,8 @@ impl DocumentsBatchStateTransitionStructureValidationV1 for BatchTransition { } } DocumentTransitionAction::ReplaceAction(replace_action) => { - let result = replace_action.validate_structure(platform_version)?; + let result = + replace_action.validate_structure(identity.id, platform_version)?; if !result.is_valid() { let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(replace_action.base(), self.owner_id(), self.user_fee_increase()), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs new file mode 100644 index 00000000000..f78a34e9d97 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs @@ -0,0 +1,945 @@ +//! End-to-end coverage for the `distinctFrom` property keyword (protocol +//! version 14): an identifier property's value must differ from the value of +//! a named property of the same document, or from the document's `$ownerId`. +//! A create or replace whose two values are equal is consensus-rejected with +//! `DocumentPropertyNotDistinctError` (basic code 10419) and leaves the stored +//! document untouched; a declaration whose named property is absent from the +//! document passes. + +use super::*; + +mod distinct_from_tests { + use super::*; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use crate::execution::validation::state_transition::batch::action_validation::document::document_replace_transition_action::DocumentReplaceTransitionActionValidation; + use dpp::consensus::basic::BasicError; + use dpp::consensus::codes::ErrorWithCode; + use dpp::document::DocumentV0Setters; + use dpp::fee::Credits; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionV0}; + use crate::execution::validation::state_transition::batch::action_validation::document::document_purchase_transition_action::DocumentPurchaseTransitionActionValidation; + use crate::execution::validation::state_transition::batch::action_validation::document::document_transfer_transition_action::DocumentTransferTransitionActionValidation; + use drive::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::{DocumentPurchaseTransitionAction, DocumentPurchaseTransitionActionV0}; + use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::{DocumentReplaceTransitionAction, DocumentReplaceTransitionActionV0}; + use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::{DocumentTransferTransitionAction, DocumentTransferTransitionActionV0}; + use std::collections::{BTreeMap, BTreeSet}; + use dpp::data_contract::schema::DataContractSchemaMethodsV0; + use dpp::document::Document; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::platform_value::platform_value; + use dpp::prelude::{DataContract, Identifier, IdentityNonce}; + use dpp::state_transition::StateTransition; + use dpp::tests::fixtures::get_data_contract_fixture; + use drive::util::storage_flags::StorageFlags; + use simple_signer::signer::SimpleSigner; + + fn identifier_property(position: u32, distinct_from: Option<&str>) -> Value { + let mut property = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32_u32, + "maxItems": 32_u32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position + }); + if let Some(distinct_from) = distinct_from { + property + .insert("distinctFrom".to_string(), distinct_from.into()) + .expect("expected to insert distinctFrom"); + } + property + } + + /// A mutable, transferable and purchasable `delegation` type: `delegateId` + /// must differ from the owner, `backupId` from `delegateId`, the nested + /// `meta.reviewerId` from its sibling `meta.approverId`, and every element + /// of the `members` identifier array from the owner. Only the string `note` + /// is required, so any identifier may be left out of a document. + fn delegation_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "transferable": 1, + "tradeMode": 1, + "properties": { + "delegateId": identifier_property(0, Some("$ownerId")), + "backupId": identifier_property(1, Some("delegateId")), + "note": {"type": "string", "position": 2, "maxLength": 63_u32}, + "meta": { + "type": "object", + "position": 3, + "properties": { + "reviewerId": identifier_property(0, Some("meta.approverId")), + "approverId": identifier_property(1, None) + }, + "additionalProperties": false + }, + "members": { + "type": "array", + "maxItems": 8_u32, + "position": 4, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32_u32, + "maxItems": 32_u32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId" + } + } + }, + "required": ["note"], + "additionalProperties": false + }) + } + + fn id(byte: u8) -> Value { + Value::Identifier([byte; 32]) + } + + /// One identity and one contract whose `delegation` type declares the + /// rules above. `create` writes a document, `replace` rewrites the last + /// accepted one. + struct DelegationFixture { + platform: TempPlatform, + identity: Identity, + signer: SimpleSigner, + key: IdentityPublicKey, + contract: DataContract, + /// The document as last accepted by the chain, once one was. + document: Option, + /// The identity contract nonce the next transition uses. Every + /// processed transition consumes one, including the ones that fail + /// with a paid consensus error. + next_nonce: IdentityNonce, + } + + impl DelegationFixture { + fn new() -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.5)); + + let mut contract = get_data_contract_fixture( + Some(identity.id()), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + contract + .set_document_schema( + "delegation", + delegation_schema(), + true, + &mut Vec::new(), + platform_version, + ) + .expect("expected to add the delegation document type"); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + + Self { + platform, + identity, + signer, + key, + contract, + document: None, + next_nonce: 1, + } + } + + fn owner_id(&self) -> Value { + Value::Identifier(self.identity.id().to_buffer()) + } + + fn process(&self, transition: &StateTransition) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let platform_state = self.platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// Creates a delegation with `note` set and whatever `fill` adds. On + /// success it becomes the fixture's document. + async fn create( + &mut self, + fill: impl FnOnce(&mut Document), + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let delegation_type = self + .contract + .document_type_for_name("delegation") + .expect("expected the delegation document type"); + + let mut rng = StdRng::seed_from_u64(433); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = delegation_type + .random_document_with_identifier_and_entropy( + &mut rng, + self.identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random delegation"); + document + .set_id_for_creation( + delegation_type, + &entropy.0, + self.next_nonce, + platform_version, + ) + .expect("expected to set the document id"); + document.set("note", "first draft".into()); + fill(&mut document); + + let transition = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + delegation_type, + entropy.0, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + self.next_nonce += 1; + + let result = self.process(&transition); + if matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ) { + self.document = Some(document); + } + result + } + + /// Replaces the stored delegation with `mutate` applied to a copy of + /// it and the revision bumped. On success the fixture's document + /// becomes the accepted version. + async fn replace( + &mut self, + mutate: impl FnOnce(&mut Document), + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let mut replacement = self + .document + .clone() + .expect("a document must have been created first"); + mutate(&mut replacement); + replacement + .increment_revision() + .expect("expected the revision to increment"); + + let transition = { + let delegation_type = self + .contract + .document_type_for_name("delegation") + .expect("expected the delegation document type"); + BatchTransition::new_document_replacement_transition_from_document( + replacement.clone(), + delegation_type, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition") + }; + self.next_nonce += 1; + + let result = self.process(&transition); + if matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ) { + self.document = Some(replacement); + } + result + } + + /// Transfers the stored delegation to `recipient`. On success the + /// fixture's document becomes the transferred version. + async fn transfer(&mut self, recipient: Identifier) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let mut transferred = self + .document + .clone() + .expect("a document must have been created first"); + transferred + .increment_revision() + .expect("expected the revision to increment"); + + let transition = { + let delegation_type = self + .contract + .document_type_for_name("delegation") + .expect("expected the delegation document type"); + BatchTransition::new_document_transfer_transition_from_document( + transferred.clone(), + delegation_type, + recipient, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the transfer transition") + }; + self.next_nonce += 1; + + let result = self.process(&transition); + if matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ) { + transferred.set_owner_id(recipient); + self.document = Some(transferred); + } + result + } + + /// Puts the stored delegation up for sale at `price`. + async fn set_price(&mut self, price: Credits) { + let platform_version = PlatformVersion::latest(); + let mut priced = self + .document + .clone() + .expect("a document must have been created first"); + priced + .increment_revision() + .expect("expected the revision to increment"); + + let transition = { + let delegation_type = self + .contract + .document_type_for_name("delegation") + .expect("expected the delegation document type"); + BatchTransition::new_document_update_price_transition_from_document( + priced.clone(), + delegation_type, + price, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the update price transition") + }; + self.next_nonce += 1; + + assert_matches!( + self.process(&transition), + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "setting the price must succeed" + ); + self.document = Some(priced); + } + + /// A second funded identity on the fixture's platform. + fn other_identity(&mut self, seed: u64) -> (Identity, SimpleSigner, IdentityPublicKey) { + setup_identity(&mut self.platform, seed, dash_to_credits!(0.5)) + } + + /// `buyer` purchases the stored delegation at `price` (its first + /// transition, so nonce 1). + async fn purchase_by( + &mut self, + buyer: &(Identity, SimpleSigner, IdentityPublicKey), + price: Credits, + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let (buyer_identity, buyer_signer, buyer_key) = buyer; + let mut bought = self + .document + .clone() + .expect("a document must have been created first"); + bought + .increment_revision() + .expect("expected the revision to increment"); + + let transition = { + let delegation_type = self + .contract + .document_type_for_name("delegation") + .expect("expected the delegation document type"); + BatchTransition::new_document_purchase_transition_from_document( + bought, + delegation_type, + buyer_identity.id(), + price, + buyer_key, + 1, + 0, + None, + buyer_signer, + platform_version, + None, + ) + .await + .expect("expected the purchase transition") + }; + + self.process(&transition) + } + + /// The stored delegations, read back from Drive. + fn stored_delegations(&self) -> Vec { + let platform_version = PlatformVersion::latest(); + let query = DriveDocumentQuery::from_sql_expr( + "select * from delegation", + &self.contract, + Some(&self.platform.config.drive), + platform_version, + ) + .expect("expected a document query"); + self.platform + .drive + .query_documents(query, None, false, None, None) + .expect("expected a query result") + .documents() + .to_vec() + } + } + + fn expect_not_distinct_error( + result: StateTransitionExecutionResult, + expected_property: &str, + expected_distinct_from: &str, + ) { + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + let ConsensusError::BasicError(BasicError::DocumentPropertyNotDistinctError(error)) = error + else { + panic!("expected DocumentPropertyNotDistinctError, got {error:?}"); + }; + assert_eq!(error.document_type_name(), "delegation"); + assert_eq!(error.property(), expected_property); + assert_eq!(error.distinct_from(), expected_distinct_from); + assert_eq!(ConsensusError::from(error).code(), 10419); + } + + /// The replace structure dispatcher on both sides of the gate: structure + /// generation 0 gained the `distinctFrom` judgement in place, so at protocol + /// version 13 it must still accept the action (no property parsed there + /// carries the keyword and the dpp gate is `None`), and at 14 refuse it. + /// The action is built by hand the way the transformer would build it, + /// against the contract as Drive hands it back. + #[test] + fn should_not_judge_distinct_from_on_replace_before_protocol_version_14() { + let platform_version = PlatformVersion::latest(); + let fixture = DelegationFixture::new(); + let owner_id = fixture.identity.id(); + + let (_, contract_fetch_info) = fixture + .platform + .drive + .get_contract_with_fetch_info_and_fee( + fixture.contract.id().to_buffer(), + None, + false, + None, + platform_version, + ) + .expect("expected to fetch the contract"); + let contract_fetch_info = contract_fetch_info.expect("the contract is in state"); + + let action = DocumentReplaceTransitionAction::V0(DocumentReplaceTransitionActionV0 { + base: DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([0xAA; 32]), + identity_contract_nonce: 1, + document_type_name: "delegation".to_string(), + data_contract: contract_fetch_info, + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::default(), + contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, + }), + revision: 2, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + data: BTreeMap::from([ + ("note".to_string(), "draft".into()), + ("delegateId".to_string(), fixture.owner_id()), + ]), + changed_data_fields: BTreeSet::new(), + added_data_fields: BTreeSet::new(), + removed_identifier_fields: BTreeMap::new(), + creator_id: None, + }); + + let before = action + .validate_structure( + owner_id, + PlatformVersion::get(13).expect("platform version 13 should exist"), + ) + .expect("structure validation should run"); + assert!( + before.is_valid(), + "structure generation 0 must not judge distinctFrom: {:?}", + before.errors + ); + + let at = action + .validate_structure(owner_id, platform_version) + .expect("structure validation should run"); + assert_matches!( + at.errors.as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertyNotDistinctError(e))] + if e.property() == "delegateId" && e.distinct_from() == "$ownerId" + ); + } + + /// Transfer and purchase structure validation v0 gained the `distinctFrom` + /// judgement in place. At protocol version 13 the same module must still + /// accept the action, as no property parsed there carries the keyword and + /// the dpp gate is `None`; at 14 it refuses the equal pair. + #[tokio::test] + async fn should_not_judge_distinct_from_on_transfer_or_purchase_before_protocol_version_14() { + let platform_version = PlatformVersion::latest(); + let platform_version_13 = + PlatformVersion::get(13).expect("platform version 13 should exist"); + let mut fixture = DelegationFixture::new(); + let recipient = Identifier::from([0xEE; 32]); + assert_matches!( + fixture + .create(|document| { + document.set("delegateId", Value::Identifier(recipient.to_buffer())) + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + // The transformer hands the validators the stored document already + // re-owned by the recipient + let mut transferred = fixture + .document + .clone() + .expect("the delegation was created"); + transferred.set_owner_id(recipient); + + let (_, contract_fetch_info) = fixture + .platform + .drive + .get_contract_with_fetch_info_and_fee( + fixture.contract.id().to_buffer(), + None, + false, + None, + platform_version, + ) + .expect("expected to fetch the contract"); + let contract_fetch_info = contract_fetch_info.expect("the contract is in state"); + let base = || { + DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: transferred.id(), + identity_contract_nonce: 2, + document_type_name: "delegation".to_string(), + data_contract: contract_fetch_info.clone(), + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::default(), + contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, + }) + }; + + let transfer = DocumentTransferTransitionAction::V0(DocumentTransferTransitionActionV0 { + base: base(), + document: transferred.clone(), + }); + let purchase = DocumentPurchaseTransitionAction::V0(DocumentPurchaseTransitionActionV0 { + base: base(), + document: transferred.clone(), + original_owner_id: fixture.identity.id(), + price: 1, + }); + + for (name, before, at) in [ + ( + "transfer", + transfer.validate_structure(platform_version_13), + transfer.validate_structure(platform_version), + ), + ( + "purchase", + purchase.validate_structure(platform_version_13), + purchase.validate_structure(platform_version), + ), + ] { + let before = before.expect("structure validation should run"); + assert!( + before.is_valid(), + "{name}: v0 must not judge distinctFrom before protocol version 14: {:?}", + before.errors + ); + let at = at.expect("structure validation should run"); + assert_matches!( + at.errors.as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertyNotDistinctError(e))] + if e.property() == "delegateId" && e.distinct_from() == "$ownerId", + "{name}" + ); + } + } + + #[tokio::test] + async fn should_reject_a_create_whose_property_equals_the_owner_id() { + let mut fixture = DelegationFixture::new(); + let owner_id = fixture.owner_id(); + + let result = fixture + .create(|document| document.set("delegateId", owner_id)) + .await; + + expect_not_distinct_error(result, "delegateId", "$ownerId"); + assert!( + fixture.stored_delegations().is_empty(), + "the refused document must not be stored" + ); + } + + #[tokio::test] + async fn should_accept_a_create_whose_property_differs_from_the_owner_id() { + let mut fixture = DelegationFixture::new(); + + let result = fixture + .create(|document| document.set("delegateId", id(1))) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let stored = fixture.stored_delegations(); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].get("delegateId"), Some(&id(1))); + } + + #[tokio::test] + async fn should_reject_a_create_whose_property_equals_the_named_sibling_property() { + let mut fixture = DelegationFixture::new(); + + let result = fixture + .create(|document| { + document.set("delegateId", id(1)); + document.set("backupId", id(1)); + }) + .await; + + expect_not_distinct_error(result, "backupId", "delegateId"); + assert!(fixture.stored_delegations().is_empty()); + } + + #[tokio::test] + async fn should_accept_a_create_when_the_named_sibling_property_is_absent() { + let mut fixture = DelegationFixture::new(); + + // `backupId` must differ from `delegateId`, which the document leaves + // out: there is nothing to differ from + let result = fixture + .create(|document| document.set("backupId", id(1))) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_delegations().len(), 1); + } + + #[tokio::test] + async fn should_accept_a_create_whose_sibling_properties_differ() { + let mut fixture = DelegationFixture::new(); + + let result = fixture + .create(|document| { + document.set("delegateId", id(1)); + document.set("backupId", id(2)); + document.set( + "meta", + platform_value!({ "reviewerId": id(3), "approverId": id(4) }), + ); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_a_create_whose_nested_property_equals_its_named_sibling() { + let mut fixture = DelegationFixture::new(); + + let result = fixture + .create(|document| { + document.set( + "meta", + platform_value!({ "reviewerId": id(3), "approverId": id(3) }), + ); + }) + .await; + + expect_not_distinct_error(result, "meta.reviewerId", "meta.approverId"); + } + + #[tokio::test] + async fn should_reject_a_replace_that_makes_the_two_properties_equal() { + let mut fixture = DelegationFixture::new(); + assert_matches!( + fixture + .create(|document| { + document.set("delegateId", id(1)); + document.set("backupId", id(2)); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = fixture + .replace(|document| document.set("backupId", id(1))) + .await; + + expect_not_distinct_error(result, "backupId", "delegateId"); + let stored = fixture.stored_delegations(); + assert_eq!(stored.len(), 1); + assert_eq!( + stored[0].get("backupId"), + Some(&id(2)), + "the refused replace must leave the stored document untouched" + ); + } + + #[tokio::test] + async fn should_reject_a_replace_that_sets_the_property_to_the_owner_id() { + let mut fixture = DelegationFixture::new(); + assert_matches!( + fixture + .create(|document| document.set("delegateId", id(1))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let owner_id = fixture.owner_id(); + + let result = fixture + .replace(|document| document.set("delegateId", owner_id)) + .await; + + expect_not_distinct_error(result, "delegateId", "$ownerId"); + assert_eq!( + fixture.stored_delegations()[0].get("delegateId"), + Some(&id(1)) + ); + } + + #[tokio::test] + async fn should_reject_a_create_whose_array_element_equals_the_owner_id() { + let mut fixture = DelegationFixture::new(); + let owner_id = fixture.owner_id(); + + let result = fixture + .create(|document| document.set("members", Value::Array(vec![id(1), owner_id]))) + .await; + + expect_not_distinct_error(result, "members", "$ownerId"); + assert!(fixture.stored_delegations().is_empty()); + } + + #[tokio::test] + async fn should_accept_a_create_whose_array_elements_all_differ_from_the_owner_id() { + let mut fixture = DelegationFixture::new(); + + let result = fixture + .create(|document| document.set("members", Value::Array(vec![id(1), id(2)]))) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!( + fixture.stored_delegations()[0].get("members"), + Some(&Value::Array(vec![id(1), id(2)])) + ); + } + + #[tokio::test] + async fn should_reject_a_transfer_to_the_identity_the_property_must_differ_from() { + let mut fixture = DelegationFixture::new(); + let (recipient, _, _) = fixture.other_identity(450); + assert_matches!( + fixture + .create(|document| { + document.set("delegateId", Value::Identifier(recipient.id().to_buffer())) + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = fixture.transfer(recipient.id()).await; + + expect_not_distinct_error(result, "delegateId", "$ownerId"); + assert_eq!( + fixture.stored_delegations()[0].owner_id(), + fixture.identity.id(), + "the refused transfer must leave the owner unchanged" + ); + } + + #[tokio::test] + async fn should_accept_a_transfer_to_another_identity() { + let mut fixture = DelegationFixture::new(); + let (recipient, _, _) = fixture.other_identity(450); + assert_matches!( + fixture + .create(|document| document.set("delegateId", id(1))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = fixture.transfer(recipient.id()).await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_delegations()[0].owner_id(), recipient.id()); + } + + #[tokio::test] + async fn should_reject_a_purchase_by_the_identity_the_property_must_differ_from() { + let mut fixture = DelegationFixture::new(); + let buyer = fixture.other_identity(450); + assert_matches!( + fixture + .create(|document| { + document.set("delegateId", Value::Identifier(buyer.0.id().to_buffer())) + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + fixture.set_price(dash_to_credits!(0.1)).await; + + let result = fixture.purchase_by(&buyer, dash_to_credits!(0.1)).await; + + expect_not_distinct_error(result, "delegateId", "$ownerId"); + assert_eq!( + fixture.stored_delegations()[0].owner_id(), + fixture.identity.id(), + "the refused purchase must leave the owner unchanged" + ); + } + + #[tokio::test] + async fn should_accept_a_purchase_by_another_identity() { + let mut fixture = DelegationFixture::new(); + let buyer = fixture.other_identity(450); + assert_matches!( + fixture + .create(|document| document.set("delegateId", id(1))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + fixture.set_price(dash_to_credits!(0.1)).await; + + let result = fixture.purchase_by(&buyer, dash_to_credits!(0.1)).await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_delegations()[0].owner_id(), buyer.0.id()); + } + + #[tokio::test] + async fn should_accept_a_replace_that_keeps_the_properties_distinct() { + let mut fixture = DelegationFixture::new(); + assert_matches!( + fixture + .create(|document| { + document.set("delegateId", id(1)); + document.set("backupId", id(2)); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = fixture + .replace(|document| { + document.set("delegateId", id(2)); + document.set("backupId", id(3)); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let stored = fixture.stored_delegations(); + assert_eq!(stored[0].get("delegateId"), Some(&id(2))); + assert_eq!(stored[0].get("backupId"), Some(&id(3))); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index cad6c0013e0..fe7a086d141 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -2,6 +2,7 @@ mod action_fees; mod creation; mod deletable_document_reference; mod deletion; +mod distinct_from; mod dpns; mod gas_sponsorship; mod id_reuse; diff --git a/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs b/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs index 3984cfb261e..517b201314d 100644 --- a/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs +++ b/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs @@ -1417,6 +1417,49 @@ pub static KEYWORD_COMPATIBILITY_RULES: Lazy = Laz ], }, ), + // `distinctFrom` (what an identifier property's value must differ + // from: `$ownerId` or another property of the same document type) is + // frozen like `refersTo`: adding, removing or changing it changes + // which documents the type accepts. + ( + "distinctFrom", + CompatibilityRules { + allow_addition: false, + allow_removal: false, + allow_replacement_callback: FALSE_CALLBACK.clone(), + subschema_levels_depth: None, + inner: None, + #[cfg(any(test, feature = "examples"))] + examples: vec![ + ( + json!({}), + json!({ "distinctFrom": "$ownerId" }), + Some(JsonSchemaChange::Add(AddOperation { + path: "/distinctFrom".to_string(), + value: json!("$ownerId"), + })), + ) + .into(), + ( + json!({ "distinctFrom": "$ownerId" }), + json!({}), + Some(JsonSchemaChange::Remove(RemoveOperation { + path: "/distinctFrom".to_string(), + })), + ) + .into(), + ( + json!({ "distinctFrom": "$ownerId" }), + json!({ "distinctFrom": "delegateId" }), + Some(JsonSchemaChange::Replace(ReplaceOperation { + path: "/distinctFrom".to_string(), + value: json!("delegateId"), + })), + ) + .into(), + ], + }, + ), ( "$defs", CompatibilityRules { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs index 33c9ebc890e..270c1be4fd7 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs @@ -75,6 +75,10 @@ pub struct DocumentTypeMethodVersions { pub max_size: FeatureVersion, pub serialize_value_for_key: FeatureVersion, pub deserialize_value_for_key: FeatureVersion, + /// `validate_distinct_from_properties`: refuses a document whose `distinctFrom` + /// property equals the value it must differ from. `None` on versions that + /// predate the keyword, where no parsed property carries it. + pub validate_distinct_from: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] @@ -92,6 +96,12 @@ pub struct DocumentTypeSchemaVersions { /// keyword: they ignore it entirely, exactly as they parsed before it /// existed. pub apply_required_since: OptionalFeatureVersion, + /// Parses the `distinctFrom` property keyword (an identifier property whose + /// value must differ from a named property of the same document, or from + /// the document's `$ownerId`) onto the property. `None` on versions that + /// predate the keyword: they ignore it entirely, exactly as they parsed + /// before it existed. + pub apply_distinct_from: OptionalFeatureVersion, /// Parses a typed array property (`type: "array"` with an `items` /// element schema instead of `byteArray`). `None` on versions that /// predate typed arrays: they leave such a property to the scalar diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs index 5063b165e5d..de3851a188c 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs @@ -45,6 +45,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + apply_distinct_from: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -63,6 +64,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { max_size: 0, serialize_value_for_key: 0, deserialize_value_for_key: 0, + validate_distinct_from: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs index 31cee6b3269..22d572bc79f 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs @@ -45,6 +45,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + apply_distinct_from: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -63,6 +64,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { max_size: 0, serialize_value_for_key: 0, deserialize_value_for_key: 0, + validate_distinct_from: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs index e5564f333c7..deaf97b1170 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs @@ -47,6 +47,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + apply_distinct_from: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -65,6 +66,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { max_size: 0, serialize_value_for_key: 0, deserialize_value_for_key: 0, + validate_distinct_from: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs index b162ccef519..e9fda9c95ed 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs @@ -47,6 +47,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + apply_distinct_from: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -65,6 +66,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { max_size: 0, serialize_value_for_key: 0, deserialize_value_for_key: 0, + validate_distinct_from: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs index 1b6d8af3412..485826b7dff 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs @@ -49,6 +49,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + apply_distinct_from: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -67,6 +68,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { max_size: 0, serialize_value_for_key: 0, deserialize_value_for_key: 0, + validate_distinct_from: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs index 66af601406e..94333ac2306 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs @@ -82,6 +82,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { find_identifier_and_binary_paths: 1, // changed: a typed array's identifier and byte array elements are registered as `path[]` conversion paths apply_property_reference: Some(0), // changed: the meta-schema v3 `refersTo` keyword is folded into the parsed property type; None before this version means the keyword is ignored, as it was before it existed apply_required_since: Some(0), // changed: the meta-schema v3 `requiredSince` keyword (contract version a property is required from) is parsed onto the property; None before this version means the keyword is ignored, as it was before it existed + apply_distinct_from: Some(0), // changed: the meta-schema v3 `distinctFrom` keyword (an identifier property whose value must differ from a named sibling property or the document's `$ownerId`) is parsed onto the property; None before this version means the keyword is ignored, as it was before it existed parse_typed_array: Some(0), // changed: a meta-schema v3 typed array (`type: "array"` with an `items` element schema) parses to `DocumentPropertyType::TypedArray`; None before this version leaves it to the scalar parser, which refuses an array that is not a byte array validate_max_depth: 0, max_depth: 256, @@ -111,6 +112,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { max_size: 0, serialize_value_for_key: 0, deserialize_value_for_key: 0, + validate_distinct_from: Some(0), // changed: `validate_distinct_from_properties` refuses a document whose `distinctFrom` property equals what it must differ from (DocumentPropertyNotDistinctError, 10419); None before this version, where no property can carry the keyword }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index bf3417cbf7b..82989d48037 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -17,6 +17,13 @@ use crate::version::drive_abci_versions::drive_abci_validation_versions::{ // the `document_reference_validation` feature version. Also bump // `delete_withdrawal_data_trigger` to 2 so owners can delete withdrawals in the // terminal FAILED status the withdrawals contract v2 admits. +// Document create structure validation 1 also refuses a `distinctFrom` +// identifier property whose value equals the named sibling property or the +// writer's `$ownerId` (DocumentPropertyNotDistinctError, 10419). Document +// replace, transfer and purchase structure validation stay at 0: their v0 +// gained the same judgement in place (replace on the transition's data, +// transfer and purchase on the stored document and its new owner), which is +// inert before this version, where no property carries the keyword. // v9 remains unchanged for PROTOCOL_VERSION_13 chain replay. pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = DriveAbciValidationVersions { @@ -219,14 +226,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = // PROTOCOL_VERSION_14: a batch that asks the contract owner to pay its gas // only has to fund its principal (purchases, contest collateral) itself. identity_minimum_balance_pre_check: 1, - document_create_transition_structure_validation: 1, + document_create_transition_structure_validation: 1, // changed: v1 also cross-checks the prefunded voting balance against the contested index and refuses a `distinctFrom` identifier property equal to the value it must differ from // Reject deletes on legacy keep-history types as paid consensus errors. // Protocols through 13 retain the original internal-error outcome. document_delete_transition_structure_validation: 1, document_index_only_delete_transition_structure_validation: 0, - document_replace_transition_structure_validation: 0, - document_transfer_transition_structure_validation: 0, - document_purchase_transition_structure_validation: 0, + document_replace_transition_structure_validation: 0, // unchanged: v0 gained the `distinctFrom` refusal in place, inert before this version + document_transfer_transition_structure_validation: 0, // unchanged: v0 gained the `distinctFrom: $ownerId` judgement in place, inert before this version + document_purchase_transition_structure_validation: 0, // unchanged: v0 gained the `distinctFrom: $ownerId` judgement in place, inert before this version document_update_price_transition_structure_validation: 0, document_base_transition_state_validation: 0, document_create_transition_state_validation: 2, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index ce2b15cbcee..9a630293657 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -199,6 +199,12 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// reference checks, re-validates a `refersTo: deletableDocument` /// reference on every replace (a dead one must be repointed or cleared), /// and lets an `immutable` one be cleared once its target is deleted. +/// Document create structure validation 1 and replace structure +/// validation 0 (extended in place) refuse a `distinctFrom` identifier +/// property equal to the value it must differ from +/// (`DocumentPropertyNotDistinctError`, 10419); transfer and purchase +/// structure validation 0, extended in place, judge the stored document's +/// `$ownerId` declarations against the new owner. /// v13 keeps the v9 table and therefore keeps accepting all of these, so /// replay of pre-upgrade blocks is unchanged. /// * `DOCUMENT_VERSIONS_V4` bumps `document_serialization_version` to @@ -577,6 +583,25 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `contentMediaType`) now refuses `uniqueItems`, which would demand that /// no byte repeat. /// +/// 26. **Distinct identifier properties**: the `distinctFrom` property +/// keyword (meta-schema v3, `apply_distinct_from` 0, `DistinctFrom` on +/// `DocumentProperty`) requires an identifier property's value to differ +/// from the value of a named property of the same document, or from the +/// document's `$ownerId`; on the `items` of a typed array of identifiers +/// it binds every element. A pure structure rule: document create +/// structure validation 1 and replace structure validation 0 call +/// `validate_distinct_from_properties` (`validate_distinct_from` 0) on the +/// transition's data and owner id after the schema validation, transfer +/// and purchase structure validation 0 call it on the stored document and +/// its new owner (the three generation-0 modules were extended in place: +/// the call is inert before this version, where no property carries the +/// keyword), and each refuses an equal pair with +/// `DocumentPropertyNotDistinctError` (10419); an absent named property +/// passes. The parser checks the target at contract +/// registration and update (it must exist, be an identifier and not be +/// the declaring property), and a changed `distinctFrom` is an +/// incompatible schema change on update. +/// /// 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 diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 06fa5f6d474..11b79dc99d4 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -67,7 +67,7 @@ use dpp::consensus::state::data_trigger::DataTriggerError::{ }; use wasm_bindgen::{JsError, JsValue}; use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, DataContractInvalidRequiredFieldsUpdateError, ContestedUniqueIndexWithUniqueIndexError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, PreProgrammedDistributionAmountOverLimitError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; -use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; +use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyNotDistinctError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; use dpp::consensus::basic::group::GroupActionNotAllowedOnTransitionError; use dpp::consensus::basic::identity::{DataContractBoundsNotPresentError, DisablingKeyIdAlsoBeingAddedInSameTransitionError, InvalidIdentityCreditWithdrawalTransitionAmountError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidKeyPurposeForContractBoundsError, TooManyMasterPublicKeyError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError}; use dpp::consensus::basic::overflow_error::OverflowError; @@ -943,6 +943,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::DocumentFieldMaxSizeExceededError(e) => { generic_consensus_error!(DocumentFieldMaxSizeExceededError, e).into() } + BasicError::DocumentPropertyNotDistinctError(e) => { + generic_consensus_error!(DocumentPropertyNotDistinctError, e).into() + } BasicError::ContestedUniqueIndexWithUniqueIndexError(e) => { generic_consensus_error!(ContestedUniqueIndexWithUniqueIndexError, e).into() } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index d1bf70c531d..7a96997b8a3 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -111,6 +111,39 @@ impl DocumentImmutabilityErrorCodeWasm { } } +/// Consensus error codes emitted by the `distinctFrom` check on document +/// creates and replaces (protocol version 14+). +/// +/// Branch on an error's `code` against this instead of matching its message: +/// +/// ```js +/// try { +/// await sdk.documents.create({ document, identityKey, signer }); +/// } catch (e) { +/// if (e.code === DocumentDistinctFromErrorCode.DocumentPropertyNotDistinct) { +/// // an identifier property equals the value it must differ from +/// } +/// } +/// ``` +#[wasm_bindgen(js_name = "DocumentDistinctFromErrorCode")] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DocumentDistinctFromErrorCodeWasm { + /// A `distinctFrom` identifier property of the written document equals + /// the value it must differ from: the document's `$ownerId`, or the named + /// property of the same document. + DocumentPropertyNotDistinct = 10419, +} + +impl DocumentDistinctFromErrorCodeWasm { + /// The distinctFrom error a code names, or `None` for any other code. + fn from_code(code: u32) -> Option { + match code { + 10419 => Some(Self::DocumentPropertyNotDistinct), + _ => None, + } + } +} + #[wasm_bindgen(js_name = "ConsensusError")] pub struct ConsensusErrorWasm(ConsensusError); @@ -151,6 +184,13 @@ impl ConsensusErrorWasm { pub fn document_immutability_error_code(&self) -> Option { DocumentImmutabilityErrorCodeWasm::from_code(self.0.code()) } + + /// The distinctFrom error this is, or `undefined` when it is not code + /// 10419. + #[wasm_bindgen(getter = "documentDistinctFromErrorCode")] + pub fn document_distinct_from_error_code(&self) -> Option { + DocumentDistinctFromErrorCodeWasm::from_code(self.0.code()) + } } impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); @@ -205,6 +245,36 @@ mod tests { assert_eq!(DocumentImmutabilityErrorCodeWasm::from_code(40127), None); } + /// Built from the real DPP error, for the same reason as the test above. + #[test] + fn distinct_from_error_code_mirrors_the_dpp_error() { + use dpp::consensus::basic::BasicError; + use dpp::consensus::basic::document::DocumentPropertyNotDistinctError; + + let error: ConsensusError = + BasicError::DocumentPropertyNotDistinctError(DocumentPropertyNotDistinctError::new( + "delegation".to_string(), + "delegateId".to_string(), + "$ownerId".to_string(), + )) + .into(); + + assert_eq!( + DocumentDistinctFromErrorCodeWasm::from_code(error.code()), + Some(DocumentDistinctFromErrorCodeWasm::DocumentPropertyNotDistinct) + ); + assert_eq!( + DocumentDistinctFromErrorCodeWasm::DocumentPropertyNotDistinct as u32, + error.code() + ); + assert_eq!( + ConsensusErrorWasm(error).document_distinct_from_error_code(), + Some(DocumentDistinctFromErrorCodeWasm::DocumentPropertyNotDistinct) + ); + // A neighbouring code is not claimed. + assert_eq!(DocumentDistinctFromErrorCodeWasm::from_code(10418), None); + } + /// The six reference-validation errors, paired with the JS enum variant /// each is advertised to be. /// diff --git a/packages/wasm-dpp2/src/data_contract/document_type_distinct_from.rs b/packages/wasm-dpp2/src/data_contract/document_type_distinct_from.rs new file mode 100644 index 00000000000..8461adeceee --- /dev/null +++ b/packages/wasm-dpp2/src/data_contract/document_type_distinct_from.rs @@ -0,0 +1,99 @@ +//! `distinctFrom` declarations: the identifier properties a document type +//! requires to differ from another value of the same document, from +//! protocol version 14 onward. +//! +//! `distinctFrom` annotates an identifier property with what its value must +//! differ from: the document's `$ownerId`, or another identifier property of +//! the same document type. Consensus compares the two on every create and +//! replace and refuses an equal pair (basic code 10419); a declaration whose +//! named property is absent from the document passes. What this module adds +//! is the ability to *discover* the declarations, "which properties of this +//! document type must differ, and from what?", without hand-parsing the +//! contract's raw JSON schema. + +use crate::error::{WasmDppError, WasmDppResult}; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use js_sys::{Array, Object, Reflect}; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const DOCUMENT_PROPERTY_DISTINCT_FROM_TS: &'static str = r#" +/** + * A single `distinctFrom` declaration on a document type. + * + * Mirrors the `distinctFrom` keyword of the v3 document meta-schema, which is + * active from protocol version 14. The field name is the schema keyword's + * own, so what `contract.toJSON()` shows under `distinctFrom` and what these + * accessors return line up key for key. + */ +export type DocumentPropertyDistinctFrom = { + /** + * Dotted path of the declaring property within the document type, for + * example `"delegateId"`, or `"meta.reviewerId"` for a nested one. An + * identifier property, or a typed array of identifiers whose `items` + * carry the declaration, in which case every element is bound. This is the same string consensus reports in the `property` field + * of `DocumentPropertyNotDistinctError` (code 10419). + */ + path: string; + /** + * What the property's value must differ from: `"$ownerId"` for the + * document's owner, or the dotted path of another identifier property of + * the same document type. Consensus refuses a create or replace whose two + * values are equal; when the named property is absent from the document + * there is nothing to differ from, so the write passes. + */ + distinctFrom: string; +}; +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Array")] + pub type DocumentPropertyDistinctFromArrayJs; + + #[wasm_bindgen(typescript_type = "Map>")] + pub type DocumentPropertyDistinctFromMapJs; +} + +/// `Reflect::set` with the collection-getter error convention the `tokens` +/// and `groups` getters on `DataContract` already use. +fn set_field(target: &Object, key: &str, value: &JsValue, path: &str) -> WasmDppResult<()> { + Reflect::set(target, &JsValue::from_str(key), value).map_err(|_| { + WasmDppError::generic(format!( + "unable to serialize the `{key}` field of the distinctFrom declaration at '{path}'" + )) + })?; + Ok(()) +} + +/// Collect every `distinctFrom` declaration of one document type, in schema +/// property order. +/// +/// Walks `flattened_properties` rather than `properties` because that is +/// what the consensus check walks, and because its error `property` is +/// built from its dotted key. Using the nested map would miss nested +/// declarations entirely. +pub(crate) fn distinct_from_for_document_type( + document_type: DocumentTypeRef<'_>, +) -> WasmDppResult { + let declarations = Array::new(); + + for (path, property) in document_type.flattened_properties() { + let Some(distinct_from) = &property.distinct_from else { + continue; + }; + let object = Object::new(); + set_field(&object, "path", &JsValue::from_str(path), path)?; + set_field( + &object, + "distinctFrom", + &JsValue::from_str(distinct_from.as_str()), + path, + )?; + declarations.push(&object); + } + + Ok(declarations) +} diff --git a/packages/wasm-dpp2/src/data_contract/mod.rs b/packages/wasm-dpp2/src/data_contract/mod.rs index ab01c052f8c..a6989437b79 100644 --- a/packages/wasm-dpp2/src/data_contract/mod.rs +++ b/packages/wasm-dpp2/src/data_contract/mod.rs @@ -1,5 +1,6 @@ pub mod contract_bounds; pub mod document; +pub mod document_type_distinct_from; pub mod document_type_immutability; pub mod document_type_reference; pub mod document_type_typed_arrays; @@ -8,6 +9,9 @@ pub mod transitions; pub use contract_bounds::ContractBoundsWasm; pub use document::DocumentWasm; +pub use document_type_distinct_from::{ + DocumentPropertyDistinctFromArrayJs, DocumentPropertyDistinctFromMapJs, +}; pub use document_type_immutability::{ DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, }; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 65272df00e2..c197b8dac8e 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -1,3 +1,7 @@ +use crate::data_contract::document_type_distinct_from::{ + DocumentPropertyDistinctFromArrayJs, DocumentPropertyDistinctFromMapJs, + distinct_from_for_document_type, +}; use crate::data_contract::document_type_immutability::{ DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, immutable_properties_for_document_type, @@ -740,6 +744,56 @@ impl DataContractWasm { Ok(JsValue::from(map).into()) } + /// The `distinctFrom` declarations of one document type, in schema + /// property order: each `{ path, distinctFrom }` names an identifier + /// property and what its value must differ from, `"$ownerId"` or the + /// dotted path of another identifier property of the same type. + /// + /// Empty when the document type declares none (the normal case). Throws + /// when the contract has no document type by that name, so "no such + /// type" and "nothing declared" stay distinguishable. + /// + /// The keyword is only parsed from protocol version 14 onward. A + /// contract deserialized against an earlier platform version reports an + /// empty list, which is exactly what consensus enforced at that version, + /// while `toJSON()` still shows the raw keyword either way. + #[wasm_bindgen(js_name = "documentTypeDistinctFrom")] + pub fn document_type_distinct_from( + &self, + #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, + ) -> WasmDppResult { + let document_type = self + .0 + .document_type_optional_for_name(document_type_name.as_str()) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + + let declarations = distinct_from_for_document_type(document_type)?; + Ok(JsValue::from(declarations).into()) + } + + /// Every document type that declares at least one `distinctFrom`, keyed + /// by document type name. + /// + /// Document types with no declarations are omitted, so an empty `Map` + /// means "this contract declares no distinctFrom at all". + #[wasm_bindgen(getter = "documentDistinctFrom")] + pub fn document_distinct_from(&self) -> WasmDppResult { + let map = js_sys::Map::new(); + + for (name, document_type) in self.0.document_types() { + let declarations = distinct_from_for_document_type(document_type.as_ref())?; + if declarations.length() > 0 { + map.set(&JsValue::from_str(name), &declarations.into()); + } + } + + Ok(JsValue::from(map).into()) + } + /// The `immutable` / `immutableAllowSetting` declarations of one /// document type: `{ immutable: string[], immutableAllowSetting: /// string[] }`, both sorted by property name.