From a8aed7db1ba93a4801c6e6da5a192b72857b61fe Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 24 Sep 2026 07:19:05 +0700 Subject: [PATCH 1/2] feat(platform)!: maxBytes on strings and sumOfProperties on objects (PV14) Two document schema keywords for bounds plain JSON Schema cannot count: `maxBytes` caps a string's UTF-8 length (maxLength counts characters) and `sumOfProperties` is the total an object's integer members must add up to. Parsed by the v3 parser, checked on document create and replace after the schema validation (DocumentPropertyMaxBytesExceededError 10421, DocumentPropertySumMismatchError 10422); replace structure validation 0 is extended in place and inert before protocol version 14. The moderation charters contract declares both on the proposal's description and reward split, replacing validate_submitted_charter, its errors 11001 and 11002 and SystemLimits::max_moderation_charter_description_length. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 32 ++ book/src/error-handling/error-codes.md | 2 +- docs/protocol/moderation-charters.md | 24 +- .../moderation-charters-contract/README.md | 15 +- ...oderation-charters-contract-documents.json | 2 + .../unit/moderationChartersContract.spec.js | 21 +- .../document/v3/document-meta.json | 51 +- .../class_methods/try_from_schema/mod.rs | 240 +++++++++ .../v3/max_bytes_and_sum_tests.rs | 484 ++++++++++++++++++ .../class_methods/try_from_schema/v3/mod.rs | 2 + .../document_type/index/preallocation.rs | 6 + .../document_type/methods/mod.rs | 177 ++++++- .../methods/validate_update/common/mod.rs | 127 +++++ .../src/data_contract/document_type/mod.rs | 9 + .../document_type/property/mod.rs | 68 +++ .../document_type/v0/random_document_type.rs | 4 + .../src/errors/consensus/basic/basic_error.rs | 27 +- ...ument_property_max_bytes_exceeded_error.rs | 70 +++ .../document_property_sum_mismatch_error.rs | 67 +++ .../errors/consensus/basic/document/mod.rs | 4 + .../consensus/basic/moderation_charter/mod.rs | 4 - ...tion_charter_description_too_long_error.rs | 54 -- ...rter_reward_split_not_one_hundred_error.rs | 67 --- packages/rs-dpp/src/errors/consensus/codes.rs | 4 +- packages/rs-dpp/src/moderation_charter/mod.rs | 78 +-- .../rs-dpp/src/moderation_charter/tests.rs | 96 +--- .../rs-dpp/src/moderation_charter/v0/mod.rs | 40 -- packages/rs-dpp/src/system_data_contracts.rs | 134 ++++- .../advanced_structure_v1/mod.rs | 19 +- .../advanced_structure_v0/mod.rs | 24 +- .../batch/tests/document/max_bytes_and_sum.rs | 437 ++++++++++++++++ .../batch/tests/document/mod.rs | 1 + .../src/rules/rule_set.rs | 78 +++ .../dpp_versions/dpp_contract_versions/mod.rs | 20 + .../dpp_versions/dpp_contract_versions/v1.rs | 4 + .../dpp_versions/dpp_contract_versions/v2.rs | 4 + .../dpp_versions/dpp_contract_versions/v3.rs | 4 + .../dpp_versions/dpp_contract_versions/v4.rs | 4 + .../dpp_versions/dpp_contract_versions/v5.rs | 4 + .../dpp_versions/dpp_contract_versions/v6.rs | 4 + .../dpp_validation_versions/mod.rs | 3 - .../dpp_validation_versions/v1.rs | 1 - .../dpp_validation_versions/v2.rs | 1 - .../dpp_validation_versions/v3.rs | 1 - .../dpp_validation_versions/v5.rs | 3 - .../drive_abci_validation_versions/v10.rs | 4 +- .../src/version/mocks/v2_test.rs | 1 - .../src/version/system_limits/mod.rs | 5 - .../src/version/system_limits/v1.rs | 1 - .../src/version/system_limits/v2.rs | 1 - .../src/version/system_limits/v3.rs | 1 - .../src/version/system_limits/v4.rs | 10 +- .../rs-platform-version/src/version/v14.rs | 38 +- .../src/errors/consensus/consensus_error.rs | 19 +- packages/wasm-dpp2/src/consensus_error.rs | 82 +++ 55 files changed, 2270 insertions(+), 413 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_and_sum_tests.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/document/document_property_max_bytes_exceeded_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/document/document_property_sum_mismatch_error.rs delete mode 100644 packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_description_too_long_error.rs delete mode 100644 packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_reward_split_not_one_hundred_error.rs delete mode 100644 packages/rs-dpp/src/moderation_charter/v0/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes_and_sum.rs diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 5b0ac70f199..91966e9d611 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -659,6 +659,38 @@ Nothing else is verifiable on chain: not that the bytes decrypt, not that they d In Rust the declaration is `DocumentProperty::encrypted_for` (`Option`), listed per document type by `DocumentTypeV0Getters::encrypted_properties()`, and the shape check is `DocumentTypeBasicMethods::validate_encrypted_property_shapes()`, versioned on the `validate_encrypted_property_shapes` method slot (`None` before protocol version 14, which is what keeps the in-place replace call inert). In JavaScript, `contract.documentTypeEncryptedProperties(name)` and `contract.documentEncryptedProperties` expose the same declarations, and the shape error reaches an app as `DocumentEncryptionErrorCode.InvalidEncryptedPropertyShape`. +## Byte Caps and Sums (`maxBytes`, `sumOfProperties`) + +Protocol version 14 adds two property keywords for bounds plain JSON Schema cannot count. Both are pure structure rules: they read the transition being written and nothing else. + +```json +"description": { + "type": "string", "minLength": 1, "maxLength": 4096, + "maxBytes": 4096, + "position": 1 +}, +"rewardSplit": { + "type": "object", + "properties": { + "leader": { "type": "integer", "minimum": 0, "maximum": 100, "position": 0 }, + "equal": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 }, + "actions": { "type": "integer", "minimum": 0, "maximum": 100, "position": 2 } + }, + "required": ["leader", "equal", "actions"], + "additionalProperties": false, + "sumOfProperties": 100, + "position": 4 +} +``` + +`maxBytes` caps a string's UTF-8 length. `maxLength` counts characters, and a character is up to four bytes, so `maxLength: 4096` alone lets a value take 16384 bytes. The keyword goes on a string property, or on the `items` of a typed array of strings, where it bounds every element; it is refused on the array itself and on elements of any other type. It is an integer from 1 to 65535 and no lower than `minLength`, since a string of `minLength` characters is at least that many bytes. On contract update it moves like `maxLength`: raising or removing it is compatible, adding or lowering it is not. + +`sumOfProperties` goes on an object property and is the total the object's members must add up to. At contract registration every member must be an integer property, required without `requiredSince`, and not transient, so the sum is defined on every stored object, and the total must be reachable from the members' `minimum` and `maximum` (their integer type's bounds where absent). A contract update that adds, removes or changes it is an incompatible schema change: readers of stored objects rely on the total. + +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 calls are inert before 14, where no property can carry either keyword), after the schema validation of the document's properties. A longer string fails the write with `DocumentPropertyMaxBytesExceededError` (basic code 10421), which names the property (`tags[2]` for an element) and both lengths; an object adding up to anything else fails it with `DocumentPropertySumMismatchError` (basic code 10422), which names the object and both sums. A property or object the document leaves out is not checked; whether it may be left out is the `required` list's business. + +In Rust the declarations are `DocumentProperty::max_bytes` (`Option`) and `DocumentProperty::sum_of_properties` (`Option`, on the object's entry in `properties()`, since objects are not in the flattened map), and the checks are `DocumentTypeBasicMethods::validate_max_bytes_properties()` and `validate_sum_of_properties()`, versioned on the `validate_max_bytes` and `validate_sum_of_properties` method slots (`None` before protocol version 14). + ## Rules and Guidelines **Do:** diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 27e6c5f57c4..31ac3dbffc5 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-10420 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401), `DocumentPropertyNotDistinctError` (10419), `InvalidEncryptedPropertyShapeError` (10420) | +| 10400-10422 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401), `DocumentPropertyNotDistinctError` (10419), `InvalidEncryptedPropertyShapeError` (10420), `DocumentPropertyMaxBytesExceededError` (10421), `DocumentPropertySumMismatchError` (10422) | | 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/docs/protocol/moderation-charters.md b/docs/protocol/moderation-charters.md index 7196c69bafd..669b831cf08 100644 --- a/docs/protocol/moderation-charters.md +++ b/docs/protocol/moderation-charters.md @@ -73,10 +73,10 @@ bound to it, the key join requests are encrypted to. | Property | Type | Meaning | | --- | --- | --- | | `targetContractId` | identifier, required, `refersTo: { "type": "contract", "contractRequirements": { "moderation": "elected" } }` | The contract the team proposes to moderate; a target that does not exist refuses the create (40120), one that does not declare elected moderation refuses it with `ReferencedContractRequirementNotMetError` (40135) | -| `description` | string, 1 to 4096 characters, required | What the team would moderate and how, for joiners and voters. Informational | +| `description` | string, 1 to 4096 characters and at most 4096 bytes (`maxBytes`), required | What the team would moderate and how, for joiners and voters. Informational | | `reasons` | typed array of at most 64 unique identifiers, required, each `refersTo` a `reason` | The moderation reasons the team's actions may name; empty is allowed, a team that can take no action; a missing reason refuses the create, naming the element (`reasons[2]`) | | `moderatorsShare` | integer 0 to 100 | The percentage of each moderated document type's declared moderators fee the team takes. Absent is the full amount; a lower number is a discount; 0 is a team that will not moderate and takes no rewards | -| `rewardSplit` | object, required | `leader`, `equal` and `actions`, three percentages summing to 100: the leader's share, the share split equally among the other members, and the share split by each member's action count | +| `rewardSplit` | object, required, `sumOfProperties: 100` | `leader`, `equal` and `actions`, three percentages summing to 100: the leader's share, the share split equally among the other members, and the share split by each member's action count | Indexes: `byTargetContract` (`targetContractId`, `$createdAt`) lists the proposals for a contract in filing order; `byOwner` (`$ownerId`) lists a @@ -173,18 +173,18 @@ contest for its target contract. Reading the join window, the vote window and the fund from the target contract comes with the seating, in a later pull request. -## Validation beyond the schema +## Reading charters Every rule above is enforced by the schema's keywords when a document is -written. Two rules of a proposal are not expressible there, and -`validate_submitted_charter` in `rs-dpp` -(`packages/rs-dpp/src/moderation_charter/`) checks them without reading state, -for the path that seats a team: +written, the reward split's total and the description's byte cap included: | Rule | Error | Code | | --- | --- | --- | -| A property is missing or of the wrong type | `ModerationCharterMalformedFieldError` | 11000 | -| The three shares of `rewardSplit` do not sum to 100 | `ModerationCharterRewardSplitNotOneHundredError` | 11001 | -| The description is over `SystemLimits::max_moderation_charter_description_length` (4096) bytes; the schema's `maxLength` counts characters | `ModerationCharterDescriptionTooLongError` | 11002 | - -`ElectedCharter` reads an elected charter's properties for the same path. +| The three shares of `rewardSplit` do not sum to 100 (`sumOfProperties`) | `DocumentPropertySumMismatchError` | 10422 | +| The description is over 4096 bytes (`maxBytes`); `maxLength` counts characters | `DocumentPropertyMaxBytesExceededError` | 10421 | + +`SubmittedCharter` and `ElectedCharter` in `rs-dpp` +(`packages/rs-dpp/src/moderation_charter/`) read a proposal's and an elected +charter's properties without reading state, and report a property that is +missing or of the wrong type with `ModerationCharterMalformedFieldError` +(11000). diff --git a/packages/moderation-charters-contract/README.md b/packages/moderation-charters-contract/README.md index b287425a2e1..72a7153728d 100644 --- a/packages/moderation-charters-contract/README.md +++ b/packages/moderation-charters-contract/README.md @@ -9,12 +9,11 @@ every network: `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`. It has seven document types. All are immutable, and all but `resignationRequest` are undeletable, so everything a charter points at, and the charter itself, is a fixed text. -The schema carries almost every rule through its keywords: typed arrays with -a reference per element, a reference resolved through a unique index -(`lookup`), `distinctFrom`, key requirements on key references and the -`encryptedFor` envelope. What it cannot say, the reward split summing to 100 -and the description's byte cap, is checked by `SubmittedCharter` in -`rs-dpp` when a team is seated. +The schema carries every rule through its keywords: typed arrays with a +reference per element, a reference resolved through a unique index +(`lookup`), `distinctFrom`, key requirements on key references, the +`encryptedFor` envelope, and `sumOfProperties` and `maxBytes` for the reward +split summing to 100 and the description's 4096-byte cap. ## `reason` @@ -36,10 +35,10 @@ decryption key bound to this type so join requests can be encrypted to it. | Property | Type | Meaning | | --- | --- | --- | | `targetContractId` | identifier, required, `refersTo` a contract with elected moderation | The contract the team proposes to moderate | -| `description` | string, 1 to 4096 characters, required | What the team would moderate and how. Informational | +| `description` | string, 1 to 4096 characters and at most 4096 bytes, required | What the team would moderate and how. Informational | | `reasons` | array of at most 64 unique reason ids, required, each `refersTo` a `reason` | The moderation reasons the team's actions may name; a team with none can take no action | | `moderatorsShare` | integer 0 to 100 | The percentage of each moderated type's declared moderators fee the team takes; absent is the full amount, 0 a team that will not moderate and takes no rewards | -| `rewardSplit` | object, required | `leader`, `equal` and `actions` percentages summing to 100 | +| `rewardSplit` | object, required, `sumOfProperties: 100` | `leader`, `equal` and `actions` percentages summing to 100 | Indexes: `byTargetContract` (target, `$createdAt`) lists the proposals for a contract in filing order; `byOwner` lists a leader's proposals. diff --git a/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json index 322ddfa767d..941ba773f0a 100644 --- a/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json +++ b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json @@ -93,6 +93,7 @@ "type": "string", "minLength": 1, "maxLength": 4096, + "maxBytes": 4096, "position": 1 }, "reasons": { @@ -151,6 +152,7 @@ "actions" ], "additionalProperties": false, + "sumOfProperties": 100, "description": "Percentages summing to 100", "position": 4 } diff --git a/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js b/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js index da2f2b1c5cc..531a7971e16 100644 --- a/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js +++ b/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js @@ -173,8 +173,12 @@ describe('Moderation Charters Contract', () => { it('should count characters, not bytes; consensus caps the bytes at 4096', async () => { // 2049 two-byte characters: within the schema's 4096 characters, over the - // 4096 bytes `SystemLimits::max_moderation_charter_description_length` - // enforces in rs-dpp (ModerationCharterDescriptionTooLongError, 11002). + // 4096 bytes its `maxBytes` declares, which the document create and replace + // structure validation checks (DocumentPropertyMaxBytesExceededError, 10421), + // not JSON Schema validation. + expect(moderationChartersContractDocumentsSchema.submittedCharter.properties + .description.maxBytes).to.equal(4096); + const raw = await rawProposal(); raw.description = 'é'.repeat(2049); @@ -272,6 +276,19 @@ describe('Moderation Charters Contract', () => { expect(error.keyword).to.equal('additionalProperties'); }); + + it('should leave the total of 100 to consensus', async () => { + // `sumOfProperties` is checked by the document create and replace structure + // validation (DocumentPropertySumMismatchError, 10422), not JSON Schema + // validation, which sees three shares each within 0 to 100. + expect(moderationChartersContractDocumentsSchema.submittedCharter.properties + .rewardSplit.sumOfProperties).to.equal(100); + + const raw = await rawProposal(); + raw.rewardSplit = { leader: 10, equal: 40, actions: 40 }; + + expect(validate('submittedCharter', raw).isValid()).to.be.true(); + }); }); }); 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 32268a6b6d3..6d2404ef132 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 @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take) and the sumOfProperties property keyword (the total an object's integer properties must add up to), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "referenceOperands": { @@ -545,6 +545,16 @@ "minLength": 1, "maxLength": 256 }, + "maxBytes": { + "description": "Only on string properties, and on the items of a typed array of strings, where it bounds every element: the most bytes the value may take in UTF-8. maxLength counts characters, which are up to four bytes each, so it cannot bound the stored size on its own. An integer from 1 to 65535, no lower than minLength (checked at contract registration). Checked on document create and replace; a longer value is refused (DocumentPropertyMaxBytesExceededError, 10421), naming the element (tags[2]) for an item. Available from protocol version 14.", + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "sumOfProperties": { + "description": "Only on object properties: the total the object's properties must add up to. Every property of the object must be an integer, required without requiredSince, and not transient, and the total must lie between what the members' minimum and maximum (their integer type's bounds where absent) can add up to; both are checked at contract registration. Checked on document create and replace; an object whose members add up to anything else is refused (DocumentPropertySumMismatchError, 10422). An absent object is not checked. Available from protocol version 14.", + "type": "integer" + }, "requiredSince": { "type": "integer", "minimum": 1, @@ -642,6 +652,28 @@ "maxItems" ] }, + "maxBytes": { + "description": "maxBytes is only allowed on string properties; on a typed array it goes on the items", + "properties": { + "type": { + "const": "string" + } + }, + "required": [ + "type" + ] + }, + "sumOfProperties": { + "description": "sumOfProperties is only allowed on object properties", + "properties": { + "type": { + "const": "object" + } + }, + "required": [ + "type" + ] + }, "refersTo": { "description": "refersTo is only allowed on identifier properties, except an identityPublicKey reference with identityProperty, which sits on the key id property: an integer with minimum 0 and maximum 4294967295, the range of a key id", "if": { @@ -895,6 +927,12 @@ "minLength": 1, "maxLength": 256 }, + "maxBytes": { + "description": "Only on string elements: the most bytes every element may take in UTF-8, exactly as maxBytes on a string property. The declaration belongs on the items, not on the array. Available from protocol version 14.", + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, "refersTo": { "description": "Only on identifier elements: what every element refers to, the refersTo declaration of an identifier property with the same keys and the same checks (a reference expression included, which each element must meet on its own), except that identityPublicKey is refused: its keyIdProperty names one sibling key id, which cannot pair with many elements. When a document is created or replaced each element is checked as a single reference is, and the first one that fails refuses the write, its error naming the element by its list path (reasons[2] for the third). A propertyAgreement's referring side is still a property of the referring document or its $ownerId, the same for every element, and its referenced side a property of that element's referenced document. The declaration belongs on the items, not on the array. Available from protocol version 14.", "$ref": "#/$defs/documentSchema/properties/refersTo", @@ -966,6 +1004,17 @@ "maxItems" ] }, + "maxBytes": { + "description": "maxBytes is only allowed on string elements", + "properties": { + "type": { + "const": "string" + } + }, + "required": [ + "type" + ] + }, "byteArray": { "description": "should be used only with array type", "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 f73d7da4be3..885fc2e9c7a 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 @@ -210,6 +210,10 @@ fn insert_values( apply_distinct_from(&inner_properties, &property_type, platform_version)?; let encrypted_for = apply_encrypted_for(&inner_properties, &property_type, platform_version)?; + let max_bytes = + apply_max_bytes(&inner_properties, &property_type, platform_version)?; + // Objects are not in the flattened map, so no entry here carries a sum: + // the nested pass reads `sumOfProperties` onto the object itself. document_properties.insert( prefixed_property_key, DocumentProperty { @@ -219,6 +223,8 @@ fn insert_values( required_since, distinct_from, encrypted_for, + max_bytes, + sum_of_properties: None, }, ); } @@ -342,6 +348,13 @@ fn insert_values_nested( apply_property_reference(&inner_properties, property_type, platform_version)?; let distinct_from = apply_distinct_from(&inner_properties, &property_type, platform_version)?; let encrypted_for = apply_encrypted_for(&inner_properties, &property_type, platform_version)?; + let max_bytes = apply_max_bytes(&inner_properties, &property_type, platform_version)?; + let sum_of_properties = apply_sum_of_properties( + &inner_properties, + &property_type, + root_schema, + platform_version, + )?; document_properties.insert( property_key, @@ -352,6 +365,8 @@ fn insert_values_nested( required_since, distinct_from, encrypted_for, + max_bytes, + sum_of_properties, }, ); @@ -532,6 +547,231 @@ fn validate_distinct_from_targets_v0( Ok(()) } +/// Reads a `maxBytes` declaration off a string property, or off the `items` +/// of a typed array of strings: the most UTF-8 bytes the value (every +/// element) may hold. `maxLength` counts characters, which are up to four +/// bytes each, so it cannot bound the stored size on its own. +/// +/// Versioned on `apply_max_bytes` 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. +fn apply_max_bytes( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Result, DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_max_bytes + { + None => Ok(None), + Some(0) => apply_max_bytes_v0(inner_properties, property_type), + Some(version) => Err(DataContractError::Unsupported(format!( + "apply_max_bytes version {version} is not supported" + ))), + } +} + +fn apply_max_bytes_v0( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, +) -> Result, DataContractError> { + // A typed array carries the declaration on its `items`: every element is + // bounded, so the elements must be strings + if let DocumentPropertyType::TypedArray(typed_array) = property_type { + if inner_properties.contains_key(property_names::MAX_BYTES) { + return Err(DataContractError::InvalidContractStructure( + "maxBytes on a typed array belongs on its items, where it bounds 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(max_bytes_value) = items_map.get(property_names::MAX_BYTES) else { + return Ok(None); + }; + let DocumentPropertyType::String(sizes) = typed_array.item_type.as_ref() else { + return Err(DataContractError::InvalidContractStructure( + "maxBytes is only allowed on string elements of a typed array".to_string(), + )); + }; + return parse_max_bytes(max_bytes_value, sizes.min_length).map(Some); + } + + let Some(max_bytes_value) = inner_properties.get(property_names::MAX_BYTES) else { + return Ok(None); + }; + let DocumentPropertyType::String(sizes) = property_type else { + return Err(DataContractError::InvalidContractStructure( + "maxBytes is only allowed on string properties".to_string(), + )); + }; + parse_max_bytes(max_bytes_value, sizes.min_length).map(Some) +} + +/// A `maxBytes` bound: 1 to 65535, and no lower than `minLength`, since a +/// string of `minLength` characters is at least that many bytes and a bound +/// below it would refuse every value. +fn parse_max_bytes(value: &Value, min_length: Option) -> Result { + let max_bytes = value + .to_integer::() + .ok() + .filter(|max_bytes| *max_bytes > 0) + .ok_or_else(|| { + DataContractError::InvalidContractStructure( + "maxBytes must be an integer from 1 to 65535".to_string(), + ) + })?; + if let Some(min_length) = min_length { + if max_bytes < min_length { + return Err(DataContractError::InvalidContractStructure(format!( + "maxBytes {max_bytes} is below minLength {min_length}: a string of \ + {min_length} characters is at least {min_length} bytes, so no value could \ + be valid" + ))); + } + } + Ok(max_bytes) +} + +/// Reads a `sumOfProperties` declaration off an object property: the total +/// its members must add up to. Every member must be an integer property that +/// every document carries (required, with no `requiredSince`, not transient), +/// so the sum is defined on every stored object, and the total must be one the +/// members' `minimum` and `maximum` can reach. +/// +/// Versioned on `apply_sum_of_properties` 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. +fn apply_sum_of_properties( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, + root_schema: &Value, + platform_version: &PlatformVersion, +) -> Result, DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_sum_of_properties + { + None => Ok(None), + Some(0) => apply_sum_of_properties_v0(inner_properties, property_type, root_schema), + Some(version) => Err(DataContractError::Unsupported(format!( + "apply_sum_of_properties version {version} is not supported" + ))), + } +} + +fn apply_sum_of_properties_v0( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, + root_schema: &Value, +) -> Result, DataContractError> { + let Some(sum_value) = inner_properties.get(property_names::SUM_OF_PROPERTIES) else { + return Ok(None); + }; + let DocumentPropertyType::Object(members) = property_type else { + return Err(DataContractError::InvalidContractStructure( + "sumOfProperties is only allowed on object properties".to_string(), + )); + }; + let total = sum_value.to_integer::().map_err(|_| { + DataContractError::InvalidContractStructure( + "sumOfProperties must be an integer in the i64 range".to_string(), + ) + })?; + if members.is_empty() { + return Err(DataContractError::InvalidContractStructure( + "sumOfProperties needs an object with at least one integer property to add up" + .to_string(), + )); + } + + let member_schemas = match inner_properties.get(property_names::PROPERTIES) { + Some(properties) => properties.to_btree_ref_string_map()?, + None => BTreeMap::new(), + }; + + // The least and the most the members can add up to, from each member's + // `minimum` and `maximum` where declared and its integer type otherwise + let (mut least, mut most) = (0i128, 0i128); + for (name, member) in members { + let Some((type_min, type_max)) = integer_range(&member.property_type) else { + return Err(DataContractError::InvalidContractStructure(format!( + "sumOfProperties adds up integer properties, but member \"{name}\" is not one" + ))); + }; + if !member.required || member.required_since.is_some() { + return Err(DataContractError::InvalidContractStructure(format!( + "sumOfProperties member \"{name}\" must be required, without requiredSince: \ + an absent member would leave the sum undefined" + ))); + } + if member.transient { + return Err(DataContractError::InvalidContractStructure(format!( + "sumOfProperties member \"{name}\" is transient: it is never stored, so the \ + stored object could not be held to the sum" + ))); + } + let (minimum, maximum) = match member_schemas.get(name.as_str()) { + Some(schema) => { + let mut schema = schema.to_btree_ref_string_map()?; + if let Some(schema_ref) = schema.get_optional_str(property_names::REF)? { + schema = resolve_uri(root_schema, schema_ref)?.to_btree_ref_string_map()?; + } + ( + schema + .get_optional_integer::(property_names::MINIMUM) + .ok() + .flatten(), + schema + .get_optional_integer::(property_names::MAXIMUM) + .ok() + .flatten(), + ) + } + None => (None, None), + }; + least = least.saturating_add(minimum.map_or(type_min, |minimum| minimum.max(type_min))); + most = most.saturating_add(maximum.map_or(type_max, |maximum| maximum.min(type_max))); + } + if i128::from(total) < least || i128::from(total) > most { + return Err(DataContractError::InvalidContractStructure(format!( + "sumOfProperties {total} is out of reach: the members add up to between {least} \ + and {most}" + ))); + } + Ok(Some(total)) +} + +/// The values an integer property type holds, widened to `i128` (a `u128` +/// maximum is clamped). `None` for every other type, key id references +/// included: a key id is a name, not an amount. +fn integer_range(property_type: &DocumentPropertyType) -> Option<(i128, i128)> { + Some(match property_type { + DocumentPropertyType::U8 => (0, u8::MAX.into()), + DocumentPropertyType::I8 => (i8::MIN.into(), i8::MAX.into()), + DocumentPropertyType::U16 => (0, u16::MAX.into()), + DocumentPropertyType::I16 => (i16::MIN.into(), i16::MAX.into()), + DocumentPropertyType::U32 => (0, u32::MAX.into()), + DocumentPropertyType::I32 => (i32::MIN.into(), i32::MAX.into()), + DocumentPropertyType::U64 => (0, u64::MAX.into()), + DocumentPropertyType::I64 => (i64::MIN.into(), i64::MAX.into()), + DocumentPropertyType::U128 => (0, i128::MAX), + DocumentPropertyType::I128 => (i128::MIN, i128::MAX), + _ => return None, + }) +} + /// Folds a `refersTo` declaration into the property type: an identifier property /// with `refersTo` becomes `IdentifierWithReference(target)`, and a `u32` key id /// property with an `identityPublicKey` declaration naming `identityProperty` diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_and_sum_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_and_sum_tests.rs new file mode 100644 index 00000000000..4c273683c48 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_and_sum_tests.rs @@ -0,0 +1,484 @@ +//! `maxBytes` and `sumOfProperties`: the two property keywords that bound a +//! value by something plain JSON Schema cannot count, the UTF-8 length of a +//! string and the total of an object's integer members. +//! +//! The grammar is the v3 document meta-schema's (protocol version 14) and the +//! parses are `apply_max_bytes` 0 and `apply_sum_of_properties` 0, which the +//! tables select from protocol version 14 only. The write-time checks are +//! `validate_max_bytes_properties` and `validate_sum_of_properties`. + +use super::typed_array_test_helpers::{ + expect_json_schema_error, expect_structure_error, parse_dispatched, +}; +use super::*; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; +use crate::validation::SimpleConsensusValidationResult; +use platform_value::platform_value; + +/// A document type with a string `note` (declaring `note_max_bytes` when +/// given), a typed string array `tags`, and an object `meta` holding a string +/// `meta.tag` and the object `meta.split` of two percentages, `a` and `b`. +fn schema(note_max_bytes: Option) -> Value { + let mut note = platform_value!({ "type": "string", "maxLength": 64, "position": 0 }); + if let Some(max_bytes) = note_max_bytes { + note.insert("maxBytes".to_string(), max_bytes) + .expect("note is a map"); + } + platform_value!({ + "type": "object", + "properties": { + "note": note, + "tags": { + "type": "array", + "maxItems": 4, + "items": { "type": "string", "maxLength": 16, "maxBytes": 8 }, + "position": 1 + }, + "meta": { + "type": "object", + "position": 2, + "properties": { + "tag": { "type": "string", "maxLength": 16, "maxBytes": 4, "position": 0 }, + "split": split_schema(100) + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }) +} + +/// Two required percentages that must add up to `total`. +fn split_schema(total: i64) -> Value { + platform_value!({ + "type": "object", + "position": 1, + "properties": { + "a": { "type": "integer", "minimum": 0, "maximum": 100, "position": 0 }, + "b": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 } + }, + "required": ["a", "b"], + "additionalProperties": false, + "sumOfProperties": total + }) +} + +/// A document type with the object `split` declared by `split`. +fn schema_with_split(split: Value) -> Value { + platform_value!({ + "type": "object", + "properties": { "split": split }, + "additionalProperties": false + }) +} + +fn parse(schema: Value) -> DocumentType { + parse_dispatched(schema, PlatformVersion::latest(), true).expect("the schema parses") +} + +fn data(value: Value) -> BTreeMap { + value.into_btree_string_map().expect("a map of properties") +} + +fn first_basic_error(result: SimpleConsensusValidationResult) -> BasicError { + match result.errors.into_iter().next() { + Some(ConsensusError::BasicError(error)) => error, + other => panic!("expected a basic error, got {other:?}"), + } +} + +// ================================================================ +// maxBytes: parse +// ================================================================ + +#[test] +fn should_parse_max_bytes_on_strings_and_on_the_items_of_a_typed_string_array() { + let document_type = parse(schema(Some(Value::U64(8)))); + let document_type = document_type.as_ref(); + let flattened = document_type.flattened_properties(); + + assert_eq!(flattened.get("note").expect("note").max_bytes, Some(8)); + assert_eq!( + flattened.get("meta.tag").expect("meta.tag").max_bytes, + Some(4) + ); + // Declared on the items, held on the array property, bounding every element + assert_eq!(flattened.get("tags").expect("tags").max_bytes, Some(8)); + assert_eq!(flattened.get("meta.split.a").expect("a").max_bytes, None); +} + +#[test] +fn should_refuse_max_bytes_on_a_property_that_is_not_a_string() { + let schema = schema_with_split(platform_value!({ + "type": "integer", + "minimum": 0, + "maximum": 100, + "maxBytes": 1, + "position": 0 + })); + let error = expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + assert_eq!(error.keyword(), "const", "{error:?}"); + + // A parse that skips the meta-schema still refuses it + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "maxBytes is only allowed on string properties", + ); +} + +#[test] +fn should_refuse_max_bytes_on_a_typed_array_itself() { + let schema = schema_with_split(platform_value!({ + "type": "array", + "maxItems": 4, + "maxBytes": 8, + "items": { "type": "string", "maxLength": 16 }, + "position": 0 + })); + expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "belongs on its items", + ); +} + +#[test] +fn should_refuse_max_bytes_on_elements_that_are_not_strings() { + let schema = schema_with_split(platform_value!({ + "type": "array", + "maxItems": 4, + "items": { "type": "integer", "minimum": 0, "maximum": 9, "maxBytes": 1 }, + "position": 0 + })); + expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "only allowed on string elements", + ); +} + +#[test] +fn should_refuse_a_max_bytes_of_zero_or_below_min_length() { + let error = expect_json_schema_error(parse_dispatched( + schema(Some(Value::U64(0))), + PlatformVersion::latest(), + true, + )); + assert_eq!(error.keyword(), "minimum", "{error:?}"); + + // Two characters are at least two bytes, so a one-byte bound refuses every value + expect_structure_error( + parse_dispatched( + schema_with_split(platform_value!({ + "type": "string", + "minLength": 2, + "maxLength": 8, + "maxBytes": 1, + "position": 0 + })), + PlatformVersion::latest(), + true, + ), + "maxBytes 1 is below minLength 2", + ); +} + +#[test] +fn should_ignore_max_bytes_before_protocol_version_14() { + // Protocol version 13's meta-schema refuses the keyword; a parse that skips it + // (a contract read back from state) ignores it, as it always did + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let document_type = parse_dispatched( + schema_with_split(platform_value!({ + "type": "string", + "maxLength": 8, + "maxBytes": 4, + "position": 0 + })), + platform_version, + false, + ) + .expect("a parse predating maxBytes ignores it"); + assert_eq!( + document_type + .as_ref() + .flattened_properties() + .get("split") + .expect("split") + .max_bytes, + None + ); +} + +// ================================================================ +// maxBytes: write time +// ================================================================ + +#[test] +fn should_refuse_a_string_over_its_max_bytes_by_its_utf8_length() { + let document_type = parse(schema(Some(Value::U64(8)))); + let platform_version = PlatformVersion::latest(); + + // Eight bytes in one-byte or two-byte characters fit; the same four characters with + // a fifth two-byte one do not, though every one of them is well within maxLength + for note in ["abcdefgh", "éééé"] { + let result = document_type + .validate_max_bytes_properties( + &data(platform_value!({ "note": note })), + platform_version, + ) + .expect("validation executes"); + assert!(result.is_valid(), "{note}: {:?}", result.errors); + } + let result = document_type + .validate_max_bytes_properties( + &data(platform_value!({ "note": "ééééé" })), + platform_version, + ) + .expect("validation executes"); + assert!(matches!( + first_basic_error(result), + BasicError::DocumentPropertyMaxBytesExceededError(e) + if e.property() == "note" && e.byte_length() == 10 && e.max_bytes() == 8 + )); + + // A nested string is named by its dotted path + let result = document_type + .validate_max_bytes_properties( + &data(platform_value!({ "meta": { "tag": "ééé" } })), + platform_version, + ) + .expect("validation executes"); + assert!(matches!( + first_basic_error(result), + BasicError::DocumentPropertyMaxBytesExceededError(e) + if e.property() == "meta.tag" && e.byte_length() == 6 && e.max_bytes() == 4 + )); + + // An absent property is not checked + assert!(document_type + .validate_max_bytes_properties(&BTreeMap::new(), platform_version) + .expect("validation executes") + .is_valid()); +} + +#[test] +fn should_name_the_element_of_a_typed_array_over_its_max_bytes() { + let document_type = parse(schema(None)); + let result = document_type + .validate_max_bytes_properties( + &data(platform_value!({ "tags": ["short", "ééééé", "ok"] })), + PlatformVersion::latest(), + ) + .expect("validation executes"); + assert!(matches!( + first_basic_error(result), + BasicError::DocumentPropertyMaxBytesExceededError(e) + if e.property() == "tags[1]" && e.byte_length() == 10 && e.max_bytes() == 8 + )); +} + +// ================================================================ +// sumOfProperties: parse +// ================================================================ + +#[test] +fn should_parse_sum_of_properties_onto_the_object() { + let document_type = parse(schema(None)); + let document_type = document_type.as_ref(); + let DocumentPropertyType::Object(meta) = &document_type + .properties() + .get("meta") + .expect("meta") + .property_type + else { + panic!("meta is an object"); + }; + assert_eq!( + meta.get("split").expect("split").sum_of_properties, + Some(100) + ); + assert_eq!(meta.get("tag").expect("tag").sum_of_properties, None); +} + +#[test] +fn should_refuse_sum_of_properties_on_a_property_that_is_not_an_object() { + let schema = schema_with_split(platform_value!({ + "type": "integer", + "minimum": 0, + "maximum": 100, + "sumOfProperties": 100, + "position": 0 + })); + expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "only allowed on object properties", + ); +} + +#[test] +fn should_refuse_sum_of_properties_over_members_that_are_not_required_integers() { + for (members, required, needle) in [ + ( + platform_value!({ + "a": { "type": "integer", "minimum": 0, "maximum": 100, "position": 0 }, + "b": { "type": "string", "maxLength": 3, "position": 1 } + }), + platform_value!(["a", "b"]), + "member \"b\" is not one", + ), + ( + platform_value!({ + "a": { "type": "integer", "minimum": 0, "maximum": 100, "position": 0 }, + "b": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 } + }), + platform_value!(["a"]), + "member \"b\" must be required", + ), + ] { + expect_structure_error( + parse_dispatched( + schema_with_split(platform_value!({ + "type": "object", + "position": 0, + "properties": members, + "required": required, + "additionalProperties": false, + "sumOfProperties": 100 + })), + PlatformVersion::latest(), + true, + ), + needle, + ); + } +} + +#[test] +fn should_refuse_a_total_the_members_cannot_reach() { + // Two members of 0 to 100 add up to 0 through 200 + for total in [-1, 201] { + let mut split = split_schema(total); + split + .insert("position".to_string(), Value::U64(0)) + .expect("split is a map"); + expect_structure_error( + parse_dispatched(schema_with_split(split), PlatformVersion::latest(), true), + "the members add up to between 0 and 200", + ); + } + for total in [0, 200] { + let mut split = split_schema(total); + split + .insert("position".to_string(), Value::U64(0)) + .expect("split is a map"); + parse_dispatched(schema_with_split(split), PlatformVersion::latest(), true) + .unwrap_or_else(|e| panic!("{total} is reachable: {e:?}")); + } +} + +#[test] +fn should_ignore_sum_of_properties_before_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let mut split = split_schema(100); + split + .insert("position".to_string(), Value::U64(0)) + .expect("split is a map"); + let document_type = parse_dispatched(schema_with_split(split), platform_version, false) + .expect("a parse predating sumOfProperties ignores it"); + assert_eq!( + document_type + .as_ref() + .properties() + .get("split") + .expect("split") + .sum_of_properties, + None + ); +} + +// ================================================================ +// sumOfProperties: write time +// ================================================================ + +#[test] +fn should_refuse_an_object_whose_members_miss_the_total() { + let document_type = parse(schema(None)); + let platform_version = PlatformVersion::latest(); + + let result = document_type + .validate_sum_of_properties( + &data(platform_value!({ "meta": { "split": { "a": 30, "b": 70 } } })), + platform_version, + ) + .expect("validation executes"); + assert!(result.is_valid(), "{:?}", result.errors); + + for (a, b) in [(30, 60), (0, 0), (100, 100)] { + let result = document_type + .validate_sum_of_properties( + &data(platform_value!({ "meta": { "split": { "a": a, "b": b } } })), + platform_version, + ) + .expect("validation executes"); + assert!( + matches!( + first_basic_error(result), + BasicError::DocumentPropertySumMismatchError(e) + if e.property() == "meta.split" + && e.expected_sum() == 100 + && e.actual_sum() == a + b + ), + "{a} + {b}" + ); + } + + // An absent object is not checked; whether it may be absent is `required`'s + for absent in [ + platform_value!({}), + platform_value!({ "meta": { "tag": "x" } }), + ] { + assert!(document_type + .validate_sum_of_properties(&data(absent), platform_version) + .expect("validation executes") + .is_valid()); + } +} + +#[test] +fn should_check_nothing_before_protocol_version_14() { + // A type parsed with the keywords, judged under protocol version 13's method table + let document_type = parse(schema(Some(Value::U64(1)))); + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let properties = data(platform_value!({ + "note": "too long", + "meta": { "split": { "a": 1, "b": 1 } } + })); + assert!(document_type + .validate_max_bytes_properties(&properties, platform_version) + .expect("validation executes") + .is_valid()); + assert!(document_type + .validate_sum_of_properties(&properties, platform_version) + .expect("validation executes") + .is_valid()); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 877f876e2d6..7d2f989c50c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -900,6 +900,8 @@ mod index_only_tests; mod keep_history_tests; #[cfg(all(test, feature = "validation"))] mod list_element_reference_tests; +#[cfg(all(test, feature = "validation"))] +mod max_bytes_and_sum_tests; #[cfg(test)] mod meta_schema_v0_stray_keyword_tests; #[cfg(test)] 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 f2ce33dbaed..3bf5198ca3d 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 @@ -198,6 +198,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, transient: false, } } @@ -213,6 +215,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, transient: false, } } @@ -224,6 +228,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: 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 3ceb6d8eace..0da9d19978e 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 @@ -14,17 +14,58 @@ use crate::version::PlatformVersion; use crate::ProtocolError; #[cfg(feature = "validation")] -use crate::consensus::basic::document::InvalidEncryptedPropertyShapeError; +use crate::consensus::basic::document::{ + DocumentPropertyMaxBytesExceededError, DocumentPropertySumMismatchError, + InvalidEncryptedPropertyShapeError, +}; use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; use crate::data_contract::document_type::methods::versioned_methods::DocumentTypeV0MethodsVersioned; +#[cfg(feature = "validation")] +use crate::data_contract::document_type::{DocumentProperty, DocumentPropertyType}; use crate::fee::Credits; use crate::voting::vote_polls::VotePoll; #[cfg(feature = "validation")] use platform_value::btreemap_extensions::BTreeValueMapPathHelper; use platform_value::{Identifier, Value}; +/// The mismatch an object property declaring `sumOfProperties` has in `properties`, the +/// document's data, with `path` the object's dotted path: `None` when the object declares +/// no sum, is absent, or its members add up to the total. +#[cfg(feature = "validation")] +fn sum_of_properties_violation( + path: &str, + property: &DocumentProperty, + properties: &BTreeMap, +) -> Option { + let total = property.sum_of_properties?; + let DocumentPropertyType::Object(members) = &property.property_type else { + return None; + }; + // An absent object has nothing to add up; whether it may be absent is `required`'s + if !matches!( + properties.get_optional_at_path(path), + Ok(Some(Value::Map(_))) + ) { + return None; + } + let sum = members.keys().fold(0i128, |sum, member| { + let value = match properties.get_optional_at_path(&format!("{path}.{member}")) { + Ok(Some(value)) => value.to_integer::().unwrap_or(i128::MAX), + _ => 0, + }; + sum.saturating_add(value) + }); + (sum != i128::from(total)).then(|| { + DocumentPropertySumMismatchError::new( + path.to_string(), + total, + i64::try_from(sum).unwrap_or(if sum < 0 { i64::MIN } else { i64::MAX }), + ) + }) +} + pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { fn unique_id_for_storage(&self) -> [u8; 32] { rand::random::<[u8; 32]>() @@ -133,6 +174,140 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { SimpleConsensusValidationResult::new() } + /// Checks every string `properties` supplies for a property declaring `maxBytes` + /// against it, counting UTF-8 bytes: the property's value, or every element of a + /// typed array of strings, whose error names the element (`tags[2]`). A declared + /// property the document leaves out is not checked. + /// + /// Meant to run after the JSON schema validation of `properties`, which already + /// established every supplied value's type; a value that still is not a string + /// holds no bytes to count. + /// + /// Versioned on `validate_max_bytes` in the document type method versions: `None` + /// before protocol version 14 returns an empty result, which keeps the shipped + /// structure validation that calls it inert. + #[cfg(feature = "validation")] + fn validate_max_bytes_properties( + &self, + properties: &BTreeMap, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .validate_max_bytes + { + None => Ok(SimpleConsensusValidationResult::default()), + Some(0) => Ok(self.validate_max_bytes_properties_v0(properties)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "validate_max_bytes_properties".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + #[cfg(feature = "validation")] + fn validate_max_bytes_properties_v0( + &self, + properties: &BTreeMap, + ) -> SimpleConsensusValidationResult { + let over = |path: String, value: &Value, max_bytes: u16| { + let length = value.as_text()?.len(); + (length > max_bytes as usize).then(|| { + DocumentPropertyMaxBytesExceededError::new( + path, + u32::try_from(length).unwrap_or(u32::MAX), + max_bytes, + ) + }) + }; + let declared = self + .flattened_properties() + .iter() + .filter_map(|(path, property)| Some((path, property, property.max_bytes?))); + for (path, property, max_bytes) in declared { + let Ok(Some(value)) = properties.get_optional_at_path(path) else { + continue; + }; + let error = match (&property.property_type, value) { + // A typed array declares on its items: every element is bounded on its own + (DocumentPropertyType::TypedArray(_), Value::Array(elements)) => { + elements.iter().enumerate().find_map(|(index, element)| { + over(format!("{path}[{index}]"), element, max_bytes) + }) + } + (DocumentPropertyType::TypedArray(_), _) => None, + _ => over(path.clone(), value, max_bytes), + }; + if let Some(error) = error { + return SimpleConsensusValidationResult::new_with_error(error.into()); + } + } + SimpleConsensusValidationResult::new() + } + + /// Checks every object `properties` supplies for an object property declaring + /// `sumOfProperties`: its integer members must add up to the declared total. An + /// object the document leaves out is not checked. Objects are not in the flattened + /// map, so the declarations are found by walking [`DocumentTypeV0Getters::properties`]. + /// + /// Meant to run after the JSON schema validation of `properties`, which already + /// established that every member is present and an integer. A member value that still + /// is not an `i128` counts as the largest one, so the sum cannot match by accident. + /// + /// Versioned on `validate_sum_of_properties` in the document type method versions: + /// `None` before protocol version 14 returns an empty result, which keeps the shipped + /// structure validation that calls it inert. + #[cfg(feature = "validation")] + fn validate_sum_of_properties( + &self, + properties: &BTreeMap, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .validate_sum_of_properties + { + None => Ok(SimpleConsensusValidationResult::default()), + Some(0) => Ok(self.validate_sum_of_properties_v0(properties)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "validate_sum_of_properties".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + #[cfg(feature = "validation")] + fn validate_sum_of_properties_v0( + &self, + properties: &BTreeMap, + ) -> SimpleConsensusValidationResult { + let mut objects = vec![(None::, self.properties())]; + while let Some((prefix, level)) = objects.pop() { + for (name, property) in level { + let DocumentPropertyType::Object(members) = &property.property_type else { + continue; + }; + let path = match &prefix { + Some(prefix) => format!("{prefix}.{name}"), + None => name.clone(), + }; + if let Some(error) = sum_of_properties_violation(&path, property, properties) { + return SimpleConsensusValidationResult::new_with_error(error.into()); + } + objects.push((Some(path), members)); + } + } + SimpleConsensusValidationResult::new() + } + fn top_level_indices(&self) -> Vec<&IndexProperty> { self.indexes() .values() 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 a9f57d4643a..6177ae7c4ca 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 @@ -2909,6 +2909,133 @@ mod tests { } } + mod max_bytes_and_sum_of_properties { + use super::*; + use crate::consensus::basic::BasicError; + use crate::data_contract::config::DataContractConfig; + use std::collections::BTreeMap; + + /// A string `note` with `maxBytes` as given, and an object `split` of two + /// percentages with `sumOfProperties` as given. + fn document_type( + max_bytes: Option, + sum_of_properties: Option, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut note = platform_value!({ "type": "string", "maxLength": 64, "position": 0 }); + if let Some(max_bytes) = max_bytes { + note.insert("maxBytes".to_string(), max_bytes.into()) + .expect("should insert maxBytes"); + } + let mut split = platform_value!({ + "type": "object", + "position": 1, + "properties": { + "a": { "type": "integer", "minimum": 0, "maximum": 100, "position": 0 }, + "b": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 } + }, + "required": ["a", "b"], + "additionalProperties": false + }); + if let Some(total) = sum_of_properties { + split + .insert("sumOfProperties".to_string(), total.into()) + .expect("should insert sumOfProperties"); + } + + let schema = platform_value!({ + "type": "object", + "properties": { "note": note, "split": split }, + "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") + } + + fn compatibility( + old: (Option, Option), + new: (Option, Option), + ) -> SimpleConsensusValidationResult { + let platform_version = PlatformVersion::latest(); + let old_document_type = document_type(old.0, old.1, platform_version); + let new_document_type = document_type(new.0, new.1, platform_version); + old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility") + } + + /// `maxBytes` moves like `maxLength`: every stored string still fits a + /// raised or dropped bound. + #[test] + fn should_return_valid_result_when_max_bytes_is_raised_or_removed() { + for (old_bound, new_bound) in [(Some(8), Some(16)), (Some(8), None), (Some(8), Some(8))] + { + let result = compatibility((old_bound, None), (new_bound, None)); + assert!( + result.is_valid(), + "{old_bound:?} -> {new_bound:?}: {:?}", + result.errors + ); + } + } + + /// A stored string may be longer than a new or lowered bound. + #[test] + fn should_return_invalid_result_when_max_bytes_is_added_or_lowered() { + for (old_bound, new_bound) in [(None, Some(8)), (Some(16), Some(8))] { + let result = compatibility((old_bound, None), (new_bound, None)); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == "/properties/note/maxBytes", + "{old_bound:?} -> {new_bound:?}" + ); + } + } + + /// Readers of stored objects rely on the total, so it is frozen. + #[test] + fn should_return_invalid_result_when_sum_of_properties_is_added_removed_or_changed() { + for (old_total, new_total) in + [(None, Some(100)), (Some(100), None), (Some(100), Some(200))] + { + let result = compatibility((None, old_total), (None, new_total)); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == "/properties/split/sumOfProperties", + "{old_total:?} -> {new_total:?}" + ); + } + } + + #[test] + fn should_return_valid_result_when_sum_of_properties_is_unchanged() { + let result = compatibility((None, Some(100)), (None, Some(100))); + assert!(result.is_valid(), "{:?}", result.errors); + } + } + mod validate_byte_array_encoding { use super::*; use std::collections::BTreeMap; 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 55dd9f21537..abbc086c161 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -158,6 +158,15 @@ pub(crate) mod property_names { pub const SENDER_KEY: &str = "senderKey"; /// `encryptedFor`: the scheme name, one of `EncryptionScheme::ALL`. pub const SCHEME: &str = "scheme"; + /// Property-level integer on a string property, or on the `items` of a + /// typed array of strings: the most UTF-8 bytes a value may hold. + /// Meta-schema v3+ (protocol version 14). See `apply_max_bytes` in + /// `try_from_schema`. + pub const MAX_BYTES: &str = "maxBytes"; + /// Property-level integer on an object property: the total its integer + /// members must add up to. Meta-schema v3+ (protocol version 14). See + /// `apply_sum_of_properties` in `try_from_schema`. + pub const SUM_OF_PROPERTIES: &str = "sumOfProperties"; pub const KEY_REQUIREMENTS: &str = "keyRequirements"; pub const PURPOSE: &str = "purpose"; pub const BOUND_TO: &str = "boundTo"; 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 a5d0f3cf976..819ec7da0a7 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 @@ -80,6 +80,22 @@ pub struct DocumentProperty { /// and only on contracts parsed from protocol version 14 on. #[serde(skip_serializing_if = "Option::is_none")] pub encrypted_for: Option, + /// The most UTF-8 bytes the property's value may hold (`maxBytes`), on a + /// string property or, declared on its `items`, on every element of a typed + /// array of strings. `maxLength` counts characters, up to four bytes each. + /// `None` for every property that declares nothing, which is every property + /// parsed before protocol version 14. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_bytes: Option, + /// The total the integer members of this object property must add up to + /// (`sumOfProperties`). Only ever `Some` on an object property, whose members + /// are then all required integers, and only on contracts parsed from protocol + /// version 14 on. Objects appear in [`DocumentTypeV0Getters::properties`], + /// not in the flattened map. + /// + /// [`DocumentTypeV0Getters::properties`]: crate::data_contract::document_type::accessors::DocumentTypeV0Getters::properties + #[serde(skip_serializing_if = "Option::is_none")] + pub sum_of_properties: Option, } /// What a `distinctFrom` identifier property must differ from. @@ -4381,6 +4397,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); sub_fields.insert( @@ -4392,6 +4410,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -7085,6 +7105,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); inner_fields.insert( @@ -7096,6 +7118,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7149,6 +7173,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7171,6 +7197,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); inner_fields.insert( @@ -7182,6 +7210,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7617,6 +7647,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7654,6 +7686,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); sub_fields.insert( @@ -7665,6 +7699,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -7685,6 +7721,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); sub_fields.insert( @@ -7696,6 +7734,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -7990,6 +8030,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); sub_fields.insert( @@ -8001,6 +8043,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -8063,6 +8107,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); sub_fields.insert( @@ -8074,6 +8120,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -8162,6 +8210,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); sub_fields.insert( @@ -8173,6 +8223,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -8441,6 +8493,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -8472,6 +8526,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); // Second field is required @@ -8484,6 +8540,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -8602,6 +8660,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -8622,6 +8682,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -8930,6 +8992,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -9195,6 +9259,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, }; let value = serde_json::to_value(&property).expect("serialization should succeed"); @@ -9218,6 +9284,8 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: 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 5143ef5c0e1..ad04ed5c139 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 @@ -200,6 +200,8 @@ impl DocumentTypeV0 { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, } }; @@ -594,6 +596,8 @@ impl DocumentTypeV0 { required_since: None, distinct_from: None, encrypted_for: None, + max_bytes: None, + sum_of_properties: None, } }; 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 888aca00d81..32fddd2ab67 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -54,7 +54,8 @@ use crate::consensus::basic::decode::{ use crate::consensus::basic::document::{ ContestedDocumentsTemporarilyNotAllowedError, DataContractNotPresentError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, - DocumentPropertyNotDistinctError, DocumentTransitionsAreAbsentError, + DocumentPropertyMaxBytesExceededError, DocumentPropertyNotDistinctError, + DocumentPropertySumMismatchError, DocumentTransitionsAreAbsentError, DuplicateDocumentTransitionsWithIdsError, DuplicateDocumentTransitionsWithIndicesError, InconsistentCompoundIndexDataError, InvalidDocumentTransitionActionError, InvalidDocumentTransitionIdError, InvalidDocumentTypeError, InvalidEncryptedPropertyShapeError, @@ -90,10 +91,7 @@ use crate::consensus::basic::identity::{ WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError, }; use crate::consensus::basic::invalid_identifier_error::InvalidIdentifierError; -use crate::consensus::basic::moderation_charter::{ - ModerationCharterDescriptionTooLongError, ModerationCharterMalformedFieldError, - ModerationCharterRewardSplitNotOneHundredError, -}; +use crate::consensus::basic::moderation_charter::ModerationCharterMalformedFieldError; use crate::consensus::basic::state_transition::{ FeeStrategyDuplicateError, FeeStrategyEmptyError, FeeStrategyIndexOutOfBoundsError, FeeStrategyTooManyStepsError, InputBelowMinimumError, InputOutputBalanceMismatchError, @@ -821,11 +819,14 @@ pub enum BasicError { #[error(transparent)] ModerationCharterMalformedFieldError(ModerationCharterMalformedFieldError), + // A string over the `maxBytes` its property declares (protocol version 14). #[error(transparent)] - ModerationCharterRewardSplitNotOneHundredError(ModerationCharterRewardSplitNotOneHundredError), + DocumentPropertyMaxBytesExceededError(DocumentPropertyMaxBytesExceededError), + // An object whose integer properties miss the `sumOfProperties` it declares (protocol + // version 14). #[error(transparent)] - ModerationCharterDescriptionTooLongError(ModerationCharterDescriptionTooLongError), + DocumentPropertySumMismatchError(DocumentPropertySumMismatchError), } impl From for ConsensusError { @@ -935,7 +936,7 @@ mod tests { )), 195 ); - // Moderation charters (protocol version 14): the tail of the enum. + // Moderation charters (protocol version 14). assert_eq!( discriminant_of(BasicError::ModerationCharterMalformedFieldError( ModerationCharterMalformedFieldError::new( @@ -945,15 +946,17 @@ mod tests { )), 196 ); + // A string over its property's `maxBytes` (protocol version 14). assert_eq!( - discriminant_of(BasicError::ModerationCharterRewardSplitNotOneHundredError( - ModerationCharterRewardSplitNotOneHundredError::new(10, 40, 40) + discriminant_of(BasicError::DocumentPropertyMaxBytesExceededError( + DocumentPropertyMaxBytesExceededError::new("description".to_string(), 4097, 4096) )), 197 ); + // An object missing its `sumOfProperties` (protocol version 14): the tail of the enum. assert_eq!( - discriminant_of(BasicError::ModerationCharterDescriptionTooLongError( - ModerationCharterDescriptionTooLongError::new(4097, 4096) + discriminant_of(BasicError::DocumentPropertySumMismatchError( + DocumentPropertySumMismatchError::new("rewardSplit".to_string(), 100, 90) )), 198 ); diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/document_property_max_bytes_exceeded_error.rs b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_max_bytes_exceeded_error.rs new file mode 100644 index 00000000000..c8b2cfc03fc --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_max_bytes_exceeded_error.rs @@ -0,0 +1,70 @@ +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 document supplied a string longer in UTF-8 bytes than the `maxBytes` its +/// type declares on the property (or on the items of a typed array of +/// strings). `maxLength` counts characters, which can be up to four bytes each; +/// `maxBytes` bounds the stored size. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Property {property} is {byte_length} bytes in UTF-8, over its maxBytes of {max_bytes}")] +#[platform_serialize(unversioned)] +pub struct DocumentPropertyMaxBytesExceededError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + property: String, + byte_length: u32, + max_bytes: u16, +} + +impl DocumentPropertyMaxBytesExceededError { + pub fn new(property: String, byte_length: u32, max_bytes: u16) -> Self { + Self { + property, + byte_length, + max_bytes, + } + } + + /// The dotted path of the property, as the document type flattens it, with + /// the element's index (`tags[2]`) for an item of a typed array. + pub fn property(&self) -> &str { + &self.property + } + + /// The UTF-8 length of the supplied string. + pub fn byte_length(&self) -> u32 { + self.byte_length + } + + /// The declared bound. + pub fn max_bytes(&self) -> u16 { + self.max_bytes + } +} + +impl From for ConsensusError { + fn from(err: DocumentPropertyMaxBytesExceededError) -> Self { + Self::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/document_property_sum_mismatch_error.rs b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_sum_mismatch_error.rs new file mode 100644 index 00000000000..be935b3be2d --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_sum_mismatch_error.rs @@ -0,0 +1,67 @@ +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 document supplied an object whose integer properties do not add up to the +/// `sumOfProperties` its type declares on that object. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("The properties of {property} sum to {actual_sum}, but must sum to {expected_sum}")] +#[platform_serialize(unversioned)] +pub struct DocumentPropertySumMismatchError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + property: String, + expected_sum: i64, + actual_sum: i64, +} + +impl DocumentPropertySumMismatchError { + pub fn new(property: String, expected_sum: i64, actual_sum: i64) -> Self { + Self { + property, + expected_sum, + actual_sum, + } + } + + /// The dotted path of the object that declares the sum. + pub fn property(&self) -> &str { + &self.property + } + + /// The declared sum. + pub fn expected_sum(&self) -> i64 { + self.expected_sum + } + + /// What the object's properties add up to, clamped to the `i64` range. + pub fn actual_sum(&self) -> i64 { + self.actual_sum + } +} + +impl From for ConsensusError { + fn from(err: DocumentPropertySumMismatchError) -> Self { + Self::BasicError(BasicError::DocumentPropertySumMismatchError(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 940fd3cbf34..6bceee66546 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs @@ -2,7 +2,9 @@ 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_max_bytes_exceeded_error; mod document_property_not_distinct_error; +mod document_property_sum_mismatch_error; mod document_transitions_are_absent_error; mod duplicate_document_transitions_with_ids_error; mod duplicate_document_transitions_with_indices_error; @@ -23,7 +25,9 @@ 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_max_bytes_exceeded_error::*; pub use document_property_not_distinct_error::*; +pub use document_property_sum_mismatch_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/basic/moderation_charter/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs index 24207bc0246..db1535a2870 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs @@ -1,7 +1,3 @@ -mod moderation_charter_description_too_long_error; mod moderation_charter_malformed_field_error; -mod moderation_charter_reward_split_not_one_hundred_error; -pub use moderation_charter_description_too_long_error::*; pub use moderation_charter_malformed_field_error::*; -pub use moderation_charter_reward_split_not_one_hundred_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_description_too_long_error.rs b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_description_too_long_error.rs deleted file mode 100644 index 7a9ceefb983..00000000000 --- a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_description_too_long_error.rs +++ /dev/null @@ -1,54 +0,0 @@ -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; - -#[derive( - Error, - Debug, - Clone, - PartialEq, - Eq, - Encode, - Decode, - PlatformSerialize, - PlatformDeserializeTrusted, - PlatformDeserializeUntrusted, - DecodeUntrusted, -)] -#[error("The moderation charter's description is {length} bytes long, the maximum is {max_length}")] -#[platform_serialize(unversioned)] -pub struct ModerationCharterDescriptionTooLongError { - /* - - DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION - - */ - length: u64, - max_length: u16, -} - -impl ModerationCharterDescriptionTooLongError { - pub fn new(length: u64, max_length: u16) -> Self { - Self { length, max_length } - } - - /// The length of the description, in bytes of UTF-8 - pub fn length(&self) -> u64 { - self.length - } - - pub fn max_length(&self) -> u16 { - self.max_length - } -} - -impl From for ConsensusError { - fn from(err: ModerationCharterDescriptionTooLongError) -> Self { - Self::BasicError(BasicError::ModerationCharterDescriptionTooLongError(err)) - } -} diff --git a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_reward_split_not_one_hundred_error.rs b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_reward_split_not_one_hundred_error.rs deleted file mode 100644 index 757f8fcf071..00000000000 --- a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_reward_split_not_one_hundred_error.rs +++ /dev/null @@ -1,67 +0,0 @@ -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; - -#[derive( - Error, - Debug, - Clone, - PartialEq, - Eq, - Encode, - Decode, - PlatformSerialize, - PlatformDeserializeTrusted, - PlatformDeserializeUntrusted, - DecodeUntrusted, -)] -#[error( - "The moderation charter's reward split of {leader}% to the leader, {equal}% equally and {actions}% by action count sums to {}%, it must sum to 100%", - *leader as u16 + *equal as u16 + *actions as u16 -)] -#[platform_serialize(unversioned)] -pub struct ModerationCharterRewardSplitNotOneHundredError { - /* - - DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION - - */ - leader: u8, - equal: u8, - actions: u8, -} - -impl ModerationCharterRewardSplitNotOneHundredError { - pub fn new(leader: u8, equal: u8, actions: u8) -> Self { - Self { - leader, - equal, - actions, - } - } - - pub fn leader(&self) -> u8 { - self.leader - } - - pub fn equal(&self) -> u8 { - self.equal - } - - pub fn actions(&self) -> u8 { - self.actions - } -} - -impl From for ConsensusError { - fn from(err: ModerationCharterRewardSplitNotOneHundredError) -> Self { - Self::BasicError(BasicError::ModerationCharterRewardSplitNotOneHundredError( - err, - )) - } -} diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index f569c1a6397..d57b321d7f1 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -168,6 +168,8 @@ impl ErrorWithCode for BasicError { Self::ContestedDocumentsTemporarilyNotAllowedError(_) => 10418, Self::DocumentPropertyNotDistinctError(_) => 10419, Self::InvalidEncryptedPropertyShapeError(_) => 10420, + Self::DocumentPropertyMaxBytesExceededError(_) => 10421, + Self::DocumentPropertySumMismatchError(_) => 10422, // Token Errors: 10450-10499 Self::InvalidTokenIdError(_) => 10450, @@ -275,8 +277,6 @@ impl ErrorWithCode for BasicError { // Moderation Team Errors: 11000-11099 Self::ModerationCharterMalformedFieldError(_) => 11000, - Self::ModerationCharterRewardSplitNotOneHundredError(_) => 11001, - Self::ModerationCharterDescriptionTooLongError(_) => 11002, } } } diff --git a/packages/rs-dpp/src/moderation_charter/mod.rs b/packages/rs-dpp/src/moderation_charter/mod.rs index 2a3bfdcc937..0fa0d57e5f2 100644 --- a/packages/rs-dpp/src/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/moderation_charter/mod.rs @@ -20,19 +20,14 @@ //! The team that acts is the leader plus [`ElectedCharter::active_members`]: the elected //! members and the additions, less the removals. //! -//! The schema carries almost every rule through its keywords (references, lookups, key -//! requirements, `distinctFrom`). What it cannot say is here: [`SubmittedCharter`] and -//! [`ElectedCharter`] read the documents' properties, and [`validate_submitted_charter`] adds -//! the proposal's two pure-data rules, which the path that seats a team runs. Nothing here reads -//! state. - -mod v0; +//! The schema carries every rule through its keywords (references, lookups, key requirements, +//! `distinctFrom`, and `sumOfProperties` and `maxBytes` for the proposal's reward split and +//! description), so every document write checks them. [`SubmittedCharter`] and +//! [`ElectedCharter`] read the documents' properties. Nothing here reads state. use crate::consensus::basic::moderation_charter::ModerationCharterMalformedFieldError; -use crate::validation::{ConsensusValidationResult, SimpleConsensusValidationResult}; -use crate::ProtocolError; +use crate::validation::ConsensusValidationResult; use platform_value::{Identifier, IdentifierBytes32, Value, ValueMap}; -use platform_version::version::PlatformVersion; use std::collections::{BTreeMap, BTreeSet}; /// The id of the moderation charters system contract, `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`. @@ -78,7 +73,8 @@ pub mod property_names { pub const MEMBER_ID: &str = "memberId"; } -/// How a team splits every claim of the moderators pot: three percentages summing to 100. +/// How a team splits every claim of the moderators pot: three percentages summing to 100, +/// which the schema's `sumOfProperties` holds every stored proposal to. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ModerationCharterRewardSplit { /// The share of the leader. @@ -90,13 +86,6 @@ pub struct ModerationCharterRewardSplit { pub actions: u8, } -impl ModerationCharterRewardSplit { - /// The three shares summed, as declared. - pub fn total(&self) -> u16 { - self.leader as u16 + self.equal as u16 + self.actions as u16 - } -} - /// A proposal to moderate a contract, as read out of a `submittedCharter` document. Its owner /// is the leader. #[derive(Debug, Clone, PartialEq, Eq)] @@ -178,8 +167,8 @@ impl SubmittedCharter { /// Reads a proposal out of the properties of a `submittedCharter` document. /// /// The result carries a consensus error, never a proposal, when a property is missing or - /// of the wrong type. The proposal's own rules are checked by - /// [`SubmittedCharter::validate`]; [`validate_submitted_charter`] does both. + /// of the wrong type. The proposal's own rules (the split sums to 100, the description + /// fits 4096 bytes) are the schema's, checked when the document is written. pub fn from_document_properties( properties: &BTreeMap, ) -> ConsensusValidationResult { @@ -276,33 +265,6 @@ impl SubmittedCharter { } properties } - - /// Checks the proposal's own rules, the two the schema cannot express: the reward split - /// sums to 100, and the description fits - /// `SystemLimits::max_moderation_charter_description_length` bytes (the schema's - /// `maxLength` counts characters). - pub fn validate( - &self, - platform_version: &PlatformVersion, - ) -> Result { - match platform_version - .dpp - .validation - .data_contract - .validate_moderation_charter - { - Some(0) => Ok(self.validate_v0(platform_version)), - Some(version) => Err(ProtocolError::UnknownVersionMismatch { - method: "SubmittedCharter::validate".to_string(), - known_versions: vec![0], - received: version, - }), - None => Err(ProtocolError::NotSupported(format!( - "moderation charters do not exist at protocol version {}", - platform_version.protocol_version - ))), - } - } } impl ElectedCharter { @@ -377,27 +339,5 @@ impl ElectedCharter { } } -/// Reads a proposal out of the properties of a `submittedCharter` document and checks its own -/// rules. The result carries the proposal when it passes, and the first error it fails on -/// otherwise. -pub fn validate_submitted_charter( - properties: &BTreeMap, - platform_version: &PlatformVersion, -) -> Result, ProtocolError> { - let result = SubmittedCharter::from_document_properties(properties); - if !result.is_valid_with_data() { - return Ok(ConsensusValidationResult::new_with_errors(result.errors)); - } - let charter = result.into_data()?; - let validation = charter.validate(platform_version)?; - if validation.is_valid() { - Ok(ConsensusValidationResult::new_with_data(charter)) - } else { - Ok(ConsensusValidationResult::new_with_errors( - validation.errors, - )) - } -} - #[cfg(test)] mod tests; diff --git a/packages/rs-dpp/src/moderation_charter/tests.rs b/packages/rs-dpp/src/moderation_charter/tests.rs index 95cb9c8e73e..378bfd5dbeb 100644 --- a/packages/rs-dpp/src/moderation_charter/tests.rs +++ b/packages/rs-dpp/src/moderation_charter/tests.rs @@ -1,11 +1,10 @@ use super::{ - property_names, validate_submitted_charter, ElectedCharter, ModerationCharterRewardSplit, - SubmittedCharter, FULL_MODERATORS_SHARE, + property_names, ElectedCharter, ModerationCharterRewardSplit, SubmittedCharter, + FULL_MODERATORS_SHARE, }; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use platform_value::{Identifier, Value}; -use platform_version::version::PlatformVersion; fn proposal() -> SubmittedCharter { SubmittedCharter { @@ -21,15 +20,6 @@ fn proposal() -> SubmittedCharter { } } -fn first_basic_error( - result: &crate::validation::ConsensusValidationResult, -) -> &BasicError { - match result.errors.first() { - Some(ConsensusError::BasicError(error)) => error, - other => panic!("expected a basic error, got {other:?}"), - } -} - #[test] fn should_round_trip_a_proposal_through_its_document_properties() { let proposal = proposal(); @@ -63,82 +53,14 @@ fn should_read_a_zero_share_as_zero() { } #[test] -fn should_accept_a_proposal_without_reasons() { +fn should_read_a_proposal_without_reasons() { let proposal = SubmittedCharter { reasons: vec![], ..proposal() }; - let result = validate_submitted_charter( - &proposal.to_document_properties(), - PlatformVersion::latest(), - ) - .expect("validation executes"); - assert!(result.is_valid_with_data()); -} - -#[test] -fn should_accept_a_valid_proposal() { - let result = validate_submitted_charter( - &proposal().to_document_properties(), - PlatformVersion::latest(), - ) - .expect("validation executes"); - assert!(result.is_valid_with_data(), "{:?}", result.errors); -} - -#[test] -fn should_refuse_a_reward_split_that_does_not_sum_to_one_hundred() { - for (leader, equal, actions) in [(10, 40, 40), (50, 50, 1), (0, 0, 0)] { - let proposal = SubmittedCharter { - reward_split: ModerationCharterRewardSplit { - leader, - equal, - actions, - }, - ..proposal() - }; - let result = validate_submitted_charter( - &proposal.to_document_properties(), - PlatformVersion::latest(), - ) - .expect("validation executes"); - assert!(matches!( - first_basic_error(&result), - BasicError::ModerationCharterRewardSplitNotOneHundredError(e) - if (e.leader(), e.equal(), e.actions()) == (leader, equal, actions) - )); - } -} - -#[test] -fn should_refuse_a_description_over_the_byte_limit() { - let platform_version = PlatformVersion::latest(); - let limit = platform_version - .system_limits - .max_moderation_charter_description_length as usize; - - let at_limit = SubmittedCharter { - description: "a".repeat(limit), - ..proposal() - }; - assert!( - validate_submitted_charter(&at_limit.to_document_properties(), platform_version) - .expect("validation executes") - .is_valid_with_data() - ); - - // Fewer characters than the schema's maxLength, more bytes than the consensus cap. - let over = SubmittedCharter { - description: "é".repeat(limit / 2 + 1), - ..proposal() - }; - let result = validate_submitted_charter(&over.to_document_properties(), platform_version) - .expect("validation executes"); - assert!(matches!( - first_basic_error(&result), - BasicError::ModerationCharterDescriptionTooLongError(e) - if e.length() == (limit + 2) as u64 - )); + let read = SubmittedCharter::from_document_properties(&proposal.to_document_properties()); + assert!(read.is_valid_with_data(), "{:?}", read.errors); + assert_eq!(read.into_data().expect("data"), proposal); } #[test] @@ -176,12 +98,6 @@ fn should_refuse_a_missing_or_mistyped_property() { )); } -#[test] -fn should_refuse_to_validate_below_protocol_version_14() { - let platform_version = PlatformVersion::get(13).expect("version 13"); - assert!(proposal().validate(platform_version).is_err()); -} - #[test] fn should_round_trip_an_elected_charter_through_its_document_properties() { let charter = ElectedCharter { diff --git a/packages/rs-dpp/src/moderation_charter/v0/mod.rs b/packages/rs-dpp/src/moderation_charter/v0/mod.rs deleted file mode 100644 index e81ce193c13..00000000000 --- a/packages/rs-dpp/src/moderation_charter/v0/mod.rs +++ /dev/null @@ -1,40 +0,0 @@ -use crate::consensus::basic::moderation_charter::{ - ModerationCharterDescriptionTooLongError, ModerationCharterRewardSplitNotOneHundredError, -}; -use crate::moderation_charter::SubmittedCharter; -use crate::validation::SimpleConsensusValidationResult; -use platform_version::version::PlatformVersion; - -impl SubmittedCharter { - #[inline(always)] - pub(super) fn validate_v0( - &self, - platform_version: &PlatformVersion, - ) -> SimpleConsensusValidationResult { - if self.reward_split.total() != 100 { - return SimpleConsensusValidationResult::new_with_error( - ModerationCharterRewardSplitNotOneHundredError::new( - self.reward_split.leader, - self.reward_split.equal, - self.reward_split.actions, - ) - .into(), - ); - } - - let max_description_length = platform_version - .system_limits - .max_moderation_charter_description_length; - if self.description.len() > max_description_length as usize { - return SimpleConsensusValidationResult::new_with_error( - ModerationCharterDescriptionTooLongError::new( - self.description.len() as u64, - max_description_length, - ) - .into(), - ); - } - - SimpleConsensusValidationResult::new() - } -} diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index d8641c708b5..b978c8aec57 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -309,11 +309,13 @@ mod app_connect_tests { #[cfg(all(test, feature = "moderation-charters-contract", feature = "validation"))] mod moderation_charters_tests { use super::*; + use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; + use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; use crate::data_contract::document_type::random_document::CreateRandomDocument; use crate::data_contract::document_type::{ ContestedIndexResolution, ContractReferenceModeration, DistinctFrom, @@ -324,8 +326,8 @@ mod moderation_charters_tests { use crate::document::{Document, DocumentV0Getters, DocumentV0Setters}; use crate::identity::Purpose; use crate::moderation_charter::{ - property_names, validate_submitted_charter, ElectedCharter, ModerationCharterRewardSplit, - SubmittedCharter, ADDED_MODERATOR_DOCUMENT_TYPE_NAME, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + property_names, ElectedCharter, ModerationCharterRewardSplit, SubmittedCharter, + ADDED_MODERATOR_DOCUMENT_TYPE_NAME, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, JOIN_REQUEST_DOCUMENT_TYPE_NAME, MODERATION_CHARTERS_CONTRACT_ID, REASON_DOCUMENT_TYPE_NAME, REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, @@ -701,13 +703,135 @@ mod moderation_charters_tests { vec![], "the encoded proposal passes the schema" ); - let read = validate_submitted_charter(document.properties(), PlatformVersion::latest()) - .expect("validation executes") + assert_eq!( + keyword_errors(&contract, &document), + vec![], + "the encoded proposal meets its split and description bounds" + ); + let read = SubmittedCharter::from_document_properties(document.properties()) .into_data() - .expect("the proposal is valid"); + .expect("the proposal reads back"); assert_eq!(read, proposal); } + /// The errors of the two write-time keyword checks a proposal is held to, in the order + /// document create and replace run them. + fn keyword_errors(contract: &DataContract, document: &Document) -> Vec { + let proposal_type = document_type(contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME); + let platform_version = PlatformVersion::latest(); + let mut errors = proposal_type + .validate_max_bytes_properties(document.properties(), platform_version) + .expect("maxBytes validation executes") + .errors; + errors.extend( + proposal_type + .validate_sum_of_properties(document.properties(), platform_version) + .expect("sumOfProperties validation executes") + .errors, + ); + errors + } + + /// The proposal's two rules that plain JSON Schema cannot express are the schema's own + /// keywords: the reward split's `sumOfProperties` and the description's `maxBytes`. + #[test] + fn should_declare_the_split_total_and_the_description_byte_cap() { + let contract = contract(); + let proposal_type = document_type(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME); + assert_eq!( + proposal_type + .flattened_properties() + .get(property_names::DESCRIPTION) + .expect("the description") + .max_bytes, + Some(4096) + ); + assert_eq!( + proposal_type + .properties() + .get(property_names::REWARD_SPLIT) + .expect("the reward split") + .sum_of_properties, + Some(100) + ); + } + + #[test] + fn should_refuse_a_reward_split_that_does_not_sum_to_one_hundred() { + let contract = contract(); + for (leader, equal, actions) in [(10, 40, 40), (50, 50, 1), (0, 0, 0)] { + let proposal = SubmittedCharter { + reward_split: ModerationCharterRewardSplit { + leader, + equal, + actions, + }, + ..proposal() + }; + let document = document_with( + &contract, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + proposal.to_document_properties(), + ); + assert_eq!( + schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document), + vec![], + "each share alone is within 0 to 100" + ); + let expected_sum = i64::from(leader) + i64::from(equal) + i64::from(actions); + assert!( + matches!( + keyword_errors(&contract, &document).as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertySumMismatchError(e))] + if e.property() == property_names::REWARD_SPLIT + && e.expected_sum() == 100 + && e.actual_sum() == expected_sum + ), + "{leader}/{equal}/{actions}" + ); + } + } + + #[test] + fn should_refuse_a_description_over_4096_bytes_within_4096_characters() { + let contract = contract(); + let with_description = |description: String| { + document_with( + &contract, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + SubmittedCharter { + description, + ..proposal() + } + .to_document_properties(), + ) + }; + + // At the cap, in one-byte and in two-byte characters + for description in ["a".repeat(4096), "é".repeat(2048)] { + let document = with_description(description); + assert_eq!( + schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document), + vec![] + ); + assert_eq!(keyword_errors(&contract, &document), vec![]); + } + + // 2049 characters pass maxLength, but their 4098 bytes do not pass maxBytes + let document = with_description("é".repeat(2049)); + assert_eq!( + schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document), + vec![] + ); + assert!(matches!( + keyword_errors(&contract, &document).as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(e))] + if e.property() == property_names::DESCRIPTION + && e.byte_length() == 4098 + && e.max_bytes() == 4096 + )); + } + #[test] fn should_round_trip_an_elected_charter_through_the_system_contract() { let contract = contract(); 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 50123948016..e347e153cac 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 @@ -171,8 +171,25 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans // The schema validation above established every supplied value is a byte array // where the type says so; what is left is whether an `encryptedFor` property has // the shape its scheme produces, which is all consensus can tell about a ciphertext. - document_type + let result = document_type .validate_encrypted_property_shapes(self.data(), platform_version) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + + // The schema validation above made every string a string and every summed object + // an object of integers; what is left is a string's UTF-8 length against its + // `maxBytes` (`maxLength` counts characters) and an object's members against its + // `sumOfProperties`. + let result = document_type + .validate_max_bytes_properties(self.data(), platform_version) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + document_type + .validate_sum_of_properties(self.data(), 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_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 c62af0551ee..7e69e18f295 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 @@ -77,8 +77,30 @@ impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTra // an `encryptedFor` property the replace supplies must have the shape its scheme // produces, which is all consensus can tell about a ciphertext; the schema // validation above already made every such value a byte array. - document_type + let result = document_type .validate_encrypted_property_shapes(self.data(), 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 `maxBytes` and `sumOfProperties`, + // their parser ignores both (`apply_max_bytes` and `apply_sum_of_properties` are + // `None`, so no parsed property carries a declaration), and `validate_max_bytes` + // and `validate_sum_of_properties` are `None` there, so both calls return an + // empty result. From 14, a string the replace supplies must fit its property's + // `maxBytes` in UTF-8, and an object must add up to its `sumOfProperties`; the + // schema validation above already made every such value a string or an object + // of integers. + let result = document_type + .validate_max_bytes_properties(self.data(), platform_version) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + document_type + .validate_sum_of_properties(self.data(), platform_version) .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes_and_sum.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes_and_sum.rs new file mode 100644 index 00000000000..24c025cb07e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes_and_sum.rs @@ -0,0 +1,437 @@ +//! End-to-end coverage for the `maxBytes` and `sumOfProperties` property +//! keywords (protocol version 14): a string bounded by its UTF-8 length, and +//! an object whose integer members must add up to a declared total. Both are +//! checked on every create and replace, after the JSON schema validation; a +//! value that breaks either is consensus-rejected and leaves the stored +//! document untouched. + +use super::*; + +mod max_bytes_and_sum_tests { + use super::*; + use crate::execution::validation::state_transition::batch::action_validation::document::document_replace_transition_action::DocumentReplaceTransitionActionValidation; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::consensus::basic::BasicError; + 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 drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::{DocumentReplaceTransitionAction, DocumentReplaceTransitionActionV0}; + 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::Identifier; + use dpp::prelude::{DataContract, 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; + + /// A mutable `profile` type: a `bio` of at most 64 characters and 16 bytes, + /// and a `split` of two percentages that must add up to 100. + fn profile_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "properties": { + "bio": { + "type": "string", + "maxLength": 64, + "maxBytes": 16, + "position": 0 + }, + "split": { + "type": "object", + "position": 1, + "properties": { + "leader": { "type": "integer", "minimum": 0, "maximum": 100, "position": 0 }, + "rest": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 } + }, + "required": ["leader", "rest"], + "additionalProperties": false, + "sumOfProperties": 100 + } + }, + "required": ["bio", "split"], + "additionalProperties": false + }) + } + + fn split(leader: u8, rest: u8) -> Value { + platform_value!({ "leader": leader, "rest": rest }) + } + + /// One identity and one contract whose `profile` type is the one above. + struct ProfileFixture { + platform: TempPlatform, + signer: SimpleSigner, + key: IdentityPublicKey, + identity: Identity, + contract: DataContract, + /// 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 ProfileFixture { + 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, 959, 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( + "profile", + profile_schema(), + true, + &mut Vec::new(), + platform_version, + ) + .expect("expected to add the profile 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, + signer, + key, + identity, + contract, + next_nonce: 1, + } + } + + async fn create(&mut self, bio: &str, split: Value) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let profile_type = self + .contract + .document_type_for_name("profile") + .expect("expected the profile document type"); + let mut rng = StdRng::seed_from_u64(434); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut profile = profile_type + .random_document_with_identifier_and_entropy( + &mut rng, + self.identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random profile"); + profile + .set_id_for_creation(profile_type, &entropy.0, self.next_nonce, platform_version) + .expect("expected to set the document id"); + profile.set("bio", Value::Text(bio.to_string())); + profile.set("split", split); + + let transition = BatchTransition::new_document_creation_transition_from_document( + profile, + profile_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; + self.process(&transition) + } + + /// Replaces `stored` with `mutate` applied and the revision bumped. + async fn replace( + &mut self, + stored: &Document, + mutate: impl FnOnce(&mut Document), + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let mut replacement = stored.clone(); + mutate(&mut replacement); + replacement + .increment_revision() + .expect("expected the revision to increment"); + let profile_type = self + .contract + .document_type_for_name("profile") + .expect("expected the profile document type"); + let transition = BatchTransition::new_document_replacement_transition_from_document( + replacement, + profile_type, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + self.next_nonce += 1; + self.process(&transition) + } + + 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) + } + + /// The stored profiles, read back from Drive. + fn stored_profiles(&self) -> Vec { + let platform_version = PlatformVersion::latest(); + let query = DriveDocumentQuery::from_sql_expr( + "select * from profile", + &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_max_bytes_error(result: StateTransitionExecutionResult, byte_length: u32) { + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + assert_matches!( + error, + ConsensusError::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(e)) + if e.property() == "bio" && e.byte_length() == byte_length && e.max_bytes() == 16 + ); + } + + fn expect_sum_error(result: StateTransitionExecutionResult, actual_sum: i64) { + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + assert_matches!( + error, + ConsensusError::BasicError(BasicError::DocumentPropertySumMismatchError(e)) + if e.property() == "split" + && e.expected_sum() == 100 + && e.actual_sum() == actual_sum + ); + } + + #[tokio::test] + async fn should_create_a_document_within_both_bounds() { + let mut fixture = ProfileFixture::new(); + + // Sixteen bytes in eight two-byte characters, and a split of exactly 100 + let result = fixture.create("éééééééé", split(40, 60)).await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_profiles().len(), 1); + } + + /// Nine two-byte characters are far inside `maxLength`, so the JSON schema + /// accepts them; their 18 bytes are over `maxBytes`. + #[tokio::test] + async fn should_refuse_a_string_over_its_max_bytes_within_its_max_length() { + let mut fixture = ProfileFixture::new(); + + let result = fixture.create("ééééééééé", split(40, 60)).await; + + expect_max_bytes_error(result, 18); + assert!(fixture.stored_profiles().is_empty()); + } + + #[tokio::test] + async fn should_refuse_an_object_whose_members_miss_their_sum() { + let mut fixture = ProfileFixture::new(); + + for (leader, rest) in [(40, 50), (0, 0), (100, 100)] { + let result = fixture.create("hello", split(leader, rest)).await; + expect_sum_error(result, i64::from(leader) + i64::from(rest)); + } + assert!(fixture.stored_profiles().is_empty()); + } + + #[tokio::test] + async fn should_refuse_a_replace_that_breaks_either_bound() { + let mut fixture = ProfileFixture::new(); + let result = fixture.create("hello", split(40, 60)).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let stored = fixture.stored_profiles().remove(0); + + let result = fixture + .replace(&stored, |profile| { + profile.set("bio", Value::Text("ééééééééé".to_string())) + }) + .await; + expect_max_bytes_error(result, 18); + + let result = fixture + .replace(&stored, |profile| profile.set("split", split(10, 10))) + .await; + expect_sum_error(result, 20); + + let after = fixture.stored_profiles(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].get("bio"), stored.get("bio")); + assert_eq!(after[0].get("split"), stored.get("split")); + + // A replace within both bounds still goes through + let result = fixture + .replace(&stored, |profile| { + profile.set("bio", Value::Text("hé".to_string())); + profile.set("split", split(0, 100)); + }) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// The replace structure dispatcher on both sides of the gate: structure + /// generation 0 gained both checks in place, so at protocol version 13 it + /// must still accept the action (no property parsed there carries either + /// keyword and both dpp gates are `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_check_either_bound_on_replace_before_protocol_version_14() { + let platform_version = PlatformVersion::latest(); + let fixture = ProfileFixture::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 = |bio: &str, leader: u8, rest: u8| { + DocumentReplaceTransitionAction::V0(DocumentReplaceTransitionActionV0 { + base: DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([0xAA; 32]), + identity_contract_nonce: 1, + document_type_name: "profile".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, + }), + 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([ + ("bio".to_string(), Value::Text(bio.to_string())), + ("split".to_string(), split(leader, rest)), + ]), + changed_data_fields: BTreeSet::new(), + added_data_fields: BTreeSet::new(), + removed_identifier_fields: BTreeMap::new(), + stored_changed_values: BTreeMap::new(), + creator_id: None, + }) + }; + let platform_version_13 = + PlatformVersion::get(13).expect("platform version 13 should exist"); + + for (bio, leader, rest) in [("ééééééééé", 40, 60), ("hello", 10, 10)] { + let before = action(bio, leader, rest) + .validate_structure(owner_id, platform_version_13) + .expect("structure validation should run"); + assert!( + before.is_valid(), + "structure generation 0 must check neither bound before 14: {:?}", + before.errors + ); + } + + let at = action("ééééééééé", 40, 60) + .validate_structure(owner_id, platform_version) + .expect("structure validation should run"); + assert_matches!( + at.errors.as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(e))] + if e.property() == "bio" && e.byte_length() == 18 + ); + let at = action("hello", 10, 10) + .validate_structure(owner_id, platform_version) + .expect("structure validation should run"); + assert_matches!( + at.errors.as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertySumMismatchError(e))] + if e.property() == "split" && e.actual_sum() == 20 + ); + } +} 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 bcbd7f6419c..8357686d4a9 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 @@ -12,6 +12,7 @@ mod index_only; mod keep_history; mod list_element_reference; mod lookup_reference; +mod max_bytes_and_sum; mod nft; mod owner_balance_proof; mod owner_reference; 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 60267028861..a6683f7df75 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 @@ -1512,6 +1512,84 @@ pub static KEYWORD_COMPATIBILITY_RULES: Lazy = Laz ], }, ), + // `maxBytes` (the most UTF-8 bytes a string may take) moves like + // `maxLength`: raising or dropping the bound keeps every stored document + // valid, adding or lowering it would not. + ( + "maxBytes", + CompatibilityRules { + allow_addition: false, + allow_removal: true, + allow_replacement_callback: U64_BIGGER_CALLBACK.clone(), + subschema_levels_depth: None, + inner: None, + #[cfg(any(test, feature = "examples"))] + examples: vec![ + ( + json!({}), + json!({ "maxBytes": 1 }), + Some(JsonSchemaChange::Add(AddOperation { + path: "/maxBytes".to_string(), + value: json!(1), + })), + ) + .into(), + (json!({ "maxBytes": 1 }), json!({}), None).into(), + (json!({ "maxBytes": 1 }), json!({ "maxBytes": 2 }), None).into(), + ( + json!({ "maxBytes": 2 }), + json!({ "maxBytes": 1 }), + Some(JsonSchemaChange::Replace(ReplaceOperation { + path: "/maxBytes".to_string(), + value: json!(1), + })), + ) + .into(), + ], + }, + ), + // `sumOfProperties` (the total an object's integer properties add up + // to) is frozen like `distinctFrom`: readers of stored documents rely on + // the total, so adding, removing or changing it is refused. + ( + "sumOfProperties", + 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!({ "sumOfProperties": 100 }), + Some(JsonSchemaChange::Add(AddOperation { + path: "/sumOfProperties".to_string(), + value: json!(100), + })), + ) + .into(), + ( + json!({ "sumOfProperties": 100 }), + json!({}), + Some(JsonSchemaChange::Remove(RemoveOperation { + path: "/sumOfProperties".to_string(), + })), + ) + .into(), + ( + json!({ "sumOfProperties": 100 }), + json!({ "sumOfProperties": 1000 }), + Some(JsonSchemaChange::Replace(ReplaceOperation { + path: "/sumOfProperties".to_string(), + value: json!(1000), + })), + ) + .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 2f55e2dd4c6..de669754d4f 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 @@ -84,6 +84,16 @@ pub struct DocumentTypeMethodVersions { /// that predate the keyword: the method returns an empty result there, so the /// shipped create and replace structure validations that call it are inert. pub validate_encrypted_property_shapes: OptionalFeatureVersion, + /// `validate_max_bytes_properties`: refuses a document supplying a string longer in + /// UTF-8 bytes than the `maxBytes` its property declares. `None` on versions that + /// predate the keyword: the method returns an empty result there, so the shipped + /// replace structure validation that calls it is inert. + pub validate_max_bytes: OptionalFeatureVersion, + /// `validate_sum_of_properties`: refuses a document supplying an object whose integer + /// properties do not add up to the `sumOfProperties` it declares. `None` on versions + /// that predate the keyword: the method returns an empty result there, so the shipped + /// replace structure validation that calls it is inert. + pub validate_sum_of_properties: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] @@ -113,6 +123,16 @@ pub struct DocumentTypeSchemaVersions { /// `None` on versions that predate the keyword: they ignore it entirely, /// exactly as they parsed before it existed. pub apply_encrypted_for: OptionalFeatureVersion, + /// Parses the `maxBytes` keyword (the most UTF-8 bytes a string property, or + /// each string element of a typed array, may hold) onto the property. `None` + /// on versions that predate the keyword: they ignore it entirely, exactly as + /// they parsed before it existed. + pub apply_max_bytes: OptionalFeatureVersion, + /// Parses the `sumOfProperties` keyword (the total an object property's + /// integer members must add up to) onto the object, and checks its members + /// at contract registration. `None` on versions that predate the keyword: + /// they ignore it entirely, exactly as they parsed before it existed. + pub apply_sum_of_properties: 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 a8d3161f6c4..4dbc485640c 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 @@ -47,6 +47,8 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { apply_required_since: None, apply_distinct_from: None, apply_encrypted_for: None, + apply_max_bytes: None, + apply_sum_of_properties: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -67,6 +69,8 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: None, + validate_max_bytes: None, + validate_sum_of_properties: 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 5f35727e72f..6095770cad7 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 @@ -47,6 +47,8 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { apply_required_since: None, apply_distinct_from: None, apply_encrypted_for: None, + apply_max_bytes: None, + apply_sum_of_properties: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -67,6 +69,8 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: None, + validate_max_bytes: None, + validate_sum_of_properties: 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 0c21c7188d4..6d0a2c353bf 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 @@ -49,6 +49,8 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { apply_required_since: None, apply_distinct_from: None, apply_encrypted_for: None, + apply_max_bytes: None, + apply_sum_of_properties: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -69,6 +71,8 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: None, + validate_max_bytes: None, + validate_sum_of_properties: 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 4e381e26d00..ff95d4e5518 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 @@ -49,6 +49,8 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { apply_required_since: None, apply_distinct_from: None, apply_encrypted_for: None, + apply_max_bytes: None, + apply_sum_of_properties: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -69,6 +71,8 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: None, + validate_max_bytes: None, + validate_sum_of_properties: 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 afc06a4c41d..7bf9887f642 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 @@ -51,6 +51,8 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { apply_required_since: None, apply_distinct_from: None, apply_encrypted_for: None, + apply_max_bytes: None, + apply_sum_of_properties: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -71,6 +73,8 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { deserialize_value_for_key: 0, validate_distinct_from: None, validate_encrypted_property_shapes: None, + validate_max_bytes: None, + validate_sum_of_properties: 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 a43ce0d7464..88d03a83ca1 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 @@ -84,6 +84,8 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { 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 apply_encrypted_for: Some(0), // changed: the meta-schema v3 `encryptedFor` keyword (recipient, key ids and scheme of a byte array property's ciphertext) is parsed onto the property and its named properties are checked at registration; None before this version means the keyword is ignored, as it was before it existed + apply_max_bytes: Some(0), // changed: the meta-schema v3 `maxBytes` keyword (the most UTF-8 bytes a string property, or each string element of a typed array, may hold) is parsed onto the property; None before this version means the keyword is ignored, as it was before it existed + apply_sum_of_properties: Some(0), // changed: the meta-schema v3 `sumOfProperties` keyword (the total an object property's integer members must add up to) is parsed onto the object and its members are checked at registration; 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, @@ -115,6 +117,8 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { 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 validate_encrypted_property_shapes: Some(0), // changed: refuses an `encryptedFor` property whose bytes are not the shape its scheme produces; None before this version returns an empty result + validate_max_bytes: Some(0), // changed: refuses a string longer in UTF-8 bytes than its property's `maxBytes` (DocumentPropertyMaxBytesExceededError, 10421); None before this version returns an empty result + validate_sum_of_properties: Some(0), // changed: refuses an object whose integer members miss its `sumOfProperties` (DocumentPropertySumMismatchError, 10422); None before this version returns an empty result }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs index 75c4ae4182c..52c521e4309 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs @@ -45,9 +45,6 @@ pub struct DataContractValidationVersions { /// version 14: version 1 distribution rules and once-per-identity claims are rejected as /// unsupported, matching older software that can not decode them. pub validate_once_per_identity_distribution: OptionalFeatureVersion, - /// `ModerationCharter::validate`, the pure-data rules of a moderation charter. `None` below - /// protocol version 14, where the moderation charters system contract does not exist. - pub validate_moderation_charter: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs index 8cf7f3672b0..622b080100a 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs @@ -21,7 +21,6 @@ pub const DPP_VALIDATION_VERSIONS_V1: DPPValidationVersions = DPPValidationVersi validate_token_config_groups_exist: 0, validate_localizations: 0, validate_once_per_identity_distribution: None, - validate_moderation_charter: None, }, document_type: DocumentTypeValidationVersions { validate_update: 0, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs index 01c2a794c6f..4d23c704c0d 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs @@ -21,7 +21,6 @@ pub const DPP_VALIDATION_VERSIONS_V2: DPPValidationVersions = DPPValidationVersi validate_token_config_groups_exist: 0, validate_localizations: 0, validate_once_per_identity_distribution: None, - validate_moderation_charter: None, }, document_type: DocumentTypeValidationVersions { validate_update: 0, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs index 931bda8f923..a1053ad10b9 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs @@ -22,7 +22,6 @@ pub const DPP_VALIDATION_VERSIONS_V3: DPPValidationVersions = DPPValidationVersi validate_token_config_groups_exist: 0, validate_localizations: 0, validate_once_per_identity_distribution: None, - validate_moderation_charter: None, }, document_type: DocumentTypeValidationVersions { validate_update: 0, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs index 823fa9a7237..90ca0d7155b 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs @@ -23,9 +23,6 @@ pub const DPP_VALIDATION_VERSIONS_V5: DPPValidationVersions = DPPValidationVersi data_contract: DataContractValidationVersions { validate_config_update: 2, validate_once_per_identity_distribution: Some(0), - // Moderation charters: the charter system contract and the pure-data rules of a - // charter exist from this protocol version on. - validate_moderation_charter: Some(0), ..DPP_VALIDATION_VERSIONS_V4.data_contract }, document_type: DocumentTypeValidationVersions { 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 e7802f8a616..520473c8ab9 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 @@ -228,12 +228,12 @@ 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, // 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 + document_create_transition_structure_validation: 1, // changed: v1 also cross-checks the prefunded voting balance against the contested index, refuses a `distinctFrom` identifier property equal to the value it must differ from, a string over its `maxBytes` and an object missing its `sumOfProperties` // 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, // unchanged: v0 gained the `distinctFrom` refusal in place, inert before this version + document_replace_transition_structure_validation: 0, // unchanged: v0 gained the `distinctFrom`, `encryptedFor` shape, `maxBytes` and `sumOfProperties` refusals 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, diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index 30afaa9e2b4..8de307739c9 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -599,7 +599,6 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, max_contract_moderation_challenge_cool_down_seconds: 94_608_000, contract_document_restore_window_ms: 604_800_000, - max_moderation_charter_description_length: 4096, max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, max_shielded_transition_actions: 16, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 8db45633aed..0c6935e38f5 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -179,11 +179,6 @@ pub struct SystemLimits { /// action): a week. Read by the `ContractUserModeration` state validation v0 (protocol /// version 14) and never reached before. pub contract_document_restore_window_ms: u64, - /// Maximum length, in bytes of UTF-8, of a moderation charter's description. Read by - /// `SubmittedCharter::validate` v0 (protocol version 14) and never reached before; the - /// charter schema pins the same number as the description's `maxLength`, which the JSON - /// schema validator counts in characters, so the byte cap is this check's. - pub max_moderation_charter_description_length: u16, /// Most members an elected moderation declaration may let a seated team's leader add /// after the election (`maxAddedModerators`). Read by the declaration's validation /// (protocol version 14) and never reached before. diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 48675f9c5e1..014f0b099af 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -60,7 +60,6 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days - max_moderation_charter_description_length: 4096, max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index 09cb333eb71..66e1a2185d6 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -41,7 +41,6 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days - max_moderation_charter_description_length: 4096, max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index ef2f8c925ae..632c475cc04 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -43,7 +43,6 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days - max_moderation_charter_description_length: 4096, max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index 0d19a7aeb54..64b39b7801a 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -63,11 +63,10 @@ use crate::version::system_limits::SystemLimits; /// most 4 operands (`max_reference_operands`) and they nest at most 4 combinators deep /// (`max_reference_expression_depth`), both backfilled into the earlier tables, whose parsers /// never read them; every leaf counts against `max_references_per_document`. -/// * Moderation charters (protocol version 14): a charter's description is at most 4096 -/// bytes (`max_moderation_charter_description_length`), and an elected moderation -/// declaration lets a seated team's leader add at most 15 members -/// (`max_contract_moderation_added_moderators`); both joined this table in place while -/// protocol version 14 was unreleased. +/// * Moderation charters (protocol version 14): an elected moderation declaration lets a +/// seated team's leader add at most 15 members (`max_contract_moderation_added_moderators`), +/// which joined this table in place while protocol version 14 was unreleased. A charter's +/// description cap is the charter schema's own `maxBytes`, not a limit here. pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB @@ -105,7 +104,6 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { min_contract_moderation_challenge_cool_down_seconds: 1_209_600, // two weeks max_contract_moderation_challenge_cool_down_seconds: 94_608_000, // three years of 365 days contract_document_restore_window_ms: 604_800_000, // 7 days - max_moderation_charter_description_length: 4096, max_contract_moderation_added_moderators: 15, max_token_redemption_cycles: 128, // NOTE: the Halo 2 proof grows with the action count (~2,273 B/action on diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 9a1a7e1ab0e..54392cb7393 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -967,18 +967,44 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// with `"resolution": 1`, the masternode vote without a Lock choice of /// item 23, so an elected charter create opens or joins the contest for /// its target. `SYSTEM_DATA_CONTRACT_VERSIONS_V3` registers it -/// (`moderation_charters: 1`), and -/// `DPP_VALIDATION_VERSIONS_V5.validate_moderation_charter = Some(0)` turns -/// on the pure-data rules the seating path will run on a proposal: its -/// reward split sums to 100 and its description fits -/// `SystemLimits::max_moderation_charter_description_length` bytes (basic -/// errors 11000 to 11002). Genesis registers it on chains born at this +/// (`moderation_charters: 1`). A proposal's reward split sums to 100 and +/// its description fits 4096 bytes through the schema's own +/// `sumOfProperties` and `maxBytes` (item 38), so every create checks +/// them; `ModerationCharterMalformedFieldError` (basic error 11000) is what +/// the readers report on a document they cannot read. Genesis registers it on chains born at this /// version (`create_genesis_state` v1, behind the app-connect branch), /// `transition_to_version_14` inserts it on upgrade, and the Drive system /// contract cache serves it from this version /// (`MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION`). Seating a /// winning team comes in a later pull request. /// +/// 38. **`maxBytes` on strings and `sumOfProperties` on objects**: two +/// property keywords for bounds plain JSON Schema cannot count. +/// `maxBytes` (1 to 65535, no lower than `minLength`) goes on a string +/// property, or on the `items` of a typed array of strings where it bounds +/// every element, and caps the value's UTF-8 length: `maxLength` counts +/// characters, which are up to four bytes each. `sumOfProperties` goes on +/// an object property and is the total its members must add up to; every +/// member must be an integer, required without `requiredSince` and not +/// transient, and the total must be reachable from the members' `minimum` +/// and `maximum`, all checked at registration. Meta-schema v3 admits both, +/// `apply_max_bytes` 0 and `apply_sum_of_properties` 0 parse them onto +/// `DocumentProperty::max_bytes` and `DocumentProperty::sum_of_properties` +/// (the object's entry in `properties()`, since objects are not in the +/// flattened map). Document create structure validation 1 and replace +/// structure validation 0 (extended in place, inert before this version, +/// where no parsed property carries either keyword) call +/// `validate_max_bytes_properties` (`validate_max_bytes` 0) and +/// `validate_sum_of_properties` (`validate_sum_of_properties` 0), `None` +/// before this version, which refuse a longer string with +/// `DocumentPropertyMaxBytesExceededError` (10421, naming the element as +/// `tags[2]` for an item) and an object adding up to anything else with +/// `DocumentPropertySumMismatchError` (10422). On update `maxBytes` moves +/// like `maxLength` (it may be raised or removed, not added or lowered) and +/// `sumOfProperties` is frozen. The moderation charters contract (item 37) +/// declares both, which replace the charter-specific rules and their +/// errors 11001 and 11002 and `SystemLimits::max_moderation_charter_description_length`. +/// /// 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 00b794efe42..135dea31b19 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, DocumentPropertyNotDistinctError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; +use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyMaxBytesExceededError, DocumentPropertyNotDistinctError, DocumentPropertySumMismatchError, InvalidEncryptedPropertyShapeError, 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; @@ -95,10 +95,7 @@ use dpp::consensus::basic::contract_moderation::{ DocumentActionFeesWithoutModerationError, InvalidContractModerationConfigError, }; -use dpp::consensus::basic::moderation_charter::{ - ModerationCharterDescriptionTooLongError, ModerationCharterMalformedFieldError, - ModerationCharterRewardSplitNotOneHundredError, -}; +use dpp::consensus::basic::moderation_charter::ModerationCharterMalformedFieldError; use dpp::consensus::state::contract_moderation::{ ContractFeeClaimNotAllowedError, ContractFeesAlreadyClaimedThisEpochError, ContractFeesNothingToClaimError, ContractModeratedDocumentTypeNotYetUsableError, @@ -1283,18 +1280,18 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::ModerationCharterMalformedFieldError(e) => { generic_consensus_error!(ModerationCharterMalformedFieldError, e).into() } - BasicError::ModerationCharterRewardSplitNotOneHundredError(e) => { - generic_consensus_error!(ModerationCharterRewardSplitNotOneHundredError, e).into() - } - BasicError::ModerationCharterDescriptionTooLongError(e) => { - generic_consensus_error!(ModerationCharterDescriptionTooLongError, e).into() - } BasicError::InvalidContractModerationReasonDocumentsError(e) => { generic_consensus_error!(InvalidContractModerationReasonDocumentsError, e).into() } BasicError::InvalidEncryptedPropertyShapeError(e) => { generic_consensus_error!(InvalidEncryptedPropertyShapeError, e).into() } + BasicError::DocumentPropertyMaxBytesExceededError(e) => { + generic_consensus_error!(DocumentPropertyMaxBytesExceededError, e).into() + } + BasicError::DocumentPropertySumMismatchError(e) => { + generic_consensus_error!(DocumentPropertySumMismatchError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 9a89473646c..d670e883131 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -201,6 +201,45 @@ impl DocumentEncryptionErrorCodeWasm { } } +/// Consensus error codes emitted by the `maxBytes` and `sumOfProperties` +/// checks, which run from protocol version 14 onward on every document create +/// and replace. +/// +/// Branch on an error's `code` against these instead of matching its +/// message: +/// +/// ```js +/// try { +/// await sdk.documents.create({ document, identityKey, signer }); +/// } catch (e) { +/// if (e.code === DocumentPropertyBoundErrorCode.MaxBytesExceeded) { +/// // a string is longer in UTF-8 bytes than its property's maxBytes +/// } +/// } +/// ``` +#[wasm_bindgen(js_name = "DocumentPropertyBoundErrorCode")] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DocumentPropertyBoundErrorCodeWasm { + /// A string, or an element of a typed array of strings, is longer in + /// UTF-8 bytes than the `maxBytes` its property declares. `maxLength` + /// counts characters, which are up to four bytes each. + MaxBytesExceeded = 10421, + /// An object's integer members do not add up to the `sumOfProperties` + /// it declares. + SumMismatch = 10422, +} + +impl DocumentPropertyBoundErrorCodeWasm { + /// The bound error a code names, or `None` for any other code. + fn from_code(code: u32) -> Option { + match code { + 10421 => Some(Self::MaxBytesExceeded), + 10422 => Some(Self::SumMismatch), + _ => None, + } + } +} + #[wasm_bindgen(js_name = "ConsensusError")] pub struct ConsensusErrorWasm(ConsensusError); @@ -254,6 +293,12 @@ impl ConsensusErrorWasm { pub fn document_encryption_error_code(&self) -> Option { DocumentEncryptionErrorCodeWasm::from_code(self.0.code()) } + /// The `maxBytes` or `sumOfProperties` error this is, or `undefined` when + /// it is not code 10421 or 10422. + #[wasm_bindgen(getter = "documentPropertyBoundErrorCode")] + pub fn document_property_bound_error_code(&self) -> Option { + DocumentPropertyBoundErrorCodeWasm::from_code(self.0.code()) + } } impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); @@ -375,6 +420,43 @@ mod tests { assert_eq!(DocumentEncryptionErrorCodeWasm::from_code(10419), None); } + /// Built from the real DPP errors rather than code literals, like the + /// encryption test above. + #[test] + fn property_bound_error_codes_mirror_the_dpp_errors() { + use dpp::consensus::basic::BasicError; + use dpp::consensus::basic::document::{ + DocumentPropertyMaxBytesExceededError, DocumentPropertySumMismatchError, + }; + + for (error, expected) in [ + ( + ConsensusError::from(BasicError::DocumentPropertyMaxBytesExceededError( + DocumentPropertyMaxBytesExceededError::new( + "description".to_string(), + 4098, + 4096, + ), + )), + DocumentPropertyBoundErrorCodeWasm::MaxBytesExceeded, + ), + ( + ConsensusError::from(BasicError::DocumentPropertySumMismatchError( + DocumentPropertySumMismatchError::new("rewardSplit".to_string(), 100, 90), + )), + DocumentPropertyBoundErrorCodeWasm::SumMismatch, + ), + ] { + assert_eq!(expected as u32, error.code()); + assert_eq!( + ConsensusErrorWasm(error).document_property_bound_error_code(), + Some(expected) + ); + } + // A neighbouring code is not claimed. + assert_eq!(DocumentPropertyBoundErrorCodeWasm::from_code(10420), None); + } + /// The six reference-validation errors, paired with the JS enum variant /// each is advertised to be. /// From 1fd989d86d82256b5ccfffd504454d52c765181e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 24 Sep 2026 08:25:46 +0700 Subject: [PATCH 2/2] refactor(platform)!: scope the PR to maxBytes and apply the review fixes Drop sumOfProperties (keyword, error 10422, version slots, compatibility rule); the charter's reward split rule goes back to validate_submitted_charter and error 11001, unchanged from the base branch. maxBytes review fixes: - the bound lives in StringPropertySizes::max_bytes, so max_byte_size, max_size and random documents respect it - the check runs inside DataContract::validate_document_properties, so client-side validation (wasm-dpp document.validate) refuses an oversized string before broadcast; the create and replace structure validations are back to the base branch - typed_array_items_keyword shared by distinctFrom and maxBytes - book: maxLength alone is bounded by the 5120-byte field cap - wasm-dpp2 DocumentMaxBytesErrorCode; test names start with "should" - tests for typed-array items compatibility and a typed-array element refusal end to end Also fixes a wasm-dpp2 lint error (a parameter shadowing `schemas` in DocumentPropertyReference.spec.ts) that failed the lint job. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 24 +- book/src/error-handling/error-codes.md | 2 +- docs/protocol/moderation-charters.md | 22 +- .../moderation-charters-contract/README.md | 11 +- ...oderation-charters-contract-documents.json | 1 - .../unit/moderationChartersContract.spec.js | 34 +- .../document/v3/document-meta.json | 19 +- .../class_methods/parse_typed_array/mod.rs | 1 + .../class_methods/try_from_schema/mod.rs | 263 +++------- .../v3/max_bytes_and_sum_tests.rs | 484 ------------------ .../try_from_schema/v3/max_bytes_tests.rs | 368 +++++++++++++ .../class_methods/try_from_schema/v3/mod.rs | 2 +- .../try_from_schema/v3/typed_array_tests.rs | 1 + .../document_type/index/preallocation.rs | 7 +- .../document_type/methods/mod.rs | 163 ++---- .../methods/validate_update/common/mod.rs | 107 ++-- .../src/data_contract/document_type/mod.rs | 4 - .../document_type/property/mod.rs | 143 +++--- .../document_type/v0/random_document_type.rs | 6 +- .../methods/validate_document/v0/mod.rs | 20 +- .../src/errors/consensus/basic/basic_error.rs | 31 +- .../document_property_sum_mismatch_error.rs | 67 --- .../errors/consensus/basic/document/mod.rs | 2 - .../consensus/basic/moderation_charter/mod.rs | 2 + ...rter_reward_split_not_one_hundred_error.rs | 67 +++ packages/rs-dpp/src/errors/consensus/codes.rs | 2 +- packages/rs-dpp/src/moderation_charter/mod.rs | 77 ++- .../rs-dpp/src/moderation_charter/tests.rs | 65 ++- .../rs-dpp/src/moderation_charter/v0/mod.rs | 21 + packages/rs-dpp/src/system_data_contracts.rs | 185 +++---- .../advanced_structure_v1/mod.rs | 19 +- .../advanced_structure_v0/mod.rs | 24 +- .../{max_bytes_and_sum.rs => max_bytes.rs} | 152 +++--- .../batch/tests/document/mod.rs | 2 +- packages/rs-drive/src/query/conditions.rs | 5 + .../src/rules/rule_set.rs | 42 -- .../dpp_versions/dpp_contract_versions/mod.rs | 20 +- .../dpp_versions/dpp_contract_versions/v1.rs | 2 - .../dpp_versions/dpp_contract_versions/v2.rs | 2 - .../dpp_versions/dpp_contract_versions/v3.rs | 2 - .../dpp_versions/dpp_contract_versions/v4.rs | 2 - .../dpp_versions/dpp_contract_versions/v5.rs | 2 - .../dpp_versions/dpp_contract_versions/v6.rs | 4 +- .../dpp_validation_versions/mod.rs | 3 + .../dpp_validation_versions/v1.rs | 1 + .../dpp_validation_versions/v2.rs | 1 + .../dpp_validation_versions/v3.rs | 1 + .../dpp_validation_versions/v5.rs | 3 + .../drive_abci_validation_versions/v10.rs | 4 +- .../rs-platform-version/src/version/v14.rs | 54 +- .../src/errors/consensus/consensus_error.rs | 12 +- packages/wasm-dpp2/src/consensus_error.rs | 75 +-- .../unit/DocumentPropertyReference.spec.ts | 4 +- 53 files changed, 1096 insertions(+), 1541 deletions(-) delete mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_and_sum_tests.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_tests.rs delete mode 100644 packages/rs-dpp/src/errors/consensus/basic/document/document_property_sum_mismatch_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_reward_split_not_one_hundred_error.rs create mode 100644 packages/rs-dpp/src/moderation_charter/v0/mod.rs rename packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/{max_bytes_and_sum.rs => max_bytes.rs} (74%) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index a2b1c6c89d4..78634309252 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -661,37 +661,23 @@ In Rust the declaration is `DocumentProperty::encrypted_for` (`Option`) and `DocumentProperty::sum_of_properties` (`Option`, on the object's entry in `properties()`, since objects are not in the flattened map), and the checks are `DocumentTypeBasicMethods::validate_max_bytes_properties()` and `validate_sum_of_properties()`, versioned on the `validate_max_bytes` and `validate_sum_of_properties` method slots (`None` before protocol version 14). +The check runs where the JSON schema validation of a document's properties runs, `DataContract::validate_document_properties`, right after it: on every document create and replace, and in every client that validates a document before sending it. A longer string is refused with `DocumentPropertyMaxBytesExceededError` (basic code 10421), which names the property (`tags[2]` for an element) and both lengths. The document validation (version 0, extended in place) is inert before protocol version 14, where no string carries a byte cap and the `validate_max_bytes` method slot is `None`. In Rust the check is `DocumentTypeBasicMethods::validate_max_bytes_properties()`; in JavaScript the error reaches an app as `DocumentMaxBytesErrorCode.MaxBytesExceeded`. ## Rules and Guidelines diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 31ac3dbffc5..b7bb7196414 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-10422 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401), `DocumentPropertyNotDistinctError` (10419), `InvalidEncryptedPropertyShapeError` (10420), `DocumentPropertyMaxBytesExceededError` (10421), `DocumentPropertySumMismatchError` (10422) | +| 10400-10421 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401), `DocumentPropertyNotDistinctError` (10419), `InvalidEncryptedPropertyShapeError` (10420), `DocumentPropertyMaxBytesExceededError` (10421) | | 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/docs/protocol/moderation-charters.md b/docs/protocol/moderation-charters.md index f22b195ab40..f9413c54bf8 100644 --- a/docs/protocol/moderation-charters.md +++ b/docs/protocol/moderation-charters.md @@ -76,7 +76,7 @@ bound to it, the key join requests are encrypted to. | `description` | string, 1 to 4096 characters and at most 4096 bytes (`maxBytes`), required | What the team would moderate and how, for joiners and voters. Informational | | `reasons` | typed array of at most 64 unique identifiers, required, each `refersTo` a `reason` | The moderation reasons the team's actions may name; empty is allowed, a team that can take no action; a missing reason refuses the create, naming the element (`reasons[2]`) | | `moderatorsShare` | integer 0 to 100 | The percentage of each moderated document type's declared moderators fee the team takes. Absent is the full amount; a lower number is a discount; 0 is a team that will not moderate and takes no rewards | -| `rewardSplit` | object, required, `sumOfProperties: 100` | `leader`, `equal` and `actions`, three percentages summing to 100: the leader's share, the share split equally among the other members, and the share split by each member's action count | +| `rewardSplit` | object, required | `leader`, `equal` and `actions`, three percentages summing to 100: the leader's share, the share split equally among the other members, and the share split by each member's action count | Indexes: `byTargetContract` (`targetContractId`, `$createdAt`) lists the proposals for a contract in filing order; `byOwner` (`$ownerId`) lists a @@ -173,21 +173,21 @@ contest for its target contract. Reading the join window, the vote window and the fund from the target contract comes with the seating, in a later pull request. -## The split total and the description cap +## Validation beyond the schema Every rule above is enforced by the schema's keywords when a document is -written, the reward split's total and the description's byte cap included: +written, the description's 4096-byte cap included: `maxBytes` refuses a longer +description with `DocumentPropertyMaxBytesExceededError` (10421). One rule of a +proposal is not expressible there, and `validate_submitted_charter` in `rs-dpp` +(`packages/rs-dpp/src/moderation_charter/`) checks it without reading state, +for the path that seats a team: | Rule | Error | Code | | --- | --- | --- | -| The three shares of `rewardSplit` do not sum to 100 (`sumOfProperties`) | `DocumentPropertySumMismatchError` | 10422 | -| The description is over 4096 bytes (`maxBytes`); `maxLength` counts characters | `DocumentPropertyMaxBytesExceededError` | 10421 | - -`SubmittedCharter` and `ElectedCharter` in `rs-dpp` -(`packages/rs-dpp/src/moderation_charter/`) read a proposal's and an elected -charter's properties without reading state, and report a property that is -missing or of the wrong type with `ModerationCharterMalformedFieldError` -(11000). +| A property is missing or of the wrong type | `ModerationCharterMalformedFieldError` | 11000 | +| The three shares of `rewardSplit` do not sum to 100 | `ModerationCharterRewardSplitNotOneHundredError` | 11001 | + +`ElectedCharter` reads an elected charter's properties for the same path. ## Reading and writing from a client diff --git a/packages/moderation-charters-contract/README.md b/packages/moderation-charters-contract/README.md index 72a7153728d..34870e88ed3 100644 --- a/packages/moderation-charters-contract/README.md +++ b/packages/moderation-charters-contract/README.md @@ -9,11 +9,12 @@ every network: `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`. It has seven document types. All are immutable, and all but `resignationRequest` are undeletable, so everything a charter points at, and the charter itself, is a fixed text. -The schema carries every rule through its keywords: typed arrays with a -reference per element, a reference resolved through a unique index +The schema carries almost every rule through its keywords: typed arrays with +a reference per element, a reference resolved through a unique index (`lookup`), `distinctFrom`, key requirements on key references, the -`encryptedFor` envelope, and `sumOfProperties` and `maxBytes` for the reward -split summing to 100 and the description's 4096-byte cap. +`encryptedFor` envelope and `maxBytes` for the description's 4096-byte cap. +What it cannot say, the reward split summing to 100, is checked by +`SubmittedCharter` in `rs-dpp` when a team is seated. ## `reason` @@ -38,7 +39,7 @@ decryption key bound to this type so join requests can be encrypted to it. | `description` | string, 1 to 4096 characters and at most 4096 bytes, required | What the team would moderate and how. Informational | | `reasons` | array of at most 64 unique reason ids, required, each `refersTo` a `reason` | The moderation reasons the team's actions may name; a team with none can take no action | | `moderatorsShare` | integer 0 to 100 | The percentage of each moderated type's declared moderators fee the team takes; absent is the full amount, 0 a team that will not moderate and takes no rewards | -| `rewardSplit` | object, required, `sumOfProperties: 100` | `leader`, `equal` and `actions` percentages summing to 100 | +| `rewardSplit` | object, required | `leader`, `equal` and `actions` percentages summing to 100 | Indexes: `byTargetContract` (target, `$createdAt`) lists the proposals for a contract in filing order; `byOwner` lists a leader's proposals. diff --git a/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json index 941ba773f0a..e4fc330a14d 100644 --- a/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json +++ b/packages/moderation-charters-contract/schema/v1/moderation-charters-contract-documents.json @@ -152,7 +152,6 @@ "actions" ], "additionalProperties": false, - "sumOfProperties": 100, "description": "Percentages summing to 100", "position": 4 } diff --git a/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js b/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js index 531a7971e16..9d4f92c1dee 100644 --- a/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js +++ b/packages/moderation-charters-contract/test/unit/moderationChartersContract.spec.js @@ -171,17 +171,22 @@ describe('Moderation Charters Contract', () => { expect(error.keyword).to.equal('maxLength'); }); - it('should count characters, not bytes; consensus caps the bytes at 4096', async () => { - // 2049 two-byte characters: within the schema's 4096 characters, over the - // 4096 bytes its `maxBytes` declares, which the document create and replace - // structure validation checks (DocumentPropertyMaxBytesExceededError, 10421), - // not JSON Schema validation. - expect(moderationChartersContractDocumentsSchema.submittedCharter.properties - .description.maxBytes).to.equal(4096); - + it('should refuse more than 4096 bytes within 4096 characters', async () => { + // 2049 two-byte characters: within `maxLength`, which counts characters, but + // 4098 bytes, over `maxBytes` (DocumentPropertyMaxBytesExceededError, 10421) const raw = await rawProposal(); raw.description = 'é'.repeat(2049); + const errors = validate('submittedCharter', raw).getErrors(); + + expect(errors).to.have.length(1); + expect(errors[0].getCode()).to.equal(10421); + }); + + it('should accept 4096 bytes in two-byte characters', async () => { + const raw = await rawProposal(); + raw.description = 'é'.repeat(2048); + expect(validate('submittedCharter', raw).isValid()).to.be.true(); }); }); @@ -276,19 +281,6 @@ describe('Moderation Charters Contract', () => { expect(error.keyword).to.equal('additionalProperties'); }); - - it('should leave the total of 100 to consensus', async () => { - // `sumOfProperties` is checked by the document create and replace structure - // validation (DocumentPropertySumMismatchError, 10422), not JSON Schema - // validation, which sees three shares each within 0 to 100. - expect(moderationChartersContractDocumentsSchema.submittedCharter.properties - .rewardSplit.sumOfProperties).to.equal(100); - - const raw = await rawProposal(); - raw.rewardSplit = { leader: 10, equal: 40, actions: 40 }; - - expect(validate('submittedCharter', raw).isValid()).to.be.true(); - }); }); }); 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 6d2404ef132..d8aa527cfb9 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 @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take) and the sumOfProperties property keyword (the total an object's integer properties must add up to), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "referenceOperands": { @@ -546,15 +546,11 @@ "maxLength": 256 }, "maxBytes": { - "description": "Only on string properties, and on the items of a typed array of strings, where it bounds every element: the most bytes the value may take in UTF-8. maxLength counts characters, which are up to four bytes each, so it cannot bound the stored size on its own. An integer from 1 to 65535, no lower than minLength (checked at contract registration). Checked on document create and replace; a longer value is refused (DocumentPropertyMaxBytesExceededError, 10421), naming the element (tags[2]) for an item. Available from protocol version 14.", + "description": "Only on string properties, and on the items of a typed array of strings, where it bounds every element: the most bytes the value may take in UTF-8. maxLength counts characters, which are up to four bytes each, so it cannot bound the stored size on its own. An integer from 1 to 65535, no lower than minLength (checked at contract registration). Checked wherever a document's properties are validated, every create and replace included, after the JSON schema; a longer value is refused (DocumentPropertyMaxBytesExceededError, 10421), naming the element (tags[2]) for an item. Available from protocol version 14.", "type": "integer", "minimum": 1, "maximum": 65535 }, - "sumOfProperties": { - "description": "Only on object properties: the total the object's properties must add up to. Every property of the object must be an integer, required without requiredSince, and not transient, and the total must lie between what the members' minimum and maximum (their integer type's bounds where absent) can add up to; both are checked at contract registration. Checked on document create and replace; an object whose members add up to anything else is refused (DocumentPropertySumMismatchError, 10422). An absent object is not checked. Available from protocol version 14.", - "type": "integer" - }, "requiredSince": { "type": "integer", "minimum": 1, @@ -663,17 +659,6 @@ "type" ] }, - "sumOfProperties": { - "description": "sumOfProperties is only allowed on object properties", - "properties": { - "type": { - "const": "object" - } - }, - "required": [ - "type" - ] - }, "refersTo": { "description": "refersTo is only allowed on identifier properties, except an identityPublicKey reference with identityProperty, which sits on the key id property: an integer with minimum 0 and maximum 4294967295, the range of a key id", "if": { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs index 89eac48eb62..6f33c592f46 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs @@ -71,6 +71,7 @@ mod tests { item_type: Box::new(DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(16), + max_bytes: None, })), item_constraints: Default::default(), min_items: Some(1), diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 885fc2e9c7a..85ea45c9341 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 @@ -206,14 +206,12 @@ fn insert_values( property_type => { let property_type = apply_property_reference(&inner_properties, property_type, platform_version)?; + let property_type = + apply_max_bytes(&inner_properties, property_type, platform_version)?; let distinct_from = apply_distinct_from(&inner_properties, &property_type, platform_version)?; let encrypted_for = apply_encrypted_for(&inner_properties, &property_type, platform_version)?; - let max_bytes = - apply_max_bytes(&inner_properties, &property_type, platform_version)?; - // Objects are not in the flattened map, so no entry here carries a sum: - // the nested pass reads `sumOfProperties` onto the object itself. document_properties.insert( prefixed_property_key, DocumentProperty { @@ -223,8 +221,6 @@ fn insert_values( required_since, distinct_from, encrypted_for, - max_bytes, - sum_of_properties: None, }, ); } @@ -346,15 +342,9 @@ fn insert_values_nested( let property_type = apply_property_reference(&inner_properties, property_type, platform_version)?; + let property_type = apply_max_bytes(&inner_properties, property_type, platform_version)?; let distinct_from = apply_distinct_from(&inner_properties, &property_type, platform_version)?; let encrypted_for = apply_encrypted_for(&inner_properties, &property_type, platform_version)?; - let max_bytes = apply_max_bytes(&inner_properties, &property_type, platform_version)?; - let sum_of_properties = apply_sum_of_properties( - &inner_properties, - &property_type, - root_schema, - platform_version, - )?; document_properties.insert( property_key, @@ -365,8 +355,6 @@ fn insert_values_nested( required_since, distinct_from, encrypted_for, - max_bytes, - sum_of_properties, }, ); @@ -412,18 +400,12 @@ fn apply_distinct_from_v0( // 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 { + let Some(distinct_from_value) = typed_array_items_keyword( + inner_properties, + property_names::DISTINCT_FROM, + "applies to every element", + )? + else { return Ok(None); }; if !matches!( @@ -547,10 +529,30 @@ fn validate_distinct_from_targets_v0( Ok(()) } -/// Reads a `maxBytes` declaration off a string property, or off the `items` -/// of a typed array of strings: the most UTF-8 bytes the value (every -/// element) may hold. `maxLength` counts characters, which are up to four -/// bytes each, so it cannot bound the stored size on its own. +/// The value of an element keyword on the `items` of a typed array property, +/// refused on the array itself: the keyword binds every element, so it belongs +/// on the items. `binds` finishes the refusal ("applies to every element"). +fn typed_array_items_keyword<'a>( + inner_properties: &BTreeMap, + keyword: &str, + binds: &str, +) -> Result, DataContractError> { + if inner_properties.contains_key(keyword) { + return Err(DataContractError::InvalidContractStructure(format!( + "{keyword} on a typed array belongs on its items, where it {binds}" + ))); + } + let Some(items) = inner_properties.get(property_names::ITEMS) else { + return Ok(None); + }; + Ok(items.to_btree_ref_string_map()?.get(keyword).copied()) +} + +/// Folds a `maxBytes` declaration into a string property's sizes, or, declared +/// on the `items` of a typed array of strings, into the element type's sizes: +/// the most UTF-8 bytes the value (every element) may take. `maxLength` counts +/// characters, which are up to four bytes each, so it cannot bound the stored +/// size on its own. /// /// Versioned on `apply_max_bytes` in the platform version's document type /// schema versions. `None` selects the behavior of the versions that predate @@ -558,9 +560,9 @@ fn validate_distinct_from_targets_v0( /// identical to what they always produced. fn apply_max_bytes( inner_properties: &BTreeMap, - property_type: &DocumentPropertyType, + property_type: DocumentPropertyType, platform_version: &PlatformVersion, -) -> Result, DataContractError> { +) -> Result { match platform_version .dpp .contract_versions @@ -568,7 +570,7 @@ fn apply_max_bytes( .schema .apply_max_bytes { - None => Ok(None), + None => Ok(property_type), Some(0) => apply_max_bytes_v0(inner_properties, property_type), Some(version) => Err(DataContractError::Unsupported(format!( "apply_max_bytes version {version} is not supported" @@ -578,41 +580,38 @@ fn apply_max_bytes( fn apply_max_bytes_v0( inner_properties: &BTreeMap, - property_type: &DocumentPropertyType, -) -> Result, DataContractError> { - // A typed array carries the declaration on its `items`: every element is - // bounded, so the elements must be strings - if let DocumentPropertyType::TypedArray(typed_array) = property_type { - if inner_properties.contains_key(property_names::MAX_BYTES) { + mut property_type: DocumentPropertyType, +) -> Result { + let declared = if matches!(property_type, DocumentPropertyType::TypedArray(_)) { + typed_array_items_keyword( + inner_properties, + property_names::MAX_BYTES, + "bounds every element", + )? + } else { + inner_properties.get(property_names::MAX_BYTES).copied() + }; + let Some(max_bytes_value) = declared else { + return Ok(property_type); + }; + let sizes = match &mut property_type { + DocumentPropertyType::String(sizes) => sizes, + DocumentPropertyType::TypedArray(typed_array) => match typed_array.item_type.as_mut() { + DocumentPropertyType::String(sizes) => sizes, + _ => { + return Err(DataContractError::InvalidContractStructure( + "maxBytes is only allowed on string elements of a typed array".to_string(), + )) + } + }, + _ => { return Err(DataContractError::InvalidContractStructure( - "maxBytes on a typed array belongs on its items, where it bounds every element" - .to_string(), - )); + "maxBytes is only allowed on string properties".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(max_bytes_value) = items_map.get(property_names::MAX_BYTES) else { - return Ok(None); - }; - let DocumentPropertyType::String(sizes) = typed_array.item_type.as_ref() else { - return Err(DataContractError::InvalidContractStructure( - "maxBytes is only allowed on string elements of a typed array".to_string(), - )); - }; - return parse_max_bytes(max_bytes_value, sizes.min_length).map(Some); - } - - let Some(max_bytes_value) = inner_properties.get(property_names::MAX_BYTES) else { - return Ok(None); }; - let DocumentPropertyType::String(sizes) = property_type else { - return Err(DataContractError::InvalidContractStructure( - "maxBytes is only allowed on string properties".to_string(), - )); - }; - parse_max_bytes(max_bytes_value, sizes.min_length).map(Some) + sizes.max_bytes = Some(parse_max_bytes(max_bytes_value, sizes.min_length)?); + Ok(property_type) } /// A `maxBytes` bound: 1 to 65535, and no lower than `minLength`, since a @@ -640,138 +639,6 @@ fn parse_max_bytes(value: &Value, min_length: Option) -> Result, - property_type: &DocumentPropertyType, - root_schema: &Value, - platform_version: &PlatformVersion, -) -> Result, DataContractError> { - match platform_version - .dpp - .contract_versions - .document_type_versions - .schema - .apply_sum_of_properties - { - None => Ok(None), - Some(0) => apply_sum_of_properties_v0(inner_properties, property_type, root_schema), - Some(version) => Err(DataContractError::Unsupported(format!( - "apply_sum_of_properties version {version} is not supported" - ))), - } -} - -fn apply_sum_of_properties_v0( - inner_properties: &BTreeMap, - property_type: &DocumentPropertyType, - root_schema: &Value, -) -> Result, DataContractError> { - let Some(sum_value) = inner_properties.get(property_names::SUM_OF_PROPERTIES) else { - return Ok(None); - }; - let DocumentPropertyType::Object(members) = property_type else { - return Err(DataContractError::InvalidContractStructure( - "sumOfProperties is only allowed on object properties".to_string(), - )); - }; - let total = sum_value.to_integer::().map_err(|_| { - DataContractError::InvalidContractStructure( - "sumOfProperties must be an integer in the i64 range".to_string(), - ) - })?; - if members.is_empty() { - return Err(DataContractError::InvalidContractStructure( - "sumOfProperties needs an object with at least one integer property to add up" - .to_string(), - )); - } - - let member_schemas = match inner_properties.get(property_names::PROPERTIES) { - Some(properties) => properties.to_btree_ref_string_map()?, - None => BTreeMap::new(), - }; - - // The least and the most the members can add up to, from each member's - // `minimum` and `maximum` where declared and its integer type otherwise - let (mut least, mut most) = (0i128, 0i128); - for (name, member) in members { - let Some((type_min, type_max)) = integer_range(&member.property_type) else { - return Err(DataContractError::InvalidContractStructure(format!( - "sumOfProperties adds up integer properties, but member \"{name}\" is not one" - ))); - }; - if !member.required || member.required_since.is_some() { - return Err(DataContractError::InvalidContractStructure(format!( - "sumOfProperties member \"{name}\" must be required, without requiredSince: \ - an absent member would leave the sum undefined" - ))); - } - if member.transient { - return Err(DataContractError::InvalidContractStructure(format!( - "sumOfProperties member \"{name}\" is transient: it is never stored, so the \ - stored object could not be held to the sum" - ))); - } - let (minimum, maximum) = match member_schemas.get(name.as_str()) { - Some(schema) => { - let mut schema = schema.to_btree_ref_string_map()?; - if let Some(schema_ref) = schema.get_optional_str(property_names::REF)? { - schema = resolve_uri(root_schema, schema_ref)?.to_btree_ref_string_map()?; - } - ( - schema - .get_optional_integer::(property_names::MINIMUM) - .ok() - .flatten(), - schema - .get_optional_integer::(property_names::MAXIMUM) - .ok() - .flatten(), - ) - } - None => (None, None), - }; - least = least.saturating_add(minimum.map_or(type_min, |minimum| minimum.max(type_min))); - most = most.saturating_add(maximum.map_or(type_max, |maximum| maximum.min(type_max))); - } - if i128::from(total) < least || i128::from(total) > most { - return Err(DataContractError::InvalidContractStructure(format!( - "sumOfProperties {total} is out of reach: the members add up to between {least} \ - and {most}" - ))); - } - Ok(Some(total)) -} - -/// The values an integer property type holds, widened to `i128` (a `u128` -/// maximum is clamped). `None` for every other type, key id references -/// included: a key id is a name, not an amount. -fn integer_range(property_type: &DocumentPropertyType) -> Option<(i128, i128)> { - Some(match property_type { - DocumentPropertyType::U8 => (0, u8::MAX.into()), - DocumentPropertyType::I8 => (i8::MIN.into(), i8::MAX.into()), - DocumentPropertyType::U16 => (0, u16::MAX.into()), - DocumentPropertyType::I16 => (i16::MIN.into(), i16::MAX.into()), - DocumentPropertyType::U32 => (0, u32::MAX.into()), - DocumentPropertyType::I32 => (i32::MIN.into(), i32::MAX.into()), - DocumentPropertyType::U64 => (0, u64::MAX.into()), - DocumentPropertyType::I64 => (i64::MIN.into(), i64::MAX.into()), - DocumentPropertyType::U128 => (0, i128::MAX), - DocumentPropertyType::I128 => (i128::MIN, i128::MAX), - _ => return None, - }) -} - /// Folds a `refersTo` declaration into the property type: an identifier property /// with `refersTo` becomes `IdentifierWithReference(target)`, and a `u32` key id /// property with an `identityPublicKey` declaration naming `identityProperty` diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_and_sum_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_and_sum_tests.rs deleted file mode 100644 index 4c273683c48..00000000000 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_and_sum_tests.rs +++ /dev/null @@ -1,484 +0,0 @@ -//! `maxBytes` and `sumOfProperties`: the two property keywords that bound a -//! value by something plain JSON Schema cannot count, the UTF-8 length of a -//! string and the total of an object's integer members. -//! -//! The grammar is the v3 document meta-schema's (protocol version 14) and the -//! parses are `apply_max_bytes` 0 and `apply_sum_of_properties` 0, which the -//! tables select from protocol version 14 only. The write-time checks are -//! `validate_max_bytes_properties` and `validate_sum_of_properties`. - -use super::typed_array_test_helpers::{ - expect_json_schema_error, expect_structure_error, parse_dispatched, -}; -use super::*; -use crate::consensus::basic::BasicError; -use crate::consensus::ConsensusError; -use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; -use crate::validation::SimpleConsensusValidationResult; -use platform_value::platform_value; - -/// A document type with a string `note` (declaring `note_max_bytes` when -/// given), a typed string array `tags`, and an object `meta` holding a string -/// `meta.tag` and the object `meta.split` of two percentages, `a` and `b`. -fn schema(note_max_bytes: Option) -> Value { - let mut note = platform_value!({ "type": "string", "maxLength": 64, "position": 0 }); - if let Some(max_bytes) = note_max_bytes { - note.insert("maxBytes".to_string(), max_bytes) - .expect("note is a map"); - } - platform_value!({ - "type": "object", - "properties": { - "note": note, - "tags": { - "type": "array", - "maxItems": 4, - "items": { "type": "string", "maxLength": 16, "maxBytes": 8 }, - "position": 1 - }, - "meta": { - "type": "object", - "position": 2, - "properties": { - "tag": { "type": "string", "maxLength": 16, "maxBytes": 4, "position": 0 }, - "split": split_schema(100) - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }) -} - -/// Two required percentages that must add up to `total`. -fn split_schema(total: i64) -> Value { - platform_value!({ - "type": "object", - "position": 1, - "properties": { - "a": { "type": "integer", "minimum": 0, "maximum": 100, "position": 0 }, - "b": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 } - }, - "required": ["a", "b"], - "additionalProperties": false, - "sumOfProperties": total - }) -} - -/// A document type with the object `split` declared by `split`. -fn schema_with_split(split: Value) -> Value { - platform_value!({ - "type": "object", - "properties": { "split": split }, - "additionalProperties": false - }) -} - -fn parse(schema: Value) -> DocumentType { - parse_dispatched(schema, PlatformVersion::latest(), true).expect("the schema parses") -} - -fn data(value: Value) -> BTreeMap { - value.into_btree_string_map().expect("a map of properties") -} - -fn first_basic_error(result: SimpleConsensusValidationResult) -> BasicError { - match result.errors.into_iter().next() { - Some(ConsensusError::BasicError(error)) => error, - other => panic!("expected a basic error, got {other:?}"), - } -} - -// ================================================================ -// maxBytes: parse -// ================================================================ - -#[test] -fn should_parse_max_bytes_on_strings_and_on_the_items_of_a_typed_string_array() { - let document_type = parse(schema(Some(Value::U64(8)))); - let document_type = document_type.as_ref(); - let flattened = document_type.flattened_properties(); - - assert_eq!(flattened.get("note").expect("note").max_bytes, Some(8)); - assert_eq!( - flattened.get("meta.tag").expect("meta.tag").max_bytes, - Some(4) - ); - // Declared on the items, held on the array property, bounding every element - assert_eq!(flattened.get("tags").expect("tags").max_bytes, Some(8)); - assert_eq!(flattened.get("meta.split.a").expect("a").max_bytes, None); -} - -#[test] -fn should_refuse_max_bytes_on_a_property_that_is_not_a_string() { - let schema = schema_with_split(platform_value!({ - "type": "integer", - "minimum": 0, - "maximum": 100, - "maxBytes": 1, - "position": 0 - })); - let error = expect_json_schema_error(parse_dispatched( - schema.clone(), - PlatformVersion::latest(), - true, - )); - assert_eq!(error.keyword(), "const", "{error:?}"); - - // A parse that skips the meta-schema still refuses it - expect_structure_error( - parse_dispatched(schema, PlatformVersion::latest(), false), - "maxBytes is only allowed on string properties", - ); -} - -#[test] -fn should_refuse_max_bytes_on_a_typed_array_itself() { - let schema = schema_with_split(platform_value!({ - "type": "array", - "maxItems": 4, - "maxBytes": 8, - "items": { "type": "string", "maxLength": 16 }, - "position": 0 - })); - expect_json_schema_error(parse_dispatched( - schema.clone(), - PlatformVersion::latest(), - true, - )); - expect_structure_error( - parse_dispatched(schema, PlatformVersion::latest(), false), - "belongs on its items", - ); -} - -#[test] -fn should_refuse_max_bytes_on_elements_that_are_not_strings() { - let schema = schema_with_split(platform_value!({ - "type": "array", - "maxItems": 4, - "items": { "type": "integer", "minimum": 0, "maximum": 9, "maxBytes": 1 }, - "position": 0 - })); - expect_json_schema_error(parse_dispatched( - schema.clone(), - PlatformVersion::latest(), - true, - )); - expect_structure_error( - parse_dispatched(schema, PlatformVersion::latest(), false), - "only allowed on string elements", - ); -} - -#[test] -fn should_refuse_a_max_bytes_of_zero_or_below_min_length() { - let error = expect_json_schema_error(parse_dispatched( - schema(Some(Value::U64(0))), - PlatformVersion::latest(), - true, - )); - assert_eq!(error.keyword(), "minimum", "{error:?}"); - - // Two characters are at least two bytes, so a one-byte bound refuses every value - expect_structure_error( - parse_dispatched( - schema_with_split(platform_value!({ - "type": "string", - "minLength": 2, - "maxLength": 8, - "maxBytes": 1, - "position": 0 - })), - PlatformVersion::latest(), - true, - ), - "maxBytes 1 is below minLength 2", - ); -} - -#[test] -fn should_ignore_max_bytes_before_protocol_version_14() { - // Protocol version 13's meta-schema refuses the keyword; a parse that skips it - // (a contract read back from state) ignores it, as it always did - let platform_version = PlatformVersion::get(13).expect("protocol version 13"); - let document_type = parse_dispatched( - schema_with_split(platform_value!({ - "type": "string", - "maxLength": 8, - "maxBytes": 4, - "position": 0 - })), - platform_version, - false, - ) - .expect("a parse predating maxBytes ignores it"); - assert_eq!( - document_type - .as_ref() - .flattened_properties() - .get("split") - .expect("split") - .max_bytes, - None - ); -} - -// ================================================================ -// maxBytes: write time -// ================================================================ - -#[test] -fn should_refuse_a_string_over_its_max_bytes_by_its_utf8_length() { - let document_type = parse(schema(Some(Value::U64(8)))); - let platform_version = PlatformVersion::latest(); - - // Eight bytes in one-byte or two-byte characters fit; the same four characters with - // a fifth two-byte one do not, though every one of them is well within maxLength - for note in ["abcdefgh", "éééé"] { - let result = document_type - .validate_max_bytes_properties( - &data(platform_value!({ "note": note })), - platform_version, - ) - .expect("validation executes"); - assert!(result.is_valid(), "{note}: {:?}", result.errors); - } - let result = document_type - .validate_max_bytes_properties( - &data(platform_value!({ "note": "ééééé" })), - platform_version, - ) - .expect("validation executes"); - assert!(matches!( - first_basic_error(result), - BasicError::DocumentPropertyMaxBytesExceededError(e) - if e.property() == "note" && e.byte_length() == 10 && e.max_bytes() == 8 - )); - - // A nested string is named by its dotted path - let result = document_type - .validate_max_bytes_properties( - &data(platform_value!({ "meta": { "tag": "ééé" } })), - platform_version, - ) - .expect("validation executes"); - assert!(matches!( - first_basic_error(result), - BasicError::DocumentPropertyMaxBytesExceededError(e) - if e.property() == "meta.tag" && e.byte_length() == 6 && e.max_bytes() == 4 - )); - - // An absent property is not checked - assert!(document_type - .validate_max_bytes_properties(&BTreeMap::new(), platform_version) - .expect("validation executes") - .is_valid()); -} - -#[test] -fn should_name_the_element_of_a_typed_array_over_its_max_bytes() { - let document_type = parse(schema(None)); - let result = document_type - .validate_max_bytes_properties( - &data(platform_value!({ "tags": ["short", "ééééé", "ok"] })), - PlatformVersion::latest(), - ) - .expect("validation executes"); - assert!(matches!( - first_basic_error(result), - BasicError::DocumentPropertyMaxBytesExceededError(e) - if e.property() == "tags[1]" && e.byte_length() == 10 && e.max_bytes() == 8 - )); -} - -// ================================================================ -// sumOfProperties: parse -// ================================================================ - -#[test] -fn should_parse_sum_of_properties_onto_the_object() { - let document_type = parse(schema(None)); - let document_type = document_type.as_ref(); - let DocumentPropertyType::Object(meta) = &document_type - .properties() - .get("meta") - .expect("meta") - .property_type - else { - panic!("meta is an object"); - }; - assert_eq!( - meta.get("split").expect("split").sum_of_properties, - Some(100) - ); - assert_eq!(meta.get("tag").expect("tag").sum_of_properties, None); -} - -#[test] -fn should_refuse_sum_of_properties_on_a_property_that_is_not_an_object() { - let schema = schema_with_split(platform_value!({ - "type": "integer", - "minimum": 0, - "maximum": 100, - "sumOfProperties": 100, - "position": 0 - })); - expect_json_schema_error(parse_dispatched( - schema.clone(), - PlatformVersion::latest(), - true, - )); - expect_structure_error( - parse_dispatched(schema, PlatformVersion::latest(), false), - "only allowed on object properties", - ); -} - -#[test] -fn should_refuse_sum_of_properties_over_members_that_are_not_required_integers() { - for (members, required, needle) in [ - ( - platform_value!({ - "a": { "type": "integer", "minimum": 0, "maximum": 100, "position": 0 }, - "b": { "type": "string", "maxLength": 3, "position": 1 } - }), - platform_value!(["a", "b"]), - "member \"b\" is not one", - ), - ( - platform_value!({ - "a": { "type": "integer", "minimum": 0, "maximum": 100, "position": 0 }, - "b": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 } - }), - platform_value!(["a"]), - "member \"b\" must be required", - ), - ] { - expect_structure_error( - parse_dispatched( - schema_with_split(platform_value!({ - "type": "object", - "position": 0, - "properties": members, - "required": required, - "additionalProperties": false, - "sumOfProperties": 100 - })), - PlatformVersion::latest(), - true, - ), - needle, - ); - } -} - -#[test] -fn should_refuse_a_total_the_members_cannot_reach() { - // Two members of 0 to 100 add up to 0 through 200 - for total in [-1, 201] { - let mut split = split_schema(total); - split - .insert("position".to_string(), Value::U64(0)) - .expect("split is a map"); - expect_structure_error( - parse_dispatched(schema_with_split(split), PlatformVersion::latest(), true), - "the members add up to between 0 and 200", - ); - } - for total in [0, 200] { - let mut split = split_schema(total); - split - .insert("position".to_string(), Value::U64(0)) - .expect("split is a map"); - parse_dispatched(schema_with_split(split), PlatformVersion::latest(), true) - .unwrap_or_else(|e| panic!("{total} is reachable: {e:?}")); - } -} - -#[test] -fn should_ignore_sum_of_properties_before_protocol_version_14() { - let platform_version = PlatformVersion::get(13).expect("protocol version 13"); - let mut split = split_schema(100); - split - .insert("position".to_string(), Value::U64(0)) - .expect("split is a map"); - let document_type = parse_dispatched(schema_with_split(split), platform_version, false) - .expect("a parse predating sumOfProperties ignores it"); - assert_eq!( - document_type - .as_ref() - .properties() - .get("split") - .expect("split") - .sum_of_properties, - None - ); -} - -// ================================================================ -// sumOfProperties: write time -// ================================================================ - -#[test] -fn should_refuse_an_object_whose_members_miss_the_total() { - let document_type = parse(schema(None)); - let platform_version = PlatformVersion::latest(); - - let result = document_type - .validate_sum_of_properties( - &data(platform_value!({ "meta": { "split": { "a": 30, "b": 70 } } })), - platform_version, - ) - .expect("validation executes"); - assert!(result.is_valid(), "{:?}", result.errors); - - for (a, b) in [(30, 60), (0, 0), (100, 100)] { - let result = document_type - .validate_sum_of_properties( - &data(platform_value!({ "meta": { "split": { "a": a, "b": b } } })), - platform_version, - ) - .expect("validation executes"); - assert!( - matches!( - first_basic_error(result), - BasicError::DocumentPropertySumMismatchError(e) - if e.property() == "meta.split" - && e.expected_sum() == 100 - && e.actual_sum() == a + b - ), - "{a} + {b}" - ); - } - - // An absent object is not checked; whether it may be absent is `required`'s - for absent in [ - platform_value!({}), - platform_value!({ "meta": { "tag": "x" } }), - ] { - assert!(document_type - .validate_sum_of_properties(&data(absent), platform_version) - .expect("validation executes") - .is_valid()); - } -} - -#[test] -fn should_check_nothing_before_protocol_version_14() { - // A type parsed with the keywords, judged under protocol version 13's method table - let document_type = parse(schema(Some(Value::U64(1)))); - let platform_version = PlatformVersion::get(13).expect("protocol version 13"); - let properties = data(platform_value!({ - "note": "too long", - "meta": { "split": { "a": 1, "b": 1 } } - })); - assert!(document_type - .validate_max_bytes_properties(&properties, platform_version) - .expect("validation executes") - .is_valid()); - assert!(document_type - .validate_sum_of_properties(&properties, platform_version) - .expect("validation executes") - .is_valid()); -} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_tests.rs new file mode 100644 index 00000000000..5c6249e4de7 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/max_bytes_tests.rs @@ -0,0 +1,368 @@ +//! `maxBytes`: the property keyword that bounds a string by its UTF-8 length, +//! which plain JSON Schema cannot count (`maxLength` counts characters). +//! +//! The grammar is the v3 document meta-schema's (protocol version 14) and the +//! parse is `apply_max_bytes` 0, which the tables select from protocol version +//! 14 only and which folds the bound into the string's `StringPropertySizes`. +//! The write-time check is `validate_max_bytes_properties`, which +//! `DataContract::validate_document_properties` runs after the JSON schema +//! validation. + +use super::typed_array_test_helpers::{ + expect_json_schema_error, expect_structure_error, parse_dispatched, +}; +use super::*; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; +use crate::data_contract::document_type::StringPropertySizes; +use crate::validation::SimpleConsensusValidationResult; +use platform_value::platform_value; +use rand::rngs::StdRng; +use rand::SeedableRng; + +/// A document type with a string `note` (declaring `note_max_bytes` when +/// given), a typed string array `tags` whose elements take at most 8 bytes, +/// and an object `meta` holding a string `meta.tag` of at most 4 bytes. +fn schema(note_max_bytes: Option) -> Value { + let mut note = platform_value!({ "type": "string", "maxLength": 64, "position": 0 }); + if let Some(max_bytes) = note_max_bytes { + note.insert("maxBytes".to_string(), max_bytes) + .expect("note is a map"); + } + platform_value!({ + "type": "object", + "properties": { + "note": note, + "tags": { + "type": "array", + "maxItems": 4, + "items": { "type": "string", "maxLength": 16, "maxBytes": 8 }, + "position": 1 + }, + "meta": { + "type": "object", + "position": 2, + "properties": { + "tag": { "type": "string", "maxLength": 16, "maxBytes": 4, "position": 0 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }) +} + +/// A document type with the one property `value` declared by `value`. +fn schema_with(value: Value) -> Value { + platform_value!({ + "type": "object", + "properties": { "value": value }, + "additionalProperties": false + }) +} + +fn parse(schema: Value) -> DocumentType { + parse_dispatched(schema, PlatformVersion::latest(), true).expect("the schema parses") +} + +fn property_type(document_type: &DocumentType, path: &str) -> DocumentPropertyType { + document_type + .as_ref() + .flattened_properties() + .get(path) + .unwrap_or_else(|| panic!("{path} is parsed")) + .property_type + .clone() +} + +/// The `maxBytes` a string, or the string elements of a typed array, carry. +fn max_bytes_of(property_type: &DocumentPropertyType) -> Option { + match property_type { + DocumentPropertyType::String(sizes) => sizes.max_bytes, + DocumentPropertyType::TypedArray(typed_array) => match typed_array.item_type.as_ref() { + DocumentPropertyType::String(sizes) => sizes.max_bytes, + other => panic!("expected string elements, got {other:?}"), + }, + other => panic!("expected a string, got {other:?}"), + } +} + +fn first_basic_error(result: SimpleConsensusValidationResult) -> BasicError { + match result.errors.into_iter().next() { + Some(ConsensusError::BasicError(error)) => error, + other => panic!("expected a basic error, got {other:?}"), + } +} + +// ================================================================ +// Parse +// ================================================================ + +#[test] +fn should_fold_max_bytes_into_the_sizes_of_strings_and_of_typed_string_elements() { + let document_type = parse(schema(Some(Value::U64(8)))); + + assert_eq!( + property_type(&document_type, "note"), + DocumentPropertyType::String(StringPropertySizes { + min_length: None, + max_length: Some(64), + max_bytes: Some(8), + }) + ); + assert_eq!( + max_bytes_of(&property_type(&document_type, "meta.tag")), + Some(4) + ); + // Declared on the items, carried by the element type, bounding every element + assert_eq!( + max_bytes_of(&property_type(&document_type, "tags")), + Some(8) + ); + + let without = parse(schema(None)); + assert_eq!(max_bytes_of(&property_type(&without, "note")), None); +} + +#[test] +fn should_refuse_max_bytes_on_a_property_that_is_not_a_string() { + let schema = schema_with(platform_value!({ + "type": "integer", + "minimum": 0, + "maximum": 100, + "maxBytes": 1, + "position": 0 + })); + let error = expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + assert_eq!(error.keyword(), "const", "{error:?}"); + + // A parse that skips the meta-schema still refuses it + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "maxBytes is only allowed on string properties", + ); +} + +#[test] +fn should_refuse_max_bytes_on_a_typed_array_itself() { + let schema = schema_with(platform_value!({ + "type": "array", + "maxItems": 4, + "maxBytes": 8, + "items": { "type": "string", "maxLength": 16 }, + "position": 0 + })); + expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "maxBytes on a typed array belongs on its items, where it bounds every element", + ); +} + +#[test] +fn should_refuse_max_bytes_on_elements_that_are_not_strings() { + let schema = schema_with(platform_value!({ + "type": "array", + "maxItems": 4, + "items": { "type": "integer", "minimum": 0, "maximum": 9, "maxBytes": 1 }, + "position": 0 + })); + expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "only allowed on string elements", + ); +} + +#[test] +fn should_refuse_a_max_bytes_of_zero_or_below_min_length() { + let error = expect_json_schema_error(parse_dispatched( + schema(Some(Value::U64(0))), + PlatformVersion::latest(), + true, + )); + assert_eq!(error.keyword(), "minimum", "{error:?}"); + + // Two characters are at least two bytes, so a one-byte bound refuses every value + expect_structure_error( + parse_dispatched( + schema_with(platform_value!({ + "type": "string", + "minLength": 2, + "maxLength": 8, + "maxBytes": 1, + "position": 0 + })), + PlatformVersion::latest(), + true, + ), + "maxBytes 1 is below minLength 2", + ); +} + +#[test] +fn should_ignore_max_bytes_before_protocol_version_14() { + // Protocol version 13's meta-schema refuses the keyword; a parse that skips it + // (a contract read back from state) ignores it, as it always did + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let document_type = parse_dispatched( + schema_with(platform_value!({ + "type": "string", + "maxLength": 8, + "maxBytes": 4, + "position": 0 + })), + platform_version, + false, + ) + .expect("a parse predating maxBytes ignores it"); + assert_eq!(max_bytes_of(&property_type(&document_type, "value")), None); +} + +// ================================================================ +// Sizes +// ================================================================ + +/// `maxLength` alone allows four bytes a character; a declared `maxBytes` is +/// the real bound, for size estimates and for the characters that fit. +#[test] +fn should_bound_the_sizes_of_a_string_by_its_max_bytes() { + let platform_version = PlatformVersion::latest(); + let string = |max_length: Option, max_bytes: Option| { + DocumentPropertyType::String(StringPropertySizes { + min_length: None, + max_length, + max_bytes, + }) + }; + + for (max_length, max_bytes, byte_size, size) in [ + (Some(64), None, 256, 64), + (Some(64), Some(16), 16, 16), + (Some(4), Some(100), 16, 4), + (None, Some(16), 16, 16), + (None, None, u16::MAX, 16383), + ] { + let property_type = string(max_length, max_bytes); + assert_eq!( + property_type + .max_byte_size(platform_version) + .expect("a byte size"), + Some(byte_size), + "{max_length:?} / {max_bytes:?}" + ); + assert_eq!( + property_type.max_size(), + Some(size), + "{max_length:?} / {max_bytes:?}" + ); + } +} + +/// Random documents of a type with a byte cap stay within it, so strategy +/// tests and random fixtures produce documents consensus accepts. +#[test] +fn should_generate_random_strings_within_their_max_bytes() { + let document_type = parse(schema(Some(Value::U64(8)))); + let mut rng = StdRng::seed_from_u64(7); + for path in ["note", "meta.tag", "tags"] { + let property_type = property_type(&document_type, path); + let max_bytes = max_bytes_of(&property_type).expect("a byte cap") as usize; + for _ in 0..50 { + let values = match property_type.random_value(&mut rng) { + Value::Array(elements) => elements, + value => vec![value], + }; + for value in values { + let text = value.as_text().expect("a string"); + assert!(text.len() <= max_bytes, "{path}: {text:?}"); + } + } + } +} + +// ================================================================ +// Write time +// ================================================================ + +#[test] +fn should_refuse_a_string_over_its_max_bytes_by_its_utf8_length() { + let document_type = parse(schema(Some(Value::U64(8)))); + let platform_version = PlatformVersion::latest(); + + // Eight bytes in one-byte or two-byte characters fit; five two-byte characters do + // not, though they are well within maxLength + for note in ["abcdefgh", "éééé"] { + let result = document_type + .validate_max_bytes_properties(&platform_value!({ "note": note }), platform_version) + .expect("validation executes"); + assert!(result.is_valid(), "{note}: {:?}", result.errors); + } + let result = document_type + .validate_max_bytes_properties(&platform_value!({ "note": "ééééé" }), platform_version) + .expect("validation executes"); + assert!(matches!( + first_basic_error(result), + BasicError::DocumentPropertyMaxBytesExceededError(e) + if e.property() == "note" && e.byte_length() == 10 && e.max_bytes() == 8 + )); + + // A nested string is named by its dotted path + let result = document_type + .validate_max_bytes_properties( + &platform_value!({ "meta": { "tag": "ééé" } }), + platform_version, + ) + .expect("validation executes"); + assert!(matches!( + first_basic_error(result), + BasicError::DocumentPropertyMaxBytesExceededError(e) + if e.property() == "meta.tag" && e.byte_length() == 6 && e.max_bytes() == 4 + )); + + // An absent property is not checked + assert!(document_type + .validate_max_bytes_properties(&platform_value!({}), platform_version) + .expect("validation executes") + .is_valid()); +} + +#[test] +fn should_name_the_element_of_a_typed_array_over_its_max_bytes() { + let document_type = parse(schema(None)); + let result = document_type + .validate_max_bytes_properties( + &platform_value!({ "tags": ["short", "ééééé", "ok"] }), + PlatformVersion::latest(), + ) + .expect("validation executes"); + assert!(matches!( + first_basic_error(result), + BasicError::DocumentPropertyMaxBytesExceededError(e) + if e.property() == "tags[1]" && e.byte_length() == 10 && e.max_bytes() == 8 + )); +} + +#[test] +fn should_check_nothing_before_protocol_version_14() { + // A type parsed with the keyword, judged under protocol version 13's method table + let document_type = parse(schema(Some(Value::U64(1)))); + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + assert!(document_type + .validate_max_bytes_properties(&platform_value!({ "note": "too long" }), platform_version) + .expect("validation executes") + .is_valid()); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 7d2f989c50c..ff0759b1ad8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -901,7 +901,7 @@ mod keep_history_tests; #[cfg(all(test, feature = "validation"))] mod list_element_reference_tests; #[cfg(all(test, feature = "validation"))] -mod max_bytes_and_sum_tests; +mod max_bytes_tests; #[cfg(test)] mod meta_schema_v0_stray_keyword_tests; #[cfg(test)] diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs index b5ee8a75e41..7aead89918c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs @@ -147,6 +147,7 @@ fn should_parse_every_scalar_element_type() { DocumentPropertyType::String(StringPropertySizes { min_length: Some(1), max_length: Some(20), + max_bytes: None, }), ), ( 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 3bf5198ca3d..52eda7adf49 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 @@ -198,8 +198,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, transient: false, } } @@ -210,13 +208,12 @@ mod tests { property_type: DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }), required: true, required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, transient: false, } } @@ -228,8 +225,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: 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 0da9d19978e..800bcb1a92e 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 @@ -15,57 +15,20 @@ use crate::ProtocolError; #[cfg(feature = "validation")] use crate::consensus::basic::document::{ - DocumentPropertyMaxBytesExceededError, DocumentPropertySumMismatchError, - InvalidEncryptedPropertyShapeError, + DocumentPropertyMaxBytesExceededError, InvalidEncryptedPropertyShapeError, }; use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; use crate::data_contract::document_type::methods::versioned_methods::DocumentTypeV0MethodsVersioned; #[cfg(feature = "validation")] -use crate::data_contract::document_type::{DocumentProperty, DocumentPropertyType}; +use crate::data_contract::document_type::{DocumentPropertyType, StringPropertySizes}; use crate::fee::Credits; use crate::voting::vote_polls::VotePoll; #[cfg(feature = "validation")] use platform_value::btreemap_extensions::BTreeValueMapPathHelper; use platform_value::{Identifier, Value}; -/// The mismatch an object property declaring `sumOfProperties` has in `properties`, the -/// document's data, with `path` the object's dotted path: `None` when the object declares -/// no sum, is absent, or its members add up to the total. -#[cfg(feature = "validation")] -fn sum_of_properties_violation( - path: &str, - property: &DocumentProperty, - properties: &BTreeMap, -) -> Option { - let total = property.sum_of_properties?; - let DocumentPropertyType::Object(members) = &property.property_type else { - return None; - }; - // An absent object has nothing to add up; whether it may be absent is `required`'s - if !matches!( - properties.get_optional_at_path(path), - Ok(Some(Value::Map(_))) - ) { - return None; - } - let sum = members.keys().fold(0i128, |sum, member| { - let value = match properties.get_optional_at_path(&format!("{path}.{member}")) { - Ok(Some(value)) => value.to_integer::().unwrap_or(i128::MAX), - _ => 0, - }; - sum.saturating_add(value) - }); - (sum != i128::from(total)).then(|| { - DocumentPropertySumMismatchError::new( - path.to_string(), - total, - i64::try_from(sum).unwrap_or(if sum < 0 { i64::MIN } else { i64::MAX }), - ) - }) -} - pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { fn unique_id_for_storage(&self) -> [u8; 32] { rand::random::<[u8; 32]>() @@ -174,22 +137,20 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { SimpleConsensusValidationResult::new() } - /// Checks every string `properties` supplies for a property declaring `maxBytes` - /// against it, counting UTF-8 bytes: the property's value, or every element of a - /// typed array of strings, whose error names the element (`tags[2]`). A declared - /// property the document leaves out is not checked. - /// - /// Meant to run after the JSON schema validation of `properties`, which already - /// established every supplied value's type; a value that still is not a string - /// holds no bytes to count. + /// Checks every string `properties` (the document's properties map) supplies for a + /// string declaring `maxBytes` against it, counting UTF-8 bytes: the property's value, + /// or every element of a typed array of strings, whose error names the element + /// (`tags[2]`). A declared property the document leaves out is not checked, and a value + /// that is not a string holds no bytes to count: the JSON schema validation that + /// `DataContract::validate_document_properties` runs alongside refuses it. /// /// Versioned on `validate_max_bytes` in the document type method versions: `None` - /// before protocol version 14 returns an empty result, which keeps the shipped - /// structure validation that calls it inert. + /// before protocol version 14 returns an empty result, which keeps the shipped document + /// validation that calls it inert. #[cfg(feature = "validation")] fn validate_max_bytes_properties( &self, - properties: &BTreeMap, + properties: &Value, platform_version: &PlatformVersion, ) -> Result { match platform_version @@ -212,7 +173,7 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { #[cfg(feature = "validation")] fn validate_max_bytes_properties_v0( &self, - properties: &BTreeMap, + properties: &Value, ) -> SimpleConsensusValidationResult { let over = |path: String, value: &Value, max_bytes: u16| { let length = value.as_text()?.len(); @@ -224,23 +185,38 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { ) }) }; - let declared = self - .flattened_properties() - .iter() - .filter_map(|(path, property)| Some((path, property, property.max_bytes?))); - for (path, property, max_bytes) in declared { - let Ok(Some(value)) = properties.get_optional_at_path(path) else { - continue; - }; - let error = match (&property.property_type, value) { + for (path, property) in self.flattened_properties() { + // A lookup error (an intermediate that is not a map) reads as absent: the schema + // validation refuses that shape on its own + let error = match &property.property_type { + DocumentPropertyType::String(StringPropertySizes { + max_bytes: Some(max_bytes), + .. + }) => { + let Ok(Some(value)) = properties.get_optional_value_at_path(path) else { + continue; + }; + over(path.clone(), value, *max_bytes) + } // A typed array declares on its items: every element is bounded on its own - (DocumentPropertyType::TypedArray(_), Value::Array(elements)) => { + DocumentPropertyType::TypedArray(typed_array) => { + let DocumentPropertyType::String(StringPropertySizes { + max_bytes: Some(max_bytes), + .. + }) = typed_array.item_type.as_ref() + else { + continue; + }; + let Ok(Some(Value::Array(elements))) = + properties.get_optional_value_at_path(path) + else { + continue; + }; elements.iter().enumerate().find_map(|(index, element)| { - over(format!("{path}[{index}]"), element, max_bytes) + over(format!("{path}[{index}]"), element, *max_bytes) }) } - (DocumentPropertyType::TypedArray(_), _) => None, - _ => over(path.clone(), value, max_bytes), + _ => continue, }; if let Some(error) = error { return SimpleConsensusValidationResult::new_with_error(error.into()); @@ -249,65 +225,6 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { SimpleConsensusValidationResult::new() } - /// Checks every object `properties` supplies for an object property declaring - /// `sumOfProperties`: its integer members must add up to the declared total. An - /// object the document leaves out is not checked. Objects are not in the flattened - /// map, so the declarations are found by walking [`DocumentTypeV0Getters::properties`]. - /// - /// Meant to run after the JSON schema validation of `properties`, which already - /// established that every member is present and an integer. A member value that still - /// is not an `i128` counts as the largest one, so the sum cannot match by accident. - /// - /// Versioned on `validate_sum_of_properties` in the document type method versions: - /// `None` before protocol version 14 returns an empty result, which keeps the shipped - /// structure validation that calls it inert. - #[cfg(feature = "validation")] - fn validate_sum_of_properties( - &self, - properties: &BTreeMap, - platform_version: &PlatformVersion, - ) -> Result { - match platform_version - .dpp - .contract_versions - .document_type_versions - .methods - .validate_sum_of_properties - { - None => Ok(SimpleConsensusValidationResult::default()), - Some(0) => Ok(self.validate_sum_of_properties_v0(properties)), - Some(version) => Err(ProtocolError::UnknownVersionMismatch { - method: "validate_sum_of_properties".to_string(), - known_versions: vec![0], - received: version, - }), - } - } - - #[cfg(feature = "validation")] - fn validate_sum_of_properties_v0( - &self, - properties: &BTreeMap, - ) -> SimpleConsensusValidationResult { - let mut objects = vec![(None::, self.properties())]; - while let Some((prefix, level)) = objects.pop() { - for (name, property) in level { - let DocumentPropertyType::Object(members) = &property.property_type else { - continue; - }; - let path = match &prefix { - Some(prefix) => format!("{prefix}.{name}"), - None => name.clone(), - }; - if let Some(error) = sum_of_properties_violation(&path, property, properties) { - return SimpleConsensusValidationResult::new_with_error(error.into()); - } - objects.push((Some(path), members)); - } - } - SimpleConsensusValidationResult::new() - } - fn top_level_indices(&self) -> Vec<&IndexProperty> { self.indexes() .values() 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 6177ae7c4ca..1cfe620ab34 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 @@ -2909,43 +2909,37 @@ mod tests { } } - mod max_bytes_and_sum_of_properties { + mod max_bytes { use super::*; use crate::consensus::basic::BasicError; use crate::data_contract::config::DataContractConfig; use std::collections::BTreeMap; - /// A string `note` with `maxBytes` as given, and an object `split` of two - /// percentages with `sumOfProperties` as given. + /// A string `note` with `maxBytes` as `note_bound`, and a typed string array + /// `tags` whose `items` carry `maxBytes` as `tags_bound`. fn document_type( - max_bytes: Option, - sum_of_properties: Option, + note_bound: Option, + tags_bound: Option, platform_version: &PlatformVersion, ) -> DocumentType { let mut note = platform_value!({ "type": "string", "maxLength": 64, "position": 0 }); - if let Some(max_bytes) = max_bytes { + if let Some(max_bytes) = note_bound { note.insert("maxBytes".to_string(), max_bytes.into()) .expect("should insert maxBytes"); } - let mut split = platform_value!({ - "type": "object", - "position": 1, - "properties": { - "a": { "type": "integer", "minimum": 0, "maximum": 100, "position": 0 }, - "b": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 } - }, - "required": ["a", "b"], - "additionalProperties": false - }); - if let Some(total) = sum_of_properties { - split - .insert("sumOfProperties".to_string(), total.into()) - .expect("should insert sumOfProperties"); + let mut items = platform_value!({ "type": "string", "maxLength": 16 }); + if let Some(max_bytes) = tags_bound { + items + .insert("maxBytes".to_string(), max_bytes.into()) + .expect("should insert maxBytes"); } let schema = platform_value!({ "type": "object", - "properties": { "note": note, "split": split }, + "properties": { + "note": note, + "tags": { "type": "array", "maxItems": 4, "items": items, "position": 1 } + }, "signatureSecurityLevelRequirement": 0, "additionalProperties": false, }); @@ -2970,8 +2964,8 @@ mod tests { } fn compatibility( - old: (Option, Option), - new: (Option, Option), + old: (Option, Option), + new: (Option, Option), ) -> SimpleConsensusValidationResult { let platform_version = PlatformVersion::latest(); let old_document_type = document_type(old.0, old.1, platform_version); @@ -2983,17 +2977,18 @@ mod tests { } /// `maxBytes` moves like `maxLength`: every stored string still fits a - /// raised or dropped bound. + /// raised or dropped bound, on a property and on typed array elements. #[test] fn should_return_valid_result_when_max_bytes_is_raised_or_removed() { for (old_bound, new_bound) in [(Some(8), Some(16)), (Some(8), None), (Some(8), Some(8))] { - let result = compatibility((old_bound, None), (new_bound, None)); - assert!( - result.is_valid(), - "{old_bound:?} -> {new_bound:?}: {:?}", - result.errors - ); + for (old, new) in [ + ((old_bound, None), (new_bound, None)), + ((None, old_bound), (None, new_bound)), + ] { + let result = compatibility(old, new); + assert!(result.is_valid(), "{old:?} -> {new:?}: {:?}", result.errors); + } } } @@ -3001,39 +2996,29 @@ mod tests { #[test] fn should_return_invalid_result_when_max_bytes_is_added_or_lowered() { for (old_bound, new_bound) in [(None, Some(8)), (Some(16), Some(8))] { - let result = compatibility((old_bound, None), (new_bound, None)); - assert_matches!( - result.errors.as_slice(), - [ConsensusError::BasicError( - BasicError::IncompatibleDocumentTypeSchemaError(e) - )] if e.property_path() == "/properties/note/maxBytes", - "{old_bound:?} -> {new_bound:?}" - ); - } - } - - /// Readers of stored objects rely on the total, so it is frozen. - #[test] - fn should_return_invalid_result_when_sum_of_properties_is_added_removed_or_changed() { - for (old_total, new_total) in - [(None, Some(100)), (Some(100), None), (Some(100), Some(200))] - { - let result = compatibility((None, old_total), (None, new_total)); - assert_matches!( - result.errors.as_slice(), - [ConsensusError::BasicError( - BasicError::IncompatibleDocumentTypeSchemaError(e) - )] if e.property_path() == "/properties/split/sumOfProperties", - "{old_total:?} -> {new_total:?}" - ); + for (old, new, changed_path) in [ + ( + (old_bound, None), + (new_bound, None), + "/properties/note/maxBytes", + ), + ( + (None, old_bound), + (None, new_bound), + "/properties/tags/items/maxBytes", + ), + ] { + let result = compatibility(old, new); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == changed_path, + "{old:?} -> {new:?}" + ); + } } } - - #[test] - fn should_return_valid_result_when_sum_of_properties_is_unchanged() { - let result = compatibility((None, Some(100)), (None, Some(100))); - assert!(result.is_valid(), "{:?}", result.errors); - } } mod validate_byte_array_encoding { 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 abbc086c161..016a08d6522 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -163,10 +163,6 @@ pub(crate) mod property_names { /// Meta-schema v3+ (protocol version 14). See `apply_max_bytes` in /// `try_from_schema`. pub const MAX_BYTES: &str = "maxBytes"; - /// Property-level integer on an object property: the total its integer - /// members must add up to. Meta-schema v3+ (protocol version 14). See - /// `apply_sum_of_properties` in `try_from_schema`. - pub const SUM_OF_PROPERTIES: &str = "sumOfProperties"; pub const KEY_REQUIREMENTS: &str = "keyRequirements"; pub const PURPOSE: &str = "purpose"; pub const BOUND_TO: &str = "boundTo"; 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 819ec7da0a7..cd6509fa52e 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 @@ -80,22 +80,6 @@ pub struct DocumentProperty { /// and only on contracts parsed from protocol version 14 on. #[serde(skip_serializing_if = "Option::is_none")] pub encrypted_for: Option, - /// The most UTF-8 bytes the property's value may hold (`maxBytes`), on a - /// string property or, declared on its `items`, on every element of a typed - /// array of strings. `maxLength` counts characters, up to four bytes each. - /// `None` for every property that declares nothing, which is every property - /// parsed before protocol version 14. - #[serde(skip_serializing_if = "Option::is_none")] - pub max_bytes: Option, - /// The total the integer members of this object property must add up to - /// (`sumOfProperties`). Only ever `Some` on an object property, whose members - /// are then all required integers, and only on contracts parsed from protocol - /// version 14 on. Objects appear in [`DocumentTypeV0Getters::properties`], - /// not in the flattened map. - /// - /// [`DocumentTypeV0Getters::properties`]: crate::data_contract::document_type::accessors::DocumentTypeV0Getters::properties - #[serde(skip_serializing_if = "Option::is_none")] - pub sum_of_properties: Option, } /// What a `distinctFrom` identifier property must differ from. @@ -227,6 +211,12 @@ impl DocumentProperty { pub struct StringPropertySizes { pub min_length: Option, pub max_length: Option, + /// The most UTF-8 bytes a value may take (`maxBytes`, meta-schema v3, + /// protocol version 14). `max_length` counts characters, which are up to + /// four bytes each. `None` on every string that declares none, which is + /// every string parsed before protocol version 14. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_bytes: Option, } #[derive(Debug, PartialEq, Clone, Serialize)] @@ -1543,6 +1533,7 @@ impl DocumentPropertyType { "string" => Ok(DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, })), "byteArray" => Ok(DocumentPropertyType::ByteArray(ByteArrayPropertySizes { min_size: None, @@ -1751,6 +1742,15 @@ impl DocumentPropertyType { DocumentPropertyType::U8 => Ok(Some(1)), DocumentPropertyType::I8 => Ok(Some(1)), DocumentPropertyType::F64 => Ok(Some(8)), + // A declared `maxBytes` bounds the value directly, below the four bytes a + // character may take; only strings parsed from protocol version 14 carry one + DocumentPropertyType::String(StringPropertySizes { + max_length, + max_bytes: Some(max_bytes), + .. + }) => Ok(Some(max_length.map_or(*max_bytes, |length| { + length.saturating_mul(4).min(*max_bytes) + }))), DocumentPropertyType::String(sizes) => match sizes.max_length { None => Ok(Some(u16::MAX)), Some(size) => { @@ -1798,9 +1798,12 @@ impl DocumentPropertyType { DocumentPropertyType::U8 => Some(1), DocumentPropertyType::I8 => Some(1), DocumentPropertyType::F64 => Some(8), - DocumentPropertyType::String(sizes) => match sizes.max_length { - None => Some(16383), - Some(size) => Some(size), + // No more characters than `maxBytes` fit, since each takes at least a byte + DocumentPropertyType::String(sizes) => match (sizes.max_length, sizes.max_bytes) { + (None, None) => Some(16383), + (Some(size), None) => Some(size), + (None, Some(max_bytes)) => Some(max_bytes.min(16383)), + (Some(size), Some(max_bytes)) => Some(size.min(max_bytes)), }, DocumentPropertyType::ByteArray(sizes) => match sizes.max_size { None => Some(u16::MAX), @@ -4031,6 +4034,7 @@ impl DocumentPropertyType { "string" => DocumentPropertyType::String(StringPropertySizes { min_length: value_map.get_optional_integer(property_names::MIN_LENGTH)?, max_length: value_map.get_optional_integer(property_names::MAX_LENGTH)?, + max_bytes: None, }), "array" => { // Only handling bytearrays for v1 @@ -4206,6 +4210,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }), "string", ), @@ -4351,12 +4356,14 @@ mod tests { let no_min = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); assert_eq!(no_min.min_size(), Some(0)); let with_min = DocumentPropertyType::String(StringPropertySizes { min_length: Some(5), max_length: None, + max_bytes: None, }); assert_eq!(with_min.min_size(), Some(5)); } @@ -4397,8 +4404,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); sub_fields.insert( @@ -4410,8 +4415,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -4445,12 +4448,14 @@ mod tests { let no_max = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); assert_eq!(no_max.max_size(), Some(16383)); let with_max = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(100), + max_bytes: None, }); assert_eq!(with_max.max_size(), Some(100)); } @@ -4543,6 +4548,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: Some(10), max_length: None, + max_bytes: None, }); // protocol version > 8 => checked_mul(4) assert_eq!(s.min_byte_size(pv).unwrap(), Some(40)); @@ -4554,6 +4560,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); assert_eq!(s.min_byte_size(pv).unwrap(), Some(0)); } @@ -4564,6 +4571,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(100), + max_bytes: None, }); assert_eq!(s.max_byte_size(pv).unwrap(), Some(400)); } @@ -4574,6 +4582,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); assert_eq!(s.max_byte_size(pv).unwrap(), Some(u16::MAX)); } @@ -4639,6 +4648,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: Some(0), max_length: Some(100), + max_bytes: None, }); // min_size=0, max_size=100 => (0+100)/2 = 50 assert_eq!(s.middle_size(pv), Some(50)); @@ -4650,6 +4660,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: Some(0), max_length: Some(101), + max_bytes: None, }); // min_size=0, max_size=101 => ceil((0+101)/2) = 51 assert_eq!(s.middle_size_ceil(pv), Some(51)); @@ -4688,6 +4699,7 @@ mod tests { let s = DocumentPropertyType::String(StringPropertySizes { min_length: Some(1), max_length: Some(10), + max_bytes: None, }); // min_byte_size = 1*4 = 4, max_byte_size = 10*4 = 40 // ceil((4+40)/2) = 22 @@ -4721,6 +4733,7 @@ mod tests { assert!(!DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }) .is_integer()); } @@ -4911,6 +4924,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop .encode_value_for_tree_keys(&Value::Text("".to_string())) @@ -4923,6 +4937,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop .encode_value_for_tree_keys(&Value::Text("hello".to_string())) @@ -4984,6 +4999,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop.decode_value_for_tree_keys(&[0]).unwrap(); assert_eq!(result, Value::Text("".to_string())); @@ -4994,6 +5010,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop.decode_value_for_tree_keys(b"hello").unwrap(); assert_eq!(result, Value::Text("hello".to_string())); @@ -5175,6 +5192,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop .encode_value_with_size(Value::Text("hi".to_string()), true) @@ -5360,6 +5378,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop.encode_value_with_size(Value::U64(42), true); assert!(result.is_err()); @@ -5381,6 +5400,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let val = Value::Text("test".to_string()); let result = prop.encode_value_ref_with_size(&val, true).unwrap(); @@ -5555,6 +5575,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop.value_from_string("hello").unwrap(); assert_eq!(result, Value::Text("hello".to_string())); @@ -5565,6 +5586,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: Some(10), max_length: None, + max_bytes: None, }); let result = prop.value_from_string("hi"); assert!(result.is_err()); @@ -5575,6 +5597,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(3), + max_bytes: None, }); let result = prop.value_from_string("hello"); assert!(result.is_err()); @@ -5787,6 +5810,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); // varint 2^62 followed by two bytes of payload let mut data = vec![0xffu8, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x3f]; @@ -5821,6 +5845,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let mut data = vec![2u8]; data.extend_from_slice(b"ab"); @@ -5901,6 +5926,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let text = b"hello"; let mut data = text.len().encode_var_vec(); @@ -6130,6 +6156,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(20), + max_bytes: None, }), vec![Value::Text("".to_string()), Value::Text("über".to_string())], ), @@ -6223,6 +6250,7 @@ mod tests { &typed_array(DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(8), + max_bytes: None, })), &Value::Array(vec![Value::Text("ab".to_string())]), ); @@ -6375,6 +6403,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: Some(3), max_length: Some(40), + max_bytes: None, }), None, 200, @@ -6390,6 +6419,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }), None, 4, @@ -6402,6 +6432,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: Some(1), max_length: Some(5000), + max_bytes: None, }), Some(1024), 1024, @@ -6579,6 +6610,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: Some(5), max_length: Some(100), + max_bytes: None, }) ); } @@ -6942,6 +6974,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let decoded = roundtrip_encode_read(&prop, Value::Text("".to_string()), true); assert_eq!(decoded, Value::Text("".to_string())); @@ -6952,6 +6985,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(100), + max_bytes: None, }); let decoded = roundtrip_encode_read(&prop, Value::Text("hello world".to_string()), true); assert_eq!(decoded, Value::Text("hello world".to_string())); @@ -6962,6 +6996,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(1000), + max_bytes: None, }); let long_string = "a".repeat(500); let decoded = roundtrip_encode_read(&prop, Value::Text(long_string.clone()), true); @@ -7099,14 +7134,13 @@ mod tests { property_type: DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(100), + max_bytes: None, }), required: true, transient: false, required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); inner_fields.insert( @@ -7118,8 +7152,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7167,14 +7199,13 @@ mod tests { property_type: DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(100), + max_bytes: None, }), required: true, transient: false, required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7197,8 +7228,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); inner_fields.insert( @@ -7210,8 +7239,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7523,6 +7550,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let enc = prop .encode_value_for_tree_keys(&Value::Text("".to_string())) @@ -7538,6 +7566,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let enc = prop .encode_value_for_tree_keys(&Value::Text("test".to_string())) @@ -7647,8 +7676,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7686,8 +7713,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); sub_fields.insert( @@ -7699,8 +7724,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -7721,8 +7744,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); sub_fields.insert( @@ -7734,8 +7755,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -7914,6 +7933,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: Some(5), max_length: Some(10), + max_bytes: None, }); // Exercise several random draws for _ in 0..5 { @@ -8030,8 +8050,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); sub_fields.insert( @@ -8043,8 +8061,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -8072,6 +8088,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: Some(7), max_length: Some(20), + max_bytes: None, }); if let Value::Text(s) = prop.random_sub_filled_value(&mut rng) { assert_eq!(s.len(), 7); @@ -8107,8 +8124,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); sub_fields.insert( @@ -8120,8 +8135,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -8175,6 +8188,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: Some(1), max_length: Some(12), + max_bytes: None, }); if let Value::Text(s) = prop.random_filled_value(&mut rng) { assert_eq!(s.len(), 12); @@ -8210,8 +8224,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); sub_fields.insert( @@ -8223,8 +8235,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -8312,6 +8322,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: Some(3), max_length: Some(6), + max_bytes: None, }); for _ in 0..10 { let sz = prop.random_size(&mut rng); @@ -8444,6 +8455,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); // Valid varint length but invalid UTF-8 bytes let invalid_bytes = vec![0xFFu8, 0xFEu8, 0xFDu8]; @@ -8459,6 +8471,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); // varint says 10 bytes follow, but only provide 2 let mut data = 10usize.encode_var_vec(); @@ -8493,8 +8506,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -8526,8 +8537,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); // Second field is required @@ -8540,8 +8549,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -8619,6 +8626,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let result = prop.encode_value_ref_with_size(&Value::U64(1), true); assert!(result.is_err()); @@ -8654,14 +8662,13 @@ mod tests { property_type: DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: Some(100), + max_bytes: None, }), required: true, transient: false, required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -8682,8 +8689,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -8743,6 +8748,7 @@ mod tests { DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }) ); } @@ -8992,8 +8998,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -9127,6 +9131,7 @@ mod tests { let prop = DocumentPropertyType::String(StringPropertySizes { min_length: Some(3), max_length: Some(5), + max_bytes: None, }); // Boundary: exactly min and exactly max assert!(prop.value_from_string("abc").is_ok()); @@ -9259,8 +9264,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, }; let value = serde_json::to_value(&property).expect("serialization should succeed"); @@ -9284,8 +9287,6 @@ mod tests { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: 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 ad04ed5c139..3e85e064161 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 @@ -156,6 +156,7 @@ impl DocumentTypeV0 { DocumentPropertyType::String(StringPropertySizes { min_length, max_length, + max_bytes: None, }) } else if random_weight < field_weights.string_weight + field_weights.integer_weight { DocumentPropertyType::I64 @@ -200,8 +201,6 @@ impl DocumentTypeV0 { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, } }; @@ -552,6 +551,7 @@ impl DocumentTypeV0 { DocumentPropertyType::String(StringPropertySizes { min_length, max_length, + max_bytes: None, }) } else if random_weight < field_weights.string_weight + field_weights.integer_weight { DocumentPropertyType::I64 @@ -596,8 +596,6 @@ impl DocumentTypeV0 { required_since: None, distinct_from: None, encrypted_for: None, - max_bytes: None, - sum_of_properties: None, } }; diff --git a/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs b/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs index fa41db9badd..30f90b46e88 100644 --- a/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs @@ -1,5 +1,6 @@ use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; use crate::data_contract::document_type::DocumentType; use crate::consensus::basic::document::{ @@ -90,6 +91,14 @@ impl DataContract { )); } + // Added in place at protocol version 14, inert before it: the meta-schemas there + // refuse `maxBytes`, their parser ignores it (`apply_max_bytes` is `None`, so no + // string carries a byte cap) and `validate_max_bytes` is `None`, so the check returns + // an empty result. Computed before the JSON conversion consumes `value`; reported + // only when the schema validation passes, so a schema error keeps precedence. + let max_bytes_result = + document_type.validate_max_bytes_properties(&value, platform_version)?; + let json_value = match value.try_into_validating_json() { Ok(json_value) => json_value, Err(e) => { @@ -100,7 +109,7 @@ impl DataContract { }; // Compile json schema validator if it's not yet compiled - if !validator.is_compiled(platform_version)? { + let schema_result = if !validator.is_compiled(platform_version)? { // It is normal that we get a protocol error here, since the document type is coming // from the state let root_schema = DocumentType::enrich_with_base_schema( @@ -115,10 +124,15 @@ impl DataContract { .try_to_validating_json() .map_err(ProtocolError::ValueError)?; - validator.compile_and_validate(&root_json_schema, &json_value, platform_version) + validator.compile_and_validate(&root_json_schema, &json_value, platform_version)? } else { - validator.validate(&json_value, platform_version) + validator.validate(&json_value, platform_version)? + }; + if !schema_result.is_valid() { + return Ok(schema_result); } + + Ok(max_bytes_result) } #[inline(always)] 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 32fddd2ab67..384e1d86f11 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -55,10 +55,10 @@ use crate::consensus::basic::document::{ ContestedDocumentsTemporarilyNotAllowedError, DataContractNotPresentError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyMaxBytesExceededError, DocumentPropertyNotDistinctError, - DocumentPropertySumMismatchError, DocumentTransitionsAreAbsentError, - DuplicateDocumentTransitionsWithIdsError, DuplicateDocumentTransitionsWithIndicesError, - InconsistentCompoundIndexDataError, InvalidDocumentTransitionActionError, - InvalidDocumentTransitionIdError, InvalidDocumentTypeError, InvalidEncryptedPropertyShapeError, + DocumentTransitionsAreAbsentError, DuplicateDocumentTransitionsWithIdsError, + DuplicateDocumentTransitionsWithIndicesError, InconsistentCompoundIndexDataError, + InvalidDocumentTransitionActionError, InvalidDocumentTransitionIdError, + InvalidDocumentTypeError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingDataContractIdBasicError, MissingDocumentTransitionActionError, MissingDocumentTransitionTypeError, MissingDocumentTypeError, MissingPositionsInDocumentTypePropertiesError, NonceOutOfBoundsError, @@ -91,7 +91,9 @@ use crate::consensus::basic::identity::{ WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError, }; use crate::consensus::basic::invalid_identifier_error::InvalidIdentifierError; -use crate::consensus::basic::moderation_charter::ModerationCharterMalformedFieldError; +use crate::consensus::basic::moderation_charter::{ + ModerationCharterMalformedFieldError, ModerationCharterRewardSplitNotOneHundredError, +}; use crate::consensus::basic::state_transition::{ FeeStrategyDuplicateError, FeeStrategyEmptyError, FeeStrategyIndexOutOfBoundsError, FeeStrategyTooManyStepsError, InputBelowMinimumError, InputOutputBalanceMismatchError, @@ -819,14 +821,12 @@ pub enum BasicError { #[error(transparent)] ModerationCharterMalformedFieldError(ModerationCharterMalformedFieldError), - // A string over the `maxBytes` its property declares (protocol version 14). #[error(transparent)] - DocumentPropertyMaxBytesExceededError(DocumentPropertyMaxBytesExceededError), + ModerationCharterRewardSplitNotOneHundredError(ModerationCharterRewardSplitNotOneHundredError), - // An object whose integer properties miss the `sumOfProperties` it declares (protocol - // version 14). + // A string over the `maxBytes` its property declares (protocol version 14). #[error(transparent)] - DocumentPropertySumMismatchError(DocumentPropertySumMismatchError), + DocumentPropertyMaxBytesExceededError(DocumentPropertyMaxBytesExceededError), } impl From for ConsensusError { @@ -946,17 +946,16 @@ mod tests { )), 196 ); - // A string over its property's `maxBytes` (protocol version 14). assert_eq!( - discriminant_of(BasicError::DocumentPropertyMaxBytesExceededError( - DocumentPropertyMaxBytesExceededError::new("description".to_string(), 4097, 4096) + discriminant_of(BasicError::ModerationCharterRewardSplitNotOneHundredError( + ModerationCharterRewardSplitNotOneHundredError::new(10, 40, 40) )), 197 ); - // An object missing its `sumOfProperties` (protocol version 14): the tail of the enum. + // A string over its property's `maxBytes` (protocol version 14): the tail of the enum. assert_eq!( - discriminant_of(BasicError::DocumentPropertySumMismatchError( - DocumentPropertySumMismatchError::new("rewardSplit".to_string(), 100, 90) + discriminant_of(BasicError::DocumentPropertyMaxBytesExceededError( + DocumentPropertyMaxBytesExceededError::new("description".to_string(), 4097, 4096) )), 198 ); diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/document_property_sum_mismatch_error.rs b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_sum_mismatch_error.rs deleted file mode 100644 index be935b3be2d..00000000000 --- a/packages/rs-dpp/src/errors/consensus/basic/document/document_property_sum_mismatch_error.rs +++ /dev/null @@ -1,67 +0,0 @@ -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 document supplied an object whose integer properties do not add up to the -/// `sumOfProperties` its type declares on that object. -#[derive( - Error, - Debug, - Clone, - PartialEq, - Eq, - Encode, - Decode, - PlatformSerialize, - PlatformDeserializeTrusted, - PlatformDeserializeUntrusted, - DecodeUntrusted, -)] -#[error("The properties of {property} sum to {actual_sum}, but must sum to {expected_sum}")] -#[platform_serialize(unversioned)] -pub struct DocumentPropertySumMismatchError { - /* - - DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION - - */ - property: String, - expected_sum: i64, - actual_sum: i64, -} - -impl DocumentPropertySumMismatchError { - pub fn new(property: String, expected_sum: i64, actual_sum: i64) -> Self { - Self { - property, - expected_sum, - actual_sum, - } - } - - /// The dotted path of the object that declares the sum. - pub fn property(&self) -> &str { - &self.property - } - - /// The declared sum. - pub fn expected_sum(&self) -> i64 { - self.expected_sum - } - - /// What the object's properties add up to, clamped to the `i64` range. - pub fn actual_sum(&self) -> i64 { - self.actual_sum - } -} - -impl From for ConsensusError { - fn from(err: DocumentPropertySumMismatchError) -> Self { - Self::BasicError(BasicError::DocumentPropertySumMismatchError(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 6bceee66546..e1364b29a39 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs @@ -4,7 +4,6 @@ mod document_creation_not_allowed_error; mod document_field_max_size_exceeded_error; mod document_property_max_bytes_exceeded_error; mod document_property_not_distinct_error; -mod document_property_sum_mismatch_error; mod document_transitions_are_absent_error; mod duplicate_document_transitions_with_ids_error; mod duplicate_document_transitions_with_indices_error; @@ -27,7 +26,6 @@ pub use document_creation_not_allowed_error::*; pub use document_field_max_size_exceeded_error::*; pub use document_property_max_bytes_exceeded_error::*; pub use document_property_not_distinct_error::*; -pub use document_property_sum_mismatch_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/basic/moderation_charter/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs index db1535a2870..d6829315dfd 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/mod.rs @@ -1,3 +1,5 @@ mod moderation_charter_malformed_field_error; +mod moderation_charter_reward_split_not_one_hundred_error; pub use moderation_charter_malformed_field_error::*; +pub use moderation_charter_reward_split_not_one_hundred_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_reward_split_not_one_hundred_error.rs b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_reward_split_not_one_hundred_error.rs new file mode 100644 index 00000000000..757f8fcf071 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/moderation_charter/moderation_charter_reward_split_not_one_hundred_error.rs @@ -0,0 +1,67 @@ +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; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "The moderation charter's reward split of {leader}% to the leader, {equal}% equally and {actions}% by action count sums to {}%, it must sum to 100%", + *leader as u16 + *equal as u16 + *actions as u16 +)] +#[platform_serialize(unversioned)] +pub struct ModerationCharterRewardSplitNotOneHundredError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + leader: u8, + equal: u8, + actions: u8, +} + +impl ModerationCharterRewardSplitNotOneHundredError { + pub fn new(leader: u8, equal: u8, actions: u8) -> Self { + Self { + leader, + equal, + actions, + } + } + + pub fn leader(&self) -> u8 { + self.leader + } + + pub fn equal(&self) -> u8 { + self.equal + } + + pub fn actions(&self) -> u8 { + self.actions + } +} + +impl From for ConsensusError { + fn from(err: ModerationCharterRewardSplitNotOneHundredError) -> Self { + Self::BasicError(BasicError::ModerationCharterRewardSplitNotOneHundredError( + err, + )) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index d57b321d7f1..b4d29937acd 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -169,7 +169,6 @@ impl ErrorWithCode for BasicError { Self::DocumentPropertyNotDistinctError(_) => 10419, Self::InvalidEncryptedPropertyShapeError(_) => 10420, Self::DocumentPropertyMaxBytesExceededError(_) => 10421, - Self::DocumentPropertySumMismatchError(_) => 10422, // Token Errors: 10450-10499 Self::InvalidTokenIdError(_) => 10450, @@ -277,6 +276,7 @@ impl ErrorWithCode for BasicError { // Moderation Team Errors: 11000-11099 Self::ModerationCharterMalformedFieldError(_) => 11000, + Self::ModerationCharterRewardSplitNotOneHundredError(_) => 11001, } } } diff --git a/packages/rs-dpp/src/moderation_charter/mod.rs b/packages/rs-dpp/src/moderation_charter/mod.rs index 0fa0d57e5f2..0ff87d5a770 100644 --- a/packages/rs-dpp/src/moderation_charter/mod.rs +++ b/packages/rs-dpp/src/moderation_charter/mod.rs @@ -20,14 +20,19 @@ //! The team that acts is the leader plus [`ElectedCharter::active_members`]: the elected //! members and the additions, less the removals. //! -//! The schema carries every rule through its keywords (references, lookups, key requirements, -//! `distinctFrom`, and `sumOfProperties` and `maxBytes` for the proposal's reward split and -//! description), so every document write checks them. [`SubmittedCharter`] and -//! [`ElectedCharter`] read the documents' properties. Nothing here reads state. +//! The schema carries almost every rule through its keywords (references, lookups, key +//! requirements, `distinctFrom`, and `maxBytes` for the description's byte cap). What it +//! cannot say is here: [`SubmittedCharter`] and [`ElectedCharter`] read the documents' +//! properties, and [`validate_submitted_charter`] adds the proposal's one pure-data rule, which +//! the path that seats a team runs. Nothing here reads state. + +mod v0; use crate::consensus::basic::moderation_charter::ModerationCharterMalformedFieldError; -use crate::validation::ConsensusValidationResult; +use crate::validation::{ConsensusValidationResult, SimpleConsensusValidationResult}; +use crate::ProtocolError; use platform_value::{Identifier, IdentifierBytes32, Value, ValueMap}; +use platform_version::version::PlatformVersion; use std::collections::{BTreeMap, BTreeSet}; /// The id of the moderation charters system contract, `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`. @@ -73,8 +78,7 @@ pub mod property_names { pub const MEMBER_ID: &str = "memberId"; } -/// How a team splits every claim of the moderators pot: three percentages summing to 100, -/// which the schema's `sumOfProperties` holds every stored proposal to. +/// How a team splits every claim of the moderators pot: three percentages summing to 100. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ModerationCharterRewardSplit { /// The share of the leader. @@ -86,6 +90,13 @@ pub struct ModerationCharterRewardSplit { pub actions: u8, } +impl ModerationCharterRewardSplit { + /// The three shares summed, as declared. + pub fn total(&self) -> u16 { + self.leader as u16 + self.equal as u16 + self.actions as u16 + } +} + /// A proposal to moderate a contract, as read out of a `submittedCharter` document. Its owner /// is the leader. #[derive(Debug, Clone, PartialEq, Eq)] @@ -167,8 +178,8 @@ impl SubmittedCharter { /// Reads a proposal out of the properties of a `submittedCharter` document. /// /// The result carries a consensus error, never a proposal, when a property is missing or - /// of the wrong type. The proposal's own rules (the split sums to 100, the description - /// fits 4096 bytes) are the schema's, checked when the document is written. + /// of the wrong type. The proposal's own rules are checked by + /// [`SubmittedCharter::validate`]; [`validate_submitted_charter`] does both. pub fn from_document_properties( properties: &BTreeMap, ) -> ConsensusValidationResult { @@ -265,6 +276,32 @@ impl SubmittedCharter { } properties } + + /// Checks the proposal's own rule, the one the schema cannot express: the reward split + /// sums to 100. The description's 4096-byte cap is the schema's `maxBytes`, checked + /// wherever the document is validated. + pub fn validate( + &self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .validation + .data_contract + .validate_moderation_charter + { + Some(0) => Ok(self.validate_v0()), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "SubmittedCharter::validate".to_string(), + known_versions: vec![0], + received: version, + }), + None => Err(ProtocolError::NotSupported(format!( + "moderation charters do not exist at protocol version {}", + platform_version.protocol_version + ))), + } + } } impl ElectedCharter { @@ -339,5 +376,27 @@ impl ElectedCharter { } } +/// Reads a proposal out of the properties of a `submittedCharter` document and checks its own +/// rules. The result carries the proposal when it passes, and the first error it fails on +/// otherwise. +pub fn validate_submitted_charter( + properties: &BTreeMap, + platform_version: &PlatformVersion, +) -> Result, ProtocolError> { + let result = SubmittedCharter::from_document_properties(properties); + if !result.is_valid_with_data() { + return Ok(ConsensusValidationResult::new_with_errors(result.errors)); + } + let charter = result.into_data()?; + let validation = charter.validate(platform_version)?; + if validation.is_valid() { + Ok(ConsensusValidationResult::new_with_data(charter)) + } else { + Ok(ConsensusValidationResult::new_with_errors( + validation.errors, + )) + } +} + #[cfg(test)] mod tests; diff --git a/packages/rs-dpp/src/moderation_charter/tests.rs b/packages/rs-dpp/src/moderation_charter/tests.rs index 378bfd5dbeb..f9a73d0534a 100644 --- a/packages/rs-dpp/src/moderation_charter/tests.rs +++ b/packages/rs-dpp/src/moderation_charter/tests.rs @@ -1,10 +1,11 @@ use super::{ - property_names, ElectedCharter, ModerationCharterRewardSplit, SubmittedCharter, - FULL_MODERATORS_SHARE, + property_names, validate_submitted_charter, ElectedCharter, ModerationCharterRewardSplit, + SubmittedCharter, FULL_MODERATORS_SHARE, }; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use platform_value::{Identifier, Value}; +use platform_version::version::PlatformVersion; fn proposal() -> SubmittedCharter { SubmittedCharter { @@ -20,6 +21,15 @@ fn proposal() -> SubmittedCharter { } } +fn first_basic_error( + result: &crate::validation::ConsensusValidationResult, +) -> &BasicError { + match result.errors.first() { + Some(ConsensusError::BasicError(error)) => error, + other => panic!("expected a basic error, got {other:?}"), + } +} + #[test] fn should_round_trip_a_proposal_through_its_document_properties() { let proposal = proposal(); @@ -53,14 +63,51 @@ fn should_read_a_zero_share_as_zero() { } #[test] -fn should_read_a_proposal_without_reasons() { +fn should_accept_a_proposal_without_reasons() { let proposal = SubmittedCharter { reasons: vec![], ..proposal() }; - let read = SubmittedCharter::from_document_properties(&proposal.to_document_properties()); - assert!(read.is_valid_with_data(), "{:?}", read.errors); - assert_eq!(read.into_data().expect("data"), proposal); + let result = validate_submitted_charter( + &proposal.to_document_properties(), + PlatformVersion::latest(), + ) + .expect("validation executes"); + assert!(result.is_valid_with_data()); +} + +#[test] +fn should_accept_a_valid_proposal() { + let result = validate_submitted_charter( + &proposal().to_document_properties(), + PlatformVersion::latest(), + ) + .expect("validation executes"); + assert!(result.is_valid_with_data(), "{:?}", result.errors); +} + +#[test] +fn should_refuse_a_reward_split_that_does_not_sum_to_one_hundred() { + for (leader, equal, actions) in [(10, 40, 40), (50, 50, 1), (0, 0, 0)] { + let proposal = SubmittedCharter { + reward_split: ModerationCharterRewardSplit { + leader, + equal, + actions, + }, + ..proposal() + }; + let result = validate_submitted_charter( + &proposal.to_document_properties(), + PlatformVersion::latest(), + ) + .expect("validation executes"); + assert!(matches!( + first_basic_error(&result), + BasicError::ModerationCharterRewardSplitNotOneHundredError(e) + if (e.leader(), e.equal(), e.actions()) == (leader, equal, actions) + )); + } } #[test] @@ -98,6 +145,12 @@ fn should_refuse_a_missing_or_mistyped_property() { )); } +#[test] +fn should_refuse_to_validate_below_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("version 13"); + assert!(proposal().validate(platform_version).is_err()); +} + #[test] fn should_round_trip_an_elected_charter_through_its_document_properties() { let charter = ElectedCharter { diff --git a/packages/rs-dpp/src/moderation_charter/v0/mod.rs b/packages/rs-dpp/src/moderation_charter/v0/mod.rs new file mode 100644 index 00000000000..e02189725ae --- /dev/null +++ b/packages/rs-dpp/src/moderation_charter/v0/mod.rs @@ -0,0 +1,21 @@ +use crate::consensus::basic::moderation_charter::ModerationCharterRewardSplitNotOneHundredError; +use crate::moderation_charter::SubmittedCharter; +use crate::validation::SimpleConsensusValidationResult; + +impl SubmittedCharter { + #[inline(always)] + pub(super) fn validate_v0(&self) -> SimpleConsensusValidationResult { + if self.reward_split.total() != 100 { + return SimpleConsensusValidationResult::new_with_error( + ModerationCharterRewardSplitNotOneHundredError::new( + self.reward_split.leader, + self.reward_split.equal, + self.reward_split.actions, + ) + .into(), + ); + } + + SimpleConsensusValidationResult::new() + } +} diff --git a/packages/rs-dpp/src/system_data_contracts.rs b/packages/rs-dpp/src/system_data_contracts.rs index b978c8aec57..0ff60876166 100644 --- a/packages/rs-dpp/src/system_data_contracts.rs +++ b/packages/rs-dpp/src/system_data_contracts.rs @@ -315,19 +315,19 @@ mod moderation_charters_tests { use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; - use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; use crate::data_contract::document_type::random_document::CreateRandomDocument; use crate::data_contract::document_type::{ ContestedIndexResolution, ContractReferenceModeration, DistinctFrom, DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, EncryptedForRecipient, EncryptionScheme, KeyReferenceIdentityProperty, LookupKeySource, PropertyReference, + StringPropertySizes, }; use crate::data_contract::validate_document::DataContractDocumentValidationMethodsV0; use crate::document::{Document, DocumentV0Getters, DocumentV0Setters}; use crate::identity::Purpose; use crate::moderation_charter::{ - property_names, ElectedCharter, ModerationCharterRewardSplit, SubmittedCharter, - ADDED_MODERATOR_DOCUMENT_TYPE_NAME, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + property_names, validate_submitted_charter, ElectedCharter, ModerationCharterRewardSplit, + SubmittedCharter, ADDED_MODERATOR_DOCUMENT_TYPE_NAME, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, JOIN_REQUEST_DOCUMENT_TYPE_NAME, MODERATION_CHARTERS_CONTRACT_ID, REASON_DOCUMENT_TYPE_NAME, REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, @@ -703,135 +703,13 @@ mod moderation_charters_tests { vec![], "the encoded proposal passes the schema" ); - assert_eq!( - keyword_errors(&contract, &document), - vec![], - "the encoded proposal meets its split and description bounds" - ); - let read = SubmittedCharter::from_document_properties(document.properties()) + let read = validate_submitted_charter(document.properties(), PlatformVersion::latest()) + .expect("validation executes") .into_data() - .expect("the proposal reads back"); + .expect("the proposal is valid"); assert_eq!(read, proposal); } - /// The errors of the two write-time keyword checks a proposal is held to, in the order - /// document create and replace run them. - fn keyword_errors(contract: &DataContract, document: &Document) -> Vec { - let proposal_type = document_type(contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME); - let platform_version = PlatformVersion::latest(); - let mut errors = proposal_type - .validate_max_bytes_properties(document.properties(), platform_version) - .expect("maxBytes validation executes") - .errors; - errors.extend( - proposal_type - .validate_sum_of_properties(document.properties(), platform_version) - .expect("sumOfProperties validation executes") - .errors, - ); - errors - } - - /// The proposal's two rules that plain JSON Schema cannot express are the schema's own - /// keywords: the reward split's `sumOfProperties` and the description's `maxBytes`. - #[test] - fn should_declare_the_split_total_and_the_description_byte_cap() { - let contract = contract(); - let proposal_type = document_type(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME); - assert_eq!( - proposal_type - .flattened_properties() - .get(property_names::DESCRIPTION) - .expect("the description") - .max_bytes, - Some(4096) - ); - assert_eq!( - proposal_type - .properties() - .get(property_names::REWARD_SPLIT) - .expect("the reward split") - .sum_of_properties, - Some(100) - ); - } - - #[test] - fn should_refuse_a_reward_split_that_does_not_sum_to_one_hundred() { - let contract = contract(); - for (leader, equal, actions) in [(10, 40, 40), (50, 50, 1), (0, 0, 0)] { - let proposal = SubmittedCharter { - reward_split: ModerationCharterRewardSplit { - leader, - equal, - actions, - }, - ..proposal() - }; - let document = document_with( - &contract, - SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, - proposal.to_document_properties(), - ); - assert_eq!( - schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document), - vec![], - "each share alone is within 0 to 100" - ); - let expected_sum = i64::from(leader) + i64::from(equal) + i64::from(actions); - assert!( - matches!( - keyword_errors(&contract, &document).as_slice(), - [ConsensusError::BasicError(BasicError::DocumentPropertySumMismatchError(e))] - if e.property() == property_names::REWARD_SPLIT - && e.expected_sum() == 100 - && e.actual_sum() == expected_sum - ), - "{leader}/{equal}/{actions}" - ); - } - } - - #[test] - fn should_refuse_a_description_over_4096_bytes_within_4096_characters() { - let contract = contract(); - let with_description = |description: String| { - document_with( - &contract, - SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, - SubmittedCharter { - description, - ..proposal() - } - .to_document_properties(), - ) - }; - - // At the cap, in one-byte and in two-byte characters - for description in ["a".repeat(4096), "é".repeat(2048)] { - let document = with_description(description); - assert_eq!( - schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document), - vec![] - ); - assert_eq!(keyword_errors(&contract, &document), vec![]); - } - - // 2049 characters pass maxLength, but their 4098 bytes do not pass maxBytes - let document = with_description("é".repeat(2049)); - assert_eq!( - schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document), - vec![] - ); - assert!(matches!( - keyword_errors(&contract, &document).as_slice(), - [ConsensusError::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(e))] - if e.property() == property_names::DESCRIPTION - && e.byte_length() == 4098 - && e.max_bytes() == 4096 - )); - } - #[test] fn should_round_trip_an_elected_charter_through_the_system_contract() { let contract = contract(); @@ -938,6 +816,57 @@ mod moderation_charters_tests { } } + /// The description's cap is 4096 bytes, not just 4096 characters: the schema's `maxBytes`, + /// which document validation checks after the JSON schema, so clients refuse an oversized + /// description before they broadcast it. + #[test] + fn should_refuse_a_description_over_4096_bytes_within_4096_characters() { + let contract = contract(); + assert_eq!( + document_type(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME) + .flattened_properties() + .get(property_names::DESCRIPTION) + .expect("the description") + .property_type, + DocumentPropertyType::String(StringPropertySizes { + min_length: Some(1), + max_length: Some(4096), + max_bytes: Some(4096), + }) + ); + let with_description = |description: String| { + document_with( + &contract, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + SubmittedCharter { + description, + ..proposal() + } + .to_document_properties(), + ) + }; + + // At the cap, in one-byte and in two-byte characters + for description in ["a".repeat(4096), "é".repeat(2048)] { + let document = with_description(description); + assert_eq!( + schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document), + vec![] + ); + } + + // 2049 characters are within maxLength, but their 4098 bytes are over maxBytes + let document = with_description("é".repeat(2049)); + assert!(matches!( + schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document) + .as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(e))] + if e.property() == property_names::DESCRIPTION + && e.byte_length() == 4098 + && e.max_bytes() == 4096 + )); + } + /// After the election the leader adds members from the same join requests and removes /// members, and a member leaves on its own: each change is written once per member, and /// only by the one entitled to it. 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 e347e153cac..50123948016 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 @@ -171,25 +171,8 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans // The schema validation above established every supplied value is a byte array // where the type says so; what is left is whether an `encryptedFor` property has // the shape its scheme produces, which is all consensus can tell about a ciphertext. - let result = document_type - .validate_encrypted_property_shapes(self.data(), platform_version) - .map_err(Error::Protocol)?; - if !result.is_valid() { - return Ok(result); - } - - // The schema validation above made every string a string and every summed object - // an object of integers; what is left is a string's UTF-8 length against its - // `maxBytes` (`maxLength` counts characters) and an object's members against its - // `sumOfProperties`. - let result = document_type - .validate_max_bytes_properties(self.data(), platform_version) - .map_err(Error::Protocol)?; - if !result.is_valid() { - return Ok(result); - } document_type - .validate_sum_of_properties(self.data(), platform_version) + .validate_encrypted_property_shapes(self.data(), 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_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 7e69e18f295..c62af0551ee 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 @@ -77,30 +77,8 @@ impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTra // an `encryptedFor` property the replace supplies must have the shape its scheme // produces, which is all consensus can tell about a ciphertext; the schema // validation above already made every such value a byte array. - let result = document_type - .validate_encrypted_property_shapes(self.data(), 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 `maxBytes` and `sumOfProperties`, - // their parser ignores both (`apply_max_bytes` and `apply_sum_of_properties` are - // `None`, so no parsed property carries a declaration), and `validate_max_bytes` - // and `validate_sum_of_properties` are `None` there, so both calls return an - // empty result. From 14, a string the replace supplies must fit its property's - // `maxBytes` in UTF-8, and an object must add up to its `sumOfProperties`; the - // schema validation above already made every such value a string or an object - // of integers. - let result = document_type - .validate_max_bytes_properties(self.data(), platform_version) - .map_err(Error::Protocol)?; - if !result.is_valid() { - return Ok(result); - } document_type - .validate_sum_of_properties(self.data(), platform_version) + .validate_encrypted_property_shapes(self.data(), platform_version) .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes_and_sum.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes.rs similarity index 74% rename from packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes_and_sum.rs rename to packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes.rs index 24c025cb07e..ef11df52322 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes_and_sum.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/max_bytes.rs @@ -1,13 +1,12 @@ -//! End-to-end coverage for the `maxBytes` and `sumOfProperties` property -//! keywords (protocol version 14): a string bounded by its UTF-8 length, and -//! an object whose integer members must add up to a declared total. Both are -//! checked on every create and replace, after the JSON schema validation; a -//! value that breaks either is consensus-rejected and leaves the stored -//! document untouched. +//! End-to-end coverage for the `maxBytes` property keyword (protocol version +//! 14): a string, or each string element of a typed array, bounded by its +//! UTF-8 length. The document validation checks it after the JSON schema on +//! every create and replace; a longer value is consensus-rejected and leaves +//! the stored document untouched. use super::*; -mod max_bytes_and_sum_tests { +mod max_bytes_tests { use super::*; use crate::execution::validation::state_transition::batch::action_validation::document::document_replace_transition_action::DocumentReplaceTransitionActionValidation; use crate::rpc::core::MockCoreRPCLike; @@ -29,7 +28,7 @@ mod max_bytes_and_sum_tests { use simple_signer::signer::SimpleSigner; /// A mutable `profile` type: a `bio` of at most 64 characters and 16 bytes, - /// and a `split` of two percentages that must add up to 100. + /// and `tags`, up to four strings of at most 8 bytes each. fn profile_schema() -> Value { platform_value!({ "type": "object", @@ -41,25 +40,24 @@ mod max_bytes_and_sum_tests { "maxBytes": 16, "position": 0 }, - "split": { - "type": "object", - "position": 1, - "properties": { - "leader": { "type": "integer", "minimum": 0, "maximum": 100, "position": 0 }, - "rest": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 } - }, - "required": ["leader", "rest"], - "additionalProperties": false, - "sumOfProperties": 100 + "tags": { + "type": "array", + "maxItems": 4, + "items": { "type": "string", "maxLength": 16, "maxBytes": 8 }, + "position": 1 } }, - "required": ["bio", "split"], + "required": ["bio", "tags"], "additionalProperties": false }) } - fn split(leader: u8, rest: u8) -> Value { - platform_value!({ "leader": leader, "rest": rest }) + fn tags(tags: &[&str]) -> Value { + Value::Array( + tags.iter() + .map(|tag| Value::Text(tag.to_string())) + .collect(), + ) } /// One identity and one contract whose `profile` type is the one above. @@ -121,7 +119,7 @@ mod max_bytes_and_sum_tests { } } - async fn create(&mut self, bio: &str, split: Value) -> StateTransitionExecutionResult { + async fn create(&mut self, bio: &str, tags: Value) -> StateTransitionExecutionResult { let platform_version = PlatformVersion::latest(); let profile_type = self .contract @@ -143,7 +141,7 @@ mod max_bytes_and_sum_tests { .set_id_for_creation(profile_type, &entropy.0, self.next_nonce, platform_version) .expect("expected to set the document id"); profile.set("bio", Value::Text(bio.to_string())); - profile.set("split", split); + profile.set("tags", tags); let transition = BatchTransition::new_document_creation_transition_from_document( profile, @@ -244,36 +242,30 @@ mod max_bytes_and_sum_tests { } } - fn expect_max_bytes_error(result: StateTransitionExecutionResult, byte_length: u32) { + fn expect_max_bytes_error( + result: StateTransitionExecutionResult, + property: &str, + byte_length: u32, + max_bytes: u16, + ) { let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { panic!("expected a paid consensus error, got {result:?}"); }; assert_matches!( error, ConsensusError::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(e)) - if e.property() == "bio" && e.byte_length() == byte_length && e.max_bytes() == 16 - ); - } - - fn expect_sum_error(result: StateTransitionExecutionResult, actual_sum: i64) { - let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { - panic!("expected a paid consensus error, got {result:?}"); - }; - assert_matches!( - error, - ConsensusError::BasicError(BasicError::DocumentPropertySumMismatchError(e)) - if e.property() == "split" - && e.expected_sum() == 100 - && e.actual_sum() == actual_sum + if e.property() == property + && e.byte_length() == byte_length + && e.max_bytes() == max_bytes ); } #[tokio::test] - async fn should_create_a_document_within_both_bounds() { + async fn should_create_a_document_within_its_byte_caps() { let mut fixture = ProfileFixture::new(); - // Sixteen bytes in eight two-byte characters, and a split of exactly 100 - let result = fixture.create("éééééééé", split(40, 60)).await; + // Sixteen bytes in eight two-byte characters, and tags of eight bytes at most + let result = fixture.create("éééééééé", tags(&["éééé", "short"])).await; assert_matches!( result, @@ -288,27 +280,30 @@ mod max_bytes_and_sum_tests { async fn should_refuse_a_string_over_its_max_bytes_within_its_max_length() { let mut fixture = ProfileFixture::new(); - let result = fixture.create("ééééééééé", split(40, 60)).await; + let result = fixture.create("ééééééééé", tags(&[])).await; - expect_max_bytes_error(result, 18); + expect_max_bytes_error(result, "bio", 18, 16); assert!(fixture.stored_profiles().is_empty()); } + /// Each element of a typed array of strings is bounded on its own, and the + /// refusal names the element. #[tokio::test] - async fn should_refuse_an_object_whose_members_miss_their_sum() { + async fn should_refuse_a_typed_array_element_over_its_max_bytes() { let mut fixture = ProfileFixture::new(); - for (leader, rest) in [(40, 50), (0, 0), (100, 100)] { - let result = fixture.create("hello", split(leader, rest)).await; - expect_sum_error(result, i64::from(leader) + i64::from(rest)); - } + let result = fixture + .create("hello", tags(&["ok", "fine", "ééééé"])) + .await; + + expect_max_bytes_error(result, "tags[2]", 10, 8); assert!(fixture.stored_profiles().is_empty()); } #[tokio::test] - async fn should_refuse_a_replace_that_breaks_either_bound() { + async fn should_refuse_a_replace_over_a_byte_cap() { let mut fixture = ProfileFixture::new(); - let result = fixture.create("hello", split(40, 60)).await; + let result = fixture.create("hello", tags(&["a"])).await; assert_matches!( result, StateTransitionExecutionResult::SuccessfulExecution { .. } @@ -320,23 +315,23 @@ mod max_bytes_and_sum_tests { profile.set("bio", Value::Text("ééééééééé".to_string())) }) .await; - expect_max_bytes_error(result, 18); + expect_max_bytes_error(result, "bio", 18, 16); let result = fixture - .replace(&stored, |profile| profile.set("split", split(10, 10))) + .replace(&stored, |profile| profile.set("tags", tags(&["ééééé"]))) .await; - expect_sum_error(result, 20); + expect_max_bytes_error(result, "tags[0]", 10, 8); let after = fixture.stored_profiles(); assert_eq!(after.len(), 1); assert_eq!(after[0].get("bio"), stored.get("bio")); - assert_eq!(after[0].get("split"), stored.get("split")); + assert_eq!(after[0].get("tags"), stored.get("tags")); - // A replace within both bounds still goes through + // A replace within both caps still goes through let result = fixture .replace(&stored, |profile| { profile.set("bio", Value::Text("hé".to_string())); - profile.set("split", split(0, 100)); + profile.set("tags", tags(&["éééé"])); }) .await; assert_matches!( @@ -345,14 +340,13 @@ mod max_bytes_and_sum_tests { ); } - /// The replace structure dispatcher on both sides of the gate: structure - /// generation 0 gained both checks in place, so at protocol version 13 it - /// must still accept the action (no property parsed there carries either - /// keyword and both dpp gates are `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. + /// The replace structure dispatcher on both sides of the gate: the document + /// validation it runs gained the check in place, so at protocol version 13 + /// it must still accept the action (the dpp gate is `None` there), 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_check_either_bound_on_replace_before_protocol_version_14() { + fn should_not_check_the_byte_cap_on_replace_before_protocol_version_14() { let platform_version = PlatformVersion::latest(); let fixture = ProfileFixture::new(); let owner_id = fixture.identity.id(); @@ -370,7 +364,7 @@ mod max_bytes_and_sum_tests { .expect("expected to fetch the contract"); let contract_fetch_info = contract_fetch_info.expect("the contract is in state"); - let action = |bio: &str, leader: u8, rest: u8| { + let action = |bio: &str| { DocumentReplaceTransitionAction::V0(DocumentReplaceTransitionActionV0 { base: DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { id: Identifier::from([0xAA; 32]), @@ -394,7 +388,7 @@ mod max_bytes_and_sum_tests { transferred_at_core_block_height: None, data: BTreeMap::from([ ("bio".to_string(), Value::Text(bio.to_string())), - ("split".to_string(), split(leader, rest)), + ("tags".to_string(), tags(&[])), ]), changed_data_fields: BTreeSet::new(), added_data_fields: BTreeSet::new(), @@ -406,18 +400,16 @@ mod max_bytes_and_sum_tests { let platform_version_13 = PlatformVersion::get(13).expect("platform version 13 should exist"); - for (bio, leader, rest) in [("ééééééééé", 40, 60), ("hello", 10, 10)] { - let before = action(bio, leader, rest) - .validate_structure(owner_id, platform_version_13) - .expect("structure validation should run"); - assert!( - before.is_valid(), - "structure generation 0 must check neither bound before 14: {:?}", - before.errors - ); - } + let before = action("ééééééééé") + .validate_structure(owner_id, platform_version_13) + .expect("structure validation should run"); + assert!( + before.is_valid(), + "the document validation must not check the byte cap before 14: {:?}", + before.errors + ); - let at = action("ééééééééé", 40, 60) + let at = action("ééééééééé") .validate_structure(owner_id, platform_version) .expect("structure validation should run"); assert_matches!( @@ -425,13 +417,5 @@ mod max_bytes_and_sum_tests { [ConsensusError::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(e))] if e.property() == "bio" && e.byte_length() == 18 ); - let at = action("hello", 10, 10) - .validate_structure(owner_id, platform_version) - .expect("structure validation should run"); - assert_matches!( - at.errors.as_slice(), - [ConsensusError::BasicError(BasicError::DocumentPropertySumMismatchError(e))] - if e.property() == "split" && e.actual_sum() == 20 - ); } } 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 8357686d4a9..a2bde5a7d88 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 @@ -12,7 +12,7 @@ mod index_only; mod keep_history; mod list_element_reference; mod lookup_reference; -mod max_bytes_and_sum; +mod max_bytes; mod nft; mod owner_balance_proof; mod owner_reference; diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 0d231e38e71..acc2b667abd 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -1519,6 +1519,7 @@ fn meta_field_property_type(field: &str) -> Option { dpp::data_contract::document_type::StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }, )), _ => None, @@ -3527,6 +3528,7 @@ mod tests { let ty = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let ops = allowed_ops_for_type(&ty); assert!(ops.contains(&super::StartsWith)); @@ -3596,6 +3598,7 @@ mod tests { let str_ty = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); assert!(WhereOperator::StartsWith.value_shape_ok(&Value::Text("abc".into()), &str_ty)); assert!(!WhereOperator::StartsWith.value_shape_ok(&Value::I64(1), &str_ty)); @@ -3623,6 +3626,7 @@ mod tests { let str_ty = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); assert!(WhereOperator::LessThan.value_shape_ok(&Value::Text("a".into()), &str_ty)); assert!(!WhereOperator::LessThan.value_shape_ok(&Value::I64(1), &str_ty)); @@ -4204,6 +4208,7 @@ mod tests { let str_ty = DocumentPropertyType::String(StringPropertySizes { min_length: None, max_length: None, + max_bytes: None, }); let good = Value::Array(vec![Value::Text("aaa".into()), Value::Text("zzz".into())]); 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 a6683f7df75..f884fe9e38c 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 @@ -1548,48 +1548,6 @@ pub static KEYWORD_COMPATIBILITY_RULES: Lazy = Laz ], }, ), - // `sumOfProperties` (the total an object's integer properties add up - // to) is frozen like `distinctFrom`: readers of stored documents rely on - // the total, so adding, removing or changing it is refused. - ( - "sumOfProperties", - 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!({ "sumOfProperties": 100 }), - Some(JsonSchemaChange::Add(AddOperation { - path: "/sumOfProperties".to_string(), - value: json!(100), - })), - ) - .into(), - ( - json!({ "sumOfProperties": 100 }), - json!({}), - Some(JsonSchemaChange::Remove(RemoveOperation { - path: "/sumOfProperties".to_string(), - })), - ) - .into(), - ( - json!({ "sumOfProperties": 100 }), - json!({ "sumOfProperties": 1000 }), - Some(JsonSchemaChange::Replace(ReplaceOperation { - path: "/sumOfProperties".to_string(), - value: json!(1000), - })), - ) - .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 de669754d4f..0c01018dbe8 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 @@ -87,13 +87,8 @@ pub struct DocumentTypeMethodVersions { /// `validate_max_bytes_properties`: refuses a document supplying a string longer in /// UTF-8 bytes than the `maxBytes` its property declares. `None` on versions that /// predate the keyword: the method returns an empty result there, so the shipped - /// replace structure validation that calls it is inert. + /// document validation that calls it is inert. pub validate_max_bytes: OptionalFeatureVersion, - /// `validate_sum_of_properties`: refuses a document supplying an object whose integer - /// properties do not add up to the `sumOfProperties` it declares. `None` on versions - /// that predate the keyword: the method returns an empty result there, so the shipped - /// replace structure validation that calls it is inert. - pub validate_sum_of_properties: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] @@ -123,16 +118,11 @@ pub struct DocumentTypeSchemaVersions { /// `None` on versions that predate the keyword: they ignore it entirely, /// exactly as they parsed before it existed. pub apply_encrypted_for: OptionalFeatureVersion, - /// Parses the `maxBytes` keyword (the most UTF-8 bytes a string property, or - /// each string element of a typed array, may hold) onto the property. `None` - /// on versions that predate the keyword: they ignore it entirely, exactly as - /// they parsed before it existed. + /// Folds the `maxBytes` keyword (the most UTF-8 bytes a string property, or + /// each string element of a typed array, may hold) into the string's + /// `StringPropertySizes`. `None` on versions that predate the keyword: they + /// ignore it entirely, exactly as they parsed before it existed. pub apply_max_bytes: OptionalFeatureVersion, - /// Parses the `sumOfProperties` keyword (the total an object property's - /// integer members must add up to) onto the object, and checks its members - /// at contract registration. `None` on versions that predate the keyword: - /// they ignore it entirely, exactly as they parsed before it existed. - pub apply_sum_of_properties: 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 4dbc485640c..1489be6adc6 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 @@ -48,7 +48,6 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { apply_distinct_from: None, apply_encrypted_for: None, apply_max_bytes: None, - apply_sum_of_properties: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -70,7 +69,6 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { validate_distinct_from: None, validate_encrypted_property_shapes: None, validate_max_bytes: None, - validate_sum_of_properties: 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 6095770cad7..e857af08eae 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 @@ -48,7 +48,6 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { apply_distinct_from: None, apply_encrypted_for: None, apply_max_bytes: None, - apply_sum_of_properties: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -70,7 +69,6 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { validate_distinct_from: None, validate_encrypted_property_shapes: None, validate_max_bytes: None, - validate_sum_of_properties: 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 6d0a2c353bf..dd3bcc8f42d 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 @@ -50,7 +50,6 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { apply_distinct_from: None, apply_encrypted_for: None, apply_max_bytes: None, - apply_sum_of_properties: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -72,7 +71,6 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { validate_distinct_from: None, validate_encrypted_property_shapes: None, validate_max_bytes: None, - validate_sum_of_properties: 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 ff95d4e5518..8eedce707de 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 @@ -50,7 +50,6 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { apply_distinct_from: None, apply_encrypted_for: None, apply_max_bytes: None, - apply_sum_of_properties: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -72,7 +71,6 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { validate_distinct_from: None, validate_encrypted_property_shapes: None, validate_max_bytes: None, - validate_sum_of_properties: 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 7bf9887f642..225ba03bf6b 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 @@ -52,7 +52,6 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { apply_distinct_from: None, apply_encrypted_for: None, apply_max_bytes: None, - apply_sum_of_properties: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -74,7 +73,6 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { validate_distinct_from: None, validate_encrypted_property_shapes: None, validate_max_bytes: None, - validate_sum_of_properties: 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 88d03a83ca1..59df9b91696 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 @@ -84,8 +84,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { 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 apply_encrypted_for: Some(0), // changed: the meta-schema v3 `encryptedFor` keyword (recipient, key ids and scheme of a byte array property's ciphertext) is parsed onto the property and its named properties are checked at registration; None before this version means the keyword is ignored, as it was before it existed - apply_max_bytes: Some(0), // changed: the meta-schema v3 `maxBytes` keyword (the most UTF-8 bytes a string property, or each string element of a typed array, may hold) is parsed onto the property; None before this version means the keyword is ignored, as it was before it existed - apply_sum_of_properties: Some(0), // changed: the meta-schema v3 `sumOfProperties` keyword (the total an object property's integer members must add up to) is parsed onto the object and its members are checked at registration; None before this version means the keyword is ignored, as it was before it existed + apply_max_bytes: Some(0), // changed: the meta-schema v3 `maxBytes` keyword (the most UTF-8 bytes a string property, or each string element of a typed array, may hold) is folded into the string's `StringPropertySizes`; 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, @@ -118,7 +117,6 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { 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 validate_encrypted_property_shapes: Some(0), // changed: refuses an `encryptedFor` property whose bytes are not the shape its scheme produces; None before this version returns an empty result validate_max_bytes: Some(0), // changed: refuses a string longer in UTF-8 bytes than its property's `maxBytes` (DocumentPropertyMaxBytesExceededError, 10421); None before this version returns an empty result - validate_sum_of_properties: Some(0), // changed: refuses an object whose integer members miss its `sumOfProperties` (DocumentPropertySumMismatchError, 10422); None before this version returns an empty result }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs index 52c521e4309..75c4ae4182c 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs @@ -45,6 +45,9 @@ pub struct DataContractValidationVersions { /// version 14: version 1 distribution rules and once-per-identity claims are rejected as /// unsupported, matching older software that can not decode them. pub validate_once_per_identity_distribution: OptionalFeatureVersion, + /// `ModerationCharter::validate`, the pure-data rules of a moderation charter. `None` below + /// protocol version 14, where the moderation charters system contract does not exist. + pub validate_moderation_charter: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs index 622b080100a..8cf7f3672b0 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs @@ -21,6 +21,7 @@ pub const DPP_VALIDATION_VERSIONS_V1: DPPValidationVersions = DPPValidationVersi validate_token_config_groups_exist: 0, validate_localizations: 0, validate_once_per_identity_distribution: None, + validate_moderation_charter: None, }, document_type: DocumentTypeValidationVersions { validate_update: 0, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs index 4d23c704c0d..01c2a794c6f 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs @@ -21,6 +21,7 @@ pub const DPP_VALIDATION_VERSIONS_V2: DPPValidationVersions = DPPValidationVersi validate_token_config_groups_exist: 0, validate_localizations: 0, validate_once_per_identity_distribution: None, + validate_moderation_charter: None, }, document_type: DocumentTypeValidationVersions { validate_update: 0, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs index a1053ad10b9..931bda8f923 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs @@ -22,6 +22,7 @@ pub const DPP_VALIDATION_VERSIONS_V3: DPPValidationVersions = DPPValidationVersi validate_token_config_groups_exist: 0, validate_localizations: 0, validate_once_per_identity_distribution: None, + validate_moderation_charter: None, }, document_type: DocumentTypeValidationVersions { validate_update: 0, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs index 90ca0d7155b..823fa9a7237 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs @@ -23,6 +23,9 @@ pub const DPP_VALIDATION_VERSIONS_V5: DPPValidationVersions = DPPValidationVersi data_contract: DataContractValidationVersions { validate_config_update: 2, validate_once_per_identity_distribution: Some(0), + // Moderation charters: the charter system contract and the pure-data rules of a + // charter exist from this protocol version on. + validate_moderation_charter: Some(0), ..DPP_VALIDATION_VERSIONS_V4.data_contract }, document_type: DocumentTypeValidationVersions { 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 520473c8ab9..e7802f8a616 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 @@ -228,12 +228,12 @@ 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, // changed: v1 also cross-checks the prefunded voting balance against the contested index, refuses a `distinctFrom` identifier property equal to the value it must differ from, a string over its `maxBytes` and an object missing its `sumOfProperties` + 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, // unchanged: v0 gained the `distinctFrom`, `encryptedFor` shape, `maxBytes` and `sumOfProperties` refusals in place, inert before this version + 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, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 54392cb7393..2f9f81d3334 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -967,43 +967,37 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// with `"resolution": 1`, the masternode vote without a Lock choice of /// item 23, so an elected charter create opens or joins the contest for /// its target. `SYSTEM_DATA_CONTRACT_VERSIONS_V3` registers it -/// (`moderation_charters: 1`). A proposal's reward split sums to 100 and -/// its description fits 4096 bytes through the schema's own -/// `sumOfProperties` and `maxBytes` (item 38), so every create checks -/// them; `ModerationCharterMalformedFieldError` (basic error 11000) is what -/// the readers report on a document they cannot read. Genesis registers it on chains born at this +/// (`moderation_charters: 1`), and +/// `DPP_VALIDATION_VERSIONS_V5.validate_moderation_charter = Some(0)` turns +/// on the pure-data rule the seating path will run on a proposal: its +/// reward split sums to 100 (basic errors 11000 and 11001). Its description +/// fits 4096 bytes through the schema's own `maxBytes` (item 38), which +/// every document validation checks. Genesis registers it on chains born at this /// version (`create_genesis_state` v1, behind the app-connect branch), /// `transition_to_version_14` inserts it on upgrade, and the Drive system /// contract cache serves it from this version /// (`MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION`). Seating a /// winning team comes in a later pull request. /// -/// 38. **`maxBytes` on strings and `sumOfProperties` on objects**: two -/// property keywords for bounds plain JSON Schema cannot count. -/// `maxBytes` (1 to 65535, no lower than `minLength`) goes on a string -/// property, or on the `items` of a typed array of strings where it bounds -/// every element, and caps the value's UTF-8 length: `maxLength` counts -/// characters, which are up to four bytes each. `sumOfProperties` goes on -/// an object property and is the total its members must add up to; every -/// member must be an integer, required without `requiredSince` and not -/// transient, and the total must be reachable from the members' `minimum` -/// and `maximum`, all checked at registration. Meta-schema v3 admits both, -/// `apply_max_bytes` 0 and `apply_sum_of_properties` 0 parse them onto -/// `DocumentProperty::max_bytes` and `DocumentProperty::sum_of_properties` -/// (the object's entry in `properties()`, since objects are not in the -/// flattened map). Document create structure validation 1 and replace -/// structure validation 0 (extended in place, inert before this version, -/// where no parsed property carries either keyword) call -/// `validate_max_bytes_properties` (`validate_max_bytes` 0) and -/// `validate_sum_of_properties` (`validate_sum_of_properties` 0), `None` -/// before this version, which refuse a longer string with +/// 38. **`maxBytes` on strings**: a property keyword for the bound plain JSON +/// Schema cannot count, the most UTF-8 bytes a string may take +/// (`maxLength` counts characters, which are up to four bytes each). It +/// goes on a string property, or on the `items` of a typed array of +/// strings where it bounds every element, and is 1 to 65535 and no lower +/// than `minLength`, checked at registration. Meta-schema v3 admits it and +/// `apply_max_bytes` 0 folds it into `StringPropertySizes::max_bytes`, so +/// `max_byte_size`, `max_size` and random documents respect it. The +/// document validation (`DataContract::validate_document_properties` 0, +/// extended in place, inert before this version) calls +/// `validate_max_bytes_properties` (`validate_max_bytes` 0, `None` before +/// this version) after the JSON schema, on every create and replace and in +/// every client that validates a document, and refuses a longer value with /// `DocumentPropertyMaxBytesExceededError` (10421, naming the element as -/// `tags[2]` for an item) and an object adding up to anything else with -/// `DocumentPropertySumMismatchError` (10422). On update `maxBytes` moves -/// like `maxLength` (it may be raised or removed, not added or lowered) and -/// `sumOfProperties` is frozen. The moderation charters contract (item 37) -/// declares both, which replace the charter-specific rules and their -/// errors 11001 and 11002 and `SystemLimits::max_moderation_charter_description_length`. +/// `tags[2]` for an item). On update it moves like `maxLength`: it may be +/// raised or removed, not added or lowered. The moderation charters +/// contract (item 37) declares it on the proposal's description, replacing +/// the charter-specific description check, its error 11002 and +/// `SystemLimits::max_moderation_charter_description_length`. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 135dea31b19..0266de44da6 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, DocumentPropertyMaxBytesExceededError, DocumentPropertyNotDistinctError, DocumentPropertySumMismatchError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; +use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyMaxBytesExceededError, DocumentPropertyNotDistinctError, InvalidEncryptedPropertyShapeError, 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; @@ -95,7 +95,9 @@ use dpp::consensus::basic::contract_moderation::{ DocumentActionFeesWithoutModerationError, InvalidContractModerationConfigError, }; -use dpp::consensus::basic::moderation_charter::ModerationCharterMalformedFieldError; +use dpp::consensus::basic::moderation_charter::{ + ModerationCharterMalformedFieldError, ModerationCharterRewardSplitNotOneHundredError, +}; use dpp::consensus::state::contract_moderation::{ ContractFeeClaimNotAllowedError, ContractFeesAlreadyClaimedThisEpochError, ContractFeesNothingToClaimError, ContractModeratedDocumentTypeNotYetUsableError, @@ -1280,6 +1282,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::ModerationCharterMalformedFieldError(e) => { generic_consensus_error!(ModerationCharterMalformedFieldError, e).into() } + BasicError::ModerationCharterRewardSplitNotOneHundredError(e) => { + generic_consensus_error!(ModerationCharterRewardSplitNotOneHundredError, e).into() + } BasicError::InvalidContractModerationReasonDocumentsError(e) => { generic_consensus_error!(InvalidContractModerationReasonDocumentsError, e).into() } @@ -1289,9 +1294,6 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::DocumentPropertyMaxBytesExceededError(e) => { generic_consensus_error!(DocumentPropertyMaxBytesExceededError, e).into() } - BasicError::DocumentPropertySumMismatchError(e) => { - generic_consensus_error!(DocumentPropertySumMismatchError, e).into() - } } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index d670e883131..c7d3e0f3bfc 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -201,9 +201,9 @@ impl DocumentEncryptionErrorCodeWasm { } } -/// Consensus error codes emitted by the `maxBytes` and `sumOfProperties` -/// checks, which run from protocol version 14 onward on every document create -/// and replace. +/// Consensus error codes emitted by the `maxBytes` check, which runs from +/// protocol version 14 onward wherever a document is validated, on every +/// create and replace included. /// /// Branch on an error's `code` against these instead of matching its /// message: @@ -212,29 +212,25 @@ impl DocumentEncryptionErrorCodeWasm { /// try { /// await sdk.documents.create({ document, identityKey, signer }); /// } catch (e) { -/// if (e.code === DocumentPropertyBoundErrorCode.MaxBytesExceeded) { +/// if (e.code === DocumentMaxBytesErrorCode.MaxBytesExceeded) { /// // a string is longer in UTF-8 bytes than its property's maxBytes /// } /// } /// ``` -#[wasm_bindgen(js_name = "DocumentPropertyBoundErrorCode")] +#[wasm_bindgen(js_name = "DocumentMaxBytesErrorCode")] #[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum DocumentPropertyBoundErrorCodeWasm { +pub enum DocumentMaxBytesErrorCodeWasm { /// A string, or an element of a typed array of strings, is longer in /// UTF-8 bytes than the `maxBytes` its property declares. `maxLength` /// counts characters, which are up to four bytes each. MaxBytesExceeded = 10421, - /// An object's integer members do not add up to the `sumOfProperties` - /// it declares. - SumMismatch = 10422, } -impl DocumentPropertyBoundErrorCodeWasm { - /// The bound error a code names, or `None` for any other code. +impl DocumentMaxBytesErrorCodeWasm { + /// The maxBytes error a code names, or `None` for any other code. fn from_code(code: u32) -> Option { match code { 10421 => Some(Self::MaxBytesExceeded), - 10422 => Some(Self::SumMismatch), _ => None, } } @@ -293,11 +289,10 @@ impl ConsensusErrorWasm { pub fn document_encryption_error_code(&self) -> Option { DocumentEncryptionErrorCodeWasm::from_code(self.0.code()) } - /// The `maxBytes` or `sumOfProperties` error this is, or `undefined` when - /// it is not code 10421 or 10422. - #[wasm_bindgen(getter = "documentPropertyBoundErrorCode")] - pub fn document_property_bound_error_code(&self) -> Option { - DocumentPropertyBoundErrorCodeWasm::from_code(self.0.code()) + /// The maxBytes error this is, or `undefined` when it is not code 10421. + #[wasm_bindgen(getter = "documentMaxBytesErrorCode")] + pub fn document_max_bytes_error_code(&self) -> Option { + DocumentMaxBytesErrorCodeWasm::from_code(self.0.code()) } } @@ -420,41 +415,27 @@ mod tests { assert_eq!(DocumentEncryptionErrorCodeWasm::from_code(10419), None); } - /// Built from the real DPP errors rather than code literals, like the + /// Built from the real DPP error rather than a code literal, like the /// encryption test above. #[test] - fn property_bound_error_codes_mirror_the_dpp_errors() { + fn should_mirror_the_dpp_error_in_the_max_bytes_error_code() { use dpp::consensus::basic::BasicError; - use dpp::consensus::basic::document::{ - DocumentPropertyMaxBytesExceededError, DocumentPropertySumMismatchError, - }; + use dpp::consensus::basic::document::DocumentPropertyMaxBytesExceededError; - for (error, expected) in [ - ( - ConsensusError::from(BasicError::DocumentPropertyMaxBytesExceededError( - DocumentPropertyMaxBytesExceededError::new( - "description".to_string(), - 4098, - 4096, - ), - )), - DocumentPropertyBoundErrorCodeWasm::MaxBytesExceeded, - ), - ( - ConsensusError::from(BasicError::DocumentPropertySumMismatchError( - DocumentPropertySumMismatchError::new("rewardSplit".to_string(), 100, 90), - )), - DocumentPropertyBoundErrorCodeWasm::SumMismatch, - ), - ] { - assert_eq!(expected as u32, error.code()); - assert_eq!( - ConsensusErrorWasm(error).document_property_bound_error_code(), - Some(expected) - ); - } + let error = ConsensusError::from(BasicError::DocumentPropertyMaxBytesExceededError( + DocumentPropertyMaxBytesExceededError::new("description".to_string(), 4098, 4096), + )); + + assert_eq!( + DocumentMaxBytesErrorCodeWasm::MaxBytesExceeded as u32, + error.code() + ); + assert_eq!( + ConsensusErrorWasm(error).document_max_bytes_error_code(), + Some(DocumentMaxBytesErrorCodeWasm::MaxBytesExceeded) + ); // A neighbouring code is not claimed. - assert_eq!(DocumentPropertyBoundErrorCodeWasm::from_code(10420), None); + assert_eq!(DocumentMaxBytesErrorCodeWasm::from_code(10420), None); } /// The six reference-validation errors, paired with the JS enum variant diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index 6d15eca3f81..33c96db7fe8 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -499,10 +499,10 @@ describe('DataContract — refersTo declarations (v14)', () => { additionalProperties: false, }, }; - const buildListElementContract = (schemas: object) => new wasm.DataContract({ + const buildListElementContract = (documentSchemas: object) => new wasm.DataContract({ ownerId, identityNonce: BigInt(2), - schemas, + schemas: documentSchemas, definitions: null, fullValidation: true, platformVersion: new PlatformVersion(14),