From 1e2d990633518007902e17c591b90d2d424cb2d2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 01:32:23 +0700 Subject: [PATCH 01/20] feat(platform)!: multiple choice decision proposals, voted on as a contest among documents (PV15) The decision proposals contract gains a multiple choice proposal: a `multipleChoiceProposal` document is the question and each `choice` document entered for it is an answer. The choices of one proposal contend on a contested unique index keyed by the proposal's id, so masternodes vote among them with the existing contest machinery. A proposal states whether it is `open`: anyone enters choices for an open one, and only its owner for one that is not (a propertyConstraints rule on `choice` reading the proposal). The owner may edit the question's text for two hours after creating it; `open` never changes. So that an identity can enter as many choices as it wants, a contested index may declare `"resolution": 2` (`ContestedIndexResolution::MasternodeVoteOnDocuments`), admitted where `parse_contested_documents_resolution` is set (protocol version 15). It is resolution 0 (vote, abstain or lock) with each document a contender of its own: the contest keeps a contender under its document id instead of its owner's id, a vote towards a contender names the document id, the stored result names the winning document, and the winner is stored under the owner its document names. The generations that write, check and award a contender branch on that resolution in place. No contract registered before protocol version 15 can declare it, so every earlier version behaves as it did. Co-Authored-By: Claude Fable 5.2 --- book/src/contract-keywords/contested.md | 10 +- book/src/data-model/contested-documents.md | 24 + docs/protocol/decision-proposals.md | 119 +- .../decision-proposals-contract/README.md | 65 +- ...decision-proposals-contract-documents.json | 199 +++ .../decision-proposals-contract/src/lib.rs | 160 +- .../decision-proposals-contract/src/v1/mod.rs | 52 + .../unit/decisionProposalsContract.spec.js | 200 ++- .../try_from_schema/common/mod.rs | 11 + .../try_from_schema/native_schema/indexes.rs | 4 +- .../index/integer_range_parse_tests.rs | 1 + .../data_contract/document_type/index/mod.rs | 170 +- .../serialized_version/v0/index.rs | 8 + .../methods/registration_cost/v2/mod.rs | 10 +- .../create_genesis_state/v1/mod.rs | 9 +- .../v0/mod.rs | 9 +- .../voting/award_document_to_winner/v0/mod.rs | 19 +- .../state_v1/mod.rs | 15 + .../token/shielded/nullifier_batch_guard.rs | 20 +- .../state_transitions/masternode_vote/mod.rs | 3 + .../multiple_choice_proposal_tests.rs | 1412 +++++++++++++++++ packages/rs-drive/grovedb-structure.json | 6 +- .../v1/mod.rs | 34 +- .../rs-drive/src/drive/votes/structure.rs | 37 +- .../dpp_versions/dpp_contract_versions/mod.rs | 7 + .../dpp_versions/dpp_contract_versions/v1.rs | 1 + .../dpp_versions/dpp_contract_versions/v2.rs | 1 + .../dpp_versions/dpp_contract_versions/v3.rs | 1 + .../dpp_versions/dpp_contract_versions/v4.rs | 1 + .../dpp_versions/dpp_contract_versions/v5.rs | 1 + .../dpp_versions/dpp_contract_versions/v6.rs | 1 + .../dpp_versions/dpp_contract_versions/v7.rs | 5 + .../rs-platform-version/src/version/v15.rs | 16 + 33 files changed, 2559 insertions(+), 72 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs diff --git a/book/src/contract-keywords/contested.md b/book/src/contract-keywords/contested.md index 831f70d9dda..59fe914d062 100644 --- a/book/src/contract-keywords/contested.md +++ b/book/src/contract-keywords/contested.md @@ -6,7 +6,7 @@ A unique index normally works first come, first served: the first document to ta |---|---| | **Where** | a unique index | | **Value** | object: `resolution` (required), `fieldMatches`, `description` | -| **Since** | protocol version 1; `resolution: 1` from protocol version 14 | +| **Since** | protocol version 1; `resolution: 1` from protocol version 14; `resolution: 2` from protocol version 15 | | **On update** | Fixed, like every index (`DataContractInvalidIndexDefinitionUpdateError`, 10217) | | **Errors** | `DocumentContestNotPaidForError` (40114), `DocumentContestCurrentlyLockedError` (40110), `DocumentContestNotJoinableError` (40111), `DocumentContestIdentityAlreadyContestantError` (40112), `DocumentContestIndexMismatchError` (40118), `DocumentContestNotRequiredError` (40119), `DocumentContestMaximumContendersReachedError` (40141); `DuplicateUniqueIndexError` (40105) for values outside the contested range | @@ -38,7 +38,7 @@ A name is unique under its parent domain. A label of 3 to 19 characters made of | Key | Value | What it does | |---|---|---| -| `resolution` | `0` or `1`, required | How the contest is decided. `0`: masternodes vote for a contender, abstain, or **lock** the value so nobody gets it. `1` (from protocol version 14): masternodes vote for a contender or abstain; there is no lock, so the contest always ends with a winner. | +| `resolution` | `0`, `1` or `2`, required | How the contest is decided. `0`: masternodes vote for a contender, abstain, or **lock** the value so nobody gets it. `1` (from protocol version 14): masternodes vote for a contender or abstain; there is no lock, so the contest always ends with a winner. `2` (from protocol version 15): as `0`, but each document is a contender of its own, named by its document id, so an identity may enter several. | | `fieldMatches` | array of at least one `{ "field", "regexPattern" }` | Which values are contested. `field` is a property path of the document; `regexPattern` a regular expression its value must match. Each is 1 to 256 characters. | | `description` | string, 1 to 256 characters | A note for readers. Consensus does not read it. | @@ -54,11 +54,11 @@ A name is unique under its parent domain. A label of 3 to 19 characters made of - with `DocumentContestIndexMismatchError` (40118, from protocol version 14) when the pair names another index than the contested one its values match. - with `DocumentContestNotRequiredError` (40119, from protocol version 14) when the document is not contested but carries a prefunded balance. - with `DocumentContestNotJoinableError` (40111) when the contest's join window (one week on mainnet) has passed. -- with `DocumentContestIdentityAlreadyContestantError` (40112) when its owner is already a contender. +- with `DocumentContestIdentityAlreadyContestantError` (40112) when its owner is already a contender, under `resolution: 0` and `1`. Under `resolution: 2` an identity may enter as many documents as it wants, each paying the fund. - with `DocumentContestMaximumContendersReachedError` (40141, from protocol version 14) when the contest already holds 1,000 contenders. - with `DocumentContestCurrentlyLockedError` (40110) when an earlier contest for these values ended locked. -**The vote.** Masternodes and evonodes vote with `MasternodeVote` transitions for the length of the poll (two weeks on mainnet). Under `resolution: 0` the contender with the most votes wins unless the lock tally beats it, and the contest always runs its full length, even with one contender. Under `resolution: 1` a contender always wins, and a contest that still has a single contender when its join window closes is awarded to it at once. From protocol version 14 a tie goes to the earliest contender. +**The vote.** Masternodes and evonodes vote with `MasternodeVote` transitions for the length of the poll (two weeks on mainnet). Under `resolution: 0` the contender with the most votes wins unless the lock tally beats it, and the contest always runs its full length, even with one contender. Under `resolution: 1` a contender always wins, and a contest that still has a single contender when its join window closes is awarded to it at once. Under `resolution: 2` the vote runs as under `0`, lock included, but among documents: a vote towards a contender names a document id where the other resolutions name the owner's identity id, and the result names the winning document. From protocol version 14 a tie goes to the earliest contender. **After the contest.** The winning document is stored and held by the index like any unique-index document; the other contenders' documents are removed. The contest's result stays readable. @@ -69,7 +69,7 @@ The fund, the windows, the tallies and the special case of moderation elections - The index is `unique: true`. A contested index that is not unique is refused (`InvalidContractStructure`, 10231). - The document type's documents cannot be replaced: `documentsMutable: false` (`ContestedUniqueIndexOnMutableDocumentTypeError`, 10248). They may still be transferred and sold, as DPNS domains are. - A document type has at most one contested index, and no other unique index beside it (`ContestedUniqueIndexWithUniqueIndexError`, 10249). -- `resolution` is present and is `0` or `1`; `1` is refused before protocol version 14. +- `resolution` is present and is `0`, `1` or `2`; `1` is refused before protocol version 14 and `2` before protocol version 15. - `fieldMatches`, when present, holds at least one entry, and each `regexPattern` is a valid regular expression (`RegexError`, 10247). - From protocol version 15, the contest is decided by the index values, so that two documents with the same values are both contested or both ordinary: every `fieldMatches` field is a property of the index, and a match on a string property that may be empty (no `minLength` of at least 1) treats `""` and `"\0"` alike, since the two share one index key (`InvalidContractStructure`, 10231). Otherwise an ordinary document could take the values of a contested one, and the contest would have nothing left to award. A contract registered earlier keeps its contest, but can no longer be updated, since an update is checked like a registration; while such a contest is live, a create outside it with the same index values is refused (`DuplicateUniqueIndexError`). - From protocol version 15, every property of the index is a top-level property of the document: no property path such as `records.identity` and no system property such as `$ownerId` (`InvalidContractStructure`, 10231). A contested create finds the contest by reading each index value from the document's top-level properties, where such a property is never found, so no contested create could open or join the contest. A contract registered earlier keeps such an index, but can no longer be updated. diff --git a/book/src/data-model/contested-documents.md b/book/src/data-model/contested-documents.md index d9f10347eba..e0d705b805d 100644 --- a/book/src/data-model/contested-documents.md +++ b/book/src/data-model/contested-documents.md @@ -79,6 +79,30 @@ contest ends then. `getVotePollsByEndDate` shows whichever end applies. The moderation charters contract uses this resolution to elect moderation teams. +## Resolution 2: masternode vote among documents + +`ContestedIndexResolution::MasternodeVoteOnDocuments`, admitted where +`parse_contested_documents_resolution` is set (protocol version 15). The choices are a contender, +abstain, or lock, and the contest runs and ends exactly as under resolution 0. What differs is +what a contender is. Under resolutions 0 and 1 a contender is an identity: the contest keeps its +document under the identity's id, so an identity enters one document, and a vote, a tally and the +result name the identity. Under resolution 2 a contender is a document: the contest keeps it +under its document id, so an identity may enter several, each paying the fund and counting +towards `max_contenders_per_contest`, and a second document from the same identity is not +refused (no `DocumentContestIdentityAlreadyContestantError`). + +Everything that names a contender then carries a document id where it carries an identity id +under the other resolutions: the identifier of a `TowardsIdentity` vote choice, a contender's +identifier in `getContestedResourceVoteState`, the contender asked for in +`getContestedResourceVotersForIdentity`, and the winner in the stored result. A vote towards an +identifier that names no contender is refused (`VoteChoiceNotAllowedForVotePollError`, 40307), +so a vote towards the id of an identity that entered documents is refused: the voter names the +document. The winning document is stored under the owner the document itself names. + +The decision proposals contract uses this resolution for the `choice` documents entered for a +`multipleChoiceProposal`: the contest is keyed by the proposal's id, each choice is a contender, +and a lock means none of the choices (see `docs/protocol/decision-proposals.md`). + ## Moderation elections An `electedCharter` contest of the moderation charters contract (protocol version 14), keyed by diff --git a/docs/protocol/decision-proposals.md b/docs/protocol/decision-proposals.md index 358ec3ffd26..02f0ada2110 100644 --- a/docs/protocol/decision-proposals.md +++ b/docs/protocol/decision-proposals.md @@ -1,9 +1,12 @@ # Decision proposals -A decision proposal is a yes/no question that any identity puts to the -masternodes. Creating one opens a masternode yes/no vote poll on it, which the -proposal's owner funds. The result is signaling only: nothing on Platform acts -on it. +A decision proposal is a question that any identity puts to the masternodes. +A yes/no one is a `proposal`: creating it opens a masternode yes/no vote poll +on it, which the proposal's owner funds. A multiple choice one is a +`multipleChoiceProposal` and the `choice` documents entered for it, which the +masternodes vote among as a contest (see +[Multiple choice proposals](#multiple-choice-proposals)). A result is signaling +only: nothing on Platform acts on it. The proposals live in the decision proposals system contract. It activates at protocol version 15: a chain born at 15 registers it at genesis @@ -15,7 +18,7 @@ system contract cache and the trusted context provider serve it from 15 on. - Owner: the all-zero system identity - Registry entry: `SystemDataContract::DecisionProposals = 11` - Schema version: 1 -- Document types: `proposal` +- Document types: `proposal`, `multipleChoiceProposal`, `choice` ## `proposal` @@ -118,6 +121,105 @@ The result is signaling only. Nothing on Platform acts on a passed or failed proposal (`on_yes_no_vote_poll_finished` does nothing for this purpose); what it means is up to the people who read it. +## Multiple choice proposals + +A multiple choice proposal is two document types. The `multipleChoiceProposal` +is the question; each `choice` is an answer to it, entered by any identity. +The choices of one proposal are the contenders of one +[contest](../../book/src/data-model/contested-documents.md), so the vote among +them runs on the contested document machinery DPNS names use. The contest is a +vote among documents (`resolution: 2`, new in protocol version 15): each choice +is a contender of its own, so an identity may enter several. + +### `multipleChoiceProposal` + +Undeletable (`canBeDeleted: false`), so a `choice` can refer to it as a +`permanentDocument`. The owner may edit `title`, `description` and `url` until +two hours after the proposal was created: each is an `immutable` entry whose +condition, +`{ "greaterThan": [{ "subtract": ["$updatedAt", "$createdAt"] }, 7200000] }`, +freezes it once that time has passed, and an edit after it is refused +(`DocumentImmutablePropertyChangedError`, 40128). The window runs from the +proposal's creation whether or not a choice has been entered, so a choice +entered in the first two hours may answer a text that is edited afterwards. +The type keeps its history (`documentsKeepHistory: true`), so every version +stays readable. `open` is listed under `immutable` by name and never changes. +Creating one opens no vote and states no fund. Additional properties are +rejected. + +| Property | Type | Meaning | +| --- | --- | --- | +| `title` | string, 1 to 128 characters and at most 512 bytes, required | The question, in one line | +| `description` | string, 1 to 4096 characters and at most 4096 bytes, required | What is being decided | +| `url` | string, `format: uri`, 1 to 512 characters and at most 512 bytes | Where the proposal is discussed | +| `open` | boolean, required | Whether anyone may enter choices. Only the proposal's owner may when it is `false` | + +Indexes: `byOwner` (`$ownerId`, `$createdAt`) and `byCreatedAt` (`$createdAt`), +neither unique. + +### `choice` + +Immutable and undeletable. Additional properties are rejected. + +| Property | Type | Meaning | +| --- | --- | --- | +| `proposalId` | identifier, required | The proposal this answers: `refersTo` a `permanentDocument` of type `multipleChoiceProposal`, so a choice for a proposal that does not exist is refused (`ReferencedEntityNotFoundError`, 40120) | +| `title` | string, 1 to 128 characters and at most 512 bytes, required | The answer, in one line | +| `description` | string, 1 to 4096 characters and at most 4096 bytes | What voting for this answer means | + +Its only index, `byProposal` on `proposalId`, is unique and `contested` with +`resolution: 2` and no `fieldMatches`, so every choice is contested, each is a +contender named by its document id, and the contest is keyed by the proposal's +id: +`ContestedDocumentResourceVotePoll { contract_id, document_type_name: "choice", index_name: "byProposal", index_values: [proposalId] }`. + +One `propertyConstraints` rule, `closedProposalTakesItsOwnersChoices`, reads +the proposal through `proposalId`: +`{ "ifThen": [{ "equal": [{ "referenced": ["proposalId", "open"] }, 0] }, { "equal": [{ "referenced": ["proposalId", "$ownerId"] }, "$ownerId"] }] }`. +If the proposal is not open (a rule reads `false` as 0), the choice's owner +must be the proposal's owner; a choice anyone else enters is refused +(`DocumentPropertyConstraintViolatedError`, 10422) and opens no vote. + +### The vote + +- **Entering a choice.** The create states the contest's fund as its prefunded + voting balance, `["byProposal", amount]`, which the transition builders fill + in: the contested document fund, 0.1 Dash, doubling once the contest holds + 250 choices and again for every 50 more. The first choice opens the vote; + later ones join it while the join window is open (one week on mainnet) and + are refused after it (`DocumentContestNotJoinableError`, 40111). A vote holds + at most 1,000 choices. +- **Who enters choices, and how many.** A choice is a contender by itself, kept + under its document id, so an identity enters as many as it wants, each + paying the fund. For an open proposal anyone may enter choices, the proposer + included, and the proposer does not decide which choices the vote holds. For + a proposal that is not open only the proposer may, so the proposer supplies + every choice. +- **Voting.** Masternodes and evonodes vote with `MasternodeVote` transitions + for the length of the poll (two weeks on mainnet, counted from the first + choice): towards a choice; abstain; or lock, which here means none of the + choices. A vote towards a choice is a `TowardsIdentity` vote choice carrying + the choice's document id, not its owner's identity id, which names no + contender here and is refused (`VoteChoiceNotAllowedForVotePollError`, + 40307). +- **The result.** The choice with the most votes is stored as an ordinary + document and the other choices are removed. A tie goes to the earliest + choice, so a vote in which nobody votes keeps the first choice entered. When + the lock votes outnumber the leading choice, no choice is stored, and no + choice can be entered for that proposal again + (`DocumentContestCurrentlyLockedError`, 40110). The vote runs its full + length even with a single choice. The stored result names the winning choice + by its document id, and the choice is stored under the identity that entered + it. +- **Reading it.** `getContestedResourceVoteState` returns the choices and their + tallies while the vote runs and its outcome afterwards. Once the vote has + ended, the text of the choices that were not kept is no longer in state; the + stored result names them by their document ids. + +Unlike a yes/no proposal, a multiple choice proposal has no pass threshold, no +minimum turnout and no end date of its own: the contest's windows and its +most-votes rule apply. + ## From the SDKs The Rust SDK (`dash_sdk::platform::decision_proposals`), the JavaScript SDKs @@ -130,3 +232,10 @@ an existing one new text to replace it with, list proposals through the three indexes a page at a time, and read and answer a proposal's poll by the proposal's id. A masternode's vote names the poll by its id, which the SDKs derive from the proposal's id with the protocol version they speak. + +A multiple choice proposal and its choices are ordinary documents and an +ordinary contest: the SDKs' document create builds both (a `choice` create is +given its contest fund like any contested create), and their contested resource +queries and masternode vote read and answer the vote, with a choice's document +id wherever they name a contender by an identity id. They have no helpers of +their own yet. diff --git a/packages/decision-proposals-contract/README.md b/packages/decision-proposals-contract/README.md index 2bbc876c5d6..aba7509e048 100644 --- a/packages/decision-proposals-contract/README.md +++ b/packages/decision-proposals-contract/README.md @@ -1,17 +1,20 @@ # Decision Proposals Contract -The decision proposals system contract holds yes/no questions that anyone puts -to the masternodes. It activates at protocol version 15, registered at genesis +The decision proposals system contract holds the questions that anyone puts to +the masternodes: yes/no questions (`proposal`) and multiple choice ones +(`multipleChoiceProposal` and `choice`). It activates at protocol version 15, registered at genesis by chains born at 15 and inserted by the upgrade to 15, and has the same ID on every network: `3iNyoNwjCTkb7XS3RHyWUtLx7pgC6AFAEQNNDHzxqngF`. -Creating a proposal opens a masternode yes/no vote poll on it. The poll is -signaling only: nothing on Platform acts on its result, which stays readable -with `getYesNoVotePollState`. +Creating a `proposal` opens a masternode yes/no vote poll on it, whose result +stays readable with `getYesNoVotePollState`. Entering a `choice` for a +`multipleChoiceProposal` opens a masternode vote among the proposal's choices, +whose result stays readable with `getContestedResourceVoteState`. Both are +signaling only: nothing on Platform acts on a result. ## `proposal` -The only document type. Any identity may create one, and nobody may delete it. +A yes/no question. Any identity may create one, and nobody may delete it. Its rule (the threshold, the minimum and the end date) never changes, so masternodes always vote by the rule the poll opened with. Its owner may edit the `title`, `description` and `url` for a day after creating it, or for a fifth of @@ -48,6 +51,56 @@ limits, or the create is refused: - `minimumTurnoutPercent` is at most 50; - the end date is one to four weeks after the block that opens the poll. +## `multipleChoiceProposal` + +A question whose answers are the choices entered for it. Any identity may +create one, and nobody may delete it. Its owner may edit the `title`, +`description` and `url` for two hours after creating it; after that they +freeze. Every version is kept (`documentsKeepHistory`), so a reader can see the +text as it was when a choice was entered. Whether it is `open` never changes. +Creating it opens no vote and states no fund: the first choice does. + +| Property | Type | Meaning | +| --- | --- | --- | +| `title` | string, 1 to 128 characters and at most 512 bytes, required | The question, in one line | +| `description` | string, 1 to 4096 characters and at most 4096 bytes, required | What is being decided | +| `url` | string, a URI of 1 to 512 characters and at most 512 bytes | Where the proposal is discussed | +| `open` | boolean, required | Whether anyone may enter choices. Only the proposal's owner may when it is false | + +Indexes: `byOwner` (`$ownerId`, `$createdAt`) lists an identity's proposals; +`byCreatedAt` (`$createdAt`) lists the newest. + +## `choice` + +An answer to a multiple choice proposal. It never changes and is never +deleted. + +| Property | Type | Meaning | +| --- | --- | --- | +| `proposalId` | identifier, required | The `multipleChoiceProposal` this answers, which must exist | +| `title` | string, 1 to 128 characters and at most 512 bytes, required | The answer, in one line | +| `description` | string, 1 to 4096 characters and at most 4096 bytes | What voting for this answer means | + +Its only index, `byProposal` (`proposalId`), is unique and contested for every +value, so the choices entered for one proposal contend in one masternode vote. +The vote is among documents (`resolution: 2`): each choice is a contender of +its own, named by its document id. + +- anyone may enter choices for an open proposal, the proposer included, and + only the proposer for one that is not open (the + `closedProposalTakesItsOwnersChoices` rule); +- an identity enters as many choices as it wants, each paying the fund; +- the first choice opens the vote, and later ones join it while its join window + is open (one week on mainnet); +- each choice prefunds the masternode votes with the contested document fund + (0.1 Dash); +- masternodes vote for a choice by its document id, abstain, or lock, which + here means none of the choices; +- when the vote ends (two weeks after the first choice on mainnet), the choice + with the most votes is stored and the others are removed, unless the lock + votes outnumber it, in which case none is stored. A tie goes to the earliest + choice. + See [the protocol guide](../../docs/protocol/decision-proposals.md) for details. diff --git a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json index e53d3f8d135..5748d32630e 100644 --- a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json +++ b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json @@ -186,5 +186,204 @@ ], "additionalProperties": false, "description": "A yes/no question put to the masternodes. Creating one opens its vote poll, funded by its owner. Its title, description and url may be edited for a day after it is created, or a fifth of the time it runs if that is shorter; its rule never changes" + }, + "multipleChoiceProposal": { + "type": "object", + "documentsMutable": true, + "documentsKeepHistory": true, + "canBeDeleted": false, + "indices": [ + { + "name": "byOwner", + "properties": [ + { + "$ownerId": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "byCreatedAt", + "properties": [ + { + "$createdAt": "asc" + } + ] + } + ], + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "maxBytes": 512, + "description": "The question, in one line", + "position": 0 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "maxBytes": 4096, + "description": "What is being decided", + "position": 1 + }, + "url": { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 512, + "maxBytes": 512, + "description": "Where the proposal is discussed", + "position": 2 + }, + "open": { + "type": "boolean", + "description": "Whether anyone may enter choices; only the proposal's owner may when it is false", + "position": 3 + } + }, + "required": [ + "$createdAt", + "$updatedAt", + "title", + "description", + "open" + ], + "immutable": [ + "open", + { + "property": "title", + "when": { + "greaterThan": [ + { + "subtract": [ + "$updatedAt", + "$createdAt" + ] + }, + 7200000 + ] + } + }, + { + "property": "description", + "when": { + "greaterThan": [ + { + "subtract": [ + "$updatedAt", + "$createdAt" + ] + }, + 7200000 + ] + } + }, + { + "property": "url", + "when": { + "greaterThan": [ + { + "subtract": [ + "$updatedAt", + "$createdAt" + ] + }, + 7200000 + ] + } + } + ], + "additionalProperties": false, + "description": "A question put to the masternodes whose answers are the choices entered for it. Anyone may enter choices when it is open, and only its owner when it is not. Its title, description and url may be edited for two hours after it is created; whether it is open never changes, and it is never deleted" + }, + "choice": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "indices": [ + { + "name": "byProposal", + "properties": [ + { + "proposalId": "asc" + } + ], + "unique": true, + "contested": { + "resolution": 2, + "description": "The choices entered for one multiple choice proposal contend in a masternode vote, each on its own; a lock means none of them" + } + } + ], + "properties": { + "proposalId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "multipleChoiceProposal" + }, + "description": "The multiple choice proposal this is an answer to", + "position": 0 + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "maxBytes": 512, + "description": "The answer, in one line", + "position": 1 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "maxBytes": 4096, + "description": "What voting for this answer means", + "position": 2 + } + }, + "required": [ + "$createdAt", + "proposalId", + "title" + ], + "propertyConstraints": { + "closedProposalTakesItsOwnersChoices": { + "ifThen": [ + { + "equal": [ + { + "referenced": [ + "proposalId", + "open" + ] + }, + 0 + ] + }, + { + "equal": [ + { + "referenced": [ + "proposalId", + "$ownerId" + ] + }, + "$ownerId" + ] + } + ] + } + }, + "additionalProperties": false, + "description": "An answer to a multiple choice proposal. Entering one opens the masternode vote among the proposal's choices, or joins it; an identity may enter several, and the choice with the most votes is kept" } } diff --git a/packages/decision-proposals-contract/src/lib.rs b/packages/decision-proposals-contract/src/lib.rs index a1d3f2aa5ac..37539f1fefb 100644 --- a/packages/decision-proposals-contract/src/lib.rs +++ b/packages/decision-proposals-contract/src/lib.rs @@ -62,8 +62,21 @@ mod tests { use v1::document_types::proposal; let schema = load_documents_schemas(PlatformVersion::latest()).expect("schema loads"); - // One type and no others. - assert_eq!(schema.as_object().map(|types| types.len()), Some(1)); + // The yes/no proposal, and the two types of a multiple choice one. + let types: BTreeSet<&str> = schema + .as_object() + .expect("the schema is an object") + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + types, + BTreeSet::from([ + proposal::NAME, + v1::document_types::multiple_choice_proposal::NAME, + v1::document_types::choice::NAME, + ]) + ); let proposal_type = &schema[proposal::NAME]; let properties = proposal_type["properties"] .as_object() @@ -141,6 +154,149 @@ mod tests { } } + #[test] + fn should_put_the_choices_of_a_multiple_choice_proposal_to_a_contest() { + use v1::document_types::{choice, multiple_choice_proposal}; + + let schema = load_documents_schemas(PlatformVersion::latest()).expect("schema loads"); + + // The question's text may be edited for two hours, every version kept, and then + // freezes. It is never deleted, which is what lets a choice refer to it. + let question = &schema[multiple_choice_proposal::NAME]; + assert_eq!(question["documentsMutable"], Value::Bool(true)); + assert_eq!(question["documentsKeepHistory"], Value::Bool(true)); + assert_eq!(question["canBeDeleted"], Value::Bool(false)); + let two_hours_after_creation = serde_json::json!({ + "greaterThan": [{ "subtract": ["$updatedAt", "$createdAt"] }, 7_200_000] + }); + let frozen: Vec<(&str, &Value)> = question["immutable"] + .as_array() + .expect("the question lists immutable properties") + .iter() + .filter(|entry| entry.is_object()) + .map(|entry| { + ( + entry["property"].as_str().expect("a conditional entry"), + &entry["when"], + ) + }) + .collect(); + assert_eq!( + frozen, + [ + ( + multiple_choice_proposal::properties::TITLE, + &two_hours_after_creation + ), + ( + multiple_choice_proposal::properties::DESCRIPTION, + &two_hours_after_creation + ), + ( + multiple_choice_proposal::properties::URL, + &two_hours_after_creation + ), + ] + ); + let declared: BTreeSet<&str> = question["properties"] + .as_object() + .expect("the question has properties") + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + declared, + BTreeSet::from([ + multiple_choice_proposal::properties::TITLE, + multiple_choice_proposal::properties::DESCRIPTION, + multiple_choice_proposal::properties::URL, + multiple_choice_proposal::properties::OPEN, + ]) + ); + // Every question says whether it is open, for good + assert_eq!( + question["properties"][multiple_choice_proposal::properties::OPEN]["type"], + "boolean" + ); + assert!(question["required"] + .as_array() + .expect("the question lists required properties") + .contains(&Value::String( + multiple_choice_proposal::properties::OPEN.to_string() + ))); + assert!(question["immutable"] + .as_array() + .expect("the question lists immutable properties") + .contains(&Value::String( + multiple_choice_proposal::properties::OPEN.to_string() + ))); + let indexes: Vec<&str> = question["indices"] + .as_array() + .expect("the question has indexes") + .iter() + .filter_map(|index| index["name"].as_str()) + .collect(); + assert_eq!( + indexes, + [ + multiple_choice_proposal::indexes::BY_OWNER, + multiple_choice_proposal::indexes::BY_CREATED_AT + ] + ); + + // A choice names its question, and the choices of one question share one contest: + // the only index is unique on the question alone, contested for every value, and + // resolved by a vote among documents, with a lock choice. + let choice_type = &schema[choice::NAME]; + assert_eq!(choice_type["documentsMutable"], Value::Bool(false)); + assert_eq!(choice_type["canBeDeleted"], Value::Bool(false)); + let reference = &choice_type["properties"][choice::properties::PROPOSAL_ID]["refersTo"]; + assert_eq!(reference["type"], "permanentDocument"); + assert_eq!(reference["documentType"], multiple_choice_proposal::NAME); + let indexes = choice_type["indices"] + .as_array() + .expect("the choice has indexes"); + assert_eq!(indexes.len(), 1); + let contest = &indexes[0]; + assert_eq!(contest["name"], choice::indexes::BY_PROPOSAL); + assert_eq!(contest["unique"], Value::Bool(true)); + assert_eq!( + contest["properties"], + serde_json::json!([{ choice::properties::PROPOSAL_ID: "asc" }]) + ); + // Resolution 2: each choice is a contender of its own, so an identity enters several + assert_eq!(contest["contested"]["resolution"], 2); + assert!(contest["contested"].get("fieldMatches").is_none()); + + // The choices of a question that is not open are its owner's + assert_eq!( + choice_type["propertyConstraints"], + serde_json::json!({ + choice::rules::CLOSED_PROPOSAL_TAKES_ITS_OWNERS_CHOICES: { + "ifThen": [ + { + "equal": [ + { + "referenced": [ + choice::properties::PROPOSAL_ID, + multiple_choice_proposal::properties::OPEN + ] + }, + 0 + ] + }, + { + "equal": [ + { "referenced": [choice::properties::PROPOSAL_ID, "$ownerId"] }, + "$ownerId" + ] + } + ] + } + }) + ); + } + #[test] fn should_refuse_to_load_below_the_activation_version() { let platform_version = PlatformVersion::get(14).expect("protocol version 14"); diff --git a/packages/decision-proposals-contract/src/v1/mod.rs b/packages/decision-proposals-contract/src/v1/mod.rs index 10db7484bde..21ed4c2299c 100644 --- a/packages/decision-proposals-contract/src/v1/mod.rs +++ b/packages/decision-proposals-contract/src/v1/mod.rs @@ -31,6 +31,58 @@ pub mod document_types { pub const BY_CREATED_AT: &str = "byCreatedAt"; } } + + /// A question put to the masternodes whose answers are the choices entered for it. Any + /// identity may enter one [`choice`] when it is open, and only its owner when it is not. Its + /// owner may edit the title, description and url for two hours after creating it, and every + /// version is kept; whether it is open never changes. Undeletable. + pub mod multiple_choice_proposal { + pub const NAME: &str = "multipleChoiceProposal"; + + pub mod properties { + pub const TITLE: &str = "title"; + pub const DESCRIPTION: &str = "description"; + pub const URL: &str = "url"; + /// Whether anyone may enter choices. Only the proposal's owner may when it is + /// false. + pub const OPEN: &str = "open"; + } + + pub mod indexes { + /// By owner, then creation time. + pub const BY_OWNER: &str = "byOwner"; + /// By creation time: the newest proposals. + pub const BY_CREATED_AT: &str = "byCreatedAt"; + } + } + + /// An answer to a multiple choice proposal. The choices entered for one proposal contend + /// in a masternode vote on the contested `byProposal` index: entering the first opens the + /// vote, a later one joins it, and an identity enters at most one. Only the owner of a + /// proposal that is not open enters a choice for it. The choice with the most votes is kept, unless + /// the lock votes (none of the choices) outnumber it. Immutable and undeletable. + pub mod choice { + pub const NAME: &str = "choice"; + + pub mod rules { + /// The `propertyConstraints` rule holding the choices of a proposal that is not + /// open to its owner. + pub const CLOSED_PROPOSAL_TAKES_ITS_OWNERS_CHOICES: &str = + "closedProposalTakesItsOwnersChoices"; + } + + pub mod properties { + /// The id of the multiple choice proposal the choice answers. + pub const PROPOSAL_ID: &str = "proposalId"; + pub const TITLE: &str = "title"; + pub const DESCRIPTION: &str = "description"; + } + + pub mod indexes { + /// The contested unique index the vote among a proposal's choices runs on. + pub const BY_PROPOSAL: &str = "byProposal"; + } + } } pub fn load_documents_schemas() -> Result { diff --git a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js index c996249466c..33ca3ad5de9 100644 --- a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js +++ b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js @@ -46,11 +46,13 @@ describe('Decision Proposals Contract', () => { ); }); - const validate = (raw) => { - const document = dpp.document.create(dataContract, identityId, 'proposal', raw); + const validateType = (type, raw) => { + const document = dpp.document.create(dataContract, identityId, type, raw); return document.validate(dpp.protocolVersion); }; + const validate = (raw) => validateType('proposal', raw); + const rawProposal = () => ({ title: 'Should evonodes run the new indexer by default?', description: 'Yes: the next release turns it on. No: it stays opt-in.', @@ -71,8 +73,12 @@ describe('Decision Proposals Contract', () => { .throw(); }); - it('should have one document type', () => { - expect(Object.keys(decisionProposalsContractDocumentsSchema)).to.deep.equal(['proposal']); + it('should have the yes/no proposal and the two types of a multiple choice one', () => { + expect(Object.keys(decisionProposalsContractDocumentsSchema)).to.deep.equal([ + 'proposal', + 'multipleChoiceProposal', + 'choice', + ]); }); describe('proposal', () => { @@ -227,4 +233,190 @@ describe('Decision Proposals Contract', () => { }); }); }); + + describe('multipleChoiceProposal', () => { + const rawQuestion = () => ({ + title: 'Which block time should Platform target?', + description: 'Enter the answer you would have the network give.', + url: 'https://www.dash.org/forum/threads/block-time.1/', + open: true, + }); + + it('should be valid', () => { + expect(validateType('multipleChoiceProposal', rawQuestion()).isValid()).to.be.true(); + }); + + it('should not need a url', () => { + const raw = rawQuestion(); + delete raw.url; + + expect(validateType('multipleChoiceProposal', raw).isValid()).to.be.true(); + }); + + it('should keep its text editable for two hours, every version, and never be deleted', () => { + const { multipleChoiceProposal } = decisionProposalsContractDocumentsSchema; + + expect(multipleChoiceProposal.documentsMutable).to.be.true(); + expect(multipleChoiceProposal.documentsKeepHistory).to.be.true(); + expect(multipleChoiceProposal.canBeDeleted).to.be.false(); + expect(multipleChoiceProposal.immutable).to.deep.equal([ + 'open', + ...['title', 'description', 'url'].map((property) => ({ + property, + when: { greaterThan: [{ subtract: ['$updatedAt', '$createdAt'] }, 7200000] }, + })), + ]); + }); + + it('should say whether it is open', () => { + const { multipleChoiceProposal } = decisionProposalsContractDocumentsSchema; + + expect(multipleChoiceProposal.properties.open.type).to.equal('boolean'); + + [true, false].forEach((open) => { + const raw = rawQuestion(); + raw.open = open; + + expect(validateType('multipleChoiceProposal', raw).isValid()).to.be.true(); + }); + }); + + it('should refuse an open that is not a boolean', () => { + const raw = rawQuestion(); + raw.open = 'yes'; + + const error = expectJsonSchemaError(validateType('multipleChoiceProposal', raw)); + + expect(error.keyword).to.equal('type'); + }); + + it('should index proposals by owner and by creation time', () => { + const { indices } = decisionProposalsContractDocumentsSchema.multipleChoiceProposal; + + expect(indices.map(({ name }) => name)).to.deep.equal(['byOwner', 'byCreatedAt']); + indices.forEach(({ unique }) => expect(unique).to.be.undefined()); + }); + + ['title', 'description', 'open'].forEach((property) => { + it(`should require ${property}`, () => { + const raw = rawQuestion(); + delete raw[property]; + + const error = expectJsonSchemaError(validateType('multipleChoiceProposal', raw)); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal(property); + }); + }); + + it('should not have additional properties', () => { + const raw = rawQuestion(); + raw.choices = ['one', 'two']; + + const error = expectJsonSchemaError(validateType('multipleChoiceProposal', raw)); + + expect(error.keyword).to.equal('additionalProperties'); + expect(error.params.additionalProperties).to.deep.equal(['choices']); + }); + + it('should refuse a title of 129 characters', () => { + const raw = rawQuestion(); + raw.title = 'a'.repeat(129); + + const error = expectJsonSchemaError(validateType('multipleChoiceProposal', raw)); + + expect(error.keyword).to.equal('maxLength'); + }); + }); + + describe('choice', () => { + const rawChoice = async () => ({ + proposalId: await generateRandomIdentifier(), + title: 'Two seconds', + description: 'Halves the time a payment waits for its block.', + }); + + it('should be valid', async () => { + expect(validateType('choice', await rawChoice()).isValid()).to.be.true(); + }); + + it('should not need a description', async () => { + const raw = await rawChoice(); + delete raw.description; + + expect(validateType('choice', raw).isValid()).to.be.true(); + }); + + it('should answer a multiple choice proposal that is never deleted', () => { + const { proposalId } = decisionProposalsContractDocumentsSchema.choice.properties; + + expect(proposalId.refersTo).to.deep.equal({ + type: 'permanentDocument', + documentType: 'multipleChoiceProposal', + }); + }); + + it('should put the choices of one proposal to a masternode vote', () => { + const { choice } = decisionProposalsContractDocumentsSchema; + + expect(choice.documentsMutable).to.be.false(); + expect(choice.canBeDeleted).to.be.false(); + expect(choice.indices).to.have.length(1); + + const [index] = choice.indices; + + expect(index.name).to.equal('byProposal'); + expect(index.properties).to.deep.equal([{ proposalId: 'asc' }]); + expect(index.unique).to.be.true(); + // Resolution 2: each choice is a contender of its own, so an identity may enter + // several, and a lock vote means none of the choices. No `fieldMatches`: every + // choice is contested. + expect(index.contested.resolution).to.equal(2); + expect(index.contested.fieldMatches).to.be.undefined(); + }); + + it('should hold the choices of a proposal that is not open to its owner', () => { + const { propertyConstraints } = decisionProposalsContractDocumentsSchema.choice; + + expect(propertyConstraints).to.deep.equal({ + closedProposalTakesItsOwnersChoices: { + ifThen: [ + { equal: [{ referenced: ['proposalId', 'open'] }, 0] }, + { equal: [{ referenced: ['proposalId', '$ownerId'] }, '$ownerId'] }, + ], + }, + }); + }); + + ['proposalId', 'title'].forEach((property) => { + it(`should require ${property}`, async () => { + const raw = await rawChoice(); + delete raw[property]; + + const error = expectJsonSchemaError(validateType('choice', raw)); + + expect(error.keyword).to.equal('required'); + expect(error.params.missingProperty).to.equal(property); + }); + }); + + it('should not have additional properties', async () => { + const raw = await rawChoice(); + raw.votes = 3; + + const error = expectJsonSchemaError(validateType('choice', raw)); + + expect(error.keyword).to.equal('additionalProperties'); + expect(error.params.additionalProperties).to.deep.equal(['votes']); + }); + + it('should refuse a title of 129 characters', async () => { + const raw = await rawChoice(); + raw.title = 'a'.repeat(129); + + const error = expectJsonSchemaError(validateType('choice', raw)); + + expect(error.keyword).to.equal('maxLength'); + }); + }); }); diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 9c9b33c5f72..1fc39c09264 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -1163,6 +1163,17 @@ fn parse_indices( .generation .admit_range_countable_implies_countable, no_locking_resolution: ctx.generation.admit_index_no_locking_resolution, + // Not a property of the generation: protocol versions 14 and + // 15 both parse generation 3, and only the tables setting this + // version admit a contest among documents + documents_resolution: ctx + .platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .parse_contested_documents_resolution + .is_some(), }, ) .map_err(consensus_or_protocol_data_contract_error)?; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/indexes.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/indexes.rs index 1833154f94b..5cc972fd102 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/indexes.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/indexes.rs @@ -148,7 +148,9 @@ impl Checker<'_> { })?; self.member(map, "resolution", |c, value| { c.integer(value, None, None)?; - c.one_of_integers(value, &[0, 1]) + // 2, a vote among documents, joined the resolutions with this check + // (protocol version 15); the meta-schemas before it admit 0 and 1 + c.one_of_integers(value, &[0, 1, 2]) })?; self.member(map, "description", |c, value| { c.string_of_length(value, 1, Some(256)).map(drop) diff --git a/packages/rs-dpp/src/data_contract/document_type/index/integer_range_parse_tests.rs b/packages/rs-dpp/src/data_contract/document_type/index/integer_range_parse_tests.rs index c744776dbc9..c405c60741a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/integer_range_parse_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/integer_range_parse_tests.rs @@ -20,6 +20,7 @@ fn admissions(integer_range: bool) -> IndexGrammarAdmissions { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, } } diff --git a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs index 8f37704ea6c..12e209ca64e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs @@ -23,7 +23,8 @@ use crate::data_contract::document_type::property_names; use crate::data_contract::document_type::ContestedIndexResolution::MasternodeVote; #[cfg(feature = "validation")] use crate::data_contract::errors::DataContractError::RegexError; -use platform_value::{Value, ValueMap}; +use platform_value::{Identifier, Value, ValueMap}; +use platform_version::version::PlatformVersion; use regex::Regex; use std::cmp::Ordering; use std::mem; @@ -211,6 +212,33 @@ pub enum ContestedIndexResolution { /// single contender is awarded at once, without the vote window. Meta-schema v3+ /// (protocol version 14). MasternodeVoteNoLocking = 1, + /// Masternodes and evonodes vote for a contender, abstain, or lock, as under + /// [`MasternodeVote`](Self::MasternodeVote), but each document entered for the index + /// values is a contender of its own, kept and named by its document id where the other + /// resolutions keep and name a contender by its owner's id. An identity may therefore + /// enter several documents, and a vote towards a contender names a document. Admitted + /// where `parse_contested_documents_resolution` is set (protocol version 15). + MasternodeVoteOnDocuments = 2, +} + +impl ContestedIndexResolution { + /// Whether each document entered is a contender of its own, named by its document id + /// ([`MasternodeVoteOnDocuments`](Self::MasternodeVoteOnDocuments)), instead of each + /// identity being one, named by its id. + pub fn contenders_are_documents(&self) -> bool { + matches!(self, ContestedIndexResolution::MasternodeVoteOnDocuments) + } + + /// The id a contest keeps and names a contender by: the document's under + /// [`MasternodeVoteOnDocuments`](Self::MasternodeVoteOnDocuments), its owner's under + /// every other resolution. + pub fn contender_id(&self, owner_id: Identifier, document_id: Identifier) -> Identifier { + if self.contenders_are_documents() { + document_id + } else { + owner_id + } + } } impl TryFrom for ContestedIndexResolution { @@ -1111,6 +1139,11 @@ pub(crate) struct IndexGrammarAdmissions { /// Whether a contested index may be resolved without a Lock choice /// (`"resolution": 1`, [`ContestedIndexResolution::MasternodeVoteNoLocking`]). pub(crate) no_locking_resolution: bool, + /// Whether a contested index may make each document a contender + /// (`"resolution": 2`, [`ContestedIndexResolution::MasternodeVoteOnDocuments`]). Not a + /// property of a schema generation: protocol versions 14 and 15 both parse generation + /// 3, and only the tables setting `parse_contested_documents_resolution` admit it. + pub(crate) documents_resolution: bool, } impl IndexGrammarAdmissions { @@ -1130,6 +1163,22 @@ impl IndexGrammarAdmissions { summable_off_count_index: generation >= 3, range_countable_implies_countable: generation >= 3, no_locking_resolution: generation >= 3, + documents_resolution: false, + } + } + + /// The admissions `platform_version` parses an index with: those of its + /// `document_type_schema` generation, and the documents resolution where + /// `parse_contested_documents_resolution` is set. + pub(crate) fn for_platform_version(platform_version: &PlatformVersion) -> Self { + let schema = &platform_version + .dpp + .contract_versions + .document_type_versions + .schema; + Self { + documents_resolution: schema.parse_contested_documents_resolution.is_some(), + ..Self::for_schema_generation(schema.document_type_schema) } } } @@ -2035,6 +2084,7 @@ impl TryFrom<&[(Value, Value)]> for Index { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) } @@ -2079,6 +2129,7 @@ impl Index { summable_off_count_index: summable_off_count_index_allowed, range_countable_implies_countable, no_locking_resolution: no_locking_resolution_allowed, + documents_resolution: documents_resolution_allowed, } = admissions; // Decouple the map // It contains properties and a unique key @@ -2247,10 +2298,19 @@ impl Index { } "resolution" => { let resolution_int = contested_value.to_integer::()?; - contested_index_information.resolution = - resolution_int.try_into().map_err(|e: ProtocolError| { - DataContractError::ValueWrongType(e.to_string()) - })?; + // Resolution 2 is read only where it is admitted: anywhere + // else it is the unknown resolution it always was, refused + // by the conversion below with the error it always gave + contested_index_information.resolution = match resolution_int { + 2 if documents_resolution_allowed => { + ContestedIndexResolution::MasternodeVoteOnDocuments + } + resolution_int => { + resolution_int.try_into().map_err(|e: ProtocolError| { + DataContractError::ValueWrongType(e.to_string()) + })? + } + }; if contested_index_information.resolution == ContestedIndexResolution::MasternodeVoteNoLocking && !no_locking_resolution_allowed @@ -3537,6 +3597,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("should parse"); @@ -3559,6 +3620,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -3581,6 +3643,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -3607,6 +3670,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }; let mut map = index_value_map("postId", None); @@ -3642,6 +3706,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -3678,6 +3743,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -3706,6 +3772,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("should parse"); @@ -3734,6 +3801,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -3763,6 +3831,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -3793,6 +3862,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("should parse"); @@ -3819,6 +3889,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("should parse"); @@ -3850,6 +3921,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("should parse"); @@ -3881,6 +3953,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -3910,6 +3983,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -3944,6 +4018,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("the parser applies structural rules only"); @@ -3973,6 +4048,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -3998,6 +4074,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -4027,6 +4104,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -4087,6 +4165,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -4129,6 +4208,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("should parse"); @@ -4164,6 +4244,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -4201,6 +4282,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -4232,6 +4314,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("a non-unique $updatedAt bucketing stays legal"); @@ -4258,6 +4341,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_err(); @@ -5513,6 +5597,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("all three ranked keywords must parse when the grammar allows them"); @@ -5545,6 +5630,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("index without ranked keywords must parse"); @@ -5593,6 +5679,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("ranked flags on a compound index must be accepted"); @@ -5629,6 +5716,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ); assert!( @@ -5666,6 +5754,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ); assert!( @@ -5701,6 +5790,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ); assert!( @@ -5733,6 +5823,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ); assert!( @@ -5767,6 +5858,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ); assert!( @@ -5801,6 +5893,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ); assert!( @@ -5837,6 +5930,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("rankedAverageable on the averageable sugar form must parse"); @@ -5879,6 +5973,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("rankedAverageable on the explicit longhand form must parse"); @@ -5911,6 +6006,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ); assert!(result.is_err(), "{key} must reject a non-boolean value"); @@ -5949,6 +6045,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ); assert!( @@ -6026,6 +6123,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: true, + documents_resolution: false, } } @@ -6522,6 +6620,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, }, ); let msg = format!( @@ -6590,6 +6689,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ); assert!( @@ -6624,6 +6724,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_or_else(|e| panic!("{axis} with no nullSearchable key must parse: {e:?}")); @@ -6654,6 +6755,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ) .unwrap_or_else(|e| { @@ -6682,6 +6784,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("nullSearchable: false on a plain index must still parse"); @@ -6705,6 +6808,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, + documents_resolution: false, }, ) .expect("nullSearchable: false on a range-averageable index must still parse"); @@ -6772,6 +6876,61 @@ mod tests { ); } + /// `"resolution": 2` is read only where `parse_contested_documents_resolution` is set: + /// protocol version 14, which parses the same generation 3, refuses it as the unknown + /// resolution it was before, and protocol version 15 parses it as the vote among + /// documents. + #[test] + fn test_index_contested_resolution_on_documents_needs_the_admission() { + let index_map = contested_unique_index_map(2); + let unknown = "contested index resolution unknown: 2"; + for (refused, admissions) in [ + ("no admission", Index::try_from(index_map.as_slice())), + ( + "generation 3", + Index::try_from_value_map(index_map.as_slice(), v3_admissions()), + ), + ( + "protocol version 14", + Index::try_from_value_map( + index_map.as_slice(), + IndexGrammarAdmissions::for_platform_version( + PlatformVersion::get(14).expect("protocol version 14"), + ), + ), + ), + ] { + let error = admissions.expect_err(refused); + assert!( + matches!(&error, DataContractError::ValueWrongType(message) if message.contains(unknown)), + "{refused}: {error:?}" + ); + } + + let admissions = IndexGrammarAdmissions::for_platform_version(PlatformVersion::latest()); + let index = Index::try_from_value_map(index_map.as_slice(), admissions) + .expect("protocol version 15 admits the vote among documents"); + let resolution = index.contested_index.expect("contested").resolution; + assert_eq!( + resolution, + ContestedIndexResolution::MasternodeVoteOnDocuments + ); + + // A contender is named by its document under it, and by its owner under the others + let owner_id = Identifier::from([1; 32]); + let document_id = Identifier::from([2; 32]); + assert_eq!(resolution.contender_id(owner_id, document_id), document_id); + for resolution in [ + ContestedIndexResolution::MasternodeVote, + ContestedIndexResolution::MasternodeVoteNoLocking, + ] { + assert!(!resolution.contenders_are_documents()); + assert_eq!(resolution.contender_id(owner_id, document_id), owner_id); + } + // The value itself converts as it always did: only the parser admits it + assert!(ContestedIndexResolution::try_from(2u8).is_err()); + } + #[test] fn test_index_try_from_contested_with_unique() { let index_map: Vec<(Value, Value)> = vec![ @@ -7047,6 +7206,7 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, + documents_resolution: false, } } diff --git a/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/index.rs b/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/index.rs index 6769ea7bd01..3949cac561d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/index.rs +++ b/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/index.rs @@ -61,6 +61,8 @@ pub(super) enum StoredContestedFieldMatch { pub(super) enum StoredContestedResolution { MasternodeVote, MasternodeVoteNoLocking, + /// Appended: the bincode discriminant is the variant position. + MasternodeVoteOnDocuments, } /// [`IndexCountability`]. @@ -195,6 +197,9 @@ impl From<&Index> for StoredIndex { ContestedIndexResolution::MasternodeVoteNoLocking => { StoredContestedResolution::MasternodeVoteNoLocking } + ContestedIndexResolution::MasternodeVoteOnDocuments => { + StoredContestedResolution::MasternodeVoteOnDocuments + } }, }), countable: match countable { @@ -304,6 +309,9 @@ impl From for Index { StoredContestedResolution::MasternodeVoteNoLocking => { ContestedIndexResolution::MasternodeVoteNoLocking } + StoredContestedResolution::MasternodeVoteOnDocuments => { + ContestedIndexResolution::MasternodeVoteOnDocuments + } }, }); Index { diff --git a/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs b/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs index 1a866480a9b..a3d2a0fbb99 100644 --- a/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs @@ -132,14 +132,8 @@ impl DataContractInSerializationFormat { // while the identical index parses fine during // validation — the fee must cover every index the // contract actually registers. - let admissions = IndexGrammarAdmissions::for_schema_generation( - platform_version - .dpp - .contract_versions - .document_type_versions - .schema - .document_type_schema, - ); + let admissions = + IndexGrammarAdmissions::for_platform_version(platform_version); if let Ok(index) = Index::try_from_value_map(index_value_map.as_slice(), admissions) { diff --git a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs index 71318924f64..00263e4e8f7 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/v1/mod.rs @@ -319,8 +319,13 @@ mod tests { if let Some(stored) = stored { assert_eq!(stored.contract.id(), decision_proposals_id); assert_eq!(stored.contract.owner_id(), Identifier::from([0u8; 32])); - assert!(stored.contract.document_type_for_name("proposal").is_ok()); - assert_eq!(stored.contract.document_types().len(), 1); + for document_type in ["proposal", "multipleChoiceProposal", "choice"] { + assert!(stored + .contract + .document_type_for_name(document_type) + .is_ok()); + } + assert_eq!(stored.contract.document_types().len(), 3); } } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index a0b099406b4..1cb30df5f2b 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -1988,8 +1988,13 @@ mod tests { assert_eq!(stored.contract.id(), decision_proposals_id); assert_eq!(stored.contract.owner_id(), Identifier::from([0u8; 32])); - assert!(stored.contract.document_type_for_name("proposal").is_ok()); - assert_eq!(stored.contract.document_types().len(), 1); + for document_type in ["proposal", "multipleChoiceProposal", "choice"] { + assert!(stored + .contract + .document_type_for_name(document_type) + .is_ok()); + } + assert_eq!(stored.contract.document_types().len(), 3); assert_eq!( platform .drive diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/award_document_to_winner/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/award_document_to_winner/v0/mod.rs index da329ef846f..5c3d166a951 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/award_document_to_winner/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/award_document_to_winner/v0/mod.rs @@ -2,6 +2,7 @@ use crate::error::Error; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; +use dpp::document::DocumentV0Getters; use dpp::version::PlatformVersion; use dpp::voting::contender_structs::FinalizedContender; use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; @@ -29,11 +30,25 @@ where serialized_document, .. } = contender; - // Let's start by getting the identity + // The contest names a contender by its owner's id, which is who the document goes + // to. In a vote among documents (protocol version 15) it names the contender by its + // document id instead, and the owner is the one the document itself names. No + // contract registered before that version declares the resolution, so every other + // contest awards its winner as it always did. + let owner_id = if vote_poll + .index()? + .contested_index + .as_ref() + .is_some_and(|contested| contested.resolution.contenders_are_documents()) + { + document.owner_id() + } else { + identity_id + }; let owned_document_info = OwnedDocumentInfo { document_info: DocumentAndSerialization((document, serialized_document, None)), - owner_id: Some(identity_id.to_buffer()), + owner_id: Some(owner_id.to_buffer()), }; // Let's insert the document into the state diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v1/mod.rs index 5c7a0a77577..00469d0ca33 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v1/mod.rs @@ -304,6 +304,21 @@ impl DocumentCreateTransitionActionStateValidationV1 for DocumentCreateTransitio ))))) } + // In a vote among documents (protocol version 15) each document is a + // contender of its own, kept under its id, which the check above + // found no contender under: an identity may enter several. No + // contract registered before that version declares the resolution, + // so every other contest reads its owner's contender as it always did + if contested_document_resource_vote_poll + .index() + .map_err(Error::Drive)? + .contested_index + .as_ref() + .is_some_and(|contested| contested.resolution.contenders_are_documents()) + { + return Ok(SimpleConsensusValidationResult::new()); + } + // we need to also make sure that we are not already a contestant let (maybe_existing_contender, fee_result) = fetch_contender(platform.drive, contested_document_resource_vote_poll, owner_id, block_info, transaction, platform_version)?; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index f1692876015..59be33d6614 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -731,7 +731,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "b9f5883991476eb6e01109ff5bf1057eab24b756170397d4d850e3e6e2c09a5b" + "773ae3ee2393ca47c64d31930dba6ce321958da979ea17802c78480a55bac06b" ); assert_eq!( pool.platform @@ -2182,39 +2182,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "6a823c2db94612652926e6b9c1ae7feec9c2547dfd4cfc4e474318da1c16d59c", - "7eea5d32409d3df173a4ec4f5e9b56d40d2cacfcea8cac49d05e170976a893df", + "513572d885b2901a779c2c5cc11666f6230ddf2dd20bf6a35da46f55ba3f80a2", ), "valid-payment" => ( "9388d5cf63c4a571b9fb009679e2e34d0b681acc88f0f5195e7287c7efbd92f7", - "2a7e8a57360fad775ab0411ed5f4e31db9bd3ac5948e3a33a21f11ee5fa2d93c", + "8eab3e7c4eb1668c5da778619e5b66ef1f053f8ff328cb4a1575af46d8d4d746", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "00315213aa7c931f2304191465a0319c9dbcc280269216e63a3e0e60ca16b178", + "646f07c40aa13297dbcb0e19ffb1ceaf1646c20e11943a920ab45ffa6596b268", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "9402a3dfd81a098f9575011fc0f6e5cd2415f0527ea9c4f45a04daaae2ab6ce9", + "a15dc66f817730014aa506d41f6d43442357e93326d2c48dbdf3bd0ba59e0ed6", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "68ecfa75165f58edcf7262697fddad9378492e71eb1b340c9683b6a08131bfb6", + "d1e9cca1967acb3b0d54ea7338e2deafc2e1575b480595703299fd237c91087b", ), "open-mint-group" => ( "5c68381297c9bf68c5e85ebe6bb9e0f1289e2c07718eecfb5e6773d6009455bb", - "5c5eb01b9f9fa0ceaa458746fdbb705e0a4aa573c8efeb0bc9f2f4d7c499e95f", + "c5410f89e9cfeb6827b0a6e914f95572569263d99e49d39a3dccf0dc78a78ddd", ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "a09c353badac216e6897ae44bfa6d20038ba2b9ac8b607dcfb5824da4e9e1a3d", + "62da8948faea086ba8f02f5061375e49bc0ba322df5b66434adb899089fd3c0a", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", - "de8b6eab60c7a94da3470facf22c1225e09e58f3779f0982a87a36b6dffec48c", + "68aea0f109f923d99dcdb31a18aff37084177766bf02224f8f40dce3fe522864", ), "closing-burn-group" => ( "93e1732286f168c4a91d2765931e181c5721bfca1bfd5f93b8baacca03e00b2b", - "bcd073ee886c1e0f07f316680756ee07ffd2961a0960090f685f25a8278c65ef", + "0420d4f51db6dbc072889bfe8de82b318ac327576e753701188e88b01fa3aa98", ), _ => unreachable!("enumerated characterization case"), }; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs index a897304e72d..f8a367decff 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs @@ -106,6 +106,9 @@ mod no_locking_contest_tests; #[cfg(test)] mod charter_election_tests; +#[cfg(test)] +mod multiple_choice_proposal_tests; + #[cfg(test)] mod end_date_cleanup_tests; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs new file mode 100644 index 00000000000..0d624c4b84f --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs @@ -0,0 +1,1412 @@ +//! Multiple choice proposals (protocol version 15): a `multipleChoiceProposal` of the decision +//! proposals contract is a question, and each `choice` entered for it is an answer. The choices +//! of one proposal contend on the contested `byProposal` index, a vote among documents: each +//! choice is a contender of its own, named by its document id, so an identity enters as many +//! as it wants. The choice with the most votes is kept, and a lock means none of them. Anyone +//! enters choices for an `open` proposal, and only its owner for one that is not. The owner +//! may edit the question's text for two hours after creating it. + +use crate::execution::validation::state_transition::state_transitions::tests::{ + setup_identity, setup_masternode_voting_identity, +}; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use dpp::block::block_info::BlockInfo; +use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0; +use dpp::consensus::basic::BasicError; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::ConsensusError; +use dpp::dash_to_credits; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::DataContract; +use dpp::decision_proposal::DECISION_PROPOSALS_CONTRACT_ID; +use dpp::document::document_methods::DocumentMethodsV0; +use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; +use dpp::fee::Credits; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::{Identity, IdentityPublicKey, TimestampMillis}; +use dpp::platform_value::{Bytes32, Identifier, Value}; +use dpp::prelude::IdentityNonce; +use dpp::serialization::PlatformSerializable; +use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; +use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; +use dpp::system_data_contracts::decision_proposals_contract::v1::document_types::{ + choice, multiple_choice_proposal, +}; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ + ContestedDocumentVotePollStatus, ContestedDocumentVotePollStoredInfoV0Getters, +}; +use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +use dpp::voting::vote_polls::VotePoll; +use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; +use dpp::voting::votes::resource_vote::ResourceVote; +use dpp::voting::votes::Vote; +use drive::drive::document::query::QueryDocumentsOutcomeV0Methods; +use drive::drive::document::ContestWindows; +use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::resolve::ContestedDocumentResourceVotePollResolver; +use drive::query::DriveDocumentQuery; +use platform_version::version::PlatformVersion; +use rand::prelude::StdRng; +use rand::{Rng, SeedableRng}; +use simple_signer::signer::SimpleSigner; +use std::collections::BTreeMap; +use std::sync::Arc; + +type IdentityInfo = (Identity, SimpleSigner, IdentityPublicKey); + +/// An identity writing to the decision proposals contract, with the identity contract nonce its +/// next transition uses. +struct Writer { + info: IdentityInfo, + next_nonce: IdentityNonce, +} + +impl Writer { + fn id(&self) -> Identifier { + self.info.0.id() + } +} + +fn block(time_ms: TimestampMillis, height: u64) -> BlockInfo { + BlockInfo { + time_ms, + height, + core_height: 42, + epoch: Default::default(), + } +} + +fn setup() -> ( + TempPlatform, + &'static PlatformVersion, + Arc, + StdRng, +) { + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let contract = platform + .drive + .cache + .system_data_contracts + .load_decision_proposals(platform_version) + .expect("expected the decision proposals contract at genesis"); + ( + platform, + platform_version, + contract, + StdRng::seed_from_u64(0x3C40_1CE5), + ) +} + +fn writer(platform: &mut TempPlatform, rng: &mut StdRng) -> Writer { + Writer { + info: setup_identity(platform, rng.gen(), dash_to_credits!(1.0)), + next_nonce: 1, + } +} + +/// The vote among the choices entered for `proposal_id`. +fn choice_poll(proposal_id: Identifier) -> ContestedDocumentResourceVotePoll { + ContestedDocumentResourceVotePoll { + contract_id: DECISION_PROPOSALS_CONTRACT_ID, + document_type_name: choice::NAME.to_string(), + index_name: choice::indexes::BY_PROPOSAL.to_string(), + index_values: vec![Value::Identifier(proposal_id.to_buffer())], + } +} + +/// A serialized create of a `document_type_name` document of the decision proposals contract +/// holding `properties`, signed by `writer`, and the id of the document it creates. A choice +/// states the contest fund, which the builder reads from its contested index. +async fn create_transition( + contract: &DataContract, + writer: &mut Writer, + document_type_name: &str, + properties: BTreeMap, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> (Vec, Identifier) { + let document_type = contract + .document_type_for_name(document_type_name) + .expect("expected the document type"); + let entropy = Bytes32::random_with_rng(rng); + let nonce = writer.next_nonce; + writer.next_nonce += 1; + let mut document: Document = DocumentV0 { + owner_id: writer.id(), + properties, + ..Default::default() + } + .into(); + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected to set the document id"); + let id = document.id(); + let (_, signer, key) = &writer.info; + let transition = BatchTransition::new_document_creation_transition_from_document( + document, + document_type, + entropy.0, + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected to create the batch transition"); + ( + transition + .serialize_to_bytes() + .expect("expected to serialize the batch transition"), + id, + ) +} + +/// Processes one transition in a block at `time_ms` and returns its execution result. +fn process( + platform: &TempPlatform, + transition: Vec, + time_ms: TimestampMillis, + platform_version: &PlatformVersion, +) -> StateTransitionExecutionResult { + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[transition], + &platform_state, + &block(time_ms, 1), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) +} + +fn process_valid( + platform: &TempPlatform, + transition: Vec, + time_ms: TimestampMillis, + platform_version: &PlatformVersion, +) { + match process(platform, transition, time_ms, platform_version) { + StateTransitionExecutionResult::SuccessfulExecution { .. } => {} + other => panic!("expected the transition to pass, got {other:?}"), + } +} + +/// Processes one transition that must be refused, paid, and returns why. +fn process_refused( + platform: &TempPlatform, + transition: Vec, + time_ms: TimestampMillis, + platform_version: &PlatformVersion, +) -> ConsensusError { + match process(platform, transition, time_ms, platform_version) { + StateTransitionExecutionResult::PaidConsensusError { error, .. } => error, + other => panic!("expected a paid refusal, got {other:?}"), + } +} + +/// `proposer` asks `question` at `time_ms`, open to anyone's choices; returns the proposal's id. +async fn ask( + platform: &TempPlatform, + contract: &DataContract, + proposer: &mut Writer, + question: &str, + time_ms: TimestampMillis, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> Identifier { + ask_stating( + platform, + contract, + proposer, + question, + true, + time_ms, + rng, + platform_version, + ) + .await +} + +/// `proposer` asks `question` at `time_ms`, stating whether it is `open`; returns the +/// proposal's id. +#[allow(clippy::too_many_arguments)] +async fn ask_stating( + platform: &TempPlatform, + contract: &DataContract, + proposer: &mut Writer, + question: &str, + open: bool, + time_ms: TimestampMillis, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> Identifier { + let properties = BTreeMap::from([ + ( + multiple_choice_proposal::properties::TITLE.to_string(), + Value::Text(question.to_string()), + ), + ( + multiple_choice_proposal::properties::DESCRIPTION.to_string(), + Value::Text("Enter the answer you would have the network give.".to_string()), + ), + ( + multiple_choice_proposal::properties::OPEN.to_string(), + Value::Bool(open), + ), + ]); + let (transition, id) = create_transition( + contract, + proposer, + multiple_choice_proposal::NAME, + properties, + rng, + platform_version, + ) + .await; + process_valid(platform, transition, time_ms, platform_version); + id +} + +/// A serialized create of `writer`'s choice `answer` for `proposal_id`, and the choice's id. +async fn choice_transition( + contract: &DataContract, + writer: &mut Writer, + proposal_id: Identifier, + answer: &str, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> (Vec, Identifier) { + create_transition( + contract, + writer, + choice::NAME, + BTreeMap::from([ + ( + choice::properties::PROPOSAL_ID.to_string(), + Value::Identifier(proposal_id.to_buffer()), + ), + ( + choice::properties::TITLE.to_string(), + Value::Text(answer.to_string()), + ), + ]), + rng, + platform_version, + ) + .await +} + +/// `writer` enters `answer` for `proposal_id` at `time_ms`; returns the choice's id. +#[allow(clippy::too_many_arguments)] +async fn enter( + platform: &TempPlatform, + contract: &DataContract, + writer: &mut Writer, + proposal_id: Identifier, + answer: &str, + time_ms: TimestampMillis, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> Identifier { + let (transition, id) = + choice_transition(contract, writer, proposal_id, answer, rng, platform_version).await; + process_valid(platform, transition, time_ms, platform_version); + id +} + +/// A masternode votes `vote_choice` in `poll` at `time_ms`, and the vote passes. +async fn vote( + platform: &mut TempPlatform, + poll: &ContestedDocumentResourceVotePoll, + vote_choice: ResourceVoteChoice, + masternode_seed: u64, + time_ms: TimestampMillis, + platform_version: &PlatformVersion, +) { + match cast_vote( + platform, + poll, + vote_choice, + masternode_seed, + time_ms, + platform_version, + ) + .await + { + StateTransitionExecutionResult::SuccessfulExecution { .. } => {} + other => panic!("expected the vote to pass, got {other:?}"), + } +} + +/// A masternode votes `vote_choice` in `poll` at `time_ms`; returns what came of the vote. +async fn cast_vote( + platform: &mut TempPlatform, + poll: &ContestedDocumentResourceVotePoll, + vote_choice: ResourceVoteChoice, + masternode_seed: u64, + time_ms: TimestampMillis, + platform_version: &PlatformVersion, +) -> StateTransitionExecutionResult { + let (pro_tx_hash, _, signer, voting_key) = + setup_masternode_voting_identity(platform, masternode_seed, platform_version); + let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::ContestedDocumentResourceVotePoll(poll.clone()), + resource_vote_choice: vote_choice, + })); + let transition = MasternodeVoteTransition::try_from_vote_with_signer( + vote, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + None, + ) + .await + .expect("expected to make the vote") + .serialize_to_bytes() + .expect("expected to serialize the vote"); + process(platform, transition, time_ms, platform_version) +} + +/// Ends every poll due at `time_ms`, as the block at that time would. +fn end_polls_at( + platform: &TempPlatform, + time_ms: TimestampMillis, + height: u64, + platform_version: &PlatformVersion, +) { + let mut platform_state = (**platform.state.load()).clone(); + let block_info = block(time_ms, height); + platform_state.set_last_committed_block_info(Some( + ExtendedBlockInfoV0 { + basic_info: block_info, + app_hash: platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(), + quorum_hash: [0u8; 32], + block_id_hash: [0u8; 32], + proposer_pro_tx_hash: [0u8; 32], + signature: [0u8; 96], + round: 0, + } + .into(), + )); + platform.state.store(Arc::new(platform_state)); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + platform + .check_for_ended_vote_polls( + &platform_state, + &platform_state, + &block_info, + Some(&transaction), + platform_version, + ) + .expect("ending the polls due must never fail"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); +} + +/// Where the vote among the choices of a proposal stands; `None` before its first choice. +fn status( + platform: &TempPlatform, + poll: &ContestedDocumentResourceVotePoll, + platform_version: &PlatformVersion, +) -> Option { + let resolved = poll + .resolve(&platform.drive, None, platform_version) + .expect("expected to resolve the contest"); + let (_, stored_info) = platform + .drive + .fetch_contested_document_vote_poll_stored_info(&resolved, None, None, platform_version) + .expect("expected to read the contest"); + stored_info.map(|info| info.vote_poll_status()) +} + +fn prefunded_balance( + platform: &TempPlatform, + poll: &ContestedDocumentResourceVotePoll, + platform_version: &PlatformVersion, +) -> Credits { + platform + .drive + .fetch_prefunded_specialized_balance( + poll.specialized_balance_id() + .expect("expected the balance id") + .to_buffer(), + None, + platform_version, + ) + .expect("expected to read the balance") + .unwrap_or_default() +} + +/// The `document_type_name` document stored under `document_id`. +fn stored_document( + platform: &TempPlatform, + contract: &DataContract, + document_type_name: &str, + document_id: Identifier, +) -> Option { + let document_type = contract + .document_type_for_name(document_type_name) + .expect("expected the document type"); + platform + .drive + .query_documents( + DriveDocumentQuery::new_primary_key_single_item_query( + contract, + document_type, + document_id, + ), + None, + false, + None, + None, + ) + .expect("expected to query the document") + .documents_owned() + .pop() +} + +/// The choice stored under `choice_id`, which only the choice a vote kept is. +fn stored_choice( + platform: &TempPlatform, + contract: &DataContract, + choice_id: Identifier, +) -> Option { + stored_document(platform, contract, choice::NAME, choice_id) +} + +/// A serialized edit of the multiple choice proposal `question` by `proposer` (its owner) +/// setting `changes`, its revision bumped. +async fn edit_transition( + contract: &DataContract, + proposer: &mut Writer, + mut question: Document, + changes: &[(&str, Value)], + platform_version: &PlatformVersion, +) -> Vec { + for (property, value) in changes { + question.set(property, value.clone()); + } + question.increment_revision().expect("revision increments"); + let nonce = proposer.next_nonce; + proposer.next_nonce += 1; + let (_, signer, key) = &proposer.info; + BatchTransition::new_document_replacement_transition_from_document( + question, + contract + .document_type_for_name(multiple_choice_proposal::NAME) + .expect("expected the multiple choice proposal document type"), + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected to build the edit") + .serialize_to_bytes() + .expect("expected to serialize the edit") +} + +fn windows( + platform: &TempPlatform, + platform_version: &PlatformVersion, +) -> ContestWindows { + ContestWindows::generic(platform.config.network, platform_version) +} + +#[tokio::test] +async fn should_keep_the_choice_the_masternodes_vote_for_most() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let poll_duration = windows(&platform, platform_version).poll_duration_ms; + let mut proposer = writer(&mut platform, &mut rng); + let mut alice = writer(&mut platform, &mut rng); + let mut bob = writer(&mut platform, &mut rng); + let mut carol = writer(&mut platform, &mut rng); + + let proposal_id = ask( + &platform, + &contract, + &mut proposer, + "Which block time should Platform target?", + 10_000, + &mut rng, + platform_version, + ) + .await; + let poll = choice_poll(proposal_id); + // A question alone opens no vote + assert_eq!(status(&platform, &poll, platform_version), None); + + // Anyone enters a choice, the proposer included: the first opens the vote, and each + // prefunds the masternode votes with the contest fund + let start = 20_000; + let alice_choice = enter( + &platform, + &contract, + &mut alice, + proposal_id, + "One second", + start, + &mut rng, + platform_version, + ) + .await; + assert!(matches!( + status(&platform, &poll, platform_version), + Some(ContestedDocumentVotePollStatus::Started(_)) + )); + let bob_choice = enter( + &platform, + &contract, + &mut bob, + proposal_id, + "Two seconds", + start + 60_000, + &mut rng, + platform_version, + ) + .await; + let carol_choice = enter( + &platform, + &contract, + &mut carol, + proposal_id, + "Three seconds", + start + 120_000, + &mut rng, + platform_version, + ) + .await; + let proposer_choice = enter( + &platform, + &contract, + &mut proposer, + proposal_id, + "Leave it as it is", + start + 180_000, + &mut rng, + platform_version, + ) + .await; + let fund = platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount; + assert_eq!( + prefunded_balance(&platform, &poll, platform_version), + 4 * fund + ); + // A choice is held by the vote, not stored, until the vote ends + assert!(stored_choice(&platform, &contract, bob_choice).is_none()); + + // A vote names a choice by its document id. Two masternodes for Bob's choice, one each for + // Alice's and Carol's, one for none of them + for (seed, vote_choice) in [ + (1, ResourceVoteChoice::TowardsIdentity(bob_choice)), + (2, ResourceVoteChoice::TowardsIdentity(alice_choice)), + (3, ResourceVoteChoice::TowardsIdentity(bob_choice)), + (4, ResourceVoteChoice::TowardsIdentity(carol_choice)), + (5, ResourceVoteChoice::Lock), + ] { + vote( + &mut platform, + &poll, + vote_choice, + seed, + start + 200_000, + platform_version, + ) + .await; + } + + end_polls_at(&platform, start + poll_duration, 11, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + Some(ContestedDocumentVotePollStatus::Awarded(bob_choice)) + ); + let kept = stored_choice(&platform, &contract, bob_choice).expect("Bob's choice is kept"); + assert_eq!(kept.owner_id(), bob.id()); + assert_eq!( + kept.properties().get(choice::properties::TITLE), + Some(&Value::Text("Two seconds".to_string())) + ); + for other in [alice_choice, carol_choice, proposer_choice] { + assert!(stored_choice(&platform, &contract, other).is_none()); + } +} + +#[tokio::test] +async fn should_keep_no_choice_when_the_lock_votes_outnumber_every_choice() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let poll_duration = windows(&platform, platform_version).poll_duration_ms; + let mut proposer = writer(&mut platform, &mut rng); + let mut alice = writer(&mut platform, &mut rng); + let mut bob = writer(&mut platform, &mut rng); + let proposal_id = ask( + &platform, + &contract, + &mut proposer, + "Which mascot?", + 10_000, + &mut rng, + platform_version, + ) + .await; + let poll = choice_poll(proposal_id); + let start = 20_000; + let alice_choice = enter( + &platform, + &contract, + &mut alice, + proposal_id, + "An owl", + start, + &mut rng, + platform_version, + ) + .await; + let bob_choice = enter( + &platform, + &contract, + &mut bob, + proposal_id, + "A fox", + start + 60_000, + &mut rng, + platform_version, + ) + .await; + + for (seed, vote_choice) in [ + (1, ResourceVoteChoice::Lock), + (2, ResourceVoteChoice::TowardsIdentity(alice_choice)), + (3, ResourceVoteChoice::Lock), + ] { + vote( + &mut platform, + &poll, + vote_choice, + seed, + start + 120_000, + platform_version, + ) + .await; + } + + end_polls_at(&platform, start + poll_duration, 11, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + Some(ContestedDocumentVotePollStatus::Locked) + ); + for entered in [alice_choice, bob_choice] { + assert!(stored_choice(&platform, &contract, entered).is_none()); + } + + // The masternodes' answer stands: no choice can be entered for the proposal again + let (again, _) = choice_transition( + &contract, + &mut proposer, + proposal_id, + "A heron", + &mut rng, + platform_version, + ) + .await; + let error = process_refused( + &platform, + again, + start + poll_duration + 60_000, + platform_version, + ); + assert!( + matches!( + error, + ConsensusError::StateError(StateError::DocumentContestCurrentlyLockedError(_)) + ), + "{error:?}" + ); +} + +#[tokio::test] +async fn should_keep_the_first_choice_entered_when_nobody_votes() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let poll_duration = windows(&platform, platform_version).poll_duration_ms; + let mut proposer = writer(&mut platform, &mut rng); + let mut alice = writer(&mut platform, &mut rng); + let mut bob = writer(&mut platform, &mut rng); + let proposal_id = ask( + &platform, + &contract, + &mut proposer, + "Which mascot?", + 10_000, + &mut rng, + platform_version, + ) + .await; + let poll = choice_poll(proposal_id); + let start = 20_000; + let bob_choice = enter( + &platform, + &contract, + &mut bob, + proposal_id, + "A fox", + start, + &mut rng, + platform_version, + ) + .await; + let alice_choice = enter( + &platform, + &contract, + &mut alice, + proposal_id, + "An owl", + start + 60_000, + &mut rng, + platform_version, + ) + .await; + + end_polls_at(&platform, start + poll_duration, 11, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + Some(ContestedDocumentVotePollStatus::Awarded(bob_choice)) + ); + assert!(stored_choice(&platform, &contract, bob_choice).is_some()); + assert!(stored_choice(&platform, &contract, alice_choice).is_none()); +} + +#[tokio::test] +async fn should_let_an_identity_enter_several_choices_and_vote_on_each() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let poll_duration = windows(&platform, platform_version).poll_duration_ms; + let mut proposer = writer(&mut platform, &mut rng); + let mut alice = writer(&mut platform, &mut rng); + let mut bob = writer(&mut platform, &mut rng); + let proposal_id = ask( + &platform, + &contract, + &mut proposer, + "Which mascot?", + 10_000, + &mut rng, + platform_version, + ) + .await; + let poll = choice_poll(proposal_id); + let start = 20_000; + let owl = enter( + &platform, + &contract, + &mut alice, + proposal_id, + "An owl", + start, + &mut rng, + platform_version, + ) + .await; + let fox = enter( + &platform, + &contract, + &mut alice, + proposal_id, + "A fox", + start + 60_000, + &mut rng, + platform_version, + ) + .await; + let heron = enter( + &platform, + &contract, + &mut bob, + proposal_id, + "A heron", + start + 120_000, + &mut rng, + platform_version, + ) + .await; + // Each choice prefunds the votes, whoever entered it + let fund = platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount; + assert_eq!( + prefunded_balance(&platform, &poll, platform_version), + 3 * fund + ); + + // A vote names a choice, not who entered it: Alice entered two, and her id names neither + let refused = cast_vote( + &mut platform, + &poll, + ResourceVoteChoice::TowardsIdentity(alice.id()), + 1, + start + 180_000, + platform_version, + ) + .await; + assert!( + matches!( + refused, + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::VoteChoiceNotAllowedForVotePollError(_) + )) + ), + "{refused:?}" + ); + + // Her two choices are voted on apart + for (seed, vote_choice) in [ + (2, ResourceVoteChoice::TowardsIdentity(fox)), + (3, ResourceVoteChoice::TowardsIdentity(owl)), + (4, ResourceVoteChoice::TowardsIdentity(fox)), + (5, ResourceVoteChoice::TowardsIdentity(heron)), + ] { + vote( + &mut platform, + &poll, + vote_choice, + seed, + start + 200_000, + platform_version, + ) + .await; + } + + end_polls_at(&platform, start + poll_duration, 11, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + Some(ContestedDocumentVotePollStatus::Awarded(fox)) + ); + // The choice kept is stored under the identity that entered it + let kept = stored_choice(&platform, &contract, fox).expect("the fox is kept"); + assert_eq!(kept.owner_id(), alice.id()); + assert_eq!( + kept.properties().get(choice::properties::TITLE), + Some(&Value::Text("A fox".to_string())) + ); + for other in [owl, heron] { + assert!(stored_choice(&platform, &contract, other).is_none()); + } +} + +#[tokio::test] +async fn should_refuse_a_choice_for_a_proposal_that_does_not_exist() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let mut alice = writer(&mut platform, &mut rng); + // The id of a yes/no proposal names no multiple choice proposal either + let (transition, _) = choice_transition( + &contract, + &mut alice, + Identifier::new(rng.gen()), + "An owl", + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, transition, 20_000, platform_version); + assert!( + matches!( + error, + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(_)) + ), + "{error:?}" + ); +} + +#[tokio::test] +async fn should_refuse_a_choice_entered_after_the_join_window() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let join_window = windows(&platform, platform_version).join_window_ms; + let mut proposer = writer(&mut platform, &mut rng); + let mut alice = writer(&mut platform, &mut rng); + let mut bob = writer(&mut platform, &mut rng); + let proposal_id = ask( + &platform, + &contract, + &mut proposer, + "Which mascot?", + 10_000, + &mut rng, + platform_version, + ) + .await; + let start = 20_000; + enter( + &platform, + &contract, + &mut alice, + proposal_id, + "An owl", + start, + &mut rng, + platform_version, + ) + .await; + + let (late, _) = choice_transition( + &contract, + &mut bob, + proposal_id, + "A fox", + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, late, start + join_window + 1, platform_version); + assert!( + matches!( + error, + ConsensusError::StateError(StateError::DocumentContestNotJoinableError(_)) + ), + "{error:?}" + ); +} + +#[tokio::test] +async fn should_run_one_vote_per_proposal() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let poll_duration = windows(&platform, platform_version).poll_duration_ms; + let mut proposer = writer(&mut platform, &mut rng); + let mut alice = writer(&mut platform, &mut rng); + let mut bob = writer(&mut platform, &mut rng); + let mascot = ask( + &platform, + &contract, + &mut proposer, + "Which mascot?", + 10_000, + &mut rng, + platform_version, + ) + .await; + let color = ask( + &platform, + &contract, + &mut proposer, + "Which color?", + 11_000, + &mut rng, + platform_version, + ) + .await; + + // The same identities answer both questions, and each question has its own vote + let start = 20_000; + let mut entered = BTreeMap::new(); + for (index, (writer, proposal_id, answer)) in + [(&mut alice, mascot, "An owl"), (&mut bob, mascot, "A fox")] + .into_iter() + .enumerate() + { + let id = enter( + &platform, + &contract, + writer, + proposal_id, + answer, + start + index as u64 * 1_000, + &mut rng, + platform_version, + ) + .await; + entered.insert(answer, id); + } + for (index, (writer, proposal_id, answer)) in + [(&mut alice, color, "Blue"), (&mut bob, color, "Green")] + .into_iter() + .enumerate() + { + let id = enter( + &platform, + &contract, + writer, + proposal_id, + answer, + start + 10_000 + index as u64 * 1_000, + &mut rng, + platform_version, + ) + .await; + entered.insert(answer, id); + } + + vote( + &mut platform, + &choice_poll(mascot), + ResourceVoteChoice::TowardsIdentity(entered["An owl"]), + 1, + start + 60_000, + platform_version, + ) + .await; + vote( + &mut platform, + &choice_poll(color), + ResourceVoteChoice::TowardsIdentity(entered["Green"]), + 2, + start + 61_000, + platform_version, + ) + .await; + + // Both votes end within the poll duration of the later one; a block ends a bounded number + // of polls, so two blocks see them both out + end_polls_at( + &platform, + start + 20_000 + poll_duration, + 11, + platform_version, + ); + end_polls_at( + &platform, + start + 21_000 + poll_duration, + 12, + platform_version, + ); + assert_eq!( + status(&platform, &choice_poll(mascot), platform_version), + Some(ContestedDocumentVotePollStatus::Awarded(entered["An owl"])) + ); + assert_eq!( + status(&platform, &choice_poll(color), platform_version), + Some(ContestedDocumentVotePollStatus::Awarded(entered["Green"])) + ); + assert!(stored_choice(&platform, &contract, entered["An owl"]).is_some()); + assert!(stored_choice(&platform, &contract, entered["Green"]).is_some()); + assert!(stored_choice(&platform, &contract, entered["A fox"]).is_none()); + assert!(stored_choice(&platform, &contract, entered["Blue"]).is_none()); +} + +#[tokio::test] +async fn should_let_the_proposer_edit_the_question_for_two_hours() { + const TWO_HOURS_MS: TimestampMillis = 7_200_000; + let (mut platform, platform_version, contract, mut rng) = setup(); + let mut proposer = writer(&mut platform, &mut rng); + let mut alice = writer(&mut platform, &mut rng); + let created_at = 10_000; + let proposal_id = ask( + &platform, + &contract, + &mut proposer, + "Which mascot?", + created_at, + &mut rng, + platform_version, + ) + .await; + let question = |platform: &TempPlatform| { + stored_document( + platform, + &contract, + multiple_choice_proposal::NAME, + proposal_id, + ) + .expect("the question is stored") + }; + + // A choice entered meanwhile does not close the window + enter( + &platform, + &contract, + &mut alice, + proposal_id, + "An owl", + created_at + 60_000, + &mut rng, + platform_version, + ) + .await; + + // The title, the description and the url may change until two hours after the creation + let edit = edit_transition( + &contract, + &mut proposer, + question(&platform), + &[ + ( + multiple_choice_proposal::properties::TITLE, + Value::Text("Which mascot for Platform?".to_string()), + ), + ( + multiple_choice_proposal::properties::DESCRIPTION, + Value::Text("One animal, in one word.".to_string()), + ), + ( + multiple_choice_proposal::properties::URL, + Value::Text("https://example.org/mascot".to_string()), + ), + ], + platform_version, + ) + .await; + process_valid(&platform, edit, created_at + 3_600_000, platform_version); + let edited = question(&platform); + assert_eq!( + edited.get(multiple_choice_proposal::properties::TITLE), + Some(&Value::Text("Which mascot for Platform?".to_string())) + ); + assert_eq!( + edited.get(multiple_choice_proposal::properties::URL), + Some(&Value::Text("https://example.org/mascot".to_string())) + ); + + // to the millisecond + let edit = edit_transition( + &contract, + &mut proposer, + edited, + &[( + multiple_choice_proposal::properties::TITLE, + Value::Text("Which animal for Platform?".to_string()), + )], + platform_version, + ) + .await; + process_valid(&platform, edit, created_at + TWO_HOURS_MS, platform_version); + + // and no longer after that, whichever of them the edit changes + for (property, value) in [ + (multiple_choice_proposal::properties::TITLE, "Which bird?"), + ( + multiple_choice_proposal::properties::DESCRIPTION, + "One bird, in one word.", + ), + ( + multiple_choice_proposal::properties::URL, + "https://example.org/bird", + ), + ] { + let edit = edit_transition( + &contract, + &mut proposer, + question(&platform), + &[(property, Value::Text(value.to_string()))], + platform_version, + ) + .await; + let error = process_refused( + &platform, + edit, + created_at + TWO_HOURS_MS + 1, + platform_version, + ); + assert!( + matches!( + error, + ConsensusError::StateError(StateError::DocumentImmutablePropertyChangedError(_)) + ), + "{property}: {error:?}" + ); + } + assert_eq!( + question(&platform).get(multiple_choice_proposal::properties::TITLE), + Some(&Value::Text("Which animal for Platform?".to_string())) + ); +} + +#[tokio::test] +async fn should_let_only_its_owner_enter_choices_for_a_proposal_that_is_not_open() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let poll_duration = windows(&platform, platform_version).poll_duration_ms; + let mut proposer = writer(&mut platform, &mut rng); + let mut alice = writer(&mut platform, &mut rng); + let proposal_id = ask_stating( + &platform, + &contract, + &mut proposer, + "Which mascot?", + false, + 10_000, + &mut rng, + platform_version, + ) + .await; + let poll = choice_poll(proposal_id); + + // Anyone else is refused, and opens no vote + let (transition, _) = choice_transition( + &contract, + &mut alice, + proposal_id, + "An owl", + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, transition, 20_000, platform_version); + match &error { + ConsensusError::BasicError(BasicError::DocumentPropertyConstraintViolatedError(e)) => { + assert_eq!( + e.constraint(), + choice::rules::CLOSED_PROPOSAL_TAKES_ITS_OWNERS_CHOICES + ); + } + other => panic!("expected the proposal's rule to refuse it, got {other:?}"), + } + assert_eq!(status(&platform, &poll, platform_version), None); + + // The proposer supplies the choices, as many as it wants + let start = 30_000; + let mut choices = Vec::new(); + for (index, answer) in ["A fox", "A heron", "A lynx"].into_iter().enumerate() { + choices.push( + enter( + &platform, + &contract, + &mut proposer, + proposal_id, + answer, + start + index as u64 * 1_000, + &mut rng, + platform_version, + ) + .await, + ); + } + assert!(matches!( + status(&platform, &poll, platform_version), + Some(ContestedDocumentVotePollStatus::Started(_)) + )); + + // and still nobody else joins them + let (transition, _) = choice_transition( + &contract, + &mut alice, + proposal_id, + "An owl", + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, transition, 40_000, platform_version); + assert!( + matches!( + error, + ConsensusError::BasicError(BasicError::DocumentPropertyConstraintViolatedError(_)) + ), + "{error:?}" + ); + + for (seed, vote_choice) in [ + (1, ResourceVoteChoice::TowardsIdentity(choices[1])), + (2, ResourceVoteChoice::TowardsIdentity(choices[0])), + (3, ResourceVoteChoice::TowardsIdentity(choices[1])), + ] { + vote( + &mut platform, + &poll, + vote_choice, + seed, + start + 60_000, + platform_version, + ) + .await; + } + end_polls_at(&platform, start + poll_duration, 11, platform_version); + assert_eq!( + status(&platform, &poll, platform_version), + Some(ContestedDocumentVotePollStatus::Awarded(choices[1])) + ); + let kept = stored_choice(&platform, &contract, choices[1]).expect("the heron is kept"); + assert_eq!(kept.owner_id(), proposer.id()); + assert_eq!( + kept.properties().get(choice::properties::TITLE), + Some(&Value::Text("A heron".to_string())) + ); + for other in [choices[0], choices[2]] { + assert!(stored_choice(&platform, &contract, other).is_none()); + } +} + +#[tokio::test] +async fn should_never_let_a_proposal_change_whether_it_is_open() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let mut proposer = writer(&mut platform, &mut rng); + let created_at = 10_000; + // Within the two hours its text may still change in + let edited_at = created_at + 60_000; + for open in [true, false] { + let proposal_id = ask_stating( + &platform, + &contract, + &mut proposer, + "Which mascot?", + open, + created_at, + &mut rng, + platform_version, + ) + .await; + let question = stored_document( + &platform, + &contract, + multiple_choice_proposal::NAME, + proposal_id, + ) + .expect("the question is stored"); + let edit = edit_transition( + &contract, + &mut proposer, + question, + &[( + multiple_choice_proposal::properties::OPEN, + Value::Bool(!open), + )], + platform_version, + ) + .await; + let error = process_refused(&platform, edit, edited_at, platform_version); + assert!( + matches!( + error, + ConsensusError::StateError(StateError::DocumentImmutablePropertyChangedError(_)) + ), + "{open} to {}: {error:?}", + !open + ); + } +} diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index e2ecc5d7bf8..64b9cda14d5 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -5098,13 +5098,13 @@ "id": "votes.contested_resource.active_polls.contract.document_type.indexes.value.contender", "key": { "type": "dynamic", - "name": "identity_id", + "name": "contender_id", "matcher": { "type": "len", "len": 32 }, "encoding": "identifier32", - "description": "The contending identity's id" + "description": "The contending identity's id, or the contending document's id in a vote among documents (contested index resolution 2, protocol version 15)" }, "kinds": [ "Tree" @@ -5117,7 +5117,7 @@ "since": 1, "presence": "always", "source": "packages/rs-drive/src/drive/votes/paths.rs", - "description": "One contender, below the last value of the index. A 32 byte key is an index value at the levels before that, so what is below the key tells the two apart.", + "description": "One contender, below the last value of the index. A 32 byte key is an index value at the levels before that, so what is below the key tells the two apart. A contest keeps one contender for each identity, except a vote among documents, which keeps one for each document entered.", "children": [ { "id": "votes.contested_resource.active_polls.contract.document_type.indexes.value.contender.document", diff --git a/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/v1/mod.rs index 87ea393239b..c0fc4b0f79a 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_indices_for_contract_operations/v1/mod.rs @@ -64,6 +64,36 @@ impl Drive { .owned_document_info .document_info .get_storage_flags_ref(); + // The key the poll keeps this contender under: its owner's id, or, in a vote among + // documents, its own id, so that an identity may enter several. No contract + // registered before protocol version 15 declares that resolution, so every poll + // before it keys its contenders by their owners, as it always did. + let contender_key = if contested_index + .contested_index + .as_ref() + .is_some_and(|contested| contested.resolution.contenders_are_documents()) + { + match document_and_contract_info + .owned_document_info + .document_info + .get_document_id_as_slice() + { + Some(document_id) => <[u8; 32]>::try_from(document_id).map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a contested document's id must be 32 bytes", + )) + })?, + // An estimate has no document: any key of this size prices the contender + None if estimated_costs_only_with_layer_info.is_some() => owner_id, + None => { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a contested document must have an id", + ))) + } + } + } else { + owner_id + }; // we need to construct the path for documents on the contract // the path is @@ -240,7 +270,7 @@ impl Drive { } self.batch_insert_empty_tree_if_not_exists( - DriveKeyInfo::Key(owner_id.to_vec()).add_path_info(index_path_info.clone()), + DriveKeyInfo::Key(contender_key.to_vec()).add_path_info(index_path_info.clone()), TreeType::NormalTree, storage_flags, choice_apply_type, @@ -275,7 +305,7 @@ impl Drive { )?; let mut towards_identity_index_path_info = index_path_info.clone(); - towards_identity_index_path_info.push(DriveKeyInfo::Key(owner_id.to_vec()))?; + towards_identity_index_path_info.push(DriveKeyInfo::Key(contender_key.to_vec()))?; // Inter-wizard championship (event type) // | diff --git a/packages/rs-drive/src/drive/votes/structure.rs b/packages/rs-drive/src/drive/votes/structure.rs index 8f8a7d21e0a..b7e92b0853a 100644 --- a/packages/rs-drive/src/drive/votes/structure.rs +++ b/packages/rs-drive/src/drive/votes/structure.rs @@ -301,23 +301,30 @@ fn index_value() -> StructureNode { .lazy() .describe("Votes to lock the value so nobody gets it.") .child(voting_storage()), - StructureNode::identifier("contender", "identity_id", "The contending identity's id") - .kind(ElementKind::Tree) - .flags(&OWNED, CONTENDER_FLAGS) - .describe( - "One contender, below the last value of the \ + StructureNode::identifier( + "contender", + "contender_id", + "The contending identity's id, or the contending document's id in a vote \ + among documents (contested index resolution 2, protocol version 15)", + ) + .kind(ElementKind::Tree) + .flags(&OWNED, CONTENDER_FLAGS) + .describe( + "One contender, below the last value of the \ index. A 32 byte key is an index value at the \ levels before that, so what is below the key \ - tells the two apart.", - ) - .children(vec![ - StructureNode::fixed("document", &[0], "Document", "") - .kind(ElementKind::Reference) - .flags(&OWNED, CONTENDER_FLAGS) - .reference(CONTESTED_DOCUMENT) - .describe("The contender's document."), - voting_storage(), - ]), + tells the two apart. A contest keeps one contender \ + for each identity, except a vote among documents, \ + which keeps one for each document entered.", + ) + .children(vec![ + StructureNode::fixed("document", &[0], "Document", "") + .kind(ElementKind::Reference) + .flags(&OWNED, CONTENDER_FLAGS) + .reference(CONTESTED_DOCUMENT) + .describe("The contender's document."), + voting_storage(), + ]), StructureNode::dynamic( "next_value", "index_value", 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 2b67fe1762a..a31eb05d569 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 @@ -210,6 +210,13 @@ pub struct DocumentTypeSchemaVersions { /// the contracts admitted under meta-schema v0, which left the doctype level open. 0 /// (protocol version 15). pub parse_private_document: OptionalFeatureVersion, + /// Admits `"resolution": 2` on a contested index + /// (`ContestedIndexResolution::MasternodeVoteOnDocuments`): each document entered for the + /// index values is a contender of its own, named by its document id, so an identity may + /// enter several. `None` on versions that predate it: the index parser refuses the value + /// as an unknown resolution, exactly as it did before it existed, and so do the + /// meta-schemas (v1 to v3 list the resolutions they admit). 0 (protocol version 15). + pub parse_contested_documents_resolution: OptionalFeatureVersion, /// Parses the `generatedFrom` property keyword (a string property whose /// value a built-in function generates from other properties of the same /// document) onto the property, and checks the properties it reads at 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 2e37ed6d894..3e9120f2d1b 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 @@ -55,6 +55,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { parse_property_constraints: None, validation_schema: None, parse_private_document: None, + parse_contested_documents_resolution: None, apply_generated_from: None, expand_property_type_shorthands: None, validate_max_depth: 0, 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 4f91a868ec8..db69d92fcf8 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 @@ -55,6 +55,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { parse_property_constraints: None, validation_schema: None, parse_private_document: None, + parse_contested_documents_resolution: None, apply_generated_from: None, expand_property_type_shorthands: None, validate_max_depth: 0, 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 e6e257b610e..dcce56520e6 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 @@ -57,6 +57,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { parse_property_constraints: None, validation_schema: None, parse_private_document: None, + parse_contested_documents_resolution: None, apply_generated_from: None, expand_property_type_shorthands: None, validate_max_depth: 0, 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 3f5e73a2bc6..bbe18185686 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 @@ -57,6 +57,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { parse_property_constraints: None, validation_schema: None, parse_private_document: None, + parse_contested_documents_resolution: None, apply_generated_from: None, expand_property_type_shorthands: None, validate_max_depth: 0, 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 f40afeeddbf..94113e6aaad 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 @@ -59,6 +59,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { parse_property_constraints: None, validation_schema: None, parse_private_document: None, + parse_contested_documents_resolution: None, apply_generated_from: None, expand_property_type_shorthands: None, validate_max_depth: 0, 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 c21b0bf83db..9630324a042 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 @@ -92,6 +92,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { parse_property_constraints: Some(0), // changed: the meta-schema v3 `propertyConstraints` doctype keyword (named comparisons between integer expressions over the document's properties) is parsed onto the document type and the properties it reads are checked; None before this version means the keyword is ignored, as it was before it existed validation_schema: None, parse_private_document: None, + parse_contested_documents_resolution: None, apply_generated_from: Some(0), // changed: the meta-schema v3 `generatedFrom` keyword (a string property whose value a built-in function generates from other properties of the same document) is parsed onto the property and the properties it reads are checked at registration; None before this version means the keyword is ignored, as it was before it existed expand_property_type_shorthands: Some(0), // new: a property schema of `"type": "identifier"`, or `"type": "bytes"` with a `size`, is read as the byte array it stands for, before the meta-schema, the parser and the document validator see it; the contract is stored as sent. None before this version leaves the schema as sent, which every earlier meta-schema refuses validate_max_depth: 1, // changed: a scalar `$ref` target is not walked and the visited set is never cleared, so the depth check is bounded; v0 was exponential on a crafted `$defs` chain diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs index af4d9953712..7cec4befc06 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs @@ -20,6 +20,9 @@ use crate::version::dpp_versions::dpp_contract_versions::{ /// and the native validator; /// - `parse_private_document`, `Some(0)` rather than `None`: the `private` keyword declares a /// private document type, which only the native schema check of registration admits; +/// - `parse_contested_documents_resolution`, `Some(0)` rather than `None`: a contested index +/// may declare `"resolution": 2`, a masternode vote among documents, where each document +/// entered is a contender named by its document id and an identity may enter several; /// - `apply_generated_from`, `Some(1)` rather than `Some(0)`: a `generatedFrom` property may /// also be generated with `sys.tokens.decimals`, the decimals of the token an identifier /// property names, on an integer property; @@ -46,6 +49,8 @@ pub const CONTRACT_VERSIONS_V7: DPPContractVersions = DPPContractVersions { validation_schema: Some(0), // changed: the `private` keyword declares a private document type parse_private_document: Some(0), + // changed: a contested index may make each document a contender (`resolution` 2) + parse_contested_documents_resolution: Some(0), apply_generated_from: Some(1), // changed: admits sys.tokens.decimals ..CONTRACT_VERSIONS_V6.document_type_versions.schema }, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index e488f588aa3..1575cf879dc 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -658,6 +658,22 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// not Platform's, fails that block. Protocol v14 and earlier keep /// `update_masternode_list` 0 or 1, `update_operator_identity` 0 and /// `add_new_keys_to_masternode_identity` 0. +/// +/// 33. **A contest among documents**: `CONTRACT_VERSIONS_V7` selects +/// `parse_contested_documents_resolution` 0: a contested index may declare +/// `"resolution": 2` (`ContestedIndexResolution::MasternodeVoteOnDocuments`), which the +/// native schema check of note 7 admits and meta-schema v3 refuses. Masternodes vote for +/// a contender, abstain or lock, as under resolution 0, but each document entered for the +/// index values is a contender of its own: the contest keeps it under its document id +/// where the other resolutions keep it under its owner's id, so an identity may enter +/// several (no `DocumentContestIdentityAlreadyContestantError`), a vote towards a +/// contender names the document id, and the stored result names the winning document. +/// The winner is stored under the owner its document names. The methods that write, check +/// and award a contender read the resolution of the contested index, which no contract +/// registered before this version can declare, so the generations protocol v14 and +/// earlier select behave as they did. The decision proposals contract's `choice` type +/// uses it for the choices of a multiple choice proposal. Protocol v14 and earlier keep +/// `parse_contested_documents_resolution` `None` and refuse the value. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V10, // changed: calculate_fee 1 prices a refund at the storing epoch's rate (note 27); apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10) and verify_private_document_entries Some(0) a page of a private type's store (note 13); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11); document method versions v5: insert_empty_private_document_store_operation Some(0) creates a private document type's store, and fetch_private_document_entries and prove_private_document_entries Some(0) read and prove a page of it (note 13); apply_drive_operations 2 resolves a batch's estimate, remove_reference_for_index_level_for_contract_operations 2, stateless_delete_of_non_tree_for_costs 1 and for_total_system_credits_update 1 describe their layers as they are (note 14); create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description (note 17); fetch_core_fork_activation_height and store_core_fork_activation_height Some(0) keep the Core fork activations (note 18); document method versions v5: append_private_document_entries_operations Some(0) appends a private document create's entries (note 25); grove_version GROVE_V5 (note 26); verify methods v4: verify_proof_keep_serialized 1 verifies a document that may be contested (note 29); document method versions v5: single_document_path_query 1 stops a single document lookup at a block time above the pointer to the latest version, and verify_proof_keep_serialized 1 verifies it (note 31); add_new_keys_to_masternode_identity 1 adds a masternode key whose hash the identity already holds a reference for (note 32) From 141db1ca7103b1d2e314687f294561486c17056d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 01:44:57 +0700 Subject: [PATCH 02/20] fix(dpp): allow the shared prefix of the stored contested resolution's variants The stored form mirrors `ContestedIndexResolution` variant for variant under the same names. With the vote among documents it has three, all starting `MasternodeVote`, which clippy's `enum_variant_names` refuses on a non-public enum. Co-Authored-By: Claude Fable 5.2 --- .../document_type/serialized_version/v0/index.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/index.rs b/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/index.rs index 3949cac561d..95f707b47d6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/index.rs +++ b/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/index.rs @@ -56,7 +56,9 @@ pub(super) enum StoredContestedFieldMatch { PositiveIntegerMatch(u128), } -/// [`ContestedIndexResolution`]. +/// [`ContestedIndexResolution`], variant for variant and under the same names, which all +/// start alike. +#[allow(clippy::enum_variant_names)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] pub(super) enum StoredContestedResolution { MasternodeVote, From fb2ed1ca281d89df1cf730cdbdd7c6dcf15ea6f1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 03:16:37 +0700 Subject: [PATCH 03/20] fix(dpp): read a contested index's resolution through a versioned method Review of the contest among documents: - The index parser took the decision to read `"resolution": 2` as a flag on its admissions, set by its callers from `parse_contested_documents_resolution` with `is_some()`. It is now a versioned method, `ContestedIndexResolution::try_from_schema_value`, taking the platform version: `None` converts the value as it always was, version 0 also reads 2, and any other version is refused with `UnknownVersionMismatch`. The schema parser and the registration cost both parse an index through `Index::try_from_value_map_for_platform_version`, which calls it; `Index::try_from_value_map` keeps the conversion that predates it. The admissions struct is as it was. - The comments of the decision proposals crate no longer say an identity enters one choice. - The contender, vote choice, winner and proof result docs say that the id naming a contender is its document's id in a vote among documents. - A test proves and verifies the vote state of a proposal whose choices include two from one identity, while the vote runs and after it ends. Co-Authored-By: Claude Fable 5.2 --- .../decision-proposals-contract/src/v1/mod.rs | 15 +- .../try_from_schema/common/mod.rs | 21 +- .../index/integer_range_parse_tests.rs | 1 - .../data_contract/document_type/index/mod.rs | 297 ++++++++++++------ .../methods/registration_cost/v2/mod.rs | 20 +- .../voting/contender_structs/contender/mod.rs | 4 +- .../contender_structs/contender/v0/mod.rs | 14 +- .../src/voting/contender_structs/mod.rs | 16 +- .../vote_choices/resource_vote_choice/mod.rs | 4 + .../mod.rs | 4 + .../multiple_choice_proposal_tests.rs | 210 +++++++++++++ packages/rs-drive-proof-verifier/src/types.rs | 11 +- .../dpp_versions/dpp_contract_versions/mod.rs | 9 +- 13 files changed, 478 insertions(+), 148 deletions(-) diff --git a/packages/decision-proposals-contract/src/v1/mod.rs b/packages/decision-proposals-contract/src/v1/mod.rs index 21ed4c2299c..d1de1b2d5e8 100644 --- a/packages/decision-proposals-contract/src/v1/mod.rs +++ b/packages/decision-proposals-contract/src/v1/mod.rs @@ -33,9 +33,10 @@ pub mod document_types { } /// A question put to the masternodes whose answers are the choices entered for it. Any - /// identity may enter one [`choice`] when it is open, and only its owner when it is not. Its - /// owner may edit the title, description and url for two hours after creating it, and every - /// version is kept; whether it is open never changes. Undeletable. + /// identity may enter [`choice`]s when it is open, and only its owner when it is not; whoever + /// may enter them may enter several. Its owner may edit the title, description and url for + /// two hours after creating it, and every version is kept; whether it is open never changes. + /// Undeletable. pub mod multiple_choice_proposal { pub const NAME: &str = "multipleChoiceProposal"; @@ -58,9 +59,11 @@ pub mod document_types { /// An answer to a multiple choice proposal. The choices entered for one proposal contend /// in a masternode vote on the contested `byProposal` index: entering the first opens the - /// vote, a later one joins it, and an identity enters at most one. Only the owner of a - /// proposal that is not open enters a choice for it. The choice with the most votes is kept, unless - /// the lock votes (none of the choices) outnumber it. Immutable and undeletable. + /// vote and a later one joins it. Each choice is a contender of its own, named by its + /// document id, so an identity may enter several, each paying the contest fund. Anyone + /// enters choices for an open proposal, and only its owner for one that is not. The + /// choice with the most votes is kept, unless the lock votes (none of the choices) + /// outnumber it. Immutable and undeletable. pub mod choice { pub const NAME: &str = "choice"; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 1fc39c09264..6dced66f769 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -1143,7 +1143,11 @@ fn parse_indices( // produced — which is what `TryFrom<&[(Value, Value)]> for // Index` does, so without generation 3 this whole call // collapses back to `.as_slice().try_into()`. - let mut index: Index = Index::try_from_value_map( + // A contested index's `resolution` is read as the platform version's + // tables say (`ContestedIndexResolution::try_from_schema_value`): that + // is a method version, not a property of the generation, since + // protocol versions 14 and 15 both parse generation 3 + let mut index: Index = Index::try_from_value_map_for_platform_version( index_value .to_map() .map_err(consensus_or_protocol_value_error)? @@ -1163,20 +1167,9 @@ fn parse_indices( .generation .admit_range_countable_implies_countable, no_locking_resolution: ctx.generation.admit_index_no_locking_resolution, - // Not a property of the generation: protocol versions 14 and - // 15 both parse generation 3, and only the tables setting this - // version admit a contest among documents - documents_resolution: ctx - .platform_version - .dpp - .contract_versions - .document_type_versions - .schema - .parse_contested_documents_resolution - .is_some(), }, - ) - .map_err(consensus_or_protocol_data_contract_error)?; + ctx.platform_version, + )?; // INTEGER RANGE: the index grammar cannot see property // types, so the source's key encoding is resolved here, diff --git a/packages/rs-dpp/src/data_contract/document_type/index/integer_range_parse_tests.rs b/packages/rs-dpp/src/data_contract/document_type/index/integer_range_parse_tests.rs index c405c60741a..c744776dbc9 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/integer_range_parse_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/integer_range_parse_tests.rs @@ -20,7 +20,6 @@ fn admissions(integer_range: bool) -> IndexGrammarAdmissions { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, } } diff --git a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs index 12e209ca64e..21de8dcd6bd 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs @@ -14,6 +14,7 @@ pub enum OrderBy { Desc, } +use crate::data_contract::document_type::class_methods::consensus_or_protocol_data_contract_error; use crate::data_contract::errors::DataContractError; use crate::ProtocolError; @@ -200,6 +201,11 @@ pub const SKIP_IF_ABSENT: &str = "skipIfAbsent"; /// the referenced type. Meta-schema v3+ (protocol version 14). pub const SUMMABLE_OFF_COUNT_INDEX: &str = "summableOffCountIndex"; +/// A conversion of the `resolution` a contested index declares, one generation of +/// [`ContestedIndexResolution::try_from_schema_value`]. +pub(crate) type ContestedResolutionReader = + fn(u8) -> Result; + #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Ord, PartialOrd)] #[cfg_attr(feature = "serde-conversion", derive(Serialize, Deserialize))] @@ -216,12 +222,62 @@ pub enum ContestedIndexResolution { /// [`MasternodeVote`](Self::MasternodeVote), but each document entered for the index /// values is a contender of its own, kept and named by its document id where the other /// resolutions keep and name a contender by its owner's id. An identity may therefore - /// enter several documents, and a vote towards a contender names a document. Admitted - /// where `parse_contested_documents_resolution` is set (protocol version 15). + /// enter several documents, and a vote towards a contender names a document. Read from + /// a schema by [`try_from_schema_value`](Self::try_from_schema_value) version 0 + /// (protocol version 15); [`TryFrom`] does not know it. MasternodeVoteOnDocuments = 2, } impl ContestedIndexResolution { + /// Reads the `resolution` a contested index declares in a document schema, as + /// `platform_version` reads it (`parse_contested_documents_resolution`). `None`, every + /// protocol version before 15, converts the value as [`TryFrom`] always did, with + /// its error for a value it does not know. Version 0 (protocol version 15) also reads + /// 2 as [`MasternodeVoteOnDocuments`](Self::MasternodeVoteOnDocuments). + /// + /// # Errors + /// + /// The conversion's own error for an unknown resolution, and + /// [`ProtocolError::UnknownVersionMismatch`] for a version of the method this build + /// does not have. + pub fn try_from_schema_value( + value: u8, + platform_version: &PlatformVersion, + ) -> Result { + Self::schema_value_reader(platform_version)?(value) + } + + /// The conversion [`try_from_schema_value`](Self::try_from_schema_value) dispatches to + /// for `platform_version`, for a parser that reads the version table once and then the + /// resolution of each index. + pub(crate) fn schema_value_reader( + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .parse_contested_documents_resolution + { + None => Ok(Self::try_from), + Some(0) => Ok(Self::try_from_schema_value_v0), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "ContestedIndexResolution::try_from_schema_value".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + /// Version 0: the resolutions [`TryFrom`] knows, and 2, the vote among documents. + fn try_from_schema_value_v0(value: u8) -> Result { + match value { + 2 => Ok(ContestedIndexResolution::MasternodeVoteOnDocuments), + value => Self::try_from(value), + } + } + /// Whether each document entered is a contender of its own, named by its document id /// ([`MasternodeVoteOnDocuments`](Self::MasternodeVoteOnDocuments)), instead of each /// identity being one, named by its id. @@ -1139,11 +1195,6 @@ pub(crate) struct IndexGrammarAdmissions { /// Whether a contested index may be resolved without a Lock choice /// (`"resolution": 1`, [`ContestedIndexResolution::MasternodeVoteNoLocking`]). pub(crate) no_locking_resolution: bool, - /// Whether a contested index may make each document a contender - /// (`"resolution": 2`, [`ContestedIndexResolution::MasternodeVoteOnDocuments`]). Not a - /// property of a schema generation: protocol versions 14 and 15 both parse generation - /// 3, and only the tables setting `parse_contested_documents_resolution` admit it. - pub(crate) documents_resolution: bool, } impl IndexGrammarAdmissions { @@ -1163,22 +1214,6 @@ impl IndexGrammarAdmissions { summable_off_count_index: generation >= 3, range_countable_implies_countable: generation >= 3, no_locking_resolution: generation >= 3, - documents_resolution: false, - } - } - - /// The admissions `platform_version` parses an index with: those of its - /// `document_type_schema` generation, and the documents resolution where - /// `parse_contested_documents_resolution` is set. - pub(crate) fn for_platform_version(platform_version: &PlatformVersion) -> Self { - let schema = &platform_version - .dpp - .contract_versions - .document_type_versions - .schema; - Self { - documents_resolution: schema.parse_contested_documents_resolution.is_some(), - ..Self::for_schema_generation(schema.document_type_schema) } } } @@ -2084,7 +2119,6 @@ impl TryFrom<&[(Value, Value)]> for Index { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) } @@ -2114,9 +2148,50 @@ impl Index { /// /// `terminal_allowed` gates the `terminal` keyword (indexOnly document /// types), which also joined the grammar at meta-schema v3. + /// + /// A contested index's `resolution` is converted as [`TryFrom`] always converted + /// it. A parse made for a platform version goes through + /// [`try_from_value_map_for_platform_version`](Self::try_from_value_map_for_platform_version) + /// instead, which reads it as that version's tables say. pub(crate) fn try_from_value_map( index_type_value_map: &[(Value, Value)], admissions: IndexGrammarAdmissions, + ) -> Result { + Self::parse_value_map( + index_type_value_map, + admissions, + ContestedIndexResolution::try_from, + ) + } + + /// [`try_from_value_map`](Self::try_from_value_map) for a platform version: a contested + /// index's `resolution` is read by + /// [`ContestedIndexResolution::try_from_schema_value`], so by the generation of it the + /// version's tables select. + /// + /// # Errors + /// + /// [`ProtocolError::UnknownVersionMismatch`] when the tables select a generation this + /// build does not have, before anything is parsed; the grammar's own refusals as the + /// consensus error (or, without validation, the data contract error) every index parse + /// gives. + pub(crate) fn try_from_value_map_for_platform_version( + index_type_value_map: &[(Value, Value)], + admissions: IndexGrammarAdmissions, + platform_version: &PlatformVersion, + ) -> Result { + let read_contested_resolution = + ContestedIndexResolution::schema_value_reader(platform_version)?; + Self::parse_value_map(index_type_value_map, admissions, read_contested_resolution) + .map_err(consensus_or_protocol_data_contract_error) + } + + /// The index grammar, reading a contested index's `resolution` with + /// `read_contested_resolution`. + fn parse_value_map( + index_type_value_map: &[(Value, Value)], + admissions: IndexGrammarAdmissions, + read_contested_resolution: ContestedResolutionReader, ) -> Result { let IndexGrammarAdmissions { ranked: ranked_aggregates_allowed, @@ -2129,7 +2204,6 @@ impl Index { summable_off_count_index: summable_off_count_index_allowed, range_countable_implies_countable, no_locking_resolution: no_locking_resolution_allowed, - documents_resolution: documents_resolution_allowed, } = admissions; // Decouple the map // It contains properties and a unique key @@ -2298,19 +2372,12 @@ impl Index { } "resolution" => { let resolution_int = contested_value.to_integer::()?; - // Resolution 2 is read only where it is admitted: anywhere - // else it is the unknown resolution it always was, refused - // by the conversion below with the error it always gave - contested_index_information.resolution = match resolution_int { - 2 if documents_resolution_allowed => { - ContestedIndexResolution::MasternodeVoteOnDocuments - } - resolution_int => { - resolution_int.try_into().map_err(|e: ProtocolError| { - DataContractError::ValueWrongType(e.to_string()) - })? - } - }; + contested_index_information.resolution = read_contested_resolution( + resolution_int, + ) + .map_err(|e: ProtocolError| { + DataContractError::ValueWrongType(e.to_string()) + })?; if contested_index_information.resolution == ContestedIndexResolution::MasternodeVoteNoLocking && !no_locking_resolution_allowed @@ -3597,7 +3664,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("should parse"); @@ -3620,7 +3686,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -3643,7 +3708,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -3670,7 +3734,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }; let mut map = index_value_map("postId", None); @@ -3706,7 +3769,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -3743,7 +3805,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -3772,7 +3833,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("should parse"); @@ -3801,7 +3861,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -3831,7 +3890,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -3862,7 +3920,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("should parse"); @@ -3889,7 +3946,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("should parse"); @@ -3921,7 +3977,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("should parse"); @@ -3953,7 +4008,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -3983,7 +4037,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -4018,7 +4071,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("the parser applies structural rules only"); @@ -4048,7 +4100,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -4074,7 +4125,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -4104,7 +4154,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -4165,7 +4214,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -4208,7 +4256,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("should parse"); @@ -4244,7 +4291,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -4282,7 +4328,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -4314,7 +4359,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("a non-unique $updatedAt bucketing stays legal"); @@ -4341,7 +4385,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_err(); @@ -5597,7 +5640,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("all three ranked keywords must parse when the grammar allows them"); @@ -5630,7 +5672,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("index without ranked keywords must parse"); @@ -5679,7 +5720,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("ranked flags on a compound index must be accepted"); @@ -5716,7 +5756,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ); assert!( @@ -5754,7 +5793,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ); assert!( @@ -5790,7 +5828,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ); assert!( @@ -5823,7 +5860,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ); assert!( @@ -5858,7 +5894,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ); assert!( @@ -5893,7 +5928,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ); assert!( @@ -5930,7 +5964,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("rankedAverageable on the averageable sugar form must parse"); @@ -5973,7 +6006,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("rankedAverageable on the explicit longhand form must parse"); @@ -6006,7 +6038,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ); assert!(result.is_err(), "{key} must reject a non-boolean value"); @@ -6045,7 +6076,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ); assert!( @@ -6123,7 +6153,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: true, - documents_resolution: false, } } @@ -6620,7 +6649,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, }, ); let msg = format!( @@ -6689,7 +6717,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ); assert!( @@ -6724,7 +6751,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_or_else(|e| panic!("{axis} with no nullSearchable key must parse: {e:?}")); @@ -6755,7 +6781,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ) .unwrap_or_else(|e| { @@ -6784,7 +6809,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("nullSearchable: false on a plain index must still parse"); @@ -6808,7 +6832,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: true, no_locking_resolution: false, - documents_resolution: false, }, ) .expect("nullSearchable: false on a range-averageable index must still parse"); @@ -6876,46 +6899,70 @@ mod tests { ); } - /// `"resolution": 2` is read only where `parse_contested_documents_resolution` is set: - /// protocol version 14, which parses the same generation 3, refuses it as the unknown - /// resolution it was before, and protocol version 15 parses it as the vote among - /// documents. + /// `"resolution": 2` is read by version 0 of + /// `ContestedIndexResolution::try_from_schema_value` alone: protocol version 14, which + /// parses the same generation 3, refuses it as the unknown resolution it was before, + /// and protocol version 15 parses it as the vote among documents. #[test] - fn test_index_contested_resolution_on_documents_needs_the_admission() { + fn test_index_contested_resolution_on_documents_is_read_from_protocol_version_15() { let index_map = contested_unique_index_map(2); let unknown = "contested index resolution unknown: 2"; - for (refused, admissions) in [ + let version_14 = PlatformVersion::get(14).expect("protocol version 14"); + + // The grammar on its own never reads it, whatever it admits + for (parse, result) in [ ("no admission", Index::try_from(index_map.as_slice())), ( "generation 3", Index::try_from_value_map(index_map.as_slice(), v3_admissions()), ), - ( - "protocol version 14", - Index::try_from_value_map( - index_map.as_slice(), - IndexGrammarAdmissions::for_platform_version( - PlatformVersion::get(14).expect("protocol version 14"), - ), - ), - ), ] { - let error = admissions.expect_err(refused); + let error = result.expect_err(parse); assert!( matches!(&error, DataContractError::ValueWrongType(message) if message.contains(unknown)), - "{refused}: {error:?}" + "{parse}: {error:?}" ); } + // nor does a parse for protocol version 14, with the same refusal + let error = Index::try_from_value_map_for_platform_version( + index_map.as_slice(), + v3_admissions(), + version_14, + ) + .expect_err("protocol version 14 does not read the vote among documents"); + assert!(format!("{error:?}").contains(unknown), "{error:?}"); + let error = ContestedIndexResolution::try_from_schema_value(2, version_14) + .expect_err("protocol version 14 converts the value as it always did"); + assert!(error.to_string().contains(unknown), "{error}"); - let admissions = IndexGrammarAdmissions::for_platform_version(PlatformVersion::latest()); - let index = Index::try_from_value_map(index_map.as_slice(), admissions) - .expect("protocol version 15 admits the vote among documents"); + let index = Index::try_from_value_map_for_platform_version( + index_map.as_slice(), + v3_admissions(), + PlatformVersion::latest(), + ) + .expect("protocol version 15 reads the vote among documents"); let resolution = index.contested_index.expect("contested").resolution; assert_eq!( resolution, ContestedIndexResolution::MasternodeVoteOnDocuments ); + // The resolutions every version knows read the same at both, and one nobody knows + // is refused at both + for platform_version in [version_14, PlatformVersion::latest()] { + for (value, expected) in [ + (0, ContestedIndexResolution::MasternodeVote), + (1, ContestedIndexResolution::MasternodeVoteNoLocking), + ] { + assert_eq!( + ContestedIndexResolution::try_from_schema_value(value, platform_version) + .expect("a resolution every version knows"), + expected + ); + } + assert!(ContestedIndexResolution::try_from_schema_value(3, platform_version).is_err()); + } + // A contender is named by its document under it, and by its owner under the others let owner_id = Identifier::from([1; 32]); let document_id = Identifier::from([2; 32]); @@ -6927,10 +6974,53 @@ mod tests { assert!(!resolution.contenders_are_documents()); assert_eq!(resolution.contender_id(owner_id, document_id), owner_id); } - // The value itself converts as it always did: only the parser admits it + // The value itself converts as it always did: only the versioned method reads it assert!(ContestedIndexResolution::try_from(2u8).is_err()); } + /// A generation of the method this build does not have is refused as such, by the + /// method and by the parse that calls it, before anything is read; every protocol + /// version this build knows selects one it has. + #[test] + fn test_contested_resolution_method_refuses_a_version_it_does_not_have() { + let mut unknown_generation = PlatformVersion::latest().clone(); + unknown_generation + .dpp + .contract_versions + .document_type_versions + .schema + .parse_contested_documents_resolution = Some(1); + let is_version_mismatch = |error: &ProtocolError| { + matches!( + error, + ProtocolError::UnknownVersionMismatch { + known_versions, + received: 1, + .. + } if known_versions == &vec![0] + ) + }; + let error = ContestedIndexResolution::try_from_schema_value(0, &unknown_generation) + .expect_err("an unknown generation reads nothing"); + assert!(is_version_mismatch(&error), "{error:?}"); + let error = Index::try_from_value_map_for_platform_version( + contested_unique_index_map(0).as_slice(), + v3_admissions(), + &unknown_generation, + ) + .expect_err("an unknown generation parses nothing"); + assert!(is_version_mismatch(&error), "{error:?}"); + + for protocol_version in 1..=PlatformVersion::latest().protocol_version { + let platform_version = + PlatformVersion::get(protocol_version).expect("a known protocol version"); + assert!( + ContestedIndexResolution::schema_value_reader(platform_version).is_ok(), + "protocol version {protocol_version}" + ); + } + } + #[test] fn test_index_try_from_contested_with_unique() { let index_map: Vec<(Value, Value)> = vec![ @@ -7206,7 +7296,6 @@ mod tests { summable_off_count_index: false, range_countable_implies_countable: false, no_locking_resolution: false, - documents_resolution: false, } } diff --git a/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs b/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs index a3d2a0fbb99..13f6085e85c 100644 --- a/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs @@ -132,11 +132,21 @@ impl DataContractInSerializationFormat { // while the identical index parses fine during // validation — the fee must cover every index the // contract actually registers. - let admissions = - IndexGrammarAdmissions::for_platform_version(platform_version); - if let Ok(index) = - Index::try_from_value_map(index_value_map.as_slice(), admissions) - { + let admissions = IndexGrammarAdmissions::for_schema_generation( + platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .document_type_schema, + ); + // and the same reading of a contested index's `resolution`, + // which the platform version's tables decide + if let Ok(index) = Index::try_from_value_map_for_platform_version( + index_value_map.as_slice(), + admissions, + platform_version, + ) { let base_index_fee = if index.contested_index.is_some() { fee_version.document_type_base_contested_index_registration_fee } else if index.unique { diff --git a/packages/rs-dpp/src/voting/contender_structs/contender/mod.rs b/packages/rs-dpp/src/voting/contender_structs/contender/mod.rs index 2e2366775cc..942f9aafb9e 100644 --- a/packages/rs-dpp/src/voting/contender_structs/contender/mod.rs +++ b/packages/rs-dpp/src/voting/contender_structs/contender/mod.rs @@ -21,7 +21,7 @@ use platform_version::version::PlatformVersion; /// Represents a contender in the contested document vote poll. /// -/// This struct holds the identity ID of the contender, the serialized document, +/// This struct holds the id the contest names the contender by, the serialized document, /// and the vote tally. #[derive(Debug, PartialEq, Clone, From)] pub enum Contender { @@ -32,7 +32,7 @@ pub enum Contender { /// Represents a contender in the contested document vote poll. /// This is for internal use where the document is in serialized form /// -/// This struct holds the identity ID of the contender, the serialized document, +/// This struct holds the id the contest names the contender by, the serialized document, /// and the vote tally. #[cfg_attr( all(feature = "json-conversion", feature = "serde-conversion"), diff --git a/packages/rs-dpp/src/voting/contender_structs/contender/v0/mod.rs b/packages/rs-dpp/src/voting/contender_structs/contender/v0/mod.rs index b265e9f392f..606acbeb1bd 100644 --- a/packages/rs-dpp/src/voting/contender_structs/contender/v0/mod.rs +++ b/packages/rs-dpp/src/voting/contender_structs/contender/v0/mod.rs @@ -11,11 +11,14 @@ use platform_version::version::PlatformVersion; /// Represents a contender in the contested document vote poll. /// -/// This struct holds the identity ID of the contender, the serialized document, +/// This struct holds the id the contest names the contender by, the serialized document, /// and the vote tally. #[derive(Debug, PartialEq, Clone, Default)] pub struct ContenderV0 { - /// The identity ID of the contender. + /// The id the contest names the contender by: the contending identity's id, or, in a + /// vote among documents (`ContestedIndexResolution::MasternodeVoteOnDocuments`), the + /// contending document's id. There the identity that entered the document is the + /// document's `owner_id`, and one identity may have entered several contenders. pub identity_id: Identifier, /// The document associated with the contender. pub document: Option, @@ -26,7 +29,7 @@ pub struct ContenderV0 { /// Represents a contender in the contested document vote poll. /// This is for internal use where the document is in serialized form /// -/// This struct holds the identity ID of the contender, the serialized document, +/// This struct holds the id the contest names the contender by, the serialized document, /// and the vote tally. #[cfg_attr(feature = "json-conversion", json_safe_fields)] #[derive(Debug, PartialEq, Eq, Clone, Default, Encode, Decode, DecodeUntrusted)] @@ -36,7 +39,10 @@ pub struct ContenderV0 { serde(rename_all = "camelCase") )] pub struct ContenderWithSerializedDocumentV0 { - /// The identity ID of the contender. + /// The id the contest names the contender by: the contending identity's id, or, in a + /// vote among documents (`ContestedIndexResolution::MasternodeVoteOnDocuments`), the + /// contending document's id. There the identity that entered the document is the + /// document's `owner_id`, and one identity may have entered several contenders. pub identity_id: Identifier, /// The serialized document associated with the contender. pub serialized_document: Option>, diff --git a/packages/rs-dpp/src/voting/contender_structs/mod.rs b/packages/rs-dpp/src/voting/contender_structs/mod.rs index 5e25b9d4163..8b4b58983a8 100644 --- a/packages/rs-dpp/src/voting/contender_structs/mod.rs +++ b/packages/rs-dpp/src/voting/contender_structs/mod.rs @@ -16,11 +16,14 @@ pub use contender::{Contender, ContenderWithSerializedDocument}; /// Represents a finalized contender in the contested document vote poll. /// This is for internal use where the document is in serialized form /// -/// This struct holds the identity ID of the contender, the serialized document, +/// This struct holds the id the contest names the contender by, the serialized document, /// and the vote tally. #[derive(Debug, PartialEq, Eq, Clone, Default)] pub struct FinalizedContenderWithSerializedDocument { - /// The identity ID of the contender. + /// The id the contest names the contender by: the contending identity's id, or, in a + /// vote among documents (`ContestedIndexResolution::MasternodeVoteOnDocuments`), the + /// contending document's id. There the identity that entered the document is the + /// document's `owner_id`, and one identity may have entered several contenders. pub identity_id: Identifier, /// The serialized document associated with the contender. pub serialized_document: Vec, @@ -31,7 +34,7 @@ pub struct FinalizedContenderWithSerializedDocument { /// Represents a finalized contender in the contested document vote poll. /// This is for keeping information about previous vote polls /// -/// This struct holds the identity ID of the contender, the serialized document, +/// This struct holds the id the contest names the contender by, the serialized document, /// and the vote tally. #[derive(Debug, PartialEq, Eq, Clone, Default, Encode, Decode, DecodeUntrusted)] pub struct FinalizedResourceVoteChoicesWithVoterInfo { @@ -59,11 +62,14 @@ impl fmt::Display for FinalizedResourceVoteChoicesWithVoterInfo { /// Represents a finalized contender in the contested document vote poll. /// This is for internal use where the document is in serialized form /// -/// This struct holds the identity ID of the contender, the document, +/// This struct holds the id the contest names the contender by, the document, /// and the vote tally. #[derive(Debug, PartialEq, Clone)] pub struct FinalizedContender { - /// The identity ID of the contender. + /// The id the contest names the contender by: the contending identity's id, or, in a + /// vote among documents (`ContestedIndexResolution::MasternodeVoteOnDocuments`), the + /// contending document's id. There the identity that entered the document is the + /// document's `owner_id`, and one identity may have entered several contenders. pub identity_id: Identifier, /// The document associated with the contender. pub document: Document, diff --git a/packages/rs-dpp/src/voting/vote_choices/resource_vote_choice/mod.rs b/packages/rs-dpp/src/voting/vote_choices/resource_vote_choice/mod.rs index 82184dec4e4..71af32c5222 100644 --- a/packages/rs-dpp/src/voting/vote_choices/resource_vote_choice/mod.rs +++ b/packages/rs-dpp/src/voting/vote_choices/resource_vote_choice/mod.rs @@ -33,6 +33,10 @@ use std::fmt; // (consensus binary format is unaffected). #[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] pub enum ResourceVoteChoice { + /// A vote for a contender, by the id the contest names its contenders by: the + /// contending identity's id, or, in a vote among documents + /// (`ContestedIndexResolution::MasternodeVoteOnDocuments`), the contending document's + /// id. The id of an identity that entered documents there names no contender. TowardsIdentity(Identifier), #[default] Abstain, diff --git a/packages/rs-dpp/src/voting/vote_info_storage/contested_document_vote_poll_winner_info/mod.rs b/packages/rs-dpp/src/voting/vote_info_storage/contested_document_vote_poll_winner_info/mod.rs index ca9f59fdc2d..d6fcc4c3c70 100644 --- a/packages/rs-dpp/src/voting/vote_info_storage/contested_document_vote_poll_winner_info/mod.rs +++ b/packages/rs-dpp/src/voting/vote_info_storage/contested_document_vote_poll_winner_info/mod.rs @@ -18,6 +18,10 @@ use std::fmt; pub enum ContestedDocumentVotePollWinnerInfo { #[default] NoWinner, + /// The contender that won, by the id the contest names its contenders by: the winning + /// identity's id, or, in a vote among documents + /// (`ContestedIndexResolution::MasternodeVoteOnDocuments`), the winning document's id, + /// whose owner is the document's `owner_id`. WonByIdentity(Identifier), Locked, } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs index 0d624c4b84f..a8af2e39aba 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs @@ -13,6 +13,15 @@ use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use dapi_grpc::platform::v0::get_contested_resource_vote_state_request::get_contested_resource_vote_state_request_v0::ResultType; +use dapi_grpc::platform::v0::get_contested_resource_vote_state_request::GetContestedResourceVoteStateRequestV0; +use dapi_grpc::platform::v0::get_contested_resource_vote_state_response::{ + get_contested_resource_vote_state_response_v0, GetContestedResourceVoteStateResponseV0, +}; +use dapi_grpc::platform::v0::{ + get_contested_resource_vote_state_request, get_contested_resource_vote_state_response, + GetContestedResourceVoteStateRequest, +}; use dpp::block::block_info::BlockInfo; use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0; use dpp::consensus::basic::BasicError; @@ -23,10 +32,12 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; use dpp::decision_proposal::DECISION_PROPOSALS_CONTRACT_ID; use dpp::document::document_methods::DocumentMethodsV0; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dpp::document::{Document, DocumentV0, DocumentV0Getters, DocumentV0Setters}; use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::{Identity, IdentityPublicKey, TimestampMillis}; +use dpp::platform_serialization::PLATFORM_BINCODE_CONFIG; use dpp::platform_value::{Bytes32, Identifier, Value}; use dpp::prelude::IdentityNonce; use dpp::serialization::PlatformSerializable; @@ -42,6 +53,7 @@ use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ ContestedDocumentVotePollStatus, ContestedDocumentVotePollStoredInfoV0Getters, }; use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +use dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; use dpp::voting::vote_polls::VotePoll; use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; use dpp::voting::votes::resource_vote::ResourceVote; @@ -49,7 +61,13 @@ use dpp::voting::votes::Vote; use drive::drive::document::query::QueryDocumentsOutcomeV0Methods; use drive::drive::document::ContestWindows; use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::resolve::ContestedDocumentResourceVotePollResolver; +use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfoAllowBorrowed; +use drive::query::vote_poll_vote_state_query::{ + ContestedDocumentVotePollDriveQueryExecutionResult, ContestedDocumentVotePollDriveQueryResultType, + ResolvedContestedDocumentVotePollDriveQuery, +}; use drive::query::DriveDocumentQuery; +use drive::util::object_size_info::DataContractResolvedInfo; use platform_version::version::PlatformVersion; use rand::prelude::StdRng; use rand::{Rng, SeedableRng}; @@ -545,6 +563,78 @@ async fn edit_transition( .expect("expected to serialize the edit") } +/// The vote among the choices of `proposal_id` as a client reads it: proved by the vote state +/// query and verified, the verified root being Drive's. +fn proved_vote_state( + platform: &TempPlatform, + contract: &DataContract, + proposal_id: Identifier, + platform_version: &PlatformVersion, +) -> ContestedDocumentVotePollDriveQueryExecutionResult { + const LIMIT: u16 = 10; + let index_value = Value::Identifier(proposal_id.to_buffer()); + let platform_state = platform.state.load(); + let response = platform + .query_contested_resource_vote_state( + GetContestedResourceVoteStateRequest { + version: Some(get_contested_resource_vote_state_request::Version::V0( + GetContestedResourceVoteStateRequestV0 { + contract_id: contract.id().to_vec(), + document_type_name: choice::NAME.to_string(), + index_name: choice::indexes::BY_PROPOSAL.to_string(), + index_values: vec![bincode::encode_to_vec( + &index_value, + PLATFORM_BINCODE_CONFIG, + ) + .expect("expected to encode the proposal id")], + result_type: ResultType::DocumentsAndVoteTally as i32, + allow_include_locked_and_abstaining_vote_tally: true, + start_at_identifier_info: None, + count: Some(LIMIT as u32), + prove: true, + }, + )), + }, + &platform_state, + platform_version, + ) + .expect("expected to execute the query") + .into_data() + .expect("expected the query to be valid"); + let get_contested_resource_vote_state_response::Version::V0( + GetContestedResourceVoteStateResponseV0 { result, .. }, + ) = response.version.expect("expected a version"); + let Some(get_contested_resource_vote_state_response_v0::Result::Proof(proof)) = result else { + panic!("expected a proof") + }; + let (root_hash, result) = ResolvedContestedDocumentVotePollDriveQuery { + vote_poll: ContestedDocumentResourceVotePollWithContractInfoAllowBorrowed { + contract: DataContractResolvedInfo::BorrowedDataContract(contract), + document_type_name: choice::NAME.to_string(), + index_name: choice::indexes::BY_PROPOSAL.to_string(), + index_values: vec![index_value], + }, + result_type: ContestedDocumentVotePollDriveQueryResultType::DocumentsAndVoteTally, + offset: None, + limit: Some(LIMIT), + start_at: None, + allow_include_locked_and_abstaining_vote_tally: true, + } + .verify_vote_poll_vote_state_proof(proof.grovedb_proof.as_ref(), platform_version) + .expect("expected to verify the proof"); + assert_eq!( + root_hash, + platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected Drive's root"), + "the proof verifies against Drive's root" + ); + result +} + fn windows( platform: &TempPlatform, platform_version: &PlatformVersion, @@ -933,6 +1023,126 @@ async fn should_let_an_identity_enter_several_choices_and_vote_on_each() { } } +#[tokio::test] +async fn should_prove_the_choices_of_one_identity_as_contenders_of_their_own() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let poll_duration = windows(&platform, platform_version).poll_duration_ms; + let mut proposer = writer(&mut platform, &mut rng); + let mut alice = writer(&mut platform, &mut rng); + let mut bob = writer(&mut platform, &mut rng); + let proposal_id = ask( + &platform, + &contract, + &mut proposer, + "Which mascot?", + 10_000, + &mut rng, + platform_version, + ) + .await; + let poll = choice_poll(proposal_id); + let start = 20_000; + let owl = enter( + &platform, + &contract, + &mut alice, + proposal_id, + "An owl", + start, + &mut rng, + platform_version, + ) + .await; + let fox = enter( + &platform, + &contract, + &mut alice, + proposal_id, + "A fox", + start + 60_000, + &mut rng, + platform_version, + ) + .await; + let heron = enter( + &platform, + &contract, + &mut bob, + proposal_id, + "A heron", + start + 120_000, + &mut rng, + platform_version, + ) + .await; + for (seed, vote_choice) in [ + (1, ResourceVoteChoice::TowardsIdentity(fox)), + (2, ResourceVoteChoice::TowardsIdentity(owl)), + (3, ResourceVoteChoice::TowardsIdentity(fox)), + (4, ResourceVoteChoice::Lock), + ] { + vote( + &mut platform, + &poll, + vote_choice, + seed, + start + 200_000, + platform_version, + ) + .await; + } + + // While the vote runs, the proof holds a contender under each choice's document id, each + // with its own tally, and the two Alice entered are both hers + let running = proved_vote_state(&platform, &contract, proposal_id, platform_version); + assert_eq!(running.winner, None); + assert_eq!(running.locked_vote_tally, Some(1)); + let document_type = contract + .document_type_for_name(choice::NAME) + .expect("expected the choice document type"); + let proved: BTreeMap, Identifier, Identifier)> = running + .contenders + .iter() + .map(|contender| { + let document = Document::from_bytes( + contender + .serialized_document() + .as_ref() + .expect("the proof holds the contender's document"), + document_type, + platform_version, + ) + .expect("expected to deserialize the choice"); + ( + contender.identity_id(), + (contender.vote_tally(), document.id(), document.owner_id()), + ) + }) + .collect(); + assert_eq!( + proved, + BTreeMap::from([ + (owl, (Some(1), owl, alice.id())), + (fox, (Some(2), fox, alice.id())), + (heron, (Some(0), heron, bob.id())), + ]) + ); + + // Once it has ended, the proof names the winning choice by its document id + end_polls_at(&platform, start + poll_duration, 11, platform_version); + let ended = proved_vote_state(&platform, &contract, proposal_id, platform_version); + assert!( + matches!( + ended.winner, + Some((ContestedDocumentVotePollWinnerInfo::WonByIdentity(winner), _)) if winner == fox + ), + "{:?}", + ended.winner + ); + let kept = stored_choice(&platform, &contract, fox).expect("the fox is kept"); + assert_eq!(kept.owner_id(), alice.id()); +} + #[tokio::test] async fn should_refuse_a_choice_for_a_proposal_that_does_not_exist() { let (mut platform, platform_version, contract, mut rng) = setup(); diff --git a/packages/rs-drive-proof-verifier/src/types.rs b/packages/rs-drive-proof-verifier/src/types.rs index cb8a252f5c0..8f53fbefefe 100644 --- a/packages/rs-drive-proof-verifier/src/types.rs +++ b/packages/rs-drive-proof-verifier/src/types.rs @@ -157,7 +157,10 @@ pub type AddressInfos = RetrievedObjects; /// Multiple contenders for a vote resolution. /// -/// Mapping between the contenders identity IDs and their info. +/// Mapping between the ids a contest names its contenders by and their info: identity ids, +/// or, in a vote among documents (`ContestedIndexResolution::MasternodeVoteOnDocuments`), +/// document ids, where a contender's owner is its document's `owner_id` and one identity +/// may own several contenders. /// If a contender is not found, it is represented as `None`. #[derive(Default, Debug, Clone)] #[cfg_attr( @@ -168,7 +171,8 @@ pub type AddressInfos = RetrievedObjects; pub struct Contenders { /// The winner if the contest is finished pub winner: Option<(ContestedDocumentVotePollWinnerInfo, BlockInfo)>, - /// Contenders indexed by their identity IDs. + /// Contenders indexed by the ids the contest names them by: identity ids, or document + /// ids in a vote among documents. pub contenders: BTreeMap, /// Tally of abstain votes. pub abstain_vote_tally: Option, @@ -177,7 +181,8 @@ pub struct Contenders { } impl Contenders { - /// Return a map of deserialized [Contender] objects indexed by their identity IDs. + /// Return a map of deserialized [Contender] objects indexed by the ids the contest + /// names them by: identity ids, or document ids in a vote among documents. pub fn to_contenders( &self, document_type: &DocumentType, 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 a31eb05d569..bc81d2dec55 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 @@ -210,12 +210,13 @@ pub struct DocumentTypeSchemaVersions { /// the contracts admitted under meta-schema v0, which left the doctype level open. 0 /// (protocol version 15). pub parse_private_document: OptionalFeatureVersion, - /// Admits `"resolution": 2` on a contested index + /// `ContestedIndexResolution::try_from_schema_value`: reads the `resolution` a contested + /// index declares. Version 0 also reads 2 /// (`ContestedIndexResolution::MasternodeVoteOnDocuments`): each document entered for the /// index values is a contender of its own, named by its document id, so an identity may - /// enter several. `None` on versions that predate it: the index parser refuses the value - /// as an unknown resolution, exactly as it did before it existed, and so do the - /// meta-schemas (v1 to v3 list the resolutions they admit). 0 (protocol version 15). + /// enter several. `None` on versions that predate it: the value is converted as it always + /// was, which refuses 2 as an unknown resolution, and so do the meta-schemas (v1 to v3 + /// list the resolutions they admit). 0 (protocol version 15). pub parse_contested_documents_resolution: OptionalFeatureVersion, /// Parses the `generatedFrom` property keyword (a string property whose /// value a built-in function generates from other properties of the same From cd6f7901c7dcb668a17c8404c5ce3f1d00f56567 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 04:40:08 +0700 Subject: [PATCH 04/20] feat(platform)!: document references whose target the document names (PV15) A `permanentDocument` or `moderatedDocument` `refersTo` on an identifier property may leave its target to the referring document: `contractIdProperty` and `documentTypeProperty` name the properties holding the contract and the document type, in place of `contractId` and `documentType`, and `documentTypeRequirements` says what the named type must look like: named slots, each a property it must have (`name`: `"$any"`, a property name, or a name read from the referring document; `type`; `maxBytes`; `distinctFrom`), and `require`, `anyOf` / `allOf` over slot names. Version 1 of `apply_property_reference` (protocol version 15) parses the declaration to `DocumentPropertyReferenceTarget::DynamicDocument`, appended, and refuses its keys wherever else a reference sits. Nothing about the target is known at registration, so `document_reference_validation` 1 checks it when a document is written: the contract and the type exist, the type is not private, meets the requirements (`ReferencedDocumentTypeRequirementNotMetError`, 40153) and is of the declared kind, and the document exists. A replace checks the reference again when its value or a property it reads the target from changed. Protocol version 14 and earlier keep `apply_property_reference` 0, which does not read the keys. Co-Authored-By: Claude Fable 5.2 --- book/src/SUMMARY.md | 1 + book/src/contract-keywords.md | 4 + .../contract-keywords/refers-to-dynamic.md | 145 ++++ book/src/contract-keywords/refers-to.md | 4 + book/src/data-model/documents.md | 8 + book/src/error-handling/error-codes.md | 2 +- .../try_from_schema/dynamic_reference.rs | 542 +++++++++++++ .../class_methods/try_from_schema/mod.rs | 74 ++ .../native_schema/references.rs | 145 +++- .../v3/dynamic_reference_tests.rs | 759 ++++++++++++++++++ .../class_methods/try_from_schema/v3/mod.rs | 13 +- .../src/data_contract/document_type/mod.rs | 13 + .../property/dynamic_document_reference.rs | 375 +++++++++ .../document_type/property/mod.rs | 56 +- packages/rs-dpp/src/errors/consensus/codes.rs | 1 + .../errors/consensus/state/document/mod.rs | 1 + ...document_type_requirement_not_met_error.rs | 69 ++ .../src/errors/consensus/state/state_error.rs | 18 + .../document_reference_validation/v0/mod.rs | 12 + .../document_reference_validation/v1/mod.rs | 96 ++- .../contract_user_moderation/tests.rs | 1 + .../tests/dynamic_document_reference.rs | 401 +++++++++ .../dpp_versions/dpp_contract_versions/mod.rs | 6 +- .../dpp_versions/dpp_contract_versions/v7.rs | 6 + .../src/version/mocks/v2_test.rs | 1 + .../src/version/system_limits/mod.rs | 8 + .../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 | 1 + .../rs-platform-version/src/version/v15.rs | 29 + .../src/errors/consensus/consensus_error.rs | 4 + packages/wasm-dpp2/src/consensus_error.rs | 23 +- .../data_contract/document_type_reference.rs | 193 ++++- 34 files changed, 3000 insertions(+), 14 deletions(-) create mode 100644 book/src/contract-keywords/refers-to-dynamic.md create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/document/referenced_document_type_requirement_not_met_error.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index c1eb611bf9f..c9ca0511b3e 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -87,6 +87,7 @@ - [Finding by Properties (findBy)](contract-keywords/refers-to-lookup.md) - [Expressions](contract-keywords/refers-to-expressions.md) - [List Elements (inList)](contract-keywords/refers-to-list-element.md) + - [A Target the Document Names](contract-keywords/refers-to-dynamic.md) - [Writer and Creator References](contract-keywords/owner-refers-to.md) - [distinctFrom](contract-keywords/distinct-from.md) - [maxBytes](contract-keywords/max-bytes.md) diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md index 1161eeebb5f..63ced2e968b 100644 --- a/book/src/contract-keywords.md +++ b/book/src/contract-keywords.md @@ -194,6 +194,9 @@ A typed array's element (`items`) takes `type` (the `identifier` and `bytes` sho | `type: "identityPublicKey"` | | The value names an identity key that exists and is not disabled. | 14 | [identityPublicKey](contract-keywords/refers-to.md#identitypublickey) | | `documentType` | document type name | The referenced document type. | 14 | [documentType](contract-keywords/refers-to.md#documenttype) | | `contractId` | identifier | The contract holding `documentType`, when it is not this one. | 14 | [contractId](contract-keywords/refers-to.md#contractid) | +| `contractIdProperty` | top-level identifier property name | On a `permanentDocument` or `moderatedDocument`: the property holding the id of the contract the referenced type lives in, in place of `contractId`. Comes with `documentTypeProperty`. | 15 | [A Target the Document Names](contract-keywords/refers-to-dynamic.md) | +| `documentTypeProperty` | top-level string property name | The property holding the name of the referenced document type, in place of `documentType`. | 15 | [A Target the Document Names](contract-keywords/refers-to-dynamic.md) | +| `documentTypeRequirements` | `{ "properties", "require"? }` | Beside the two above: 1 to 4 named slots, each a property the named type must have (`name`, `type`, `maxBytes`, `distinctFrom`), and which of them must be filled. | 15 | [documentTypeRequirements](contract-keywords/refers-to-dynamic.md#documenttyperequirements) | | `findBy` | 1 to 10 entries: referenced property → `"."`, `"$ownerId"`, a path or a function | Finds the document through the unique index of `documentType` over exactly these properties, in any order; `"."` is the value itself. Without it the value is the document's `$id`. | 14 | [findBy](contract-keywords/refers-to-lookup.md) · [internals](data-model/documents.md#found-by-a-unique-index-findby) | | `findBy.` | `{ "function": "sys.hash.sha256d", "params" }` | A function entry: the referenced property holds the hash of `params` (paths, `{ "const": text }`, `"."` for a value without a path), which fills that part of the key, finding a commitment made earlier. At most one. | 14 | [Commit and reveal](contract-keywords/refers-to-lookup.md#commit-and-reveal) · [internals](data-model/documents.md#commit-and-reveal-a-findby-function) | | `where` | 1 to 10 entries `{ "": "" }` | Checked on the document found: each referenced property (or `$ownerId`, `$creatorId`, `$id`) must equal the referring property. A value of `"$ownerId"`, the writer, makes a write gate. Never finds the document. | 14 | [where](contract-keywords/refers-to.md#where) | @@ -328,6 +331,7 @@ The first three limits come from the meta-schema, the rest from protocol version | `max_property_constraints` | 16 | rules in one `propertyConstraints` | | `max_property_constraint_nodes` | 32 | nodes in one rule | | `max_choose_first_candidates` | 1000 | commitments a create reads for a [`chooseFirst`](contract-keywords/refers-to-lookup.md#choosing-the-first-commitment-choosefirst) | +| `max_document_type_requirement_slots` | 4 | slots a [`documentTypeRequirements`](contract-keywords/refers-to-dynamic.md#documenttyperequirements) may declare | | `min_document_ttl_seconds`, `max_document_ttl_seconds` | 3600, 31536000 | `ttl` | | `max_time_range_ttl_seconds` | 604800 | a `timeRange` index's `ttl` | | `max_contested_summed_value_magnitude` | 134217728 (2^27) | the `minimum` and `maximum` of a summed property on a type with a contested index | diff --git a/book/src/contract-keywords/refers-to-dynamic.md b/book/src/contract-keywords/refers-to-dynamic.md new file mode 100644 index 00000000000..111c46a4150 --- /dev/null +++ b/book/src/contract-keywords/refers-to-dynamic.md @@ -0,0 +1,145 @@ +# A Target the Document Names + +A document reference normally names its target in the schema: a `documentType`, and a `contractId` when the type is in another contract. A reference with `contractIdProperty` and `documentTypeProperty` leaves both to the document: two of its own properties say which contract and which document type the value points into. `documentTypeRequirements` then says what that document type must look like. Reach for it when a contract should accept a document of any contract that qualifies: a proposal described by a moderated text someone else hosts, a listing attached to a record of whichever registry issued it. + +| | | +|---|---| +| **Where** | `refersTo` on an identifier property, at the top level or inside an object. Not on the `items` of a typed array, not an operand of an [expression](refers-to-expressions.md), not [`ownerRefersTo` or `creatorRefersTo`](owner-refers-to.md). | +| **Value** | `contractIdProperty` and `documentTypeProperty`, both required, and optionally `documentTypeRequirements`, in a `"type": "permanentDocument"` or `"type": "moderatedDocument"` declaration that carries nothing else. | +| **Since** | protocol version 15 | +| **On update** | Fixed, like the rest of `refersTo` (`IncompatibleDocumentTypeSchemaError`, 10246). | +| **Errors** | When a document is written: `ReferencedDocumentTypeNotFoundError` (40121), `ReferencedDocumentTypePrivateError` (40150), `ReferencedDocumentTypeRequirementNotMetError` (40153), `ReferencedDocumentTypeDeletableError` (40122), `ReferencedDocumentTypeNotModeratedError` (40143), `ReferencedDocumentTypeModeratedError` (40144), `ReferencedEntityNotFoundError` (40120). At registration: `InvalidContractStructure` (10231), `JsonSchemaError` (10101). | + +## Example + +```json +"note": { + "type": "object", + "properties": { + "descriptionId": { + "type": "identifier", + "refersTo": { + "type": "moderatedDocument", + "contractIdProperty": "descriptionContractId", + "documentTypeProperty": "descriptionDocumentType", + "documentTypeRequirements": { + "properties": { + "body": { "name": "$any", "type": "string" }, + "bodyBytes": { "name": "$any", "type": "array", "byteArray": true } + }, + "require": { "anyOf": ["body", "bodyBytes"] } + } + }, + "position": 0 + }, + "descriptionContractId": { "type": "identifier", "position": 1 }, + "descriptionDocumentType": { "type": "string", "minLength": 1, "maxLength": 64, "position": 2 } + }, + "required": ["descriptionId", "descriptionContractId", "descriptionDocumentType"], + "additionalProperties": false +} +``` + +A note is described by a document its author points at: `descriptionContractId` names a contract, `descriptionDocumentType` a document type of it, and `descriptionId` a document of that type. The type must be one whose documents only the contract's moderators remove, on the record, and it must hold text, as a string or as bytes. Nothing about that contract is in this schema. + +## The keys + +| Key | Value | What it does | +|---|---|---| +| `contractIdProperty` | the name of a top-level identifier property of the document type, 1 to 64 letters, digits or underscores | The property holding the id of the contract the referenced document type lives in. Takes the place of `contractId`. | +| `documentTypeProperty` | the name of a top-level string property | The property holding the name of the referenced document type. Takes the place of `documentType`. | +| `documentTypeRequirements` | object: `properties` (required), `require` | What the named document type must look like. Absent, the type is held to its kind alone. | + +The two properties come together: a declaration names its target in the schema or through the document, never half of each. The declaring contract is named like any other: a document pointing into its own contract writes that contract's id. + +## documentTypeRequirements + +`properties` holds 1 to 4 named **slots**. Each describes a top-level property the referenced document type must have, and is **filled** by one property of that type matching the description. + +| Slot key | Value | Meaning | +|---|---|---| +| `name` | `"$any"`, a property name, or `{ "property": "" }` | Which property may fill the slot: any property matching the rest; the one called this; or the one the referring document names in that property of its own. | +| `type` | `"string"`, `"identifier"`, `"integer"`, `"boolean"`, or `"array"` with `"byteArray": true` | What the property holds. A byte array slot is not filled by an identifier, nor an identifier slot by a byte array. | +| `maxBytes` | integer, 1 to 4294967295 | On a string or byte array slot: the property can never hold more bytes than this. A string's bound is its `maxBytes`, or four times its `maxLength`; a byte array's is its `maxItems`. A property declaring no bound meets none. | +| `distinctFrom` | a slot name, or a list of slot names | This slot is filled by a different property than each of them. Saying it on one side is enough. | + +A slot name is 1 to 64 letters, digits or underscores. A transient property fills nothing: it is never stored. + +`require` says which slots must be filled: + +- absent, every slot; +- a slot name, that slot; +- `{ "anyOf": [...] }` or `{ "allOf": [...] }` over two or more terms, each a slot name or the other combinator over two or more slot names. Two levels say everything `anyOf` and `allOf` can say of a set of slots. + +Every slot is named by `require` when there is one: a slot it never names could not matter. + +```json +"documentTypeRequirements": { + "properties": { + "title": { "name": "$any", "type": "string", "maxBytes": 512 }, + "body": { "name": { "property": "bodyProperty" }, "type": "string", "distinctFrom": "title" }, + "bodyBytes": { "name": "$any", "type": "array", "byteArray": true } + }, + "require": { "allOf": ["title", { "anyOf": ["body", "bodyBytes"] }] } +} +``` + +A short text property, and either the string property the document names in its `bodyProperty`, other than the one taken as the title, or a byte array. + +**When the requirements are met.** They are met when some choice of properties fills a set of slots `require` holds for, with slots that are `distinctFrom` each other filled by different properties. Consensus only asks whether such a choice exists. A client that needs to know which property is the title and which the body can settle it the same way every time: take the slots in the order of their names and give each the first property, in the type's `position` order, that matches it and that no slot it must differ from has taken, backing up when a later slot is left with nothing. Naming the property outright, or through the referring document, leaves nothing to settle. + +## How it works + +Nothing about the target is known when the contract is registered, so registration checks the declaration alone. Everything about the target is checked when a document is written, in this order, the first failure refusing the write: + +1. The document names a contract and a document type. A document that holds the reference but leaves out `contractIdProperty`'s or `documentTypeProperty`'s property points nowhere: `ReferencedEntityNotFoundError` (40120). +2. The contract exists and has a document type of that name: `ReferencedDocumentTypeNotFoundError` (40121) otherwise. Another contract costs a billed fetch, once per write. +3. The type is not [private](../contract-keywords.md#document-type): `ReferencedDocumentTypePrivateError` (40150). +4. The type meets `documentTypeRequirements`: `ReferencedDocumentTypeRequirementNotMetError` (40153). +5. The type is of the declared kind, as a reference naming it in the schema is held to at registration: for `permanentDocument` its documents never disappear (40122 or 40144 otherwise), for `moderatedDocument` they disappear only through a moderator's recorded removal (40143). +6. The document exists: `ReferencedEntityNotFoundError` (40120). + +**On a replace** the reference is checked again, whole, when its value changed or when a property it reads its target from changed: the contract id, the document type name, or a property a slot takes its name from. A changed one points the same id somewhere else. Otherwise it is kept as a permanent or moderated reference is: a moderated target a moderator removed since resolves to its removal record, and an edit of the referring document's other properties passes. + +**What stays true.** A document type's deletion and moderation settings never change, and neither do its existing properties' types, so a target that qualified keeps its kind. The requirements are judged when the reference is checked, not afterwards. + +## Rules at registration + +- Both `contractIdProperty` and `documentTypeProperty` are present, name two different properties, and `type` is `permanentDocument` or `moderatedDocument`. A `deletableDocument` reference always names its type in the schema: a dynamic target is trusted for what its type promises about its documents staying, and a deletable type promises nothing. +- The declaration carries nothing else: no `contractId`, `documentType`, `findBy`, `where`, `inList`, `consume` or `constraints`. Each needs the referenced type when the contract is registered. +- It sits on a single identifier property: not on the elements of a typed array, in a reference expression, or in `ownerRefersTo` and `creatorRefersTo`. +- `contractIdProperty` names a top-level identifier property, and `documentTypeProperty` and every slot's `{ "property": ... }` a top-level string property. None is transient or the reference property itself, and each is required wherever the reference property is, so a document that must hold the reference always says where it points. +- `documentTypeRequirements` holds 1 to 4 slots (`max_document_type_requirement_slots`), each well formed; `distinctFrom` names other slots of the same `properties`; `require` names slots, and all of them. +- No [`propertyConstraints` rule](property-constraints.md#values-of-a-referenced-document) reads a value through the reference, no [index](derived-index-properties.md) holds one, and no chained query joins through it: each resolves the referenced type from the schema. + +Before protocol version 15 the keys are not part of `refersTo`: registration refuses them. + +## Who vouches for the target + +The requirements say what the target's type looks like. They do not say who runs its contract. A `moderatedDocument` target is removed by the moderators of the contract the document names, and the author of the document chose that contract. Where that matters, hold the contract to something as well: give the contract id property its own reference, + +```json +"descriptionContractId": { + "type": "identifier", + "refersTo": { "type": "contract", "contractRequirements": { "moderation": "elected" } }, + "position": 1 +} +``` + +or compare it with the contracts the application trusts in a [`propertyConstraints`](property-constraints.md) rule (`{ "in": ["descriptionContractId", ["", ""]] }`). + +## Errors + +| Error | Code | When | +|---|---|---| +| `ReferencedDocumentTypeRequirementNotMetError` | 40153 | Write: the named document type does not meet the reference's `documentTypeRequirements`. | +| `ReferencedDocumentTypeNotFoundError` | 40121 | Write: the named contract does not exist, or has no document type of that name. | +| `ReferencedDocumentTypePrivateError` | 40150 | Write: the named document type is private. | +| `ReferencedDocumentTypeDeletableError`, `ReferencedDocumentTypeModeratedError`, `ReferencedDocumentTypeNotModeratedError` | 40122, 40144, 40143 | Write: the named document type is not of the declared kind. | +| `ReferencedEntityNotFoundError` | 40120 | Write: the document does not say where the reference points, or no document with that id exists in the named type. | + +## See also + +- [References (refersTo)](refers-to.md) for the targets and the replace rules. +- [Contract Moderation](../data-model/contract-moderation.md) for what makes a document type a moderated one. +- [distinctFrom](distinct-from.md), the document keyword the slot key is named after. diff --git a/book/src/contract-keywords/refers-to.md b/book/src/contract-keywords/refers-to.md index 935501ee342..ca8955a6ff9 100644 --- a/book/src/contract-keywords/refers-to.md +++ b/book/src/contract-keywords/refers-to.md @@ -57,6 +57,8 @@ An identifier (a 32-byte id) can hold any value. `refersTo` says what it points A key that belongs to another target is refused when the contract is registered. +From protocol version 15 a `permanentDocument` or `moderatedDocument` reference may leave its target to the document: `contractIdProperty` and `documentTypeProperty` name the properties holding the contract and the document type, in place of `contractId` and `documentType`, and `documentTypeRequirements` says what that type must look like. See [A Target the Document Names](refers-to-dynamic.md). + ### `identity` The value is the id of an identity that exists. The check reads the identity's revision. Identities are never removed, so a validated reference never dangles. @@ -413,6 +415,7 @@ A contract's declarations are checked when it is registered, and again for the w | `ReferencedDocumentRuleReadInvalidError` | 40148 | Registration: a [`propertyConstraints` rule](property-constraints.md#values-of-a-referenced-document) reads, through a reference into another contract's document type, a field that type can not give it. A rule reading a type of the declaring contract is refused by the contract parse instead (10231). | | `ReferencedDocumentPrecededError` | 40149 | Write: the commitment a [`chooseFirst`](refers-to-lookup.md#choosing-the-first-commitment-choosefirst) reference found is preceded, in its `by` order, by a valid, unexpired commitment of another identity holding the same value at `among`. | | `ReferencedDocumentTypePrivateError` | 40150 | Registration: a reference of any kind names a private type, whose documents are encrypted entries the chain never reads (protocol version 15). | +| `ReferencedDocumentTypeRequirementNotMetError` | 40153 | Write: a reference [naming its target through the document](refers-to-dynamic.md) points at a document type that does not meet its `documentTypeRequirements` (protocol version 15). | The registration errors name the declaration as `.`, `.[]` for typed array elements, `.$ownerId` or `.$creatorId` for the writer and creator references, and add the operand for a leaf of an expression (`resignation.memberId.anyOf[1]`). When a document is written, the referenced document type is looked up and its kind checked again as a safeguard (40121, 40122, 40131, 40143), but a registered contract cannot fail those checks later: contracts and document types are never removed, and neither the deletion flags nor `ttl` nor `moderatorAbilities` can change. Nor can `indexOnly`, so the indexOnly rule (40146) is checked at registration only. The other codes in the range (40128 to 40130, 40132 to 40134, 40139 to 40142, 40147) belong to other keywords. See [Error Codes](../error-handling/error-codes.md). @@ -421,6 +424,7 @@ The registration errors name the declaration as `.`, ` DataContractError { + DataContractError::InvalidContractStructure(message) +} + +/// Whether a `refersTo` declaration names its target through the referring document: it +/// carries one of the keys only a dynamic reference does. +pub(super) fn declares_dynamic_target(refers_to_value: &Value) -> bool { + refers_to_value + .to_btree_ref_string_map() + .is_ok_and(|declaration| { + DYNAMIC_KEYS + .iter() + .any(|key| declaration.contains_key(*key)) + }) +} + +/// Whether a `refersTo` declaration, or an operand anywhere inside a reference expression +/// it holds, names its target through the referring document. +fn declares_dynamic_target_anywhere(refers_to_value: &Value) -> bool { + if declares_dynamic_target(refers_to_value) { + return true; + } + let Ok(declaration) = refers_to_value.to_btree_ref_string_map() else { + return false; + }; + ["anyOf", "allOf"].iter().any(|combinator| { + declaration + .get(*combinator) + .and_then(|operands| operands.as_array()) + .is_some_and(|operands| operands.iter().any(declares_dynamic_target_anywhere)) + }) +} + +/// Refuses a dynamic target where a reference cannot be one: `place` says where, as the end +/// of a sentence ("on the elements of a typed array"). `refers_to_value` is the declaration, +/// if there is one. +pub(super) fn refuse_dynamic_target( + refers_to_value: Option<&Value>, + place: &str, +) -> Result<(), DataContractError> { + if refers_to_value.is_some_and(declares_dynamic_target_anywhere) { + return Err(refused(format!( + "a refersTo naming its target through contractIdProperty and documentTypeProperty \ + is not allowed {place}: it sits on an identifier property, alone" + ))); + } + Ok(()) +} + +/// A name of 1 to 64 letters, digits or underscores: a top-level property, a document +/// type, or a slot. +fn is_word_name(name: &str) -> bool { + (1..=64).contains(&name.len()) + && name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') +} + +fn word_name(value: &Value, what: &str) -> Result { + value + .as_text() + .filter(|name| is_word_name(name)) + .map(str::to_string) + .ok_or_else(|| { + refused(format!( + "{what} must be a name of 1 to 64 letters, digits or underscores" + )) + }) +} + +/// Parses a declaration [`declares_dynamic_target`] holds for, on an identifier property. +pub(super) fn parse_dynamic_document_reference( + refers_to_map: &BTreeMap, + platform_version: &PlatformVersion, +) -> Result { + let reference_type = refers_to_map + .get_str(property_names::TYPE) + .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; + let kind = match reference_type { + "permanentDocument" => DynamicDocumentReferenceKind::Permanent, + "moderatedDocument" => DynamicDocumentReferenceKind::Moderated, + other => { + return Err(refused(format!( + "{other} refersTo does not take contractIdProperty, documentTypeProperty or \ + documentTypeRequirements: only a permanentDocument or a moderatedDocument \ + reference names its target through the referring document, since only those \ + document types promise their documents stay" + ))) + } + }; + + // The target is known only when a document is written, so nothing that needs the + // referenced type at registration can sit beside it + if let Some(key) = refers_to_map + .keys() + .find(|key| key.as_str() != property_names::TYPE && !DYNAMIC_KEYS.contains(&key.as_str())) + { + return Err(refused(format!( + "{reference_type} refersTo naming its target through contractIdProperty and \ + documentTypeProperty does not take {key}: the referenced document type is not \ + known until a document is written" + ))); + } + + let property = |key: &str| -> Result { + let value = refers_to_map.get(key).ok_or_else(|| { + refused(format!( + "{reference_type} refersTo naming its target through the referring document \ + needs both contractIdProperty and documentTypeProperty: {key} is missing" + )) + })?; + word_name(value, &format!("{reference_type} refersTo {key}")) + }; + let contract_id_property = property(property_names::CONTRACT_ID_PROPERTY)?; + let document_type_property = property(property_names::DOCUMENT_TYPE_PROPERTY)?; + if contract_id_property == document_type_property { + return Err(refused(format!( + "{reference_type} refersTo contractIdProperty and documentTypeProperty name the \ + same property, \"{contract_id_property}\"" + ))); + } + + let requirements = refers_to_map + .get(property_names::DOCUMENT_TYPE_REQUIREMENTS) + .map(|value| parse_document_type_requirements(value, platform_version)) + .transpose()?; + + Ok(DynamicDocumentReference { + kind, + contract_id_property, + document_type_property, + requirements, + }) +} + +fn parse_document_type_requirements( + value: &Value, + platform_version: &PlatformVersion, +) -> Result { + let requirements = value + .to_btree_ref_string_map() + .map_err(|_| refused("documentTypeRequirements must be an object".to_string()))?; + if let Some(key) = requirements + .keys() + .find(|key| !matches!(key.as_str(), "properties" | "require")) + { + return Err(refused(format!( + "documentTypeRequirements does not take {key}: it holds properties and require" + ))); + } + let slot_values = requirements + .get("properties") + .and_then(|properties| properties.to_btree_ref_string_map().ok()) + .ok_or_else(|| { + refused( + "documentTypeRequirements needs properties, an object of named slots".to_string(), + ) + })?; + let max_slots = platform_version + .system_limits + .max_document_type_requirement_slots as usize; + if slot_values.is_empty() || slot_values.len() > max_slots { + return Err(refused(format!( + "documentTypeRequirements properties must hold 1 to {max_slots} slots, got {}", + slot_values.len() + ))); + } + + let slots = slot_values + .iter() + .map(|(slot_name, slot_value)| { + if !is_word_name(slot_name) { + return Err(refused(format!( + "documentTypeRequirements slot \"{slot_name}\" must be named with 1 to 64 \ + letters, digits or underscores" + ))); + } + Ok((slot_name.clone(), parse_slot(slot_name, slot_value)?)) + }) + .collect::, _>>()?; + + for (slot_name, slot) in &slots { + if let Some(other) = slot + .distinct_from + .iter() + .find(|other| *other == slot_name || !slots.contains_key(*other)) + { + return Err(refused(format!( + "documentTypeRequirements slot \"{slot_name}\" distinctFrom \"{other}\" must \ + name another slot of the same properties" + ))); + } + } + + let require = requirements + .get("require") + .map(|value| parse_require(value, &slots)) + .transpose()?; + + Ok(DocumentTypeRequirements { slots, require }) +} + +fn parse_slot(slot_name: &str, value: &Value) -> Result { + let at = || format!("documentTypeRequirements slot \"{slot_name}\""); + let slot = value + .to_btree_ref_string_map() + .map_err(|_| refused(format!("{} must be an object", at())))?; + if let Some(key) = slot.keys().find(|key| !SLOT_KEYS.contains(&key.as_str())) { + return Err(refused(format!("{} does not take {key}", at()))); + } + + let name = match slot.get("name") { + Some(Value::Text(name)) if name == ANY_PROPERTY_NAME => SlotPropertyName::Any, + Some(Value::Text(name)) if is_word_name(name) => SlotPropertyName::Named(name.clone()), + Some(from_property @ Value::Map(_)) => { + let from_property = from_property + .to_btree_ref_string_map() + .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; + match (from_property.len(), from_property.get("property")) { + (1, Some(property)) => SlotPropertyName::FromProperty(word_name( + property, + &format!("{} name property", at()), + )?), + _ => { + return Err(refused(format!( + "{} name must be \"$any\", a property name, or {{ \"property\": }}", + at() + ))) + } + } + } + _ => { + return Err(refused(format!( + "{} needs a name: \"$any\", a property name of 1 to 64 letters, digits or \ + underscores, or {{ \"property\": }}", + at() + ))) + } + }; + + let byte_array = match slot.get("byteArray") { + None => false, + Some(Value::Bool(true)) => true, + Some(_) => { + return Err(refused(format!( + "{} byteArray, when present, must be true", + at() + ))) + } + }; + let value_kind = match ( + slot.get("type").and_then(|value_type| value_type.as_text()), + byte_array, + ) { + (Some("string"), false) => SlotValue::String, + (Some("identifier"), false) => SlotValue::Identifier, + (Some("integer"), false) => SlotValue::Integer, + (Some("boolean"), false) => SlotValue::Boolean, + (Some("array"), true) => SlotValue::ByteArray, + _ => { + return Err(refused(format!( + "{} needs a type: \"string\", \"identifier\", \"integer\", \"boolean\", or \ + \"array\" with \"byteArray\": true", + at() + ))) + } + }; + + let max_bytes = slot + .get("maxBytes") + .map(|max_bytes| { + if !matches!(value_kind, SlotValue::String | SlotValue::ByteArray) { + return Err(refused(format!( + "{} maxBytes bounds a string or a byte array, not a {}", + at(), + value_kind.wire_name() + ))); + } + max_bytes + .to_integer::() + .ok() + .filter(|max_bytes| *max_bytes >= 1) + .ok_or_else(|| { + refused(format!( + "{} maxBytes must be an integer from 1 to 4294967295", + at() + )) + }) + }) + .transpose()?; + + let distinct_from = match slot.get("distinctFrom") { + None => BTreeSet::new(), + Some(Value::Text(other)) => BTreeSet::from([other.clone()]), + Some(Value::Array(others)) if !others.is_empty() => { + let names = others + .iter() + .map(|other| other.as_text().map(str::to_string)) + .collect::>>() + .ok_or_else(|| { + refused(format!( + "{} distinctFrom must be a slot name or a list of slot names", + at() + )) + })?; + let distinct: BTreeSet = names.iter().cloned().collect(); + if distinct.len() != names.len() { + return Err(refused(format!("{} distinctFrom names a slot twice", at()))); + } + distinct + } + Some(_) => { + return Err(refused(format!( + "{} distinctFrom must be a slot name or a list of at least one slot name", + at() + ))) + } + }; + + Ok(RequiredPropertySlot { + name, + value: value_kind, + max_bytes, + distinct_from, + }) +} + +/// The one key of a combinator object and its operands, at least two of them. +fn combinator_operands(value: &Value) -> Option<(&'static str, &Vec)> { + let map = value.to_btree_ref_string_map().ok()?; + if map.len() != 1 { + return None; + } + ["anyOf", "allOf"].into_iter().find_map(|combinator| { + map.get(combinator) + .and_then(|operands| operands.as_array()) + .filter(|operands| operands.len() >= 2) + .map(|operands| (combinator, operands)) + }) +} + +fn parse_require( + value: &Value, + slots: &BTreeMap, +) -> Result { + let shape = || { + refused( + "documentTypeRequirements require must be a slot name, or anyOf or allOf over two \ + or more terms, each a slot name or the other combinator over two or more slot \ + names" + .to_string(), + ) + }; + let slot = |value: &Value| -> Result { + let name = value.as_text().ok_or_else(shape)?; + if !slots.contains_key(name) { + return Err(refused(format!( + "documentTypeRequirements require names \"{name}\", which is not a slot of \ + properties" + ))); + } + Ok(name.to_string()) + }; + + let requirement = if value.is_text() { + SlotRequirement::Slot(slot(value)?) + } else { + let (combinator, operands) = combinator_operands(value).ok_or_else(shape)?; + let terms = operands + .iter() + .map(|operand| { + if operand.is_text() { + return Ok(SlotRequirementTerm::Slot(slot(operand)?)); + } + let (inner, inner_operands) = combinator_operands(operand).ok_or_else(shape)?; + // A combinator directly inside itself says what one flat list says + if inner == combinator { + return Err(shape()); + } + Ok(SlotRequirementTerm::Group( + inner_operands + .iter() + .map(&slot) + .collect::, _>>()?, + )) + }) + .collect::, DataContractError>>()?; + match combinator { + "anyOf" => SlotRequirement::AnyOf(terms), + _ => SlotRequirement::AllOf(terms), + } + }; + + // A slot `require` never names could never matter: every slot is part of the condition + let named: BTreeSet<&str> = requirement.slots().into_iter().collect(); + if let Some(unnamed) = slots.keys().find(|slot| !named.contains(slot.as_str())) { + return Err(refused(format!( + "documentTypeRequirements require does not name the slot \"{unnamed}\": every slot \ + of properties is part of the condition, or is left out of properties" + ))); + } + Ok(requirement) +} + +/// Checks the referring side of every dynamic reference of a document type, once all its +/// properties are parsed. The contract id comes from a top-level identifier property and +/// the document type name, like every property name a slot reads, from a top-level string +/// property; none of them is transient or the reference property itself; and each is +/// required wherever the reference property is, so a document that must hold the reference +/// always says where it points. +/// +/// Runs on every parse, validating or not, like the `findBy` source check: the write-time +/// check reads the target through these properties. +pub(super) fn validate_dynamic_reference_sources( + document_type: DocumentTypeRef, + document_type_name: &str, +) -> Result<(), DataContractError> { + for (holder, reference) in document_type.reference_declarations() { + let Some(dynamic) = reference + .target() + .and_then(|target| target.as_dynamic_document_reference()) + else { + continue; + }; + // `describe` ends with the keyword: `property "descriptionId" refersTo` + let at = format!( + "document type \"{document_type_name}\" {}", + holder.describe() + ); + // The parser admits one on an identifier property alone + let (ReferenceHolder::Property(reference_path), PropertyReference::Value(_)) = + (&holder, &reference) + else { + return Err(refused(format!( + "{at} names its target through the referring document, which only a single \ + identifier property's reference may" + ))); + }; + let reference_path: &str = reference_path; + let reference_is_required = document_type + .flattened_properties() + .get(reference_path) + .is_some_and(|property| property.required); + + // `holds` says whether the property is of the kind `kind` names + let source = |key: &str, + name: &str, + kind: &str, + holds: fn(&DocumentPropertyType) -> bool| + -> Result<(), DataContractError> { + if name == reference_path { + return Err(refused(format!( + "{at} {key} names \"{name}\", the property carrying the reference" + ))); + } + let property = document_type.properties().get(name).ok_or_else(|| { + refused(format!( + "{at} {key} names \"{name}\", which is not a top-level property of the \ + document type" + )) + })?; + if !holds(&property.property_type) { + return Err(refused(format!( + "{at} {key} names \"{name}\", which is not {kind} property" + ))); + } + if property.transient { + return Err(refused(format!( + "{at} {key} names \"{name}\", a transient property: the reference is \ + checked again from the stored document on a replace" + ))); + } + if reference_is_required && !property.required { + return Err(refused(format!( + "{at} {key} names \"{name}\", which is not required while the reference \ + is: a document holding the reference must say where it points" + ))); + } + Ok(()) + }; + + source( + property_names::CONTRACT_ID_PROPERTY, + &dynamic.contract_id_property, + "an identifier", + |property_type| { + matches!( + property_type, + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) + ) + }, + )?; + let string_sources = std::iter::once(( + property_names::DOCUMENT_TYPE_PROPERTY, + dynamic.document_type_property.as_str(), + )) + .chain( + dynamic + .requirements + .iter() + .flat_map(|requirements| requirements.name_properties()) + .map(|name| ("documentTypeRequirements slot name property", name)), + ); + for (key, name) in string_sources { + source(key, name, "a string", |property_type| { + matches!(property_type, DocumentPropertyType::String(_)) + })?; + } + } + Ok(()) +} 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 e7edf4e6255..b6dc8982db0 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 @@ -54,6 +54,7 @@ use platform_version::version::PlatformVersion; use std::collections::{BTreeMap, BTreeSet}; mod common; +mod dynamic_reference; #[cfg(feature = "validation")] mod native_schema; mod v0; @@ -756,12 +757,52 @@ pub(in crate::data_contract::document_type::class_methods) fn apply_property_ref { None => Ok(property_type), Some(0) => apply_property_reference_v0(inner_properties, property_type), + Some(1) => apply_property_reference_v1(inner_properties, property_type, platform_version), Some(version) => Err(DataContractError::Unsupported(format!( "apply_property_reference version {version} is not supported" ))), } } +/// Version 1 (protocol version 15): also reads a reference whose target the referring +/// document names (`contractIdProperty`, `documentTypeProperty`, +/// `documentTypeRequirements`), on an identifier property. Every other declaration is +/// version 0's, except that an operand of a reference expression naming its target that way +/// is refused, where version 0 does not read those keys. +fn apply_property_reference_v1( + inner_properties: &BTreeMap, + property_type: DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Result { + let Some(refers_to_value) = inner_properties + .get(property_names::REFERS_TO) + .filter(|refers_to_value| dynamic_reference::declares_dynamic_target(refers_to_value)) + else { + dynamic_reference::refuse_dynamic_target( + inner_properties.get(property_names::REFERS_TO).copied(), + "in a reference expression", + )?; + return apply_property_reference_v0(inner_properties, property_type); + }; + if !matches!( + property_type, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(DataContractError::InvalidContractStructure( + "a refersTo naming its target through contractIdProperty and documentTypeProperty is only allowed on identifier properties: its value is the referenced document's id" + .to_string(), + )); + } + Ok(DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::DynamicDocument( + dynamic_reference::parse_dynamic_document_reference( + &refers_to_value.to_btree_ref_string_map()?, + platform_version, + )?, + ), + )) +} + fn apply_property_reference_v0( inner_properties: &BTreeMap, property_type: DocumentPropertyType, @@ -874,6 +915,19 @@ fn apply_revealed_reference( { None => Ok(None), Some(0) => apply_revealed_reference_v0(inner_properties, property_type), + // Version 1 reads a dynamic target on an identifier property alone + Some(1) => { + dynamic_reference::refuse_dynamic_target( + inner_properties + .get(property_names::REFERS_TO) + .copied() + .filter(|refers_to_value| { + is_revealed_declaration(property_type, refers_to_value) + }), + "on a string or byte array property", + )?; + apply_revealed_reference_v0(inner_properties, property_type) + } Some(version) => Err(DataContractError::Unsupported(format!( "apply_property_reference version {version} is not supported" ))), @@ -2004,6 +2058,14 @@ pub(in crate::data_contract::document_type::class_methods) fn apply_element_refe { None => Ok(element_type), Some(0) => apply_element_reference_v0(items, element_type), + // Version 1 reads a dynamic target on an identifier property alone + Some(1) => { + dynamic_reference::refuse_dynamic_target( + items.get(property_names::REFERS_TO).copied(), + "on the elements of a typed array", + )?; + apply_element_reference_v0(items, element_type) + } Some(version) => Err(DataContractError::Unsupported(format!( "apply_element_reference version {version} is not supported" ))), @@ -2079,6 +2141,18 @@ pub(super) fn parse_doctype_reference( let Some(declaration) = schema_map.get_optional_key(keyword) else { return Ok(None); }; + // Version 1 (protocol version 15) reads a dynamic target on an identifier property + // alone; version 0 does not read its keys at all + if platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_property_reference + == Some(1) + { + dynamic_reference::refuse_dynamic_target(Some(declaration), &format!("in {keyword}"))?; + } let reference_type = declaration .to_btree_ref_string_map()? diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs index 80b181f6442..07566c7c923 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs @@ -3,13 +3,24 @@ use super::*; +/// The keys only a `refersTo` naming its target through the referring document carries +/// (protocol version 15). +const DYNAMIC_TARGET_KEYS: [&str; 3] = [ + "contractIdProperty", + "documentTypeProperty", + "documentTypeRequirements", +]; + /// The keys of a `refersTo` declaration. -const REFERS_TO_KEYS: [&str; 14] = [ +const REFERS_TO_KEYS: [&str; 17] = [ "type", "anyOf", "allOf", "contractId", "documentType", + "contractIdProperty", + "documentTypeProperty", + "documentTypeRequirements", "contractRequirements", "keyIdProperty", "identityProperty", @@ -53,7 +64,26 @@ impl Checker<'_> { self.required(map, &["type"])?; self.member_constant(map, "type", json!("deletableDocument"))?; } - if matches!( + // A reference naming its target through the referring document: a permanent or + // moderated document found by its id, with nothing beside it that needs the + // referenced type at registration + if DYNAMIC_TARGET_KEYS.iter().any(|key| has(key)) { + self.required(map, &["type", "contractIdProperty", "documentTypeProperty"])?; + self.member(map, "type", |c, target| { + c.one_of_strings(target, &["permanentDocument", "moderatedDocument"]) + })?; + for name in [ + "contractId", + "documentType", + "findBy", + "where", + "inList", + "consume", + "constraints", + ] { + self.absent(map, name)?; + } + } else if matches!( target, Some("permanentDocument" | "deletableDocument" | "moderatedDocument") ) { @@ -144,10 +174,11 @@ impl Checker<'_> { } Err(self.fail("oneOfNotValid", format!("{value} is not a contract id"))) } - "documentType" => { + "documentType" | "contractIdProperty" | "documentTypeProperty" => { self.string_of_length(value, 1, Some(64))?; self.word(value) } + "documentTypeRequirements" => self.document_type_requirements(value), "contractRequirements" => { let map = self.object(value)?; self.closed( @@ -248,6 +279,108 @@ impl Checker<'_> { } } + /// `documentTypeRequirements`: named slots, each describing a property the referenced + /// document type must have, and `require`, which of them must be filled. That the names + /// it uses are slots is the parser's to check. + fn document_type_requirements(&self, value: &JsonValue) -> Checked { + let map = self.object(value)?; + self.closed(map, &["properties", "require"])?; + self.required(map, &["properties"])?; + self.member(map, "properties", |c, properties| { + let slots = c.object(properties)?; + c.property_count(slots, Some(1), None)?; + for (name, slot) in slots { + c.at(name, |c| { + c.name_of_length(name, 1, 64)?; + c.requirement_slot(slot) + })?; + } + Ok(()) + })?; + self.member(map, "require", |c, require| { + if require.is_string() { + return c.word(require); + } + c.slot_combinator(require, true) + }) + } + + /// One slot of `documentTypeRequirements.properties`. + fn requirement_slot(&self, value: &JsonValue) -> Checked { + let map = self.object(value)?; + self.closed( + map, + &["name", "type", "byteArray", "maxBytes", "distinctFrom"], + )?; + self.required(map, &["name", "type"])?; + self.member(map, "name", |c, name| { + if name.is_string() { + return c.string_of_length(name, 1, Some(64)).map(drop); + } + let from_property = c.object(name)?; + c.closed(from_property, &["property"])?; + c.required(from_property, &["property"])?; + c.member(from_property, "property", |c, property| { + c.string_of_length(property, 1, Some(64))?; + c.word(property) + }) + })?; + self.member(map, "type", |c, value_type| { + c.one_of_strings( + value_type, + &["string", "identifier", "integer", "boolean", "array"], + ) + })?; + self.member(map, "byteArray", |c, byte_array| { + c.constant(byte_array, &json!(true)) + })?; + self.member(map, "maxBytes", |c, max_bytes| { + c.integer(max_bytes, Some(1), Some(i64::from(u32::MAX))) + })?; + self.member(map, "distinctFrom", |c, distinct_from| { + if distinct_from.is_string() { + return c.word(distinct_from); + } + let others = c.array(distinct_from)?; + c.item_count(others, Some(1), None)?; + c.unique(others)?; + for (index, other) in others.iter().enumerate() { + c.at(index, |c| c.word(other))?; + } + Ok(()) + }) + } + + /// `anyOf` or `allOf` over slots: the one key of `value`, holding two or more terms. A + /// term is a slot name or, at the top level alone, the other combinator over slot + /// names. + fn slot_combinator(&self, value: &JsonValue, top_level: bool) -> Checked { + let map = self.object(value)?; + self.closed(map, &["anyOf", "allOf"])?; + self.property_count(map, Some(1), Some(1))?; + for (combinator, terms) in map { + self.at(combinator, |c| { + let terms = c.array(terms)?; + c.item_count(terms, Some(2), None)?; + for (index, term) in terms.iter().enumerate() { + c.at(index, |c| { + if term.is_string() { + return c.word(term); + } + if !top_level { + return Err(c.no_branch(term)); + } + let inner = c.object(term)?; + c.absent(inner, combinator)?; + c.slot_combinator(term, false) + })?; + } + Ok(()) + })?; + } + Ok(()) + } + /// The operands of a `refersTo` `anyOf` or `allOf`. fn reference_operands(&self, combinator: &str, value: &JsonValue) -> Checked { let items = self.array(value)?; @@ -259,6 +392,9 @@ impl Checker<'_> { let map = c.object(operand)?; c.absent(map, combinator)?; c.absent(map, "identityProperty")?; + for key in DYNAMIC_TARGET_KEYS { + c.absent(map, key)?; + } c.member(map, "type", |c, target| { c.one_of_strings( target, @@ -279,6 +415,9 @@ impl Checker<'_> { pub(super) fn owner_refers_to(&self, value: &JsonValue) -> Checked { self.refers_to(value)?; let map = self.object(value)?; + for key in DYNAMIC_TARGET_KEYS { + self.absent(map, key)?; + } self.member(map, "type", |c, target| { c.one_of_strings( target, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs new file mode 100644 index 00000000000..15941394368 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs @@ -0,0 +1,759 @@ +//! References whose target the referring document names (`contractIdProperty`, +//! `documentTypeProperty`, `documentTypeRequirements`, protocol version 15): the parse of +//! the declaration, where it may sit, the checks of the properties it reads, the protocol +//! version gate, what a document type must look like to meet the requirements, and the +//! platform serialization round trip. + +use super::reference_test_helpers::{ + assert_refused, assert_refused_by_the_parser, contract, contract_on, identifier, CONTRACT_ID, +}; +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRequirements, + DynamicDocumentReference, DynamicDocumentReferenceKind, RequiredPropertySlot, SlotPropertyName, + SlotRequirement, SlotRequirementTerm, SlotValue, +}; +use crate::data_contract::DataContract; +use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, +}; +use platform_value::string_encoding::Encoding; +use platform_value::Identifier; +use platform_version::version::PlatformVersion; +use serde_json::json; +use std::collections::{BTreeMap, BTreeSet}; + +fn string(position: u32) -> serde_json::Value { + json!({ "type": "string", "maxLength": 64, "position": position }) +} + +/// The declaration the decision proposals design was sketched with: a moderated document +/// of a type holding text, as a string or as bytes. +fn text_or_bytes() -> serde_json::Value { + json!({ + "type": "moderatedDocument", + "contractIdProperty": "descriptionContractId", + "documentTypeProperty": "descriptionDocumentType", + "documentTypeRequirements": { + "properties": { + "body": { "name": "$any", "type": "string" }, + "bodyBytes": { "name": "$any", "type": "array", "byteArray": true } + }, + "require": { "anyOf": ["body", "bodyBytes"] } + } + }) +} + +/// A dynamic `moderatedDocument` reference with `requirements`. +fn requiring(requirements: serde_json::Value) -> serde_json::Value { + json!({ + "type": "moderatedDocument", + "contractIdProperty": "descriptionContractId", + "documentTypeProperty": "descriptionDocumentType", + "documentTypeRequirements": requirements + }) +} + +/// A contract with a `note` type whose `descriptionId` declares `refers_to`, beside the +/// properties a dynamic reference reads (`descriptionContractId`, an identifier, and +/// `descriptionDocumentType` and `bodyProperty`, strings), an optional `label`, and a +/// permanent `post` type with properties of every kind a slot describes: `title` (a string +/// of at most 252 bytes), `body` (at most 4096 bytes), `summary` (unbounded), `blob` (at +/// most 1024 bytes), `authorId`, `count` and `pinned`. +fn note_contract(refers_to: serde_json::Value) -> serde_json::Value { + let mut description_id = identifier(0); + description_id["refersTo"] = refers_to; + json!({ + "$formatVersion": "1", + "id": Identifier::from(CONTRACT_ID).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "descriptionId": description_id, + "descriptionContractId": identifier(1), + "descriptionDocumentType": string(2), + "bodyProperty": string(3), + "label": string(4) + }, + "required": [ + "descriptionId", + "descriptionContractId", + "descriptionDocumentType", + "bodyProperty" + ], + "additionalProperties": false + }, + "post": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "title": { "type": "string", "maxLength": 63, "position": 0 }, + "body": { "type": "string", "maxLength": 4096, "maxBytes": 4096, "position": 1 }, + "summary": { "type": "string", "position": 2 }, + "blob": { "type": "array", "byteArray": true, "maxItems": 1024, "position": 3 }, + "authorId": identifier(4), + "count": { "type": "integer", "minimum": 0, "position": 5 }, + "pinned": { "type": "boolean", "position": 6 } + }, + "required": ["title"], + "additionalProperties": false + } + } + }) +} + +/// The dynamic reference `note.descriptionId` parsed to. +fn dynamic_reference(contract: &DataContract) -> DynamicDocumentReference { + let note = contract.document_type_for_name("note").expect("note"); + match ¬e + .properties() + .get("descriptionId") + .expect("descriptionId") + .property_type + { + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::DynamicDocument(reference), + ) => reference.clone(), + other => panic!("expected a dynamic reference, got {other:?}"), + } +} + +/// `requirements` as the parser reads them. +fn requirements(requirements: serde_json::Value) -> DocumentTypeRequirements { + dynamic_reference(&contract(note_contract(requiring(requirements))).expect("parses")) + .requirements + .expect("requirements") +} + +/// Whether the fixture's `post` type meets `requirements`, a slot naming its property +/// through the referring document's `bodyProperty` reading `body_property` there. +fn post_meets(requirements_value: serde_json::Value, body_property: Option<&str>) -> bool { + let parsed = contract(note_contract(requiring(requirements_value))).expect("parses"); + let post = parsed.document_type_for_name("post").expect("post"); + dynamic_reference(&parsed) + .requirements + .expect("requirements") + .met_by(post, |referring_property| { + assert_eq!(referring_property, "bodyProperty"); + body_property.map(str::to_string) + }) +} + +fn slot(name: SlotPropertyName, value: SlotValue) -> RequiredPropertySlot { + RequiredPropertySlot { + name, + value, + max_bytes: None, + distinct_from: BTreeSet::new(), + } +} + +#[test] +fn should_parse_a_reference_naming_its_target_through_the_referring_document() { + let parsed = contract(note_contract(text_or_bytes())).expect("parses"); + let reference = dynamic_reference(&parsed); + assert_eq!( + reference, + DynamicDocumentReference { + kind: DynamicDocumentReferenceKind::Moderated, + contract_id_property: "descriptionContractId".to_string(), + document_type_property: "descriptionDocumentType".to_string(), + requirements: Some(DocumentTypeRequirements { + slots: BTreeMap::from([ + ( + "body".to_string(), + slot(SlotPropertyName::Any, SlotValue::String) + ), + ( + "bodyBytes".to_string(), + slot(SlotPropertyName::Any, SlotValue::ByteArray) + ), + ]), + require: Some(SlotRequirement::AnyOf(vec![ + SlotRequirementTerm::Slot("body".to_string()), + SlotRequirementTerm::Slot("bodyBytes".to_string()), + ])), + }), + } + ); + assert_eq!(reference.wire_name(), "moderatedDocument"); + assert_eq!( + reference.target_properties().collect::>(), + ["descriptionContractId", "descriptionDocumentType"] + ); + // It names no contract and no document type, so it is no declaration for the code that + // resolves a target from the schema + let target = DocumentPropertyReferenceTarget::DynamicDocument(reference); + assert!(target.as_any_document_reference().is_none()); + assert!(target.as_document_reference().is_none()); + assert_eq!( + target.to_string(), + "moderated document (contract named by descriptionContractId, document type named by \ + descriptionDocumentType)" + ); + + // A permanent document, with no requirements + let parsed = contract(note_contract(json!({ + "type": "permanentDocument", + "contractIdProperty": "descriptionContractId", + "documentTypeProperty": "descriptionDocumentType" + }))) + .expect("parses"); + let reference = dynamic_reference(&parsed); + assert_eq!(reference.kind, DynamicDocumentReferenceKind::Permanent); + assert_eq!(reference.requirements, None); +} + +#[test] +fn should_parse_every_form_of_a_slot_and_of_require() { + let parsed = requirements(json!({ + "properties": { + "title": { "name": "$any", "type": "string", "maxBytes": 256 }, + "body": { + "name": { "property": "bodyProperty" }, + "type": "string", + "distinctFrom": "title" + }, + "author": { "name": "authorId", "type": "identifier" }, + "extra": { + "name": "$any", + "type": "array", + "byteArray": true, + "maxBytes": 2048, + "distinctFrom": ["title", "body"] + } + }, + "require": { "allOf": ["title", "author", { "anyOf": ["body", "extra"] }] } + })); + assert_eq!( + parsed.slots["title"], + RequiredPropertySlot { + max_bytes: Some(256), + ..slot(SlotPropertyName::Any, SlotValue::String) + } + ); + assert_eq!( + parsed.slots["body"], + RequiredPropertySlot { + distinct_from: BTreeSet::from(["title".to_string()]), + ..slot( + SlotPropertyName::FromProperty("bodyProperty".to_string()), + SlotValue::String + ) + } + ); + assert_eq!( + parsed.slots["author"], + slot( + SlotPropertyName::Named("authorId".to_string()), + SlotValue::Identifier + ) + ); + assert_eq!( + parsed.slots["extra"], + RequiredPropertySlot { + max_bytes: Some(2048), + distinct_from: BTreeSet::from(["title".to_string(), "body".to_string()]), + ..slot(SlotPropertyName::Any, SlotValue::ByteArray) + } + ); + assert_eq!( + parsed.require, + Some(SlotRequirement::AllOf(vec![ + SlotRequirementTerm::Slot("title".to_string()), + SlotRequirementTerm::Slot("author".to_string()), + SlotRequirementTerm::Group(vec!["body".to_string(), "extra".to_string()]), + ])) + ); + assert_eq!( + parsed.name_properties().collect::>(), + ["bodyProperty"] + ); + + // One slot, required by name + let parsed = requirements(json!({ + "properties": { "count": { "name": "count", "type": "integer" } }, + "require": "count" + })); + assert_eq!( + parsed.require, + Some(SlotRequirement::Slot("count".to_string())) + ); +} + +#[test] +fn should_not_read_a_dynamic_target_before_protocol_version_15() { + let platform_version_14 = PlatformVersion::get(14).expect("protocol version 14"); + // Registration refuses the keys it does not know, and the parser of version 14, which + // does not read them, refuses the document reference they leave without a documentType + for full_validation in [true, false] { + contract_on( + note_contract(text_or_bytes()), + full_validation, + platform_version_14, + ) + .expect_err("protocol version 14 has no dynamic target"); + } + // Beside a documentType, version 14's parser reads the reference it always read + let mut refers_to = text_or_bytes(); + refers_to["documentType"] = json!("post"); + let parsed = contract_on(note_contract(refers_to.clone()), false, platform_version_14) + .expect("version 14 does not read the keys"); + let note = parsed.document_type_for_name("note").expect("note"); + assert!(matches!( + note.properties()["descriptionId"].property_type, + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::ModeratedDocument { .. } + ) + )); + // where protocol version 15 refuses the two together + assert_refused_by_the_parser(note_contract(refers_to), "does not take documentType"); +} + +#[test] +fn should_refuse_what_cannot_sit_beside_a_dynamic_target() { + let with = |key: &str, value: serde_json::Value| { + let mut refers_to = text_or_bytes(); + refers_to[key] = value; + note_contract(refers_to) + }; + for (key, value) in [ + ("documentType", json!("post")), + ( + "contractId", + json!(Identifier::from(CONTRACT_ID).to_string(Encoding::Base58)), + ), + ("where", json!({ "$ownerId": "$ownerId" })), + ("findBy", json!({ "title": "." })), + ] { + assert_refused_by_the_parser(with(key, value), &format!("does not take {key}")); + } + + // Only a permanent or a moderated document + let mut deletable = text_or_bytes(); + deletable["type"] = json!("deletableDocument"); + assert_refused_by_the_parser( + note_contract(deletable), + "only a permanentDocument or a moderatedDocument reference names its target", + ); + + // Both properties, and two different ones + let mut missing = text_or_bytes(); + missing + .as_object_mut() + .expect("an object") + .remove("documentTypeProperty"); + assert_refused_by_the_parser( + note_contract(missing), + "needs both contractIdProperty and documentTypeProperty", + ); + let mut same = text_or_bytes(); + same["documentTypeProperty"] = json!("descriptionContractId"); + assert_refused_by_the_parser(note_contract(same), "name the same property"); +} + +#[test] +fn should_refuse_a_dynamic_target_anywhere_but_on_an_identifier_property() { + // On a string property + let mut on_a_string = note_contract(text_or_bytes()); + on_a_string["documentSchemas"]["note"]["properties"]["descriptionId"] = + json!({ "type": "string", "maxLength": 64, "position": 0, "refersTo": text_or_bytes() }); + assert_refused_by_the_parser(on_a_string, "only allowed on identifier properties"); + + // On the elements of a typed array + let mut on_elements = note_contract(text_or_bytes()); + let mut items = identifier(0); + items.as_object_mut().expect("an object").remove("position"); + items["refersTo"] = text_or_bytes(); + on_elements["documentSchemas"]["note"]["properties"]["descriptionId"] = + json!({ "type": "array", "maxItems": 4, "items": items, "position": 0 }); + assert_refused_by_the_parser(on_elements, "on the elements of a typed array"); + + // As an operand of a reference expression + let in_an_expression = note_contract(json!({ + "anyOf": [{ "type": "identity" }, text_or_bytes()] + })); + assert_refused_by_the_parser(in_an_expression, "in a reference expression"); + + // As the writer's reference + let mut as_owner = note_contract(json!({ "type": "identity" })); + as_owner["documentSchemas"]["note"]["ownerRefersTo"] = text_or_bytes(); + assert_refused_by_the_parser(as_owner, "in ownerRefersTo"); +} + +#[test] +fn should_hold_the_properties_a_dynamic_target_reads_to_the_declaring_type() { + let naming = |key: &str, property: &str| { + let mut refers_to = text_or_bytes(); + refers_to[key] = json!(property); + note_contract(refers_to) + }; + // The contract id is a top-level identifier property, other than the reference + assert_refused_by_the_parser( + naming("contractIdProperty", "label"), + "which is not an identifier property", + ); + assert_refused_by_the_parser( + naming("contractIdProperty", "missing"), + "which is not a top-level property", + ); + assert_refused_by_the_parser( + naming("contractIdProperty", "descriptionId"), + "the property carrying the reference", + ); + // The document type name is a string property + assert_refused_by_the_parser( + naming("documentTypeProperty", "descriptionId"), + "the property carrying the reference", + ); + let mut type_from_an_identifier = note_contract(text_or_bytes()); + type_from_an_identifier["documentSchemas"]["note"]["properties"]["descriptionDocumentType"] = + identifier(2); + assert_refused_by_the_parser(type_from_an_identifier, "which is not a string property"); + // and each is required where the reference is + assert_refused_by_the_parser( + naming("documentTypeProperty", "label"), + "which is not required while the reference is", + ); + // A slot's name property too + let named_by = |property: &str| { + note_contract(requiring(json!({ + "properties": { + "body": { "name": { "property": property }, "type": "string" } + } + }))) + }; + assert_refused_by_the_parser( + named_by("descriptionContractId"), + "which is not a string property", + ); + assert_refused_by_the_parser( + named_by("label"), + "which is not required while the reference is", + ); + contract(named_by("bodyProperty")).expect("a required string property names the slot's"); + + // An optional reference reads optional properties + let mut optional = naming("documentTypeProperty", "label"); + optional["documentSchemas"]["note"]["required"] = json!(["descriptionContractId"]); + contract(optional).expect("nothing is required where the reference is not"); +} + +#[test] +fn should_refuse_malformed_document_type_requirements() { + let refused = |requirements: serde_json::Value, fragment: &str| { + assert_refused_by_the_parser(note_contract(requiring(requirements)), fragment); + }; + let any_string = || json!({ "name": "$any", "type": "string" }); + + refused(json!({ "require": "body" }), "needs properties"); + refused(json!({ "properties": {} }), "must hold 1 to 4 slots"); + refused( + json!({ "properties": { + "a": any_string(), "b": any_string(), "c": any_string(), + "d": any_string(), "e": any_string() + } }), + "must hold 1 to 4 slots, got 5", + ); + refused( + json!({ "properties": { "body": any_string() }, "allOf": ["body"] }), + "does not take allOf", + ); + + // A slot + refused( + json!({ "properties": { "body": { "type": "string" } } }), + "needs a name", + ); + refused( + json!({ "properties": { "body": { "name": "$all", "type": "string" } } }), + "needs a name", + ); + refused( + json!({ "properties": { "body": { "name": "$any" } } }), + "needs a type", + ); + refused( + json!({ "properties": { "body": { "name": "$any", "type": "array" } } }), + "needs a type", + ); + refused( + json!({ "properties": { "body": { "name": "$any", "type": "string", "byteArray": true } } }), + "needs a type", + ); + refused( + json!({ "properties": { "body": { "name": "$any", "type": "object" } } }), + "needs a type", + ); + refused( + json!({ "properties": { "n": { "name": "$any", "type": "integer", "maxBytes": 8 } } }), + "maxBytes bounds a string or a byte array", + ); + refused( + json!({ "properties": { "body": { "name": "$any", "type": "string", "maxBytes": 0 } } }), + "maxBytes must be an integer from 1", + ); + refused( + json!({ "properties": { "body": { "name": "$any", "type": "string", "present": true } } }), + "does not take present", + ); + + // distinctFrom names other slots + refused( + json!({ "properties": { + "body": { "name": "$any", "type": "string", "distinctFrom": "body" } + } }), + "must name another slot", + ); + refused( + json!({ "properties": { + "body": { "name": "$any", "type": "string", "distinctFrom": ["title"] } + } }), + "must name another slot", + ); + refused( + json!({ "properties": { + "title": any_string(), + "body": { "name": "$any", "type": "string", "distinctFrom": ["title", "title"] } + } }), + "names a slot twice", + ); + + // require names every slot, and only slots + refused( + json!({ "properties": { "body": any_string() }, "require": "title" }), + "which is not a slot", + ); + refused( + json!({ "properties": { "title": any_string(), "body": any_string() }, "require": "body" }), + "does not name the slot \"title\"", + ); + refused( + json!({ "properties": { "title": any_string(), "body": any_string() }, + "require": { "anyOf": ["title"] } }), + "require must be a slot name", + ); + refused( + json!({ "properties": { "title": any_string(), "body": any_string() }, + "require": { "anyOf": ["title", { "anyOf": ["title", "body"] }] } }), + "require must be a slot name", + ); +} + +#[test] +fn should_fill_a_slot_with_a_property_of_the_described_kind() { + let one = |slot: serde_json::Value| json!({ "properties": { "slot": slot } }); + // Any property of the kind + for value_type in ["string", "identifier", "integer", "boolean"] { + assert!( + post_meets(one(json!({ "name": "$any", "type": value_type })), None), + "{value_type}" + ); + } + assert!(post_meets( + one(json!({ "name": "$any", "type": "array", "byteArray": true })), + None + )); + // A named one, of that kind + assert!(post_meets( + one(json!({ "name": "body", "type": "string" })), + None + )); + assert!(!post_meets( + one(json!({ "name": "body", "type": "integer" })), + None + )); + assert!(!post_meets( + one(json!({ "name": "text", "type": "string" })), + None + )); + // An identifier is not a byte array slot's, nor a byte array an identifier's + assert!(!post_meets( + one(json!({ "name": "authorId", "type": "array", "byteArray": true })), + None + )); + assert!(!post_meets( + one(json!({ "name": "blob", "type": "identifier" })), + None + )); + // One the referring document names + let named = one(json!({ "name": { "property": "bodyProperty" }, "type": "string" })); + assert!(post_meets(named.clone(), Some("body"))); + assert!(!post_meets(named.clone(), Some("count"))); + assert!(!post_meets(named.clone(), Some("missing"))); + assert!(!post_meets(named, None)); +} + +#[test] +fn should_hold_a_slot_to_its_byte_bound() { + let string_of = |max_bytes: u32| json!({ "properties": { "text": { "name": "$any", "type": "string", "maxBytes": max_bytes } } }); + // `title` holds at most 63 characters, 252 bytes; `body` 4096 bytes; `summary` declares + // no bound, which no bound admits + assert!(post_meets(string_of(252), None)); + assert!(!post_meets(string_of(251), None)); + let named = |name: &str, max_bytes: u32| json!({ "properties": { "text": { "name": name, "type": "string", "maxBytes": max_bytes } } }); + assert!(post_meets(named("body", 4096), None)); + assert!(!post_meets(named("body", 4095), None)); + assert!(!post_meets(named("summary", u32::MAX), None)); + assert!(post_meets( + json!({ "properties": { "text": { "name": "summary", "type": "string" } } }), + None + )); + let bytes_of = |max_bytes: u32| { + json!({ "properties": { + "bytes": { "name": "$any", "type": "array", "byteArray": true, "maxBytes": max_bytes } + } }) + }; + assert!(post_meets(bytes_of(1024), None)); + assert!(!post_meets(bytes_of(1023), None)); +} + +#[test] +fn should_fill_distinct_slots_with_different_properties() { + let short = || json!({ "name": "$any", "type": "string", "maxBytes": 252 }); + // One property is short enough: it fills one slot, or both where they need not differ + assert!(post_meets( + json!({ "properties": { "a": short(), "b": short() } }), + None + )); + let mut distinct = short(); + distinct["distinctFrom"] = json!("a"); + assert!(!post_meets( + json!({ "properties": { "a": short(), "b": distinct } }), + None + )); + // Stated on either side + let mut first = short(); + first["distinctFrom"] = json!(["b"]); + assert!(!post_meets( + json!({ "properties": { "a": first, "b": short() } }), + None + )); + // Three strings fill three distinct slots, whichever order the candidates come in: the + // slot only `title` fills takes it, though every other slot admits it too + let any = |distinct_from: serde_json::Value| json!({ "name": "$any", "type": "string", "distinctFrom": distinct_from }); + assert!(post_meets( + json!({ "properties": { + "a": { "name": "$any", "type": "string" }, + "b": any(json!("a")), + "c": { "name": "$any", "type": "string", "maxBytes": 252, "distinctFrom": ["a", "b"] } + } }), + None + )); + // and there is no fourth + assert!(!post_meets( + json!({ "properties": { + "a": { "name": "$any", "type": "string" }, + "b": any(json!("a")), + "c": any(json!(["a", "b"])), + "d": any(json!(["a", "b", "c"])) + } }), + None + )); +} + +#[test] +fn should_require_the_slots_require_names() { + let slots = json!({ + "text": { "name": "text", "type": "string" }, + "bytes": { "name": "$any", "type": "array", "byteArray": true }, + "count": { "name": "count", "type": "integer" } + }); + let requiring_slots = |require: Option| { + let mut requirements = json!({ "properties": slots.clone() }); + if let Some(require) = require { + requirements["require"] = require; + } + requirements + }; + // The type has no `text`: every slot is required where nothing says otherwise + assert!(!post_meets(requiring_slots(None), None)); + // Either the text or the bytes, and the count + assert!(post_meets( + requiring_slots(Some( + json!({ "allOf": ["count", { "anyOf": ["text", "bytes"] }] }) + )), + None + )); + // The text and the count, or the bytes + assert!(post_meets( + requiring_slots(Some( + json!({ "anyOf": ["bytes", { "allOf": ["text", "count"] }] }) + )), + None + )); + // The text, with or without the rest + assert!(!post_meets( + requiring_slots(Some( + json!({ "allOf": ["text", { "anyOf": ["bytes", "count"] }] }) + )), + None + )); + assert!(!post_meets( + requiring_slots(Some( + json!({ "anyOf": [{ "allOf": ["text", "bytes"] }, { "allOf": ["text", "count"] }] }) + )), + None + )); + + // A slot left empty frees its property for a slot that needs it: `first` and `second` + // both want `title` alone and must differ, and `require` asks for `second` only + let contended = json!({ + "properties": { + "first": { "name": "title", "type": "string" }, + "second": { "name": "title", "type": "string", "distinctFrom": "first" } + }, + "require": { "anyOf": ["second", { "allOf": ["first", "second"] }] } + }); + assert!(post_meets(contended, None)); +} + +#[test] +fn should_round_trip_a_contract_through_platform_serialization_with_a_dynamic_reference() { + let platform_version = PlatformVersion::latest(); + let original = contract(note_contract(requiring(json!({ + "properties": { + "title": { "name": "$any", "type": "string", "maxBytes": 256 }, + "body": { + "name": { "property": "bodyProperty" }, + "type": "string", + "distinctFrom": ["title"] + }, + "bytes": { "name": "blob", "type": "array", "byteArray": true } + }, + "require": { "allOf": ["title", { "anyOf": ["body", "bytes"] }] } + })))) + .expect("parses"); + let bytes = original + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract should serialize"); + let recovered = DataContract::versioned_deserialize_untrusted(&bytes, false, platform_version) + .expect("the contract should deserialize"); + assert_eq!(original, recovered); + assert_eq!(dynamic_reference(&original), dynamic_reference(&recovered)); +} + +#[test] +fn should_refuse_a_rule_or_an_index_reading_through_a_dynamic_reference() { + // A rule reads the values of a document a reference names in the schema + let mut with_a_rule = note_contract(text_or_bytes()); + with_a_rule["documentSchemas"]["note"]["propertyConstraints"] = json!({ + "ownDescription": { + "equal": [{ "referenced": ["descriptionId", "$ownerId"] }, "$ownerId"] + } + }); + assert_refused(contract(with_a_rule), "descriptionId"); + + // and an index holds a value of one + let mut with_an_index = note_contract(text_or_bytes()); + with_an_index["documentSchemas"]["note"]["indices"] = json!([ + { "name": "byDescriptionOwner", "properties": [{ "descriptionId.$ownerId": "asc" }] } + ]); + contract(with_an_index).expect_err("an index reads no value through a dynamic reference"); +} 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 0dbec5f6a71..5ed520059f2 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 @@ -70,6 +70,7 @@ use crate::data_contract::document_type::schema::validation_schema::keep_native_ use platform_value::btreemap_extensions::BTreeValueMapHelper; use super::common; +use super::dynamic_reference::validate_dynamic_reference_sources; #[cfg(feature = "validation")] use super::schema_at_path; use super::{ @@ -723,6 +724,12 @@ fn parse_generation_3( // the lookup of the `ownerRefersTo` declaration included. validate_reference_lookup_sources(DocumentTypeRef::V2(&v2), name) .map_err(consensus_or_protocol_data_contract_error)?; + // and for the properties a reference naming its target through the referring document + // reads the contract, the document type and its slots' property names from. No + // version before protocol version 15 parses such a reference, so there is none to + // check there. + validate_dynamic_reference_sources(DocumentTypeRef::V2(&v2), name) + .map_err(consensus_or_protocol_data_contract_error)?; // The `propertyConstraints` rules are parsed onto the type here, where the // integer properties they read and their transient flags are known; their // limits are checked under full validation only. The `enum`s their string @@ -1824,10 +1831,12 @@ fn with_own_contract_id_omitted( .collect(), ); } + // A dynamic reference names no contract in the schema DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Contract { .. } | DocumentPropertyReferenceTarget::Token - | DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => {} + | DocumentPropertyReferenceTarget::IdentityPublicKey { .. } + | DocumentPropertyReferenceTarget::DynamicDocument(_) => {} } target } @@ -2117,6 +2126,8 @@ mod refusal_test_support; #[cfg(all(test, feature = "validation"))] mod summable_off_count_index_tests; +#[cfg(all(test, feature = "validation"))] +mod dynamic_reference_tests; #[cfg(all(test, feature = "validation"))] mod generated_from_tests; #[cfg(all(test, feature = "validation"))] 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 06cf577d693..f4ccef589bb 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -172,6 +172,19 @@ pub(crate) mod property_names { pub const CONTRACT_ID: &str = "contractId"; pub const DOCUMENT_TYPE: &str = "documentType"; pub const KEY_ID_PROPERTY: &str = "keyIdProperty"; + /// `refersTo` on a permanent or moderated document reference: the top-level identifier + /// property of the referring document holding the id of the contract the referenced + /// document type lives in. Takes the place of [`CONTRACT_ID`], and comes with + /// [`DOCUMENT_TYPE_PROPERTY`] (protocol version 15). + pub const CONTRACT_ID_PROPERTY: &str = "contractIdProperty"; + /// The top-level string property of the referring document holding the name of the + /// referenced document type. Takes the place of [`DOCUMENT_TYPE`], and comes with + /// [`CONTRACT_ID_PROPERTY`] (protocol version 15). + pub const DOCUMENT_TYPE_PROPERTY: &str = "documentTypeProperty"; + /// What the document type a dynamic reference names must look like: named slots, each + /// describing a property it must have, and which of them must be filled (protocol + /// version 15). + pub const DOCUMENT_TYPE_REQUIREMENTS: &str = "documentTypeRequirements"; /// `identityPublicKey` reference on the key id property itself: whose key /// the value names (`"$ownerId"`, the writer). Takes the place of /// [`KEY_ID_PROPERTY`]; a declaration carries one or the other. diff --git a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs new file mode 100644 index 00000000000..a1968a028b0 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs @@ -0,0 +1,375 @@ +//! A document reference whose target the referring document names (protocol version 15). +//! +//! Every other document reference names its target in the schema: a `contractId` (or the +//! declaring contract) and a `documentType`, so the referenced document type is known when +//! the contract is registered and is checked then. A dynamic reference names two properties +//! of the referring document instead (`contractIdProperty`, `documentTypeProperty`), and the +//! document says, when it is written, which contract and which document type its value +//! points into: +//! +//! ```json +//! "descriptionId": { +//! "type": "identifier", +//! "refersTo": { +//! "type": "moderatedDocument", +//! "contractIdProperty": "descriptionContractId", +//! "documentTypeProperty": "descriptionDocumentType", +//! "documentTypeRequirements": { +//! "properties": { +//! "body": { "name": "$any", "type": "string" }, +//! "bodyBytes": { "name": "$any", "type": "array", "byteArray": true } +//! }, +//! "require": { "anyOf": ["body", "bodyBytes"] } +//! } +//! } +//! } +//! ``` +//! +//! Nothing about the target can be checked at registration, so everything is checked when +//! the document is written: that the contract and the document type exist, that the type is +//! of the declared kind, that it meets the `documentTypeRequirements`, and that the document +//! exists. A type's deletion and moderation settings never change, so a target of the +//! declared kind keeps it; the requirements are judged whenever the reference is checked. + +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::property::{ + DocumentProperty, DocumentPropertyType, DocumentReferenceKind, +}; +use crate::data_contract::document_type::DocumentTypeRef; +use bincode::{Decode, DecodeUntrusted, Encode}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; + +/// The name a slot takes for "any property": `"name": "$any"`. +pub const ANY_PROPERTY_NAME: &str = "$any"; + +/// Which kind of document a dynamic reference points at. A `deletableDocument` reference is +/// never dynamic: a dynamic target is trusted for what its type promises about its +/// documents staying, and a deletable type promises nothing. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Encode, Decode, DecodeUntrusted)] +pub enum DynamicDocumentReferenceKind { + /// `permanentDocument`: the documents never leave state. + Permanent, + /// `moderatedDocument`: the documents leave state only through a moderator's recorded + /// removal. + Moderated, +} + +impl From for DocumentReferenceKind { + fn from(kind: DynamicDocumentReferenceKind) -> Self { + match kind { + DynamicDocumentReferenceKind::Permanent => DocumentReferenceKind::Permanent, + DynamicDocumentReferenceKind::Moderated => DocumentReferenceKind::Moderated, + } + } +} + +/// A `refersTo` with `contractIdProperty` and `documentTypeProperty`: the value is the id of +/// a document of the document type the referring document names. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +pub struct DynamicDocumentReference { + /// What the named document type must promise about its documents leaving state. + pub kind: DynamicDocumentReferenceKind, + /// The top-level identifier property of the declaring type holding the id of the + /// contract the referenced document type lives in. + pub contract_id_property: String, + /// The top-level string property of the declaring type holding the name of the + /// referenced document type. + pub document_type_property: String, + /// What the named document type must look like (`documentTypeRequirements`); `None` + /// asks nothing of it beyond its kind. + pub requirements: Option, +} + +impl DynamicDocumentReference { + /// The `refersTo` `type` the declaration is written with. + pub fn wire_name(&self) -> &'static str { + DocumentReferenceKind::from(self.kind).wire_name() + } + + /// The properties of the declaring type the target is read from: the contract id, the + /// document type name, and every property a slot takes its property name from. A + /// replace that changes one of them points the reference somewhere else, so it is + /// checked again. + pub fn target_properties(&self) -> impl Iterator { + [ + self.contract_id_property.as_str(), + self.document_type_property.as_str(), + ] + .into_iter() + .chain( + self.requirements + .iter() + .flat_map(|requirements| requirements.name_properties()), + ) + } +} + +/// `documentTypeRequirements`: named slots, each describing a top-level property the +/// referenced document type must have, and which of the slots must be filled. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +pub struct DocumentTypeRequirements { + /// The slots by name. A slot is filled by one property of the referenced type that + /// matches its description. + pub slots: BTreeMap, + /// Which slots must be filled (`require`); `None` means every one of them. + pub require: Option, +} + +/// One slot of a [`DocumentTypeRequirements`]. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +pub struct RequiredPropertySlot { + /// Which property may fill the slot (`name`). + pub name: SlotPropertyName, + /// What the property holds (`type`, with `byteArray` for a byte array). + pub value: SlotValue, + /// The most bytes a value of the property may ever take (`maxBytes`), for a string or a + /// byte array slot: the property must declare a bound that is at most this one. `None` + /// admits a property of any size. + pub max_bytes: Option, + /// The other slots this one must be filled by a different property than + /// (`distinctFrom`). The relation is symmetric: naming it on one side is enough. + pub distinct_from: BTreeSet, +} + +/// Which property of the referenced type may fill a slot. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +pub enum SlotPropertyName { + /// `"$any"`: any property matching the rest of the description. + Any, + /// A literal name: the property called this. + Named(String), + /// `{ "property": "" }`: the property the referring document names in this + /// top-level string property of its own. + FromProperty(String), +} + +/// What a property filling a slot holds. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Encode, Decode, DecodeUntrusted)] +pub enum SlotValue { + /// `"type": "string"`. + String, + /// `"type": "array"` with `"byteArray": true`, other than an identifier. + ByteArray, + /// `"type": "identifier"`. + Identifier, + /// `"type": "integer"`. + Integer, + /// `"type": "boolean"`. + Boolean, +} + +impl SlotValue { + /// The `type` the slot is written with, for messages. + pub const fn wire_name(self) -> &'static str { + match self { + SlotValue::String => "string", + SlotValue::ByteArray => "byte array", + SlotValue::Identifier => "identifier", + SlotValue::Integer => "integer", + SlotValue::Boolean => "boolean", + } + } +} + +/// `require`: which slots must be filled. One slot, or `anyOf` / `allOf` over terms, a term +/// being a slot or one group of slots joined by the other combinator. Two levels say every +/// condition `anyOf` and `allOf` can say of a set of slots, and keep the type from nesting +/// without bound in a decoder. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +pub enum SlotRequirement { + /// The slot must be filled. + Slot(String), + /// At least one term must hold; a group holds when all of its slots are filled. + AnyOf(Vec), + /// Every term must hold; a group holds when one of its slots is filled. + AllOf(Vec), +} + +/// A term of an `anyOf` or `allOf` [`SlotRequirement`]. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +pub enum SlotRequirementTerm { + /// One slot. + Slot(String), + /// A nested group of slots, joined by the combinator the enclosing one is not: `allOf` + /// inside an `anyOf`, `anyOf` inside an `allOf`. + Group(Vec), +} + +impl SlotRequirement { + /// Whether the requirement holds when exactly the slots `filled` reports are filled. + pub fn holds(&self, filled: impl Fn(&str) -> bool) -> bool { + match self { + SlotRequirement::Slot(slot) => filled(slot), + SlotRequirement::AnyOf(terms) => terms.iter().any(|term| match term { + SlotRequirementTerm::Slot(slot) => filled(slot), + SlotRequirementTerm::Group(slots) => slots.iter().all(|slot| filled(slot)), + }), + SlotRequirement::AllOf(terms) => terms.iter().all(|term| match term { + SlotRequirementTerm::Slot(slot) => filled(slot), + SlotRequirementTerm::Group(slots) => slots.iter().any(|slot| filled(slot)), + }), + } + } + + /// Every slot the requirement names, a slot named twice given twice. + pub fn slots(&self) -> Vec<&str> { + match self { + SlotRequirement::Slot(slot) => vec![slot.as_str()], + SlotRequirement::AnyOf(terms) | SlotRequirement::AllOf(terms) => terms + .iter() + .flat_map(|term| match term { + SlotRequirementTerm::Slot(slot) => vec![slot.as_str()], + SlotRequirementTerm::Group(slots) => slots.iter().map(String::as_str).collect(), + }) + .collect(), + } + } +} + +impl RequiredPropertySlot { + /// Whether `property`, a top-level property of the referenced type, matches the slot's + /// description, its name aside. A transient property is never stored, so it fills + /// nothing. + pub fn admits(&self, property: &DocumentProperty) -> bool { + if property.transient { + return false; + } + let within = |declared: Option| { + self.max_bytes + .is_none_or(|bound| declared.is_some_and(|declared| declared <= bound)) + }; + match (self.value, &property.property_type) { + // A string that declares no `maxBytes` holds at most four bytes a character + (SlotValue::String, DocumentPropertyType::String(sizes)) => within( + sizes.max_bytes.map(u32::from).or(sizes + .max_length + .map(|characters| u32::from(characters).saturating_mul(4))), + ), + (SlotValue::ByteArray, DocumentPropertyType::ByteArray(sizes)) => { + within(sizes.max_size.map(u32::from)) + } + ( + SlotValue::Identifier, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_), + ) => true, + (SlotValue::Integer, property_type) => property_type.is_integer(), + (SlotValue::Boolean, DocumentPropertyType::Boolean) => true, + _ => false, + } + } +} + +impl DocumentTypeRequirements { + /// The properties of the declaring type a slot takes its property name from. + pub fn name_properties(&self) -> impl Iterator { + self.slots.values().filter_map(|slot| match &slot.name { + SlotPropertyName::FromProperty(property) => Some(property.as_str()), + SlotPropertyName::Any | SlotPropertyName::Named(_) => None, + }) + } + + /// Whether `document_type` meets the requirements. + /// + /// A slot is filled by one top-level property of the type that its description admits: + /// any such property for `"$any"`, the named one for a literal name, and for a name read + /// from the referring document the property `name_from_property` gives for that + /// referring property (`None` when the document leaves it out, which fills nothing). + /// Slots that are `distinctFrom` each other are filled by different properties. The + /// requirements are met when some choice of properties fills a set of slots `require` + /// holds for, which is every slot when there is no `require`. + /// + /// The search tries the fullest sets of slots first and, within a set, the slot with the + /// fewest candidates first. A set that cannot be filled has a group of slots with fewer + /// candidates between them than slots, so with the number of slots held to + /// `max_document_type_requirement_slots` the search stays small whatever the type + /// declares. + pub fn met_by( + &self, + document_type: DocumentTypeRef, + name_from_property: impl Fn(&str) -> Option, + ) -> bool { + let properties = document_type.properties(); + let slots: Vec<(&str, &RequiredPropertySlot, Vec<&str>)> = self + .slots + .iter() + .map(|(slot_name, slot)| { + let named = |name: &str| { + properties + .get_key_value(name) + .filter(|(_, property)| slot.admits(property)) + .map(|(name, _)| vec![name.as_str()]) + .unwrap_or_default() + }; + let candidates = match &slot.name { + SlotPropertyName::Any => properties + .iter() + .filter(|(_, property)| slot.admits(property)) + .map(|(name, _)| name.as_str()) + .collect(), + SlotPropertyName::Named(name) => named(name), + SlotPropertyName::FromProperty(referring_property) => { + name_from_property(referring_property) + .map(|name| named(&name)) + .unwrap_or_default() + } + }; + (slot_name.as_str(), slot, candidates) + }) + .collect(); + + // Slot sets as bit masks, the fullest first: `require` only ever asks for slots to + // be filled, so a fuller set never does worse + let slot_count = slots.len(); + let mut sets: Vec = (0..(1u32 << slot_count)).collect(); + sets.sort_by_key(|set| std::cmp::Reverse(set.count_ones())); + sets.into_iter().any(|set| { + let in_set = |index: usize| set & (1 << index) != 0; + let required = match &self.require { + None => set.count_ones() as usize == slot_count, + Some(require) => require.holds(|slot_name| { + slots + .iter() + .position(|(name, _, _)| *name == slot_name) + .is_some_and(in_set) + }), + }; + if !required { + return false; + } + let mut order: Vec = (0..slot_count).filter(|index| in_set(*index)).collect(); + order.sort_by_key(|index| slots[*index].2.len()); + fill(&slots, &order, &mut Vec::new()) + }) + } +} + +/// Fills the slots `order` lists, in that order, with properties no slot distinct from them +/// holds; `filled` holds the slots filled so far with the property each took. +fn fill<'a>( + slots: &[(&'a str, &'a RequiredPropertySlot, Vec<&'a str>)], + order: &[usize], + filled: &mut Vec<(usize, &'a str)>, +) -> bool { + let Some((index, rest)) = order.split_first() else { + return true; + }; + let (slot_name, slot, candidates) = &slots[*index]; + for candidate in candidates { + let taken = filled.iter().any(|(other, property)| { + let (other_name, other_slot, _) = &slots[*other]; + property == candidate + && (slot.distinct_from.contains(*other_name) + || other_slot.distinct_from.contains(*slot_name)) + }); + if taken { + continue; + } + filled.push((*index, candidate)); + if fill(slots, rest, filled) { + return true; + } + filled.pop(); + } + false +} 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 a1f37c863f9..79616a8b3c0 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 @@ -40,6 +40,7 @@ use rand::Rng; use serde::{Deserialize, Serialize}; pub mod array; +pub mod dynamic_document_reference; pub mod encrypted_for; pub mod generated_from; pub mod list_element_reference; @@ -48,6 +49,11 @@ pub mod reference_expression; pub mod reference_lookup; pub mod reference_where; +pub use dynamic_document_reference::{ + DocumentTypeRequirements, DynamicDocumentReference, DynamicDocumentReferenceKind, + RequiredPropertySlot, SlotPropertyName, SlotRequirement, SlotRequirementTerm, SlotValue, + ANY_PROPERTY_NAME, +}; pub use encrypted_for::{EncryptedFor, EncryptedForRecipient, EncryptionScheme}; pub use generated_from::{ GeneratedFrom, GenerationParam, HashFunction, StringTransformation, SystemFunction, @@ -1052,6 +1058,21 @@ pub enum DocumentPropertyReferenceTarget { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] where_conditions: BTreeMap, }, + /// A document of a document type the referring document names + /// (`contractIdProperty`, `documentTypeProperty`), permanent or moderated, found by the + /// value as its id (protocol version 15). The contract and the document type are read + /// from the referring document when it is written, so nothing about the target is + /// checked at contract registration: that the type exists, is of the declared kind and + /// meets the declaration's `documentTypeRequirements` is checked on every write that + /// checks the reference. + /// + /// The id form only, on an identifier property: no `findBy`, no `inList`, no `where`, + /// no operand of a reference expression, no typed array element, and no rule or index + /// reads through it, since each of those needs to know the referenced type when the + /// contract is registered. Appended, so every earlier variant keeps its consensus + /// encoding. + #[serde(rename = "dynamicDocument")] + DynamicDocument(DynamicDocumentReference), } /// Which of the three document reference kinds a declaration is, and so what @@ -1261,12 +1282,25 @@ impl DocumentPropertyReferenceTarget { in_list: Some(&reference.in_list), }) } + // A dynamic reference names no contract and no document type: the referring + // document does, when it is written. It has no declaration to give the code + // that resolves a reference's target from the schema DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Contract { .. } | DocumentPropertyReferenceTarget::Token | DocumentPropertyReferenceTarget::IdentityPublicKey { .. } | DocumentPropertyReferenceTarget::AnyOf(_) - | DocumentPropertyReferenceTarget::AllOf(_) => None, + | DocumentPropertyReferenceTarget::AllOf(_) + | DocumentPropertyReferenceTarget::DynamicDocument(_) => None, + } + } + + /// The declaration of a reference whose target the referring document names + /// ([`Self::DynamicDocument`]); `None` for every other target. + pub fn as_dynamic_document_reference(&self) -> Option<&DynamicDocumentReference> { + match self { + DocumentPropertyReferenceTarget::DynamicDocument(reference) => Some(reference), + _ => None, } } @@ -1683,6 +1717,16 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { .. } => write_document_reference(f, "moderated", *contract_id, document_type_name, None), DocumentPropertyReferenceTarget::ListElement(reference) => reference.fmt(f), + DocumentPropertyReferenceTarget::DynamicDocument(reference) => write!( + f, + "{} document (contract named by {}, document type named by {})", + match reference.kind { + DynamicDocumentReferenceKind::Permanent => "permanent", + DynamicDocumentReferenceKind::Moderated => "moderated", + }, + reference.contract_id_property, + reference.document_type_property + ), DocumentPropertyReferenceTarget::AnyOf(operands) | DocumentPropertyReferenceTarget::AllOf(operands) => { let (name, joiner) = match self { @@ -11283,6 +11327,12 @@ mod tests { where_conditions: Default::default(), property_agreement: Default::default(), }, + DocumentPropertyReferenceTarget::DynamicDocument(DynamicDocumentReference { + kind: DynamicDocumentReferenceKind::Moderated, + contract_id_property: "postContractId".to_string(), + document_type_property: "postDocumentType".to_string(), + requirements: None, + }), ]; for target in &targets { @@ -11301,6 +11351,10 @@ mod tests { "deletableDocument" } DocumentPropertyReferenceTarget::ModeratedDocument { .. } => "moderatedDocument", + // The `type` it is declared with, permanent or moderated + DocumentPropertyReferenceTarget::DynamicDocument(reference) => { + reference.wire_name() + } // Not a `type`: the schema declares them under their own keys DocumentPropertyReferenceTarget::ListElement(_) => "listElement", DocumentPropertyReferenceTarget::AnyOf(_) => "anyOf", diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 9b3326f5a4c..eeeccb546ba 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -400,6 +400,7 @@ impl ErrorWithCode for StateError { Self::ReferencedDocumentTypePrivateError(_) => 40150, Self::DocumentTypeNotPrivateError(_) => 40151, Self::PrivateDocumentEntrySizeMismatchError(_) => 40152, + Self::ReferencedDocumentTypeRequirementNotMetError(_) => 40153, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, diff --git a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs index 544d7db4ec7..1950a04597d 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs @@ -41,6 +41,7 @@ pub mod referenced_document_type_not_deletable_error; pub mod referenced_document_type_not_found_error; pub mod referenced_document_type_not_moderated_error; pub mod referenced_document_type_private_error; +pub mod referenced_document_type_requirement_not_met_error; pub mod referenced_entity_not_found_error; pub mod referenced_identity_key_disabled_error; pub mod referenced_identity_key_not_found_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_type_requirement_not_met_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_type_requirement_not_met_error.rs new file mode 100644 index 00000000000..68c850f7ec5 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_type_requirement_not_met_error.rs @@ -0,0 +1,69 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +/// A document reference naming its target through the referring document +/// (`contractIdProperty`, `documentTypeProperty`, protocol version 15) points at a document +/// type that does not meet the reference's `documentTypeRequirements`: no choice of its +/// properties fills the slots `require` asks for. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("referenced document type {document_type_name} in contract {contract_id} does not meet the documentTypeRequirements of the document reference at path {path}: its properties do not fill the slots the reference requires")] +#[platform_serialize(unversioned)] +pub struct ReferencedDocumentTypeRequirementNotMetError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + document_type_name: String, + path: String, +} + +impl ReferencedDocumentTypeRequirementNotMetError { + pub fn new(contract_id: Identifier, document_type_name: String, path: String) -> Self { + Self { + contract_id, + document_type_name, + path, + } + } + + pub fn contract_id(&self) -> &Identifier { + &self.contract_id + } + + pub fn document_type_name(&self) -> &str { + &self.document_type_name + } + + pub fn path(&self) -> &str { + &self.path + } +} + +impl From for ConsensusError { + fn from(err: ReferencedDocumentTypeRequirementNotMetError) -> Self { + Self::StateError(StateError::ReferencedDocumentTypeRequirementNotMetError( + err, + )) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 2d07f7c7f9d..e301828062f 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -84,6 +84,7 @@ use crate::consensus::state::document::referenced_document_removed_error::Refere use crate::consensus::state::document::referenced_document_type_moderated_error::ReferencedDocumentTypeModeratedError; use crate::consensus::state::document::referenced_document_type_index_only_error::ReferencedDocumentTypeIndexOnlyError; use crate::consensus::state::document::referenced_document_type_private_error::ReferencedDocumentTypePrivateError; +use crate::consensus::state::document::referenced_document_type_requirement_not_met_error::ReferencedDocumentTypeRequirementNotMetError; use crate::consensus::state::document::document_type_not_private_error::DocumentTypeNotPrivateError; use crate::consensus::state::document::private_document_entry_size_mismatch_error::PrivateDocumentEntrySizeMismatchError; use crate::consensus::state::document::document_delete_constraint_violated_error::DocumentDeleteConstraintViolatedError; @@ -771,6 +772,11 @@ pub enum StateError { #[error(transparent)] PrivateDocumentEntrySizeMismatchError(PrivateDocumentEntrySizeMismatchError), + + // A document reference naming its target through the referring document points at a + // document type that does not meet its `documentTypeRequirements` (protocol version 15). + #[error(transparent)] + ReferencedDocumentTypeRequirementNotMetError(ReferencedDocumentTypeRequirementNotMetError), } impl From for ConsensusError { @@ -1750,5 +1756,17 @@ mod tests { )), 180 ); + // A dynamic document reference's unmet `documentTypeRequirements` (protocol version + // 15): the tail of the enum. + assert_eq!( + discriminant_of(StateError::ReferencedDocumentTypeRequirementNotMetError( + ReferencedDocumentTypeRequirementNotMetError::new( + identity_id, + "post".to_string(), + "descriptionId".to_string(), + ) + )), + 181 + ); } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index 9e871ec97b8..71bf567e90c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -902,6 +902,11 @@ fn binds_a_changed_property( .operands() .iter() .any(|operand| binds_a_changed_property(operand, changed_fields, writer_can_change)), + // Only protocol version 15 parses a reference naming its target through the + // referring document, and it selects generation 1: none reaches this generation. + // One that did would be checked, and refused there as one this generation cannot + // resolve + DocumentPropertyReferenceTarget::DynamicDocument(_) => true, } } @@ -1756,6 +1761,13 @@ fn validate_reference_target_v0( "a reference expression reached the leaf check, which only its evaluator calls", ))) } + // Only protocol version 15 parses one, and it selects generation 1 + DocumentPropertyReferenceTarget::DynamicDocument(_) => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a reference naming its target through the referring document reached \ + generation 0 of the reference check, which no version parsing one selects", + ))) + } }; if !exists { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs index 1d3ffbc0c28..2727633baa7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs @@ -36,6 +36,8 @@ use dpp::errors::consensus::state::document::referenced_document_preceded_error: use dpp::errors::consensus::state::document::referenced_document_property_mismatch_error::ReferencedDocumentPropertyMismatchError; use dpp::errors::consensus::state::document::referenced_document_removed_error::ReferencedDocumentRemovedError; use dpp::errors::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; +use dpp::errors::consensus::state::document::referenced_document_type_private_error::ReferencedDocumentTypePrivateError; +use dpp::errors::consensus::state::document::referenced_document_type_requirement_not_met_error::ReferencedDocumentTypeRequirementNotMetError; use dpp::errors::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; use dpp::errors::consensus::state::document::referenced_document_requirement_not_met_error::ReferencedDocumentRequirementNotMetError; use dpp::errors::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; @@ -776,6 +778,14 @@ fn binds_a_changed_property( DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property, .. } => is_changed_field(changed_fields, key_id_property), + // A reference naming its target through the referring document binds the + // properties it reads the target from: a changed one points the value into another + // contract or document type, or names another property for a slot, so the whole + // reference is checked again. Kept otherwise, as a permanent or moderated + // reference is + DocumentPropertyReferenceTarget::DynamicDocument(dynamic) => dynamic + .target_properties() + .any(|target_property| is_changed_field(changed_fields, target_property)), // A list element binds the properties its agreement reads, the // `$id` pair's among them: a changed one may name another document, // whose list is then checked against every value @@ -1125,7 +1135,52 @@ fn validate_reference_target_v1( | DocumentPropertyReferenceTarget::DeletableDocument { .. } | DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. } | DocumentPropertyReferenceTarget::ModeratedDocument { .. } - | DocumentPropertyReferenceTarget::ListElement(_) => { + | DocumentPropertyReferenceTarget::ListElement(_) + | DocumentPropertyReferenceTarget::DynamicDocument(_) => { + // A reference naming its target through the referring document (protocol + // version 15) has no declaration in the schema: the document says which + // contract and which document type its value points into, and from there it + // is checked as a reference by id with no `where`. A document that holds the + // reference without saying where it points names nothing that exists + let no_agreement = BTreeMap::new(); + let no_conditions = BTreeMap::new(); + let named_document_type; + let declaration = match reference_target.as_dynamic_document_reference() { + Some(dynamic) => { + let named_contract_id = document_data + .get(&dynamic.contract_id_property) + .and_then(|contract_id| contract_id.to_identifier().ok()); + let named_type = document_data + .get(&dynamic.document_type_property) + .and_then(|document_type_name| document_type_name.as_text()); + let (Some(named_contract_id), Some(named_type)) = + (named_contract_id, named_type) + else { + let missing_id = Identifier::from_bytes(&referenced_id) + .map_err(|e| Error::Protocol(e.into()))?; + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError( + ReferencedEntityNotFoundError::new( + missing_id, + reference_target.clone(), + path.to_string(), + ), + )), + )); + }; + named_document_type = named_type.to_string(); + Some(DocumentReferenceDeclaration { + contract_id: Some(named_contract_id), + document_type_name: &named_document_type, + property_agreement: &no_agreement, + where_conditions: &no_conditions, + kind: dynamic.kind.into(), + lookup: None, + in_list: None, + }) + } + None => reference_target.as_any_document_reference(), + }; let Some(DocumentReferenceDeclaration { contract_id: referenced_contract_id, document_type_name, @@ -1134,7 +1189,7 @@ fn validate_reference_target_v1( kind, lookup, in_list: _, - }) = reference_target.as_any_document_reference() + }) = declaration else { return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( "every document reference target carries a document reference declaration", @@ -1218,6 +1273,43 @@ fn validate_reference_target_v1( )); }; + // What registration checks of a reference naming its target in the schema, a + // reference naming it through the referring document meets here, on every + // write that checks it: the type is not private, whose documents are encrypted + // entries the chain never reads, and it has the properties the reference's + // `documentTypeRequirements` ask for. A slot naming its property through the + // referring document reads the name there + if let Some(dynamic) = reference_target.as_dynamic_document_reference() { + if referenced_document_type.is_private() { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentTypePrivateError::new( + effective_contract_id, + document_type_name.to_string(), + path.to_string(), + ) + .into(), + )); + } + let meets_requirements = dynamic.requirements.as_ref().is_none_or(|requirements| { + requirements.met_by(referenced_document_type, |referring_property| { + document_data + .get(referring_property) + .and_then(|name| name.as_text()) + .map(str::to_string) + }) + }); + if !meets_requirements { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentTypeRequirementNotMetError::new( + effective_contract_id, + document_type_name.to_string(), + path.to_string(), + ) + .into(), + )); + } + } + // A `permanentDocument` reference admits only document types // whose documents never leave state: what could make them leave // it is immutable on contract updates and document types can not diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs index 807ab5b95df..16e61bfa105 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs @@ -79,6 +79,7 @@ mod delete_constraints; mod deletion_options; mod derived_index_properties; mod duplicate_keys; +mod dynamic_document_reference; mod moderated_document_reference; mod moderator_fields; mod preallocated_through_moderated_reference; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs new file mode 100644 index 00000000000..76e54f14d9d --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs @@ -0,0 +1,401 @@ +//! A document reference naming its target through the referring document +//! (`contractIdProperty`, `documentTypeProperty`, `documentTypeRequirements`, protocol version +//! 15): the document says which contract and which document type its value points into, so +//! everything about the target is checked when the document is written. The target here is +//! `post`, a type whose documents only the contract's moderators remove, each removal leaving +//! a record. + +use super::*; + +const NOTE: &str = "note"; +const BYTES_NOTE: &str = "bytesNote"; +const NAMED_NOTE: &str = "namedNote"; +const PLAIN: &str = "plain"; +const REFERENCED_ENTITY_NOT_FOUND: u32 = 40120; +const REFERENCED_DOCUMENT_TYPE_NOT_FOUND: u32 = 40121; +const REFERENCED_DOCUMENT_TYPE_NOT_MODERATED: u32 = 40143; +const REFERENCED_DOCUMENT_TYPE_REQUIREMENT_NOT_MET: u32 = 40153; + +fn id_value(id: Identifier) -> Value { + Value::Identifier(id.to_buffer()) +} + +fn identifier_property(position: u64) -> Value { + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position, + }) +} + +/// A note describing itself with a moderated document of a type it names, which must meet +/// `requirements`. `textProperty` is there for a slot to read its property name from. +fn note_schema(requirements: Value) -> Value { + let mut description_id = identifier_property(0); + description_id + .insert( + "refersTo".to_string(), + platform_value!({ + "type": "moderatedDocument", + "contractIdProperty": "descriptionContractId", + "documentTypeProperty": "descriptionDocumentType", + "documentTypeRequirements": requirements, + }), + ) + .expect("expected to declare the reference"); + platform_value!({ + "type": "object", + "properties": { + "descriptionId": description_id, + "descriptionContractId": identifier_property(1), + "descriptionDocumentType": { "type": "string", "minLength": 1, "maxLength": 64, "position": 2 }, + "textProperty": { "type": "string", "minLength": 1, "maxLength": 64, "position": 3 }, + "body": { "type": "string", "minLength": 1, "maxLength": 50, "position": 4 }, + }, + "required": [ + "descriptionId", + "descriptionContractId", + "descriptionDocumentType", + "textProperty", + "body" + ], + "additionalProperties": false, + }) +} + +/// A type holding text, as a string or as bytes: `post` holds a string +fn text_or_bytes() -> Value { + platform_value!({ + "properties": { + "body": { "name": "$any", "type": "string" }, + "bodyBytes": { "name": "$any", "type": "array", "byteArray": true }, + }, + "require": { "anyOf": ["body", "bodyBytes"] }, + }) +} + +/// A document type anyone's documents of which their owner deletes: not a moderated one +fn plain_schema() -> Value { + platform_value!({ + "type": "object", + "properties": { + "text": { "type": "string", "maxLength": 50, "position": 0 }, + }, + "required": ["text"], + "additionalProperties": false, + }) +} + +/// A contract whose named moderator removes posts, with notes referring to a document of a +/// type they name: `note` asks for text or bytes, `bytesNote` for bytes, and `namedNote` for +/// a string property the note names. +async fn setup() -> Setup { + Setup::new_at_with( + Some(moderators_without_lists()), + PlatformVersion::latest(), + |contract| { + add_document_type( + contract, + POST, + post_schema_with(platform_value!({ "canBeDeleted": false })), + ); + add_document_type(contract, PLAIN, plain_schema()); + add_document_type(contract, NOTE, note_schema(text_or_bytes())); + add_document_type( + contract, + BYTES_NOTE, + note_schema(platform_value!({ + "properties": { + "bodyBytes": { "name": "$any", "type": "array", "byteArray": true }, + }, + })), + ); + add_document_type( + contract, + NAMED_NOTE, + note_schema(platform_value!({ + "properties": { + "text": { "name": { "property": "textProperty" }, "type": "string" }, + }, + })), + ); + }, + ) + .await +} + +/// The properties of a note describing itself with `document_id` of `document_type_name` in +/// `contract_id` +fn note_naming( + contract_id: Identifier, + document_type_name: &str, + document_id: Identifier, +) -> Vec<(&'static str, Value)> { + vec![ + ("descriptionId", id_value(document_id)), + ("descriptionContractId", id_value(contract_id)), + ("descriptionDocumentType", document_type_name.into()), + ("textProperty", "text".into()), + ("body", "first".into()), + ] +} + +/// The document names where its reference points: the contract and the type must exist, the +/// type must be a moderated one, and the document must be there. +#[tokio::test] +async fn should_check_a_reference_into_the_document_type_the_document_names() { + let setup = setup().await; + let transaction = setup.platform.drive.grove.start_transaction(); + let contract_id = setup.contract.id(); + let (post, create_post) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create_post, &transaction)); + let (plain, create_plain) = setup.create_document_of_type(&setup.user, PLAIN).await; + assert_success(&setup.process(&create_plain, &transaction)); + + let (_, create_note) = create_with( + &setup, + &setup.stranger, + NOTE, + note_naming(contract_id, POST, post.id()), + ) + .await; + assert_success(&setup.process(&create_note, &transaction)); + + // A contract that does not exist, and a type the contract does not have + let (_, no_contract) = create_with( + &setup, + &setup.stranger, + NOTE, + note_naming(Identifier::random(), POST, post.id()), + ) + .await; + assert_paid_with_code( + &setup.process(&no_contract, &transaction), + REFERENCED_DOCUMENT_TYPE_NOT_FOUND, + ); + let (_, no_type) = create_with( + &setup, + &setup.stranger, + NOTE, + note_naming(contract_id, "essay", post.id()), + ) + .await; + assert_paid_with_code( + &setup.process(&no_type, &transaction), + REFERENCED_DOCUMENT_TYPE_NOT_FOUND, + ); + + // A type whose documents their owner deletes is not one a moderated reference names + let (_, not_moderated) = create_with( + &setup, + &setup.stranger, + NOTE, + note_naming(contract_id, PLAIN, plain.id()), + ) + .await; + assert_paid_with_code( + &setup.process(¬_moderated, &transaction), + REFERENCED_DOCUMENT_TYPE_NOT_MODERATED, + ); + + // A document that is not there, though its type qualifies + let (_, no_document) = create_with( + &setup, + &setup.stranger, + NOTE, + note_naming(contract_id, POST, Identifier::random()), + ) + .await; + assert_paid_with_code( + &setup.process(&no_document, &transaction), + REFERENCED_ENTITY_NOT_FOUND, + ); +} + +/// The named type must have the properties the reference's requirements ask for: `post` +/// holds a string, so it fills a string slot and not a byte array one, and a slot reading +/// its property name from the note is filled by the property the note names. +#[tokio::test] +async fn should_hold_the_named_document_type_to_the_requirements() { + let setup = setup().await; + let transaction = setup.platform.drive.grove.start_transaction(); + let contract_id = setup.contract.id(); + let (post, create_post) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create_post, &transaction)); + + let (_, needs_bytes) = create_with( + &setup, + &setup.stranger, + BYTES_NOTE, + note_naming(contract_id, POST, post.id()), + ) + .await; + assert_paid_with_code( + &setup.process(&needs_bytes, &transaction), + REFERENCED_DOCUMENT_TYPE_REQUIREMENT_NOT_MET, + ); + + // `textProperty` names `text`, the post's string + let (_, names_the_text) = create_with( + &setup, + &setup.stranger, + NAMED_NOTE, + note_naming(contract_id, POST, post.id()), + ) + .await; + assert_success(&setup.process(&names_the_text, &transaction)); + // and a property the post does not have fills nothing + let mut names_nothing = note_naming(contract_id, POST, post.id()); + names_nothing[3] = ("textProperty", "summary".into()); + let (_, names_nothing) = create_with(&setup, &setup.stranger, NAMED_NOTE, names_nothing).await; + assert_paid_with_code( + &setup.process(&names_nothing, &transaction), + REFERENCED_DOCUMENT_TYPE_REQUIREMENT_NOT_MET, + ); +} + +/// A replace keeps a note describing itself with a post a moderator removed, as it keeps a +/// moderated reference naming its type in the schema. A replace that changes where the note +/// says its reference points checks the reference again, against the new target. +#[tokio::test] +async fn should_keep_a_dynamic_reference_until_a_replace_points_it_elsewhere() { + let setup = setup().await; + let transaction = setup.platform.drive.grove.start_transaction(); + let contract_id = setup.contract.id(); + let (post, create_post) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create_post, &transaction)); + let (other_post, create_other_post) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create_other_post, &transaction)); + + let (mut note, create_note) = create_with( + &setup, + &setup.stranger, + NOTE, + note_naming(contract_id, POST, post.id()), + ) + .await; + assert_success(&setup.process(&create_note, &transaction)); + + let remove = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + assert_success(&setup.process(&remove, &transaction)); + assert!(setup + .stored_document(POST, post.id(), Some(&transaction)) + .is_none()); + + // The reference is kept: nothing it reads changed + note.set("body", "an edit".into()); + let edit = replacement(&setup.stranger, &setup.contract, NOTE, &mut note).await; + assert_success(&setup.process(&edit, &transaction)); + + // A new note cannot name the removed post + let (_, names_removed) = create_with( + &setup, + &setup.stranger, + NOTE, + note_naming(contract_id, POST, post.id()), + ) + .await; + assert_paid_with_code( + &setup.process(&names_removed, &transaction), + REFERENCED_ENTITY_NOT_FOUND, + ); + + // Naming another type points the same id somewhere else: the reference is checked + // again, and that type is not a moderated one + let mut retyped = note.clone(); + retyped.set("descriptionDocumentType", PLAIN.into()); + let retype = replacement(&setup.stranger, &setup.contract, NOTE, &mut retyped).await; + assert_paid_with_code( + &setup.process(&retype, &transaction), + REFERENCED_DOCUMENT_TYPE_NOT_MODERATED, + ); + // Naming another contract too + let mut moved = note.clone(); + moved.set("descriptionContractId", id_value(Identifier::random())); + let move_it = replacement(&setup.stranger, &setup.contract, NOTE, &mut moved).await; + assert_paid_with_code( + &setup.process(&move_it, &transaction), + REFERENCED_DOCUMENT_TYPE_NOT_FOUND, + ); + // and a new value names a post in state + note.set("descriptionId", id_value(other_post.id())); + let repoint = replacement(&setup.stranger, &setup.contract, NOTE, &mut note).await; + assert_success(&setup.process(&repoint, &transaction)); +} + +/// The contract a note names need not be its own: a note of another contract describes +/// itself with a post of the posts contract, whose moderators' removal record it keeps +/// resolving to. +#[tokio::test] +async fn should_follow_a_dynamic_reference_into_another_contract() { + let mut setup = setup().await; + let posts_contract = setup.contract.clone(); + let transaction = setup.platform.drive.grove.start_transaction(); + let (post, create_post) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create_post, &transaction)); + + // Nothing about the posts contract is in the notes contract's schema + let identity_nonce = setup.stranger.identity_nonce(); + let mut notes_contract = get_data_contract_fixture( + Some(setup.stranger.id()), + identity_nonce, + PlatformVersion::latest().protocol_version, + ) + .data_contract_owned(); + add_document_type(&mut notes_contract, NOTE, note_schema(text_or_bytes())); + let create = DataContractCreateTransition::new_from_data_contract( + notes_contract.clone(), + identity_nonce, + &setup.stranger.identity.clone().into_partial_identity_info(), + CRITICAL_KEY_ID, + &setup.stranger.signer, + PlatformVersion::latest(), + None, + ) + .await + .expect("expected to build the contract create"); + assert_success(&setup.process(&create, &transaction)); + // The create sets the stranger's nonce for the contract to the identity nonce it used + setup.stranger.next_contract_nonce.set(identity_nonce + 1); + + // Documents of the notes contract are written through it + setup.contract = notes_contract.clone(); + let (mut note, create_note) = create_with( + &setup, + &setup.stranger, + NOTE, + note_naming(posts_contract.id(), POST, post.id()), + ) + .await; + assert_success(&setup.process(&create_note, &transaction)); + // The notes contract has no `post` type of its own to find the post in + let (_, names_itself) = create_with( + &setup, + &setup.stranger, + NOTE, + note_naming(notes_contract.id(), POST, post.id()), + ) + .await; + assert_paid_with_code( + &setup.process(&names_itself, &transaction), + REFERENCED_DOCUMENT_TYPE_NOT_FOUND, + ); + + // The posts contract's moderator removes the post + setup.contract = posts_contract.clone(); + let remove = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + assert_success(&setup.process(&remove, &transaction)); + + // The note keeps its reference + setup.contract = notes_contract; + note.set("body", "an edit".into()); + let edit = replacement(&setup.stranger, &setup.contract, NOTE, &mut note).await; + assert_success(&setup.process(&edit, &transaction)); +} 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 2b67fe1762a..cac0f0eb029 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 @@ -152,7 +152,11 @@ pub struct DocumentTypeSchemaVersions { pub find_identifier_and_binary_paths: FeatureVersion, /// Folds the `refersTo` reference keyword into the parsed property type. /// `None` on versions that predate the keyword: they ignore it entirely, - /// exactly as they parsed before it existed. + /// exactly as they parsed before it existed. 0 (protocol version 14) reads every + /// reference that names its target in the schema. 1 (protocol version 15) also reads, + /// on an identifier property, a permanent or moderated document reference whose target + /// the referring document names (`contractIdProperty`, `documentTypeProperty`, with + /// `documentTypeRequirements`), and refuses those keys wherever else a reference sits. pub apply_property_reference: OptionalFeatureVersion, /// Parses the `requiredSince` property keyword (the contract version from /// which a property is required). `None` on versions that predate the diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs index af4d9953712..7dd882321cd 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs @@ -20,6 +20,10 @@ use crate::version::dpp_versions::dpp_contract_versions::{ /// and the native validator; /// - `parse_private_document`, `Some(0)` rather than `None`: the `private` keyword declares a /// private document type, which only the native schema check of registration admits; +/// - `apply_property_reference`, `Some(1)` rather than `Some(0)`: a permanent or moderated +/// document reference on an identifier property may name its target through the +/// referring document (`contractIdProperty`, `documentTypeProperty`), with what the named +/// document type must look like (`documentTypeRequirements`); /// - `apply_generated_from`, `Some(1)` rather than `Some(0)`: a `generatedFrom` property may /// also be generated with `sys.tokens.decimals`, the decimals of the token an identifier /// property names, on an integer property; @@ -47,6 +51,8 @@ pub const CONTRACT_VERSIONS_V7: DPPContractVersions = DPPContractVersions { // changed: the `private` keyword declares a private document type parse_private_document: Some(0), apply_generated_from: Some(1), // changed: admits sys.tokens.decimals + // changed: also reads a reference whose target the referring document names + apply_property_reference: Some(1), ..CONTRACT_VERSIONS_V6.document_type_versions.schema }, ..CONTRACT_VERSIONS_V6.document_type_versions 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 b7999175f90..66926d91b06 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -614,6 +614,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_references_per_document: 256, max_reference_operands: 4, max_reference_expression_depth: 4, + max_document_type_requirement_slots: 4, max_property_constraints: 16, max_property_constraint_nodes: 32, max_property_constraint_aggregates: 4, 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 02fc849dca2..5e3ff3c6665 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -91,6 +91,14 @@ pub struct SystemLimits { /// it. Read by document type parser generation 3 (protocol version 14) and never reached /// before. pub max_reference_expression_depth: u16, + /// Maximum number of slots the `documentTypeRequirements` of a `refersTo` naming its + /// target through `contractIdProperty` and `documentTypeProperty` may declare (it holds + /// at least one). Every write that checks such a reference looks for properties of the + /// referenced document type to fill them with, a search that stays small only while + /// the slots are few. Refused on every parse. Read by `apply_property_reference` 1 + /// (protocol version 15), the only version that parses such a reference, and never + /// reached before. + pub max_document_type_requirement_slots: u16, /// Maximum number of named rules one document type's `propertyConstraints` may /// declare. Every rule is evaluated on each create and replace of a document of the /// type, so this and `max_property_constraint_nodes` are what bound the arithmetic one 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 dedcc28fc5a..56cee421b75 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -17,6 +17,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_references_per_document: 256, max_reference_operands: 4, max_reference_expression_depth: 4, + max_document_type_requirement_slots: 4, max_property_constraints: 16, max_property_constraint_nodes: 32, max_property_constraint_aggregates: 4, 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 eabb93e7285..d85a2b327ef 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -23,6 +23,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_references_per_document: 256, max_reference_operands: 4, max_reference_expression_depth: 4, + max_document_type_requirement_slots: 4, max_property_constraints: 16, max_property_constraint_nodes: 32, max_property_constraint_aggregates: 4, 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 802e21da2f5..2ff95a78862 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -25,6 +25,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_references_per_document: 256, max_reference_operands: 4, max_reference_expression_depth: 4, + max_document_type_requirement_slots: 4, max_property_constraints: 16, max_property_constraint_nodes: 32, max_property_constraint_aggregates: 4, 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 87b81fbe198..e89be1f9325 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -146,6 +146,7 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_references_per_document: 256, // refersTo (new in v14): contract registration caps the references one document carries, a typed array of references counting its maxItems max_reference_operands: 4, // refersTo anyOf / allOf (new in v14): contract registration caps the operands one list holds max_reference_expression_depth: 4, // refersTo anyOf / allOf (new in v14): contract registration caps how deep they nest + max_document_type_requirement_slots: 4, // backfilled: dynamic references parse from protocol version 15 max_property_constraints: 16, // propertyConstraints (new in v14): contract registration caps the rules one document type declares max_property_constraint_nodes: 32, // propertyConstraints (new in v14): contract registration caps the nodes (comparison, operators, properties, values) of one rule max_property_constraint_aggregates: 4, // propertyConstraints countOf / sumOf (new in v14): contract registration caps the distinct totals one document type's rules read diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index e488f588aa3..ddd121666cf 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -658,6 +658,35 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// not Platform's, fails that block. Protocol v14 and earlier keep /// `update_masternode_list` 0 or 1, `update_operator_identity` 0 and /// `add_new_keys_to_masternode_identity` 0. +/// +/// 33. **A reference whose target the document names**: `CONTRACT_VERSIONS_V7` selects +/// `apply_property_reference` 1: a `permanentDocument` or `moderatedDocument` `refersTo` +/// on an identifier property may name `contractIdProperty` and `documentTypeProperty`, +/// two top-level properties of the referring document (an identifier and a string), in +/// place of `contractId` and `documentType`, with `documentTypeRequirements`: up to +/// `max_document_type_requirement_slots` (4, `SYSTEM_LIMITS_V5`, backfilled) named +/// slots, each a top-level property the named type must have (`name`: `"$any"`, a +/// property name, or `{ "property": }`; +/// `type`; `maxBytes`; `distinctFrom`, one or several other slots it is filled by a +/// different property than), and `require`, `anyOf` / `allOf` over slot names, every +/// slot when absent. The native schema check of note 7 admits the keys and meta-schema +/// v3 refuses them. It parses to `DocumentPropertyReferenceTarget::DynamicDocument`, +/// appended. Such a declaration carries nothing else (no `contractId`, `documentType`, +/// `findBy`, `where`, `inList`, `consume` or `constraints`), is no operand of a reference +/// expression, sits on no typed array's elements and in no `ownerRefersTo` or +/// `creatorRefersTo`, and no rule, derived index property or chained query reads through +/// it, each of which resolves the referenced type from the schema. Registration checks +/// the declaration and the properties it reads alone. `document_reference_validation` 1 +/// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) checks the target when a document is written: +/// the document names a contract and a document type (`ReferencedEntityNotFoundError`, +/// 40120, when it does not), they exist (`ReferencedDocumentTypeNotFoundError`, 40121), +/// the type is not private (`ReferencedDocumentTypePrivateError`, 40150), meets the +/// requirements (`ReferencedDocumentTypeRequirementNotMetError`, 40153, new) and is of +/// the declared kind (40122, 40143, 40144), and the document exists (40120) or, for a +/// moderated one a replace keeps, was removed on the record. A replace checks the +/// reference again when its value or a property it reads the target from changed. +/// Protocol v14 and earlier keep `apply_property_reference` 0, which does not read the +/// keys, and `document_reference_validation` 0, which no dynamic reference reaches. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V10, // changed: calculate_fee 1 prices a refund at the storing epoch's rate (note 27); apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10) and verify_private_document_entries Some(0) a page of a private type's store (note 13); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11); document method versions v5: insert_empty_private_document_store_operation Some(0) creates a private document type's store, and fetch_private_document_entries and prove_private_document_entries Some(0) read and prove a page of it (note 13); apply_drive_operations 2 resolves a batch's estimate, remove_reference_for_index_level_for_contract_operations 2, stateless_delete_of_non_tree_for_costs 1 and for_total_system_credits_update 1 describe their layers as they are (note 14); create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description (note 17); fetch_core_fork_activation_height and store_core_fork_activation_height Some(0) keep the Core fork activations (note 18); document method versions v5: append_private_document_entries_operations Some(0) appends a private document create's entries (note 25); grove_version GROVE_V5 (note 26); verify methods v4: verify_proof_keep_serialized 1 verifies a document that may be contested (note 29); document method versions v5: single_document_path_query 1 stops a single document lookup at a block time above the pointer to the latest version, and verify_proof_keep_serialized 1 verifies it (note 31); add_new_keys_to_masternode_identity 1 adds a masternode key whose hash the identity already holds a reference for (note 32) diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index ac96756afa9..7edf6b9498b 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -84,6 +84,7 @@ use dpp::consensus::state::document::referenced_document_removed_error::Referenc use dpp::consensus::state::document::referenced_document_type_moderated_error::ReferencedDocumentTypeModeratedError; use dpp::consensus::state::document::referenced_document_type_index_only_error::ReferencedDocumentTypeIndexOnlyError; use dpp::consensus::state::document::referenced_document_type_private_error::ReferencedDocumentTypePrivateError; +use dpp::consensus::state::document::referenced_document_type_requirement_not_met_error::ReferencedDocumentTypeRequirementNotMetError; use dpp::consensus::state::document::document_type_not_private_error::DocumentTypeNotPrivateError; use dpp::consensus::state::document::private_document_entry_size_mismatch_error::PrivateDocumentEntrySizeMismatchError; use dpp::consensus::basic::document::{ @@ -835,6 +836,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::PrivateDocumentEntrySizeMismatchError(e) => { generic_consensus_error!(PrivateDocumentEntrySizeMismatchError, e).into() } + StateError::ReferencedDocumentTypeRequirementNotMetError(e) => { + generic_consensus_error!(ReferencedDocumentTypeRequirementNotMetError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 8dd9d853bc2..2f4044c89ba 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -130,6 +130,12 @@ pub enum DocumentReferenceErrorCodeWasm { /// encrypted entries an anonymous transition appends and the chain never /// reads, so no write could check the reference. ReferencedDocumentTypePrivate = 40150, + /// A reference naming its target through the referring document + /// (`contractIdProperty`, `documentTypeProperty`, protocol version 15) + /// points at a document type that does not meet the reference's + /// `documentTypeRequirements`: its properties do not fill the slots the + /// reference requires. + ReferencedDocumentTypeRequirementNotMet = 40153, /// A create cannot reveal the preimage of a `refersTo` `findBy` /// function: a value a param reads is absent, or a variable-length /// value holds the one-byte separator that follows it in the preimage. @@ -139,7 +145,7 @@ pub enum DocumentReferenceErrorCodeWasm { impl DocumentReferenceErrorCodeWasm { /// The reference-validation error a code names, or `None` when the code /// is not in the 40120-40125 range, 40131, 40135-40138, 40142-40146, - /// 40148-40150 or 10423. + /// 40148-40150, 40153 or 10423. fn from_code(code: u32) -> Option { match code { 40120 => Some(Self::ReferencedEntityNotFound), @@ -161,6 +167,7 @@ impl DocumentReferenceErrorCodeWasm { 40148 => Some(Self::ReferencedDocumentRuleReadInvalid), 40149 => Some(Self::ReferencedDocumentPreceded), 40150 => Some(Self::ReferencedDocumentTypePrivate), + 40153 => Some(Self::ReferencedDocumentTypeRequirementNotMet), 10423 => Some(Self::DocumentReferencePreimageInvalid), _ => None, } @@ -403,7 +410,7 @@ impl ConsensusErrorWasm { /// The reference-validation error this is, or `undefined` when it is /// not one of codes 40120-40125, 40131, 40135-40138, 40142-40146, - /// 40148-40150 and 10423. + /// 40148-40150, 40153 and 10423. #[wasm_bindgen(getter = "documentReferenceErrorCode")] pub fn document_reference_error_code(&self) -> Option { DocumentReferenceErrorCodeWasm::from_code(self.0.code()) @@ -477,6 +484,7 @@ mod tests { use dpp::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; use dpp::consensus::state::document::referenced_document_type_not_moderated_error::ReferencedDocumentTypeNotModeratedError; use dpp::consensus::state::document::referenced_document_type_private_error::ReferencedDocumentTypePrivateError; + use dpp::consensus::state::document::referenced_document_type_requirement_not_met_error::ReferencedDocumentTypeRequirementNotMetError; use dpp::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; use dpp::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; use dpp::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; @@ -858,6 +866,17 @@ mod tests { .into(), DocumentReferenceErrorCodeWasm::ReferencedDocumentTypePrivate, ), + ( + StateError::ReferencedDocumentTypeRequirementNotMetError( + ReferencedDocumentTypeRequirementNotMetError::new( + id(), + "post".to_string(), + "descriptionId".to_string(), + ), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedDocumentTypeRequirementNotMet, + ), ( StateError::ReferencedDocumentRuleReadInvalidError( ReferencedDocumentRuleReadInvalidError::new( diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index 353543802a5..d3c3221b310 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -16,9 +16,10 @@ use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::IdentifierWasm; use crate::utils::define_own_property; use dpp::data_contract::document_type::{ - DocumentPropertyReferenceTarget, DocumentReferenceLookup, DocumentTypeRef, - IdentityKeyReferenceRequirements, KeyIdReference, LookupHashKey, LookupKeyParam, - LookupKeySource, PropertyReference, WhereCondition, WhereContainsBound, WhereLiteral, + ANY_PROPERTY_NAME, DocumentPropertyReferenceTarget, DocumentReferenceLookup, DocumentTypeRef, + DocumentTypeRequirements, IdentityKeyReferenceRequirements, KeyIdReference, LookupHashKey, + LookupKeyParam, LookupKeySource, PropertyReference, SlotPropertyName, SlotRequirement, + SlotRequirementTerm, SlotValue, WhereCondition, WhereContainsBound, WhereLiteral, WhereNotEqualBound, WhereOrderingBound, }; use dpp::document::property_names::{ID, OWNER_ID}; @@ -269,8 +270,74 @@ export type DocumentPropertyReferenceTarget = */ where?: DocumentReferenceWhere; } + | { + /** + * A document of a document type the referring document names + * (protocol version 15): permanent or moderated, found by the value + * as its id. Told from the other members of the same `type` by its + * `contractIdProperty`: it has no `contractId` and no `documentType`, + * since the contract and the type are read from the referring + * document when it is written, where consensus checks that both + * exist, that the type is of this kind and that it meets + * `documentTypeRequirements` (code 40153 when it does not). + */ + type: 'permanentDocument' | 'moderatedDocument'; + /** + * The top-level identifier property of the referring document + * holding the id of the contract the referenced document type lives + * in. + */ + contractIdProperty: string; + /** + * The top-level string property of the referring document holding + * the name of the referenced document type. + */ + documentTypeProperty: string; + /** + * What the named document type must look like. Absent when the + * declaration asks nothing of it beyond its kind. + */ + documentTypeRequirements?: DocumentTypeRequirements; + } | DocumentPropertyReferenceExpression; +/** + * What the document type a dynamic reference names must look like: named + * slots, each describing a top-level property it must have, and which of the + * slots must be filled. A slot is filled by one property matching its + * description, and slots that are `distinctFrom` each other by different + * properties. + */ +export type DocumentTypeRequirements = { + properties: Record; + /** + * Which slots must be filled: a slot name, or `anyOf` / `allOf` over slot + * names and, one level down, the other combinator over slot names. Absent + * when every slot must be filled. + */ + require?: string | DocumentTypeSlotRequirement; +}; + +export type DocumentTypeRequirementSlot = { + /** + * Which property may fill the slot: `'$any'` for any property matching + * the rest, a property name, or `{ property }` for the property the + * referring document names in that string property of its own. + */ + name: string | { property: string }; + /** What the property holds; `'array'` comes with `byteArray: true`. */ + type: 'string' | 'identifier' | 'integer' | 'boolean' | 'array'; + byteArray?: true; + /** The most bytes a value of the property may ever take. */ + maxBytes?: number; + /** The other slots this one is filled by a different property than. */ + distinctFrom?: Array; +}; + +export type DocumentTypeSlotRequirement = + | { anyOf: Array }> } + | { allOf: Array }> }; + /** * A reference expression, declared as `refersTo: { anyOf: [...] }` or * `refersTo: { allOf: [...] }`. The operands sit under the combinator's own @@ -829,6 +896,8 @@ fn set_reference_target_fields( DocumentPropertyReferenceTarget::DeletableDocument { .. } | DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. } => "deletableDocument", DocumentPropertyReferenceTarget::ModeratedDocument { .. } => "moderatedDocument", + // Permanent or moderated, as declared + DocumentPropertyReferenceTarget::DynamicDocument(reference) => reference.wire_name(), DocumentPropertyReferenceTarget::AnyOf(_) | DocumentPropertyReferenceTarget::AllOf(_) => { return Err(WasmDppError::generic(format!( "the reference expression declared at '{path}' has no single target kind" @@ -989,11 +1058,129 @@ fn set_reference_target_fields( )?; set_key_requirements_field(object, key_requirements, path)?; } + // The keys the schema declares it with: no contract and no document type, which + // the referring document names when it is written + DocumentPropertyReferenceTarget::DynamicDocument(reference) => { + set_field( + object, + "contractIdProperty", + &JsValue::from_str(&reference.contract_id_property), + path, + )?; + set_field( + object, + "documentTypeProperty", + &JsValue::from_str(&reference.document_type_property), + path, + )?; + if let Some(requirements) = &reference.requirements { + set_field( + object, + "documentTypeRequirements", + &document_type_requirements_to_js(requirements, path)?.into(), + path, + )?; + } + } } Ok(()) } +/// `documentTypeRequirements` as the schema declares it: the slots under `properties`, and +/// `require` when the declaration has one. +fn document_type_requirements_to_js( + requirements: &DocumentTypeRequirements, + path: &str, +) -> WasmDppResult { + let names = + |slots: &[String]| -> Array { slots.iter().map(|slot| JsValue::from_str(slot)).collect() }; + let properties = Object::new(); + for (slot_name, slot) in &requirements.slots { + let fields = Object::new(); + let name = match &slot.name { + SlotPropertyName::Any => JsValue::from_str(ANY_PROPERTY_NAME), + SlotPropertyName::Named(name) => JsValue::from_str(name), + SlotPropertyName::FromProperty(property) => { + let from_property = Object::new(); + set_field( + &from_property, + "property", + &JsValue::from_str(property), + path, + )?; + from_property.into() + } + }; + set_field(&fields, "name", &name, path)?; + let value_type = match slot.value { + SlotValue::String => "string", + SlotValue::Identifier => "identifier", + SlotValue::Integer => "integer", + SlotValue::Boolean => "boolean", + SlotValue::ByteArray => "array", + }; + set_field(&fields, "type", &JsValue::from_str(value_type), path)?; + if slot.value == SlotValue::ByteArray { + set_field(&fields, "byteArray", &JsValue::TRUE, path)?; + } + if let Some(max_bytes) = slot.max_bytes { + set_field(&fields, "maxBytes", &JsValue::from(max_bytes), path)?; + } + if !slot.distinct_from.is_empty() { + let others: Vec = slot.distinct_from.iter().cloned().collect(); + set_field(&fields, "distinctFrom", &names(&others), path)?; + } + set_schema_field(&properties, slot_name, fields.into(), path)?; + } + let object = Object::new(); + set_field(&object, "properties", &properties, path)?; + + if let Some(require) = &requirements.require { + let terms_to_js = |terms: &[SlotRequirementTerm], inner: &str| -> WasmDppResult { + let operands = Array::new(); + for term in terms { + match term { + SlotRequirementTerm::Slot(slot) => { + operands.push(&JsValue::from_str(slot)); + } + SlotRequirementTerm::Group(slots) => { + let group = Object::new(); + set_field(&group, inner, &names(slots), path)?; + operands.push(&group); + } + } + } + Ok(operands) + }; + let require: JsValue = match require { + SlotRequirement::Slot(slot) => JsValue::from_str(slot), + SlotRequirement::AnyOf(terms) => { + let combinator = Object::new(); + set_field( + &combinator, + "anyOf", + &terms_to_js(terms, "allOf")?.into(), + path, + )?; + combinator.into() + } + SlotRequirement::AllOf(terms) => { + let combinator = Object::new(); + set_field( + &combinator, + "allOf", + &terms_to_js(terms, "anyOf")?.into(), + path, + )?; + combinator.into() + } + }; + set_field(&object, "require", &require, path)?; + } + Ok(object) +} + /// Collect every reference declaration of one document type: its /// `ownerRefersTo` or `creatorRefersTo` first, listed at `$ownerId` or /// `$creatorId`, the path consensus names it by, then in schema property From 52b91da89d81f8e43197978b49e6d5574401530c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 04:49:54 +0700 Subject: [PATCH 05/20] test(dpp): charge a contested index resolved among documents its registration fee The registration cost of a state transition is read from the schema, where an index that does not parse is charged nothing. A vote among documents (`resolution` 2) parses there through the versioned index parse; this holds the fee to it, from the contract and from the serialization format, beside the two resolutions that predate it. Co-Authored-By: Claude Fable 5.2 --- .../methods/registration_cost/v2/mod.rs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs b/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs index 13f6085e85c..6eaefe1e057 100644 --- a/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/registration_cost/v2/mod.rs @@ -210,8 +210,12 @@ mod tests { use crate::data_contract::associated_token::token_distribution_rules::accessors::v1::TokenDistributionRulesV1Setters; use crate::data_contract::associated_token::token_once_per_identity_distribution::v0::TokenOncePerIdentityDistributionV0; use crate::data_contract::associated_token::token_once_per_identity_distribution::TokenOncePerIdentityDistribution; + use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; use crate::tests::fixtures::get_dashpay_contract_fixture; + use platform_value::string_encoding::Encoding; + use platform_value::Identifier; use platform_version::TryFromPlatformVersioned; + use serde_json::json; use std::collections::BTreeMap; fn contract_with_token(once_per_identity_amount: Option) -> DataContract { @@ -268,4 +272,96 @@ mod tests { without_serialized.registration_cost_v2(platform_version) + surcharge ); } + + /// A contract with one document type, whose only index, when it has one, is contested + /// with `resolution`. + fn contract_with_contested_index(resolution: Option) -> DataContract { + let mut choice = json!({ + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "proposalId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "required": ["proposalId"], + "additionalProperties": false + }); + if let Some(resolution) = resolution { + choice["indices"] = json!([{ + "name": "byProposal", + "properties": [{ "proposalId": "asc" }], + "unique": true, + "contested": { "resolution": resolution } + }]); + } + let value = platform_value::to_value(json!({ + "$formatVersion": "1", + "id": Identifier::from([7; 32]).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { "choice": choice } + })) + .expect("the contract should convert"); + DataContract::from_value(value, false, PlatformVersion::latest()) + .expect("the contract should parse") + } + + /// A contested index is charged the contested index fee whichever way it is resolved, + /// from the parsed contract and from the serialization format a state transition is + /// charged from, which reads the index out of the schema: a vote among documents + /// (`resolution` 2, protocol version 15) must parse there as it does in the contract, or + /// its index would be registered without its fee. + #[test] + fn should_charge_a_contested_index_resolved_among_documents() { + let platform_version = PlatformVersion::latest(); + let contested_index_fee = platform_version + .fee_version + .data_contract_registration + .document_type_base_contested_index_registration_fee; + assert!(contested_index_fee > 0); + + let serialized = |contract: DataContract| { + DataContractInSerializationFormat::try_from_platform_versioned( + contract, + platform_version, + ) + .expect("expected the serialization format") + }; + let without = contract_with_contested_index(None); + let cost_without = without + .registration_cost(platform_version) + .expect("expected a cost"); + assert_eq!( + serialized(without) + .registration_cost(platform_version) + .expect("expected a cost"), + cost_without + ); + + for resolution in [0, 1, 2] { + let with = contract_with_contested_index(Some(resolution)); + let cost = with + .registration_cost(platform_version) + .expect("expected a cost"); + assert_eq!( + cost, + cost_without + contested_index_fee, + "resolution {resolution}, from the contract" + ); + assert_eq!( + serialized(with) + .registration_cost(platform_version) + .expect("expected a cost"), + cost, + "resolution {resolution}, from the serialization format" + ); + } + } } From 82fc9b2ac2ca3799004b0b4b3699644e8f271327 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 05:40:19 +0700 Subject: [PATCH 06/20] feat(platform)!: either-kind targets and slot conditions for document-named references (PV15) A refersTo naming its target through the referring document may list both kinds, ["permanentDocument", "moderatedDocument"]: the named type is then either, and the reference is checked as one of the kind the type is. A documentTypeRequirements slot may carry `when`, a condition in the forms of a propertyConstraints condition read against the referenced type: $kind, $self, $moderatorAbilities.deleteKeepsFields, $moderatorAbilities.changeFields and { "const": ... }. A property the condition does not hold for does not fill the slot. So a contract can ask that a text it points at is one a moderator's removal takes away, only when the type is a moderated one. The condition is stored in the compiled contract and carried by consensus errors, so its decoders refuse a nesting past 16, and the parser holds one `when` to max_document_type_requirement_condition_nodes (16). Co-Authored-By: Claude Fable 5.2 --- book/src/contract-keywords.md | 5 +- .../contract-keywords/refers-to-dynamic.md | 70 ++- book/src/data-model/documents.md | 2 + .../try_from_schema/dynamic_reference.rs | 344 ++++++++++- .../native_schema/constraints.rs | 2 +- .../native_schema/references.rs | 145 ++++- .../v3/dynamic_reference_tests.rs | 577 +++++++++++++++++- .../property/dynamic_document_reference.rs | 89 ++- .../document_type/property/mod.rs | 12 +- .../document_type/property/slot_condition.rs | 534 ++++++++++++++++ .../document_reference_validation/v1/mod.rs | 16 +- .../tests/dynamic_document_reference.rs | 224 ++++++- .../src/version/mocks/v2_test.rs | 1 + .../src/version/system_limits/mod.rs | 9 + .../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 | 1 + .../rs-platform-version/src/version/v15.rs | 17 +- .../data_contract/document_type_reference.rs | 143 ++++- 20 files changed, 2123 insertions(+), 71 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md index 63ced2e968b..28386f3fa8b 100644 --- a/book/src/contract-keywords.md +++ b/book/src/contract-keywords.md @@ -194,9 +194,9 @@ A typed array's element (`items`) takes `type` (the `identifier` and `bytes` sho | `type: "identityPublicKey"` | | The value names an identity key that exists and is not disabled. | 14 | [identityPublicKey](contract-keywords/refers-to.md#identitypublickey) | | `documentType` | document type name | The referenced document type. | 14 | [documentType](contract-keywords/refers-to.md#documenttype) | | `contractId` | identifier | The contract holding `documentType`, when it is not this one. | 14 | [contractId](contract-keywords/refers-to.md#contractid) | -| `contractIdProperty` | top-level identifier property name | On a `permanentDocument` or `moderatedDocument`: the property holding the id of the contract the referenced type lives in, in place of `contractId`. Comes with `documentTypeProperty`. | 15 | [A Target the Document Names](contract-keywords/refers-to-dynamic.md) | +| `contractIdProperty` | top-level identifier property name | On a `permanentDocument` or `moderatedDocument`, or a `type` [listing both](contract-keywords/refers-to-dynamic.md#either-kind): the property holding the id of the contract the referenced type lives in, in place of `contractId`. Comes with `documentTypeProperty`. | 15 | [A Target the Document Names](contract-keywords/refers-to-dynamic.md) | | `documentTypeProperty` | top-level string property name | The property holding the name of the referenced document type, in place of `documentType`. | 15 | [A Target the Document Names](contract-keywords/refers-to-dynamic.md) | -| `documentTypeRequirements` | `{ "properties", "require"? }` | Beside the two above: 1 to 4 named slots, each a property the named type must have (`name`, `type`, `maxBytes`, `distinctFrom`), and which of them must be filled. | 15 | [documentTypeRequirements](contract-keywords/refers-to-dynamic.md#documenttyperequirements) | +| `documentTypeRequirements` | `{ "properties", "require"? }` | Beside the two above: 1 to 4 named slots, each a property the named type must have (`name`, `type`, `maxBytes`, `distinctFrom`, and `when`, a [condition](contract-keywords/refers-to-dynamic.md#a-condition-on-the-property-when) on what the type says of the property), and which of them must be filled. | 15 | [documentTypeRequirements](contract-keywords/refers-to-dynamic.md#documenttyperequirements) | | `findBy` | 1 to 10 entries: referenced property → `"."`, `"$ownerId"`, a path or a function | Finds the document through the unique index of `documentType` over exactly these properties, in any order; `"."` is the value itself. Without it the value is the document's `$id`. | 14 | [findBy](contract-keywords/refers-to-lookup.md) · [internals](data-model/documents.md#found-by-a-unique-index-findby) | | `findBy.` | `{ "function": "sys.hash.sha256d", "params" }` | A function entry: the referenced property holds the hash of `params` (paths, `{ "const": text }`, `"."` for a value without a path), which fills that part of the key, finding a commitment made earlier. At most one. | 14 | [Commit and reveal](contract-keywords/refers-to-lookup.md#commit-and-reveal) · [internals](data-model/documents.md#commit-and-reveal-a-findby-function) | | `where` | 1 to 10 entries `{ "": "" }` | Checked on the document found: each referenced property (or `$ownerId`, `$creatorId`, `$id`) must equal the referring property. A value of `"$ownerId"`, the writer, makes a write gate. Never finds the document. | 14 | [where](contract-keywords/refers-to.md#where) | @@ -332,6 +332,7 @@ The first three limits come from the meta-schema, the rest from protocol version | `max_property_constraint_nodes` | 32 | nodes in one rule | | `max_choose_first_candidates` | 1000 | commitments a create reads for a [`chooseFirst`](contract-keywords/refers-to-lookup.md#choosing-the-first-commitment-choosefirst) | | `max_document_type_requirement_slots` | 4 | slots a [`documentTypeRequirements`](contract-keywords/refers-to-dynamic.md#documenttyperequirements) may declare | +| `max_document_type_requirement_condition_nodes` | 16 | conditions in one slot's [`when`](contract-keywords/refers-to-dynamic.md#a-condition-on-the-property-when) | | `min_document_ttl_seconds`, `max_document_ttl_seconds` | 3600, 31536000 | `ttl` | | `max_time_range_ttl_seconds` | 604800 | a `timeRange` index's `ttl` | | `max_contested_summed_value_magnitude` | 134217728 (2^27) | the `minimum` and `maximum` of a summed property on a type with a contested index | diff --git a/book/src/contract-keywords/refers-to-dynamic.md b/book/src/contract-keywords/refers-to-dynamic.md index 111c46a4150..792d79e92ff 100644 --- a/book/src/contract-keywords/refers-to-dynamic.md +++ b/book/src/contract-keywords/refers-to-dynamic.md @@ -5,7 +5,7 @@ A document reference normally names its target in the schema: a `documentType`, | | | |---|---| | **Where** | `refersTo` on an identifier property, at the top level or inside an object. Not on the `items` of a typed array, not an operand of an [expression](refers-to-expressions.md), not [`ownerRefersTo` or `creatorRefersTo`](owner-refers-to.md). | -| **Value** | `contractIdProperty` and `documentTypeProperty`, both required, and optionally `documentTypeRequirements`, in a `"type": "permanentDocument"` or `"type": "moderatedDocument"` declaration that carries nothing else. | +| **Value** | `contractIdProperty` and `documentTypeProperty`, both required, and optionally `documentTypeRequirements`, in a `"type": "permanentDocument"` or `"type": "moderatedDocument"` declaration, or one [listing both](#either-kind), that carries nothing else. | | **Since** | protocol version 15 | | **On update** | Fixed, like the rest of `refersTo` (`IncompatibleDocumentTypeSchemaError`, 10246). | | **Errors** | When a document is written: `ReferencedDocumentTypeNotFoundError` (40121), `ReferencedDocumentTypePrivateError` (40150), `ReferencedDocumentTypeRequirementNotMetError` (40153), `ReferencedDocumentTypeDeletableError` (40122), `ReferencedDocumentTypeNotModeratedError` (40143), `ReferencedDocumentTypeModeratedError` (40144), `ReferencedEntityNotFoundError` (40120). At registration: `InvalidContractStructure` (10231), `JsonSchemaError` (10101). | @@ -62,6 +62,7 @@ The two properties come together: a declaration names its target in the schema o | `type` | `"string"`, `"identifier"`, `"integer"`, `"boolean"`, or `"array"` with `"byteArray": true` | What the property holds. A byte array slot is not filled by an identifier, nor an identifier slot by a byte array. | | `maxBytes` | integer, 1 to 4294967295 | On a string or byte array slot: the property can never hold more bytes than this. A string's bound is its `maxBytes`, or four times its `maxLength`; a byte array's is its `maxItems`. A property declaring no bound meets none. | | `distinctFrom` | a slot name, or a list of slot names | This slot is filled by a different property than each of them. Saying it on one side is enough. | +| `when` | a condition | What the referenced type must say of the property: one the condition does not hold for does not fill the slot. See [A condition on the property](#a-condition-on-the-property-when). | A slot name is 1 to 64 letters, digits or underscores. A transient property fills nothing: it is never stored. @@ -88,6 +89,64 @@ A short text property, and either the string property the document names in its **When the requirements are met.** They are met when some choice of properties fills a set of slots `require` holds for, with slots that are `distinctFrom` each other filled by different properties. Consensus only asks whether such a choice exists. A client that needs to know which property is the title and which the body can settle it the same way every time: take the slots in the order of their names and give each the first property, in the type's `position` order, that matches it and that no slot it must differ from has taken, backing up when a later slot is left with nothing. Naming the property outright, or through the referring document, leaves nothing to settle. +## Either kind + +`type` may list both kinds: + +```json +"refersTo": { + "type": ["permanentDocument", "moderatedDocument"], + "contractIdProperty": "descriptionContractId", + "documentTypeProperty": "descriptionDocumentType" +} +``` + +The named type is then a permanent one or a moderated one, and the reference is checked as a reference of the kind the type is: into a moderated type it resolves to the document or to its removal record, as a `moderatedDocument` reference does, and into a permanent type the document is always there. A type whose documents their owner can delete is neither, and is refused as it is for a `permanentDocument` reference (40122). The list holds the two names, once each, in either order. Only a reference naming its target through the document lists them: one naming its type in the schema knows the type's kind. + +## A condition on the property (`when`) + +A slot's `name`, `type` and `maxBytes` describe the property. `when` adds a condition on what the referenced type says about it, written like a [`propertyConstraints`](property-constraints.md) condition and read against the referenced type's schema instead of a document: + +```json +"documentTypeRequirements": { + "properties": { + "body": { + "name": "$any", + "type": "string", + "when": { + "ifThen": [ + { "equal": ["$kind", { "const": "moderatedDocument" }] }, + { "allOf": [ + { "not": { "contains": ["$moderatorAbilities.deleteKeepsFields", "$self"] } }, + { "not": { "contains": ["$moderatorAbilities.changeFields", "$self"] } } + ] } + ] + } + } + } +} +``` + +When the type is a moderated one, the string filling `body` is one a moderator's removal takes away (the removal record does not keep it) and one the document's author writes (it is not the moderators' field). A permanent type is asked nothing. With `"type": ["permanentDocument", "moderatedDocument"]` this accepts a text that is never removed, or a text moderators can take down, and not a text that would stay public after they did. + +| Operand | Reads | +|---|---| +| `"$kind"` | The kind of the referenced type, by the `refersTo` `type` it is referred to with: `permanentDocument` or `moderatedDocument`. | +| `"$self"` | The name of the property tried for the slot. | +| `"$moderatorAbilities.deleteKeepsFields"` | The referenced type's [`deleteKeepsFields`](moderator-abilities.md): the property paths a removal record keeps. Empty when the type lists none. | +| `"$moderatorAbilities.changeFields"` | The referenced type's [`changeFields`](moderator-abilities.md): the properties only moderators write. Empty when the type lists none. | +| `{ "const": "" }` | The string, 1 to 256 characters. | + +| Condition | Holds when | +|---|---| +| `{ "equal": [a, b] }`, `{ "notEqual": [a, b] }` | `$kind` or `$self` is, or is not, the constant on the other side. Compared with `$kind`, the constant is `permanentDocument` or `moderatedDocument`. | +| `{ "in": [a, [...]] }`, `{ "notIn": [a, [...]] }` | `$kind` or `$self` is, or is not, one of two or more distinct strings. | +| `{ "contains": [list, a] }` | One of the two lists holds `$self` or a constant, as a whole entry. | +| `{ "not": c }`, `{ "anyOf": [...] }`, `{ "allOf": [...] }` | As in `propertyConstraints`: `anyOf` and `allOf` take two or more conditions, none listed twice and none the same combinator; `not` holds no `not` and no `notIn`. | +| `{ "ifThen": [c, then] }`, `{ "ifThenElse": [c, then, else] }` | `then` holds whenever `c` does; with an `else`, it holds when `c` does not. | + +One `when` is made of at most 16 conditions (`max_document_type_requirement_condition_nodes`), combinators counted. It reads the type, never a document: no property path, no arithmetic, and no value of the referenced document. `distinctFrom`, `require` and the search for an assignment work as before, over the properties each slot's `when` leaves it. + ## How it works Nothing about the target is known when the contract is registered, so registration checks the declaration alone. Everything about the target is checked when a document is written, in this order, the first failure refusing the write: @@ -95,21 +154,22 @@ Nothing about the target is known when the contract is registered, so registrati 1. The document names a contract and a document type. A document that holds the reference but leaves out `contractIdProperty`'s or `documentTypeProperty`'s property points nowhere: `ReferencedEntityNotFoundError` (40120). 2. The contract exists and has a document type of that name: `ReferencedDocumentTypeNotFoundError` (40121) otherwise. Another contract costs a billed fetch, once per write. 3. The type is not [private](../contract-keywords.md#document-type): `ReferencedDocumentTypePrivateError` (40150). -4. The type meets `documentTypeRequirements`: `ReferencedDocumentTypeRequirementNotMetError` (40153). -5. The type is of the declared kind, as a reference naming it in the schema is held to at registration: for `permanentDocument` its documents never disappear (40122 or 40144 otherwise), for `moderatedDocument` they disappear only through a moderator's recorded removal (40143). +4. The type meets `documentTypeRequirements`, each slot filled by a property its `when` holds for: `ReferencedDocumentTypeRequirementNotMetError` (40153). +5. The type is of the declared kind, as a reference naming it in the schema is held to at registration: for `permanentDocument` its documents never disappear (40122 or 40144 otherwise), for `moderatedDocument` they disappear only through a moderator's recorded removal (40143). A declaration listing both kinds is a moderated reference into a moderated type and a permanent one into any other, so it refuses only a type whose documents can be deleted (40122). 6. The document exists: `ReferencedEntityNotFoundError` (40120). **On a replace** the reference is checked again, whole, when its value changed or when a property it reads its target from changed: the contract id, the document type name, or a property a slot takes its name from. A changed one points the same id somewhere else. Otherwise it is kept as a permanent or moderated reference is: a moderated target a moderator removed since resolves to its removal record, and an edit of the referring document's other properties passes. -**What stays true.** A document type's deletion and moderation settings never change, and neither do its existing properties' types, so a target that qualified keeps its kind. The requirements are judged when the reference is checked, not afterwards. +**What stays true.** A document type's deletion and moderation settings never change, its `moderatorAbilities` among them, and neither do its existing properties' types, so a target that qualified keeps its kind and what a `when` read of it. The requirements are judged when the reference is checked, not afterwards. ## Rules at registration -- Both `contractIdProperty` and `documentTypeProperty` are present, name two different properties, and `type` is `permanentDocument` or `moderatedDocument`. A `deletableDocument` reference always names its type in the schema: a dynamic target is trusted for what its type promises about its documents staying, and a deletable type promises nothing. +- Both `contractIdProperty` and `documentTypeProperty` are present, name two different properties, and `type` is `permanentDocument`, `moderatedDocument` or the list of both. A `deletableDocument` reference always names its type in the schema: a dynamic target is trusted for what its type promises about its documents staying, and a deletable type promises nothing. - The declaration carries nothing else: no `contractId`, `documentType`, `findBy`, `where`, `inList`, `consume` or `constraints`. Each needs the referenced type when the contract is registered. - It sits on a single identifier property: not on the elements of a typed array, in a reference expression, or in `ownerRefersTo` and `creatorRefersTo`. - `contractIdProperty` names a top-level identifier property, and `documentTypeProperty` and every slot's `{ "property": ... }` a top-level string property. None is transient or the reference property itself, and each is required wherever the reference property is, so a document that must hold the reference always says where it points. - `documentTypeRequirements` holds 1 to 4 slots (`max_document_type_requirement_slots`), each well formed; `distinctFrom` names other slots of the same `properties`; `require` names slots, and all of them. +- A slot's `when` is a condition of at most 16 conditions (`max_document_type_requirement_condition_nodes`) over the operands above, each comparison pairing what it may pair. - No [`propertyConstraints` rule](property-constraints.md#values-of-a-referenced-document) reads a value through the reference, no [index](derived-index-properties.md) holds one, and no chained query joins through it: each resolves the referenced type from the schema. Before protocol version 15 the keys are not part of `refersTo`: registration refuses them. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index ccab4b9a93c..1498dc03c79 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -638,6 +638,8 @@ Version 1 of `apply_property_reference` reads the declaration (`try_from_schema/ The write-time check, `document_reference_validation` 1, builds the declaration a by-id reference would carry from the document's own values (the contract id and the type name, no `where`) and runs the flow every document reference runs: contract, document type, kind, document, removal record. Between the document type and the kind it does what registration does for a reference naming its type in the schema, refusing a private type, and judges the requirements with `DocumentTypeRequirements::met_by`. That search is over sets of slots, fullest first, and within a set fills the slot with the fewest candidates first; with at most `max_document_type_requirement_slots` slots it stays small whatever the referenced type declares. A replace checks the reference again when one of `DynamicDocumentReference::target_properties` changed. +A `type` listing both kinds parses to `DynamicDocumentReferenceKind::PermanentOrModerated`, and the write-time check takes the kind the reference is checked as from the named type (`checked_as`): moderated into a moderated type, permanent into any other. A slot's `when` is a `SlotCondition` (`property/slot_condition.rs`), judged by `met_by` for each property that could fill the slot against a `SlotConditionSubject`: the type's kind, the property's name, and the type's `moderator_deletion_kept_fields` and `moderator_changeable_fields`. The condition is stored in the compiled contract and travels in the consensus errors that carry a reference target, so the conditions it holds inside it sit behind `SlotConditions`, whose decoders refuse a nesting past `MAX_SLOT_CONDITION_DECODE_DEPTH` (16), as `ReferenceOperands` does for reference expressions. The parser holds one `when` to `max_document_type_requirement_condition_nodes` conditions (16), which keeps every registered condition within that depth. + ## Immutable Properties on Mutable Document Types A document type either allows replaces (`documentsMutable: true`, the default) or freezes its documents entirely. Protocol version 14 adds a middle ground: the doctype-level `immutable` keyword lists top-level properties a replace may not change while the rest of the document stays replaceable. An entry naming a property freezes it at creation; an entry `{ "property", "when" }` freezes it for any replace its condition holds for. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs index 33aeea9d39d..7c880c5a1d7 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs @@ -7,14 +7,17 @@ use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::property::{ DocumentPropertyType, DocumentTypeRequirements, DynamicDocumentReference, DynamicDocumentReferenceKind, PropertyReference, ReferenceHolder, RequiredPropertySlot, - SlotPropertyName, SlotRequirement, SlotRequirementTerm, SlotValue, ANY_PROPERTY_NAME, + SlotCondition, SlotConditionList, SlotConditionValue, SlotConditions, SlotPropertyName, + SlotRequirement, SlotRequirementTerm, SlotValue, ANY_PROPERTY_NAME, + SLOT_CONDITION_CHANGE_FIELDS, SLOT_CONDITION_DELETE_KEEPS_FIELDS, SLOT_CONDITION_KIND, + SLOT_CONDITION_SELF, }; use crate::data_contract::document_type::{property_names, DocumentTypeRef}; use crate::data_contract::errors::DataContractError; -use platform_value::btreemap_extensions::BTreeValueMapHelper; use platform_value::Value; use platform_version::version::PlatformVersion; use std::collections::{BTreeMap, BTreeSet}; +use std::ops::RangeInclusive; /// The keys only a dynamic reference carries. const DYNAMIC_KEYS: [&str; 3] = [ @@ -24,7 +27,23 @@ const DYNAMIC_KEYS: [&str; 3] = [ ]; /// The keys of a slot of `documentTypeRequirements.properties`. -const SLOT_KEYS: [&str; 5] = ["name", "type", "byteArray", "maxBytes", "distinctFrom"]; +const SLOT_KEYS: [&str; 6] = [ + "name", + "type", + "byteArray", + "maxBytes", + "distinctFrom", + "when", +]; + +/// The kinds of document type a reference naming its target through the referring document +/// points into, by the `refersTo` `type` each is written with: what `$kind` reads in a +/// slot's `when` for a type the reference admits. +const DYNAMIC_KIND_NAMES: [&str; 2] = ["permanentDocument", "moderatedDocument"]; + +/// The most characters a `{ "const": ... }` of a slot's `when` holds: a property path, or +/// the name of a kind. +const MAX_SLOT_CONDITION_CONSTANT_LENGTH: usize = 256; fn refused(message: String) -> DataContractError { DataContractError::InvalidContractStructure(message) @@ -101,19 +120,43 @@ pub(super) fn parse_dynamic_document_reference( refers_to_map: &BTreeMap, platform_version: &PlatformVersion, ) -> Result { - let reference_type = refers_to_map - .get_str(property_names::TYPE) - .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; - let kind = match reference_type { - "permanentDocument" => DynamicDocumentReferenceKind::Permanent, - "moderatedDocument" => DynamicDocumentReferenceKind::Moderated, - other => { - return Err(refused(format!( - "{other} refersTo does not take contractIdProperty, documentTypeProperty or \ - documentTypeRequirements: only a permanentDocument or a moderatedDocument \ - reference names its target through the referring document, since only those \ - document types promise their documents stay" - ))) + // One kind, or the list of both: a type of either is then admitted + let (kind, reference_type) = match refers_to_map.get(property_names::TYPE) { + Some(Value::Text(reference_type)) => match reference_type.as_str() { + "permanentDocument" => (DynamicDocumentReferenceKind::Permanent, "permanentDocument"), + "moderatedDocument" => (DynamicDocumentReferenceKind::Moderated, "moderatedDocument"), + other => { + return Err(refused(format!( + "{other} refersTo does not take contractIdProperty, documentTypeProperty \ + or documentTypeRequirements: only a permanentDocument or a \ + moderatedDocument reference names its target through the referring \ + document, since only those document types promise their documents stay" + ))) + } + }, + Some(Value::Array(kinds)) => { + let listed: BTreeSet<&str> = kinds.iter().filter_map(|kind| kind.as_text()).collect(); + if kinds.len() != DYNAMIC_KIND_NAMES.len() + || listed != BTreeSet::from(DYNAMIC_KIND_NAMES) + { + return Err(refused( + "a refersTo type list holds permanentDocument and moderatedDocument, once \ + each: a reference naming its target through the referring document \ + points into a type of either kind, or names one kind alone" + .to_string(), + )); + } + ( + DynamicDocumentReferenceKind::PermanentOrModerated, + "permanentDocument or moderatedDocument", + ) + } + _ => { + return Err(DataContractError::ValueWrongType( + "refersTo type must be a string, or the list of permanentDocument and \ + moderatedDocument" + .to_string(), + )) } }; @@ -203,7 +246,10 @@ fn parse_document_type_requirements( letters, digits or underscores" ))); } - Ok((slot_name.clone(), parse_slot(slot_name, slot_value)?)) + Ok(( + slot_name.clone(), + parse_slot(slot_name, slot_value, platform_version)?, + )) }) .collect::, _>>()?; @@ -228,7 +274,11 @@ fn parse_document_type_requirements( Ok(DocumentTypeRequirements { slots, require }) } -fn parse_slot(slot_name: &str, value: &Value) -> Result { +fn parse_slot( + slot_name: &str, + value: &Value, + platform_version: &PlatformVersion, +) -> Result { let at = || format!("documentTypeRequirements slot \"{slot_name}\""); let slot = value .to_btree_ref_string_map() @@ -347,14 +397,272 @@ fn parse_slot(slot_name: &str, value: &Value) -> Result }`. +fn slot_condition_value(value: &Value, at: &str) -> Result { + let shape = || { + refused(format!( + "{at} when compares \"{SLOT_CONDITION_KIND}\", \"{SLOT_CONDITION_SELF}\" or {{ \ + \"const\": }}, \ + got {value}" + )) + }; + match value { + Value::Text(operand) if operand == SLOT_CONDITION_KIND => Ok(SlotConditionValue::Kind), + Value::Text(operand) if operand == SLOT_CONDITION_SELF => { + Ok(SlotConditionValue::PropertyName) + } + Value::Map(_) => { + let constant = value.to_btree_ref_string_map().map_err(|_| shape())?; + match (constant.len(), constant.get("const")) { + (1, Some(Value::Text(constant))) + if (1..=MAX_SLOT_CONDITION_CONSTANT_LENGTH) + .contains(&constant.chars().count()) => + { + Ok(SlotConditionValue::Constant(constant.clone())) + } + _ => Err(shape()), + } + } + _ => Err(shape()), + } +} + +/// Refuses a constant `$kind` is compared with that names no kind the reference admits: the +/// comparison could never hold, or always would. +fn kind_name(constant: &str, at: &str) -> Result<(), DataContractError> { + if DYNAMIC_KIND_NAMES.contains(&constant) { + return Ok(()); + } + Err(refused(format!( + "{at} when compares \"{SLOT_CONDITION_KIND}\" with \"{constant}\": the kind of the \ + referenced document type is permanentDocument or moderatedDocument" + ))) +} + +/// The conditions of an `anyOf`, `allOf`, `ifThen` or `ifThenElse`, or the one of a `not`. +fn parse_slot_conditions( + values: &[Value], + at: &str, + nodes: &mut usize, + max_nodes: usize, +) -> Result { + values + .iter() + .map(|value| parse_slot_condition(value, at, nodes, max_nodes)) + .collect::, _>>() + .map(SlotConditions::new) +} + +/// Parses a slot's `when`, or a condition inside one. `nodes` counts the conditions read so +/// far, refused past `max_nodes` +/// (`SystemLimits::max_document_type_requirement_condition_nodes`) before anything is read +/// of the one that passes it, so the count also bounds how deep this recurses. +fn parse_slot_condition( + value: &Value, + at: &str, + nodes: &mut usize, + max_nodes: usize, +) -> Result { + *nodes += 1; + if *nodes > max_nodes { + return Err(refused(format!( + "{at} when is made of more than {max_nodes} conditions" + ))); + } + let condition = value + .to_btree_ref_string_map() + .ok() + .filter(|condition| condition.len() == 1) + .ok_or_else(|| { + refused(format!( + "{at} when, and every condition inside it, is an object of one key: equal, \ + notEqual, in, notIn, contains, not, anyOf, allOf, ifThen or ifThenElse" + )) + })?; + let Some((key, operand)) = condition.iter().next() else { + return Err(refused(format!("{at} when holds an empty condition"))); + }; + let key = key.as_str(); + let items = + |count: RangeInclusive, what: &str| -> Result<&Vec, DataContractError> { + operand + .as_array() + .filter(|items| count.contains(&items.len())) + .ok_or_else(|| refused(format!("{at} when {key} takes {what}"))) + }; + + match key { + "equal" | "notEqual" => { + let pair = items(2..=2, "two strings")?; + let left = slot_condition_value(&pair[0], at)?; + let right = slot_condition_value(&pair[1], at)?; + match (&left, &right) { + (SlotConditionValue::Kind, SlotConditionValue::Constant(constant)) + | (SlotConditionValue::Constant(constant), SlotConditionValue::Kind) => { + kind_name(constant, at)? + } + (SlotConditionValue::PropertyName, SlotConditionValue::Constant(_)) + | (SlotConditionValue::Constant(_), SlotConditionValue::PropertyName) => {} + _ => { + return Err(refused(format!( + "{at} when {key} compares \"{SLOT_CONDITION_KIND}\" or \ + \"{SLOT_CONDITION_SELF}\" with a {{ \"const\": ... }}" + ))) + } + } + Ok(if key == "equal" { + SlotCondition::Equal(left, right) + } else { + SlotCondition::NotEqual(left, right) + }) + } + "in" | "notIn" => { + let pair = items(2..=2, "a string and a list of two or more distinct strings")?; + let value = slot_condition_value(&pair[0], at)?; + let among = pair[1] + .as_array() + .filter(|among| among.len() >= 2) + .and_then(|among| { + let texts = among + .iter() + .map(|text| { + text.as_text() + .filter(|text| { + (1..=MAX_SLOT_CONDITION_CONSTANT_LENGTH) + .contains(&text.chars().count()) + }) + .map(str::to_string) + }) + .collect::>>()?; + (texts.len() == among.len()).then_some(texts) + }) + .ok_or_else(|| { + refused(format!( + "{at} when {key} takes a string and a list of two or more distinct \ + strings of 1 to {MAX_SLOT_CONDITION_CONSTANT_LENGTH} characters" + )) + })?; + match value { + SlotConditionValue::Kind => { + for constant in &among { + kind_name(constant, at)?; + } + } + SlotConditionValue::PropertyName => {} + SlotConditionValue::Constant(_) => { + return Err(refused(format!( + "{at} when {key} looks for \"{SLOT_CONDITION_KIND}\" or \ + \"{SLOT_CONDITION_SELF}\" in the list, not for a constant" + ))) + } + } + Ok(if key == "in" { + SlotCondition::In(value, among) + } else { + SlotCondition::NotIn(value, among) + }) + } + "contains" => { + let pair = items(2..=2, "a list of the referenced type and a string")?; + let list = match pair[0].as_text() { + Some(SLOT_CONDITION_DELETE_KEEPS_FIELDS) => SlotConditionList::DeletionKeptFields, + Some(SLOT_CONDITION_CHANGE_FIELDS) => SlotConditionList::ModeratorChangeableFields, + _ => { + return Err(refused(format!( + "{at} when contains looks into \"{SLOT_CONDITION_DELETE_KEEPS_FIELDS}\" \ + or \"{SLOT_CONDITION_CHANGE_FIELDS}\", got {}", + pair[0] + ))) + } + }; + let needle = slot_condition_value(&pair[1], at)?; + if needle == SlotConditionValue::Kind { + return Err(refused(format!( + "{at} when contains looks for \"{SLOT_CONDITION_SELF}\" or a {{ \"const\": \ + ... }} in {}, which lists properties, not for the kind of the type", + list.wire_name() + ))); + } + Ok(SlotCondition::Contains(list, needle)) + } + "not" => { + // As in `propertyConstraints`: `not` of `not` says what the inner condition + // says, and `not` of `notIn` what `in` says + if operand + .to_btree_ref_string_map() + .is_ok_and(|negated| negated.contains_key("not") || negated.contains_key("notIn")) + { + return Err(refused(format!( + "{at} when not holds a not or a notIn: write the condition it negates, or in" + ))); + } + parse_slot_conditions(std::slice::from_ref(operand), at, nodes, max_nodes) + .map(SlotCondition::Not) + } + "anyOf" | "allOf" => { + let operands = items(2..=usize::MAX, "two or more conditions")?; + // One directly inside itself says what one flat list says + if operands.iter().any(|operand| { + operand + .to_btree_ref_string_map() + .is_ok_and(|operand| operand.contains_key(key)) + }) { + return Err(refused(format!( + "{at} when {key} holds another {key}: list its conditions in the outer one" + ))); + } + let conditions = parse_slot_conditions(operands, at, nodes, max_nodes)?; + let listed = conditions.conditions(); + if listed + .iter() + .enumerate() + .any(|(index, condition)| listed[..index].contains(condition)) + { + return Err(refused(format!( + "{at} when {key} lists the same condition twice" + ))); + } + Ok(if key == "anyOf" { + SlotCondition::AnyOf(conditions) + } else { + SlotCondition::AllOf(conditions) + }) + } + "ifThen" => parse_slot_conditions(items(2..=2, "two conditions")?, at, nodes, max_nodes) + .map(SlotCondition::IfThen), + "ifThenElse" => { + parse_slot_conditions(items(3..=3, "three conditions")?, at, nodes, max_nodes) + .map(SlotCondition::IfThenElse) + } + other => Err(refused(format!( + "{at} when does not take {other}: a condition is equal, notEqual, in, notIn, \ + contains, not, anyOf, allOf, ifThen or ifThenElse" + ))), + } +} + /// The one key of a combinator object and its operands, at least two of them. fn combinator_operands(value: &Value) -> Option<(&'static str, &Vec)> { let map = value.to_btree_ref_string_map().ok()?; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/constraints.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/constraints.rs index c55e444e685..2627f72834d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/constraints.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/constraints.rs @@ -113,7 +113,7 @@ impl Checker<'_> { } /// An array of `minimum` to `maximum` items, `check` run on each with its index. - fn tuple( + pub(super) fn tuple( &self, value: &JsonValue, minimum: usize, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs index 07566c7c923..5971c96ff5b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs @@ -11,6 +11,31 @@ const DYNAMIC_TARGET_KEYS: [&str; 3] = [ "documentTypeRequirements", ]; +/// The kinds of document a `refersTo` naming its target through the referring document +/// points at. +const DYNAMIC_TARGET_KINDS: [&str; 2] = ["permanentDocument", "moderatedDocument"]; + +/// The operands a slot's `when` reads as a string, and the two lists it looks into. +const SLOT_CONDITION_STRINGS: [&str; 2] = ["$kind", "$self"]; +const SLOT_CONDITION_LISTS: [&str; 2] = [ + "$moderatorAbilities.deleteKeepsFields", + "$moderatorAbilities.changeFields", +]; + +/// The keys of a condition of a slot's `when`, each the one key of its object. +const SLOT_CONDITION_KEYS: [&str; 10] = [ + "equal", + "notEqual", + "in", + "notIn", + "contains", + "not", + "anyOf", + "allOf", + "ifThen", + "ifThenElse", +]; + /// The keys of a `refersTo` declaration. const REFERS_TO_KEYS: [&str; 17] = [ "type", @@ -43,8 +68,16 @@ impl Checker<'_> { } else { self.required(map, &["type"])?; } + let names_its_target = DYNAMIC_TARGET_KEYS.iter().any(|key| has(key)); for (key, keyword) in map { - self.at(key, |c| c.refers_to_keyword(key, keyword))?; + self.at(key, |c| { + // Both kinds, which only a reference naming its target through the + // referring document lists + if key == "type" && names_its_target && keyword.is_array() { + return c.reference_kind_list(keyword); + } + c.refers_to_keyword(key, keyword) + })?; } let target = map.get("type").and_then(JsonValue::as_str); if has("constraints") || has("consume") { @@ -67,10 +100,13 @@ impl Checker<'_> { // A reference naming its target through the referring document: a permanent or // moderated document found by its id, with nothing beside it that needs the // referenced type at registration - if DYNAMIC_TARGET_KEYS.iter().any(|key| has(key)) { + if names_its_target { self.required(map, &["type", "contractIdProperty", "documentTypeProperty"])?; self.member(map, "type", |c, target| { - c.one_of_strings(target, &["permanentDocument", "moderatedDocument"]) + if target.is_array() { + return Ok(()); + } + c.one_of_strings(target, &DYNAMIC_TARGET_KINDS) })?; for name in [ "contractId", @@ -135,6 +171,21 @@ impl Checker<'_> { Ok(()) } + /// A `type` listing both kinds a reference naming its target through the referring + /// document points at. + fn reference_kind_list(&self, value: &JsonValue) -> Checked { + let kinds = self.array(value)?; + for (index, kind) in kinds.iter().enumerate() { + self.at(index, |c| c.one_of_strings(kind, &DYNAMIC_TARGET_KINDS))?; + } + self.item_count( + kinds, + Some(DYNAMIC_TARGET_KINDS.len()), + Some(DYNAMIC_TARGET_KINDS.len()), + )?; + self.unique(kinds) + } + fn refers_to_keyword(&self, key: &str, value: &JsonValue) -> Checked { match key { "type" => self.one_of_strings( @@ -310,7 +361,14 @@ impl Checker<'_> { let map = self.object(value)?; self.closed( map, - &["name", "type", "byteArray", "maxBytes", "distinctFrom"], + &[ + "name", + "type", + "byteArray", + "maxBytes", + "distinctFrom", + "when", + ], )?; self.required(map, &["name", "type"])?; self.member(map, "name", |c, name| { @@ -348,6 +406,85 @@ impl Checker<'_> { c.at(index, |c| c.word(other))?; } Ok(()) + })?; + self.member(map, "when", |c, when| c.slot_condition(when)) + } + + /// A slot's `when`, or a condition inside one: the shape of a `propertyConstraints` + /// condition over what the referenced document type says of a property. What each + /// comparison may pair, and how many conditions one `when` holds, is the parser's to + /// check. + fn slot_condition(&self, value: &JsonValue) -> Checked { + let map = self.object(value)?; + self.closed(map, &SLOT_CONDITION_KEYS)?; + self.property_count(map, Some(1), Some(1))?; + for (key, operand) in map { + self.at(key, |c| match key.as_str() { + "equal" | "notEqual" => c.tuple(operand, 2, 2, |c, _, item| { + c.slot_condition_string(item, &SLOT_CONDITION_STRINGS) + }), + "in" | "notIn" => c.tuple(operand, 2, 2, |c, index, item| { + if index == 0 { + return c.one_of_strings(item, &SLOT_CONDITION_STRINGS); + } + let members = c.array(item)?; + for (index, member) in members.iter().enumerate() { + c.at(index, |c| { + c.string_of_length(member, 1, Some(256)).map(drop) + })?; + } + c.item_count(members, Some(2), None)?; + c.unique(members) + }), + "contains" => c.tuple(operand, 2, 2, |c, index, item| { + if index == 0 { + c.one_of_strings(item, &SLOT_CONDITION_LISTS) + } else { + c.slot_condition_string(item, &["$self"]) + } + }), + "anyOf" | "allOf" => { + let items = c.array(operand)?; + for (index, item) in items.iter().enumerate() { + c.at(index, |c| { + c.slot_condition(item)?; + match item.as_object() { + Some(item) => c.absent(item, key), + None => Ok(()), + } + })?; + } + c.item_count(items, Some(2), None)?; + c.unique(items) + } + "not" => { + c.slot_condition(operand)?; + match operand.as_object() { + Some(negated) => { + c.absent(negated, "not")?; + c.absent(negated, "notIn") + } + None => Ok(()), + } + } + "ifThen" => c.tuple(operand, 2, 2, |c, _, item| c.slot_condition(item)), + "ifThenElse" => c.tuple(operand, 3, 3, |c, _, item| c.slot_condition(item)), + _ => Ok(()), + })?; + } + Ok(()) + } + + /// A string a slot's `when` compares: one of `operands`, or `{ "const": }`. + fn slot_condition_string(&self, value: &JsonValue, operands: &[&str]) -> Checked { + if value.is_string() { + return self.one_of_strings(value, operands); + } + let constant = self.object(value)?; + self.closed(constant, &["const"])?; + self.required(constant, &["const"])?; + self.member(constant, "const", |c, text| { + c.string_of_length(text, 1, Some(256)).map(drop) }) } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs index 15941394368..47f7e4d97b6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs @@ -1,18 +1,23 @@ //! References whose target the referring document names (`contractIdProperty`, //! `documentTypeProperty`, `documentTypeRequirements`, protocol version 15): the parse of //! the declaration, where it may sit, the checks of the properties it reads, the protocol -//! version gate, what a document type must look like to meet the requirements, and the -//! platform serialization round trip. +//! version gate, what a document type must look like to meet the requirements, a `type` +//! listing both kinds, a slot's `when`, and the platform serialization round trip. use super::reference_test_helpers::{ assert_refused, assert_refused_by_the_parser, contract, contract_on, identifier, CONTRACT_ID, }; use crate::data_contract::accessors::v0::DataContractV0Getters; -use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::config::moderation::{ContractModerationConfig, ContractModerators}; +use crate::data_contract::config::DataContractConfig; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; use crate::data_contract::document_type::{ - DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRequirements, - DynamicDocumentReference, DynamicDocumentReferenceKind, RequiredPropertySlot, SlotPropertyName, - SlotRequirement, SlotRequirementTerm, SlotValue, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceKind, DocumentType, + DocumentTypeRequirements, DynamicDocumentReference, DynamicDocumentReferenceKind, + RequiredPropertySlot, SlotCondition, SlotConditionList, SlotConditionValue, SlotConditions, + SlotPropertyName, SlotRequirement, SlotRequirementTerm, SlotValue, }; use crate::data_contract::DataContract; use crate::serialization::{ @@ -151,9 +156,88 @@ fn slot(name: SlotPropertyName, value: SlotValue) -> RequiredPropertySlot { value, max_bytes: None, distinct_from: BTreeSet::new(), + when: None, } } +/// A dynamic reference to a document of either kind, with `requirements`. +fn either_kind_requiring(requirements: serde_json::Value) -> serde_json::Value { + json!({ + "type": ["permanentDocument", "moderatedDocument"], + "contractIdProperty": "descriptionContractId", + "documentTypeProperty": "descriptionDocumentType", + "documentTypeRequirements": requirements + }) +} + +/// "When the type is moderated, a removal takes the property and its author writes it." +fn removed_with_the_document() -> serde_json::Value { + json!({ + "ifThen": [ + { "equal": ["$kind", { "const": "moderatedDocument" }] }, + { "allOf": [ + { "not": { "contains": ["$moderatorAbilities.deleteKeepsFields", "$self"] } }, + { "not": { "contains": ["$moderatorAbilities.changeFields", "$self"] } } + ] } + ] + }) +} + +/// One slot, `body`, any string property `when` holds for. +fn body_when(when: serde_json::Value) -> serde_json::Value { + json!({ "properties": { "body": { "name": "$any", "type": "string", "when": when } } }) +} + +/// The `when` of the slot `body` as the parser reads it. +fn parsed_when(when: serde_json::Value) -> SlotCondition { + requirements(body_when(when)) + .slots + .remove("body") + .expect("body") + .when + .expect("when") +} + +/// A `post` of a contract its owner moderates, holding the strings `text`, `tags` and +/// `label`, with `extra` merged into its schema. +fn moderated_post(extra: serde_json::Value) -> DocumentType { + let platform_version = PlatformVersion::latest(); + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config") + .with_moderation(Some(ContractModerationConfig { + banlist: false, + suspensions: false, + moderators: ContractModerators::ContractOwner, + warnings: false, + })); + let mut schema = json!({ + "type": "object", + "properties": { + "text": { "type": "string", "maxLength": 50, "position": 0 }, + "tags": { "type": "string", "maxLength": 50, "position": 1 }, + "label": { "type": "string", "maxLength": 50, "position": 2 } + }, + "additionalProperties": false + }); + for (key, value) in extra.as_object().expect("an object") { + schema[key] = value.clone(); + } + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "post", + platform_value::to_value(schema).expect("the schema should convert"), + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + ) + .expect("the post type parses") +} + #[test] fn should_parse_a_reference_naming_its_target_through_the_referring_document() { let parsed = contract(note_contract(text_or_bytes())).expect("parses"); @@ -182,7 +266,7 @@ fn should_parse_a_reference_naming_its_target_through_the_referring_document() { }), } ); - assert_eq!(reference.wire_name(), "moderatedDocument"); + assert_eq!(reference.wire_names(), ["moderatedDocument"]); assert_eq!( reference.target_properties().collect::>(), ["descriptionContractId", "descriptionDocumentType"] @@ -757,3 +841,482 @@ fn should_refuse_a_rule_or_an_index_reading_through_a_dynamic_reference() { ]); contract(with_an_index).expect_err("an index reads no value through a dynamic reference"); } + +#[test] +fn should_parse_a_type_listing_both_kinds() { + for kinds in [ + json!(["permanentDocument", "moderatedDocument"]), + json!(["moderatedDocument", "permanentDocument"]), + ] { + let parsed = contract(note_contract(json!({ + "type": kinds, + "contractIdProperty": "descriptionContractId", + "documentTypeProperty": "descriptionDocumentType" + }))) + .expect("parses"); + let reference = dynamic_reference(&parsed); + assert_eq!( + reference.kind, + DynamicDocumentReferenceKind::PermanentOrModerated + ); + assert_eq!( + reference.wire_names(), + ["permanentDocument", "moderatedDocument"] + ); + assert_eq!( + DocumentPropertyReferenceTarget::DynamicDocument(reference).to_string(), + "permanent or moderated document (contract named by descriptionContractId, document \ + type named by descriptionDocumentType)" + ); + } + assert_eq!( + DynamicDocumentReferenceKind::Moderated.wire_names(), + ["moderatedDocument"] + ); + + // A list is the two kinds, once each + for kinds in [ + json!(["permanentDocument"]), + json!(["permanentDocument", "permanentDocument"]), + json!(["permanentDocument", "deletableDocument"]), + json!([ + "permanentDocument", + "moderatedDocument", + "deletableDocument" + ]), + json!(["permanentDocument", 1]), + json!([]), + ] { + assert_refused_by_the_parser( + note_contract(json!({ + "type": kinds, + "contractIdProperty": "descriptionContractId", + "documentTypeProperty": "descriptionDocumentType" + })), + "holds permanentDocument and moderatedDocument, once each", + ); + } + // and only a reference naming its target through the referring document lists them + for full_validation in [true, false] { + contract_on( + note_contract(json!({ + "type": ["permanentDocument", "moderatedDocument"], + "documentType": "post" + })), + full_validation, + PlatformVersion::latest(), + ) + .expect_err("a reference naming its target in the schema is of one kind"); + } +} + +#[test] +fn should_check_a_reference_as_the_kind_the_named_type_is() { + let (permanent, moderated, deletable) = ( + DocumentReferenceKind::Permanent, + DocumentReferenceKind::Moderated, + DocumentReferenceKind::Deletable, + ); + // A declaration of one kind is that kind, whatever the type + for actual in [permanent, moderated, deletable] { + assert_eq!( + DynamicDocumentReferenceKind::Permanent.checked_as(actual), + permanent + ); + assert_eq!( + DynamicDocumentReferenceKind::Moderated.checked_as(actual), + moderated + ); + } + // A declaration of either kind is the kind of the type, and a permanent reference into + // a type whose documents can be deleted, which the kind check then refuses + let either = DynamicDocumentReferenceKind::PermanentOrModerated; + assert_eq!(either.checked_as(permanent), permanent); + assert_eq!(either.checked_as(moderated), moderated); + assert_eq!(either.checked_as(deletable), permanent); +} + +#[test] +fn should_parse_every_form_of_a_slot_condition() { + let constant = |text: &str| SlotConditionValue::Constant(text.to_string()); + let list = SlotConditions::new; + let not_listed = |listed: SlotConditionList| { + SlotCondition::Not(list(vec![SlotCondition::Contains( + listed, + SlotConditionValue::PropertyName, + )])) + }; + + assert_eq!( + parsed_when(removed_with_the_document()), + SlotCondition::IfThen(list(vec![ + SlotCondition::Equal(SlotConditionValue::Kind, constant("moderatedDocument")), + SlotCondition::AllOf(list(vec![ + not_listed(SlotConditionList::DeletionKeptFields), + not_listed(SlotConditionList::ModeratorChangeableFields), + ])), + ])) + ); + + // A comparison reads either way round + assert_eq!( + parsed_when(json!({ "notEqual": [{ "const": "permanentDocument" }, "$kind"] })), + SlotCondition::NotEqual(constant("permanentDocument"), SlotConditionValue::Kind) + ); + assert_eq!( + parsed_when(json!({ "in": ["$self", ["body", "summary"]] })), + SlotCondition::In( + SlotConditionValue::PropertyName, + BTreeSet::from(["body".to_string(), "summary".to_string()]) + ) + ); + assert_eq!( + parsed_when(json!({ "notIn": ["$kind", ["moderatedDocument", "permanentDocument"]] })), + SlotCondition::NotIn( + SlotConditionValue::Kind, + BTreeSet::from([ + "moderatedDocument".to_string(), + "permanentDocument".to_string() + ]) + ) + ); + assert_eq!( + parsed_when(json!({ + "contains": ["$moderatorAbilities.changeFields", { "const": "label" }] + })), + SlotCondition::Contains( + SlotConditionList::ModeratorChangeableFields, + constant("label") + ) + ); + assert_eq!( + parsed_when(json!({ "ifThenElse": [ + { "equal": ["$kind", { "const": "permanentDocument" }] }, + { "anyOf": [ + { "equal": ["$self", { "const": "body" }] }, + { "equal": ["$self", { "const": "summary" }] } + ] }, + { "notEqual": ["$self", { "const": "title" }] } + ] })), + SlotCondition::IfThenElse(list(vec![ + SlotCondition::Equal(SlotConditionValue::Kind, constant("permanentDocument")), + SlotCondition::AnyOf(list(vec![ + SlotCondition::Equal(SlotConditionValue::PropertyName, constant("body")), + SlotCondition::Equal(SlotConditionValue::PropertyName, constant("summary")), + ])), + SlotCondition::NotEqual(SlotConditionValue::PropertyName, constant("title")), + ])) + ); +} + +#[test] +fn should_refuse_a_malformed_slot_condition() { + let refused = |when: serde_json::Value, fragment: &str| { + assert_refused_by_the_parser(note_contract(requiring(body_when(when))), fragment); + }; + let is_body = || json!({ "equal": ["$self", { "const": "body" }] }); + + // The shape of a condition + refused(json!("$self"), "is an object of one key"); + refused(json!({}), "is an object of one key"); + refused( + json!({ "equal": ["$self", { "const": "a" }], "not": is_body() }), + "is an object of one key", + ); + refused(json!({ "present": "$self" }), "does not take present"); + refused( + json!({ "greaterThan": ["$self", { "const": "a" }] }), + "does not take greaterThan", + ); + + // What a comparison reads + refused(json!({ "equal": ["$self"] }), "equal takes two strings"); + refused( + json!({ "equal": ["title", { "const": "title" }] }), + "when compares \"$kind\", \"$self\" or", + ); + refused( + json!({ "equal": ["$self", { "const": "" }] }), + "when compares \"$kind\", \"$self\" or", + ); + refused( + json!({ "equal": ["$self", { "const": 1 }] }), + "when compares \"$kind\", \"$self\" or", + ); + refused( + json!({ "equal": ["$self", { "const": "a", "other": "b" }] }), + "when compares \"$kind\", \"$self\" or", + ); + refused( + json!({ "equal": ["$self", "$kind"] }), + "equal compares \"$kind\" or \"$self\" with a", + ); + refused( + json!({ "notEqual": [{ "const": "a" }, { "const": "b" }] }), + "notEqual compares \"$kind\" or \"$self\" with a", + ); + refused( + json!({ "equal": ["$self", "$self"] }), + "equal compares \"$kind\" or \"$self\" with a", + ); + // The kind is one a reference of this sort admits + refused( + json!({ "equal": ["$kind", { "const": "moderated" }] }), + "the kind of the referenced document type is permanentDocument or moderatedDocument", + ); + refused( + json!({ "equal": ["$kind", { "const": "deletableDocument" }] }), + "the kind of the referenced document type is permanentDocument or moderatedDocument", + ); + refused( + json!({ "in": ["$kind", ["permanentDocument", "post"]] }), + "the kind of the referenced document type is permanentDocument or moderatedDocument", + ); + + // `in` and `notIn` + refused( + json!({ "in": ["$self", ["body"]] }), + "in takes a string and a list of two or more distinct strings", + ); + refused( + json!({ "notIn": ["$self", ["body", "body"]] }), + "notIn takes a string and a list of two or more distinct strings", + ); + refused( + json!({ "in": ["$self", ["body", 1]] }), + "in takes a string and a list of two or more distinct strings", + ); + refused( + json!({ "in": [{ "const": "body" }, ["body", "title"]] }), + "not for a constant", + ); + + // `contains` + refused( + json!({ "contains": ["$self", "$self"] }), + "contains looks into \"$moderatorAbilities.deleteKeepsFields\" or", + ); + refused( + json!({ "contains": ["$moderatorAbilities.delete", "$self"] }), + "contains looks into \"$moderatorAbilities.deleteKeepsFields\" or", + ); + refused( + json!({ "contains": ["$moderatorAbilities.changeFields", "$kind"] }), + "not for the kind of the type", + ); + + // The combinators + refused( + json!({ "not": { "not": is_body() } }), + "not holds a not or a notIn", + ); + refused( + json!({ "not": { "notIn": ["$self", ["a", "b"]] } }), + "not holds a not or a notIn", + ); + refused( + json!({ "anyOf": [is_body()] }), + "anyOf takes two or more conditions", + ); + refused( + json!({ "allOf": [is_body(), is_body()] }), + "allOf lists the same condition twice", + ); + refused( + json!({ "anyOf": [is_body(), { "anyOf": [ + { "equal": ["$self", { "const": "a" }] }, + { "equal": ["$self", { "const": "b" }] } + ] }] }), + "anyOf holds another anyOf", + ); + refused( + json!({ "ifThen": [is_body()] }), + "ifThen takes two conditions", + ); + refused( + json!({ "ifThenElse": [is_body(), is_body()] }), + "ifThenElse takes three conditions", + ); +} + +#[test] +fn should_hold_a_slot_condition_to_the_node_limit() { + let max_nodes = usize::from( + PlatformVersion::latest() + .system_limits + .max_document_type_requirement_condition_nodes, + ); + // `allOf` over `count - 1` comparisons: `count` conditions + let of = |count: usize| { + json!({ "allOf": (1..count) + .map(|index| json!({ "notEqual": ["$self", { "const": format!("p{index}") }] })) + .collect::>() }) + }; + assert_eq!(parsed_when(of(max_nodes)).node_count(), max_nodes); + assert_refused_by_the_parser( + note_contract(requiring(body_when(of(max_nodes + 1)))), + &format!("when is made of more than {max_nodes} conditions"), + ); + + // The limit also holds what nests: `not` and `ifThen` in turn, as deep as it allows + let mut deepest = json!({ "equal": ["$self", { "const": "body" }] }); + let mut nodes = 1; + while nodes * 2 + 2 <= max_nodes { + deepest = json!({ "not": { "ifThen": [deepest.clone(), deepest] } }); + nodes = nodes * 2 + 2; + } + assert!(parsed_when(deepest.clone()).node_count() <= max_nodes); + let too_many = json!({ "ifThen": [deepest.clone(), deepest] }); + assert_refused_by_the_parser( + note_contract(requiring(body_when(too_many))), + &format!("when is made of more than {max_nodes} conditions"), + ); +} + +#[test] +fn should_fill_a_slot_only_with_a_property_its_condition_holds_for() { + // The fixture's `post` is permanent: `title`, `body` and `summary` are its strings + assert!(post_meets( + body_when(json!({ "in": ["$self", ["body", "content"]] })), + None + )); + assert!(!post_meets( + body_when(json!({ "in": ["$self", ["content", "text"]] })), + None + )); + // A condition on the kind decides for every property at once + assert!(post_meets( + body_when(json!({ "equal": ["$kind", { "const": "permanentDocument" }] })), + None + )); + assert!(!post_meets( + body_when(json!({ "equal": ["$kind", { "const": "moderatedDocument" }] })), + None + )); + // The condition narrows the candidates distinct slots are filled from: three strings, + // of which the two slots may not take `summary` + let two_of = |excluded: &[&str]| { + let when = json!({ "notIn": ["$self", excluded] }); + json!({ "properties": { + "first": { "name": "$any", "type": "string", "when": when }, + "second": { "name": "$any", "type": "string", "when": when, "distinctFrom": "first" } + } }) + }; + assert!(post_meets(two_of(&["summary", "none"]), None)); + assert!(!post_meets(two_of(&["summary", "body"]), None)); + // A named property is held to it too + assert!(!post_meets( + json!({ "properties": { "body": { + "name": "title", + "type": "string", + "when": { "notEqual": ["$self", { "const": "title" }] } + } } }), + None + )); + assert!(!post_meets( + json!({ "properties": { "body": { + "name": { "property": "bodyProperty" }, + "type": "string", + "when": { "notEqual": ["$self", { "const": "title" }] } + } } }), + Some("title") + )); +} + +#[test] +fn should_ask_of_a_moderated_type_what_it_does_not_ask_of_a_permanent_one() { + let slot_requirements = |name: serde_json::Value| { + dynamic_reference( + &contract(note_contract(either_kind_requiring( + json!({ "properties": { "body": { + "name": name, + "type": "string", + "when": removed_with_the_document() + } } }), + ))) + .expect("parses"), + ) + .requirements + .expect("requirements") + }; + let met = |name: serde_json::Value, post: &DocumentType| { + slot_requirements(name).met_by(post.as_ref(), |_| None) + }; + + // Moderators delete posts, the removal record keeping `tags`, and they alone write + // `label`: `text` is the one string a removal takes and the author writes + let moderated = moderated_post(json!({ + "canBeDeleted": false, + "moderatorAbilities": { + "delete": true, + "deleteKeepsFields": ["tags"], + "changeFields": ["label"] + } + })); + assert_eq!( + moderated.document_reference_kind(), + DocumentReferenceKind::Moderated + ); + assert!(met(json!("$any"), &moderated)); + assert!(met(json!("text"), &moderated)); + assert!(!met(json!("tags"), &moderated)); + assert!(!met(json!("label"), &moderated)); + + // Every string is kept or is the moderators': nothing fills the slot + let all_kept = moderated_post(json!({ + "canBeDeleted": false, + "moderatorAbilities": { + "delete": true, + "deleteKeepsFields": ["text", "tags"], + "changeFields": ["label"] + } + })); + assert!(!met(json!("$any"), &all_kept)); + + // A permanent type whose moderators write `label`: the condition asks nothing of it + let permanent = moderated_post(json!({ + "canBeDeleted": false, + "documentsMutable": false, + "moderatorAbilities": { "changeFields": ["label"] } + })); + assert_eq!( + permanent.document_reference_kind(), + DocumentReferenceKind::Permanent + ); + assert!(met(json!("label"), &permanent)); + assert!(met(json!("tags"), &permanent)); +} + +#[test] +fn should_round_trip_a_contract_through_platform_serialization_with_a_slot_condition() { + let platform_version = PlatformVersion::latest(); + let original = contract(note_contract(either_kind_requiring(json!({ + "properties": { + "body": { "name": "$any", "type": "string", "when": removed_with_the_document() }, + "bytes": { + "name": "$any", + "type": "array", + "byteArray": true, + "when": { "ifThenElse": [ + { "in": ["$kind", ["permanentDocument", "moderatedDocument"]] }, + { "notIn": ["$self", ["blob", "raw"]] }, + { "notEqual": ["$self", { "const": "blob" }] } + ] } + } + }, + "require": { "anyOf": ["body", "bytes"] } + })))) + .expect("parses"); + let bytes = original + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract should serialize"); + let recovered = DataContract::versioned_deserialize_untrusted(&bytes, false, platform_version) + .expect("the contract should deserialize"); + assert_eq!(original, recovered); + let reference = dynamic_reference(&recovered); + assert_eq!( + reference.kind, + DynamicDocumentReferenceKind::PermanentOrModerated + ); + assert_eq!(dynamic_reference(&original), reference); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs index a1968a028b0..9979fa4b542 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs @@ -30,10 +30,18 @@ //! of the declared kind, that it meets the `documentTypeRequirements`, and that the document //! exists. A type's deletion and moderation settings never change, so a target of the //! declared kind keeps it; the requirements are judged whenever the reference is checked. +//! +//! `type` may list both kinds, `["permanentDocument", "moderatedDocument"]`: the named type +//! is then either, and the reference is checked as one of the kind the type is. A slot may +//! carry a `when` ([`SlotCondition`]) telling the two apart, so that it asks of a moderated +//! type what it does not ask of a permanent one. -use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; use crate::data_contract::document_type::property::{ - DocumentProperty, DocumentPropertyType, DocumentReferenceKind, + DocumentProperty, DocumentPropertyType, DocumentReferenceKind, SlotCondition, + SlotConditionSubject, }; use crate::data_contract::document_type::DocumentTypeRef; use bincode::{Decode, DecodeUntrusted, Encode}; @@ -53,13 +61,39 @@ pub enum DynamicDocumentReferenceKind { /// `moderatedDocument`: the documents leave state only through a moderator's recorded /// removal. Moderated, + /// `["permanentDocument", "moderatedDocument"]`: either of the two, whichever the named + /// type is. + PermanentOrModerated, } -impl From for DocumentReferenceKind { - fn from(kind: DynamicDocumentReferenceKind) -> Self { - match kind { - DynamicDocumentReferenceKind::Permanent => DocumentReferenceKind::Permanent, - DynamicDocumentReferenceKind::Moderated => DocumentReferenceKind::Moderated, +impl DynamicDocumentReferenceKind { + /// The `refersTo` `type` the kind is written with: one name, or the two of a list. + pub const fn wire_names(self) -> &'static [&'static str] { + match self { + DynamicDocumentReferenceKind::Permanent => &["permanentDocument"], + DynamicDocumentReferenceKind::Moderated => &["moderatedDocument"], + DynamicDocumentReferenceKind::PermanentOrModerated => { + &["permanentDocument", "moderatedDocument"] + } + } + } + + /// The kind of reference a document pointing into a type of kind `actual` is checked + /// as. A declaration of one kind is that kind whatever the type is, so a type of + /// another is refused as it is for a reference naming its target in the schema. A + /// declaration of either kind is a moderated reference into a moderated type and a + /// permanent one into any other, which refuses a type whose documents can be deleted. + pub const fn checked_as(self, actual: DocumentReferenceKind) -> DocumentReferenceKind { + match (self, actual) { + (DynamicDocumentReferenceKind::Permanent, _) => DocumentReferenceKind::Permanent, + (DynamicDocumentReferenceKind::Moderated, _) => DocumentReferenceKind::Moderated, + ( + DynamicDocumentReferenceKind::PermanentOrModerated, + DocumentReferenceKind::Moderated, + ) => DocumentReferenceKind::Moderated, + (DynamicDocumentReferenceKind::PermanentOrModerated, _) => { + DocumentReferenceKind::Permanent + } } } } @@ -82,9 +116,10 @@ pub struct DynamicDocumentReference { } impl DynamicDocumentReference { - /// The `refersTo` `type` the declaration is written with. - pub fn wire_name(&self) -> &'static str { - DocumentReferenceKind::from(self.kind).wire_name() + /// The `refersTo` `type` the declaration is written with: one name, or the two of a + /// list. + pub fn wire_names(&self) -> &'static [&'static str] { + self.kind.wire_names() } /// The properties of the declaring type the target is read from: the contract id, the @@ -130,6 +165,9 @@ pub struct RequiredPropertySlot { /// The other slots this one must be filled by a different property than /// (`distinctFrom`). The relation is symmetric: naming it on one side is enough. pub distinct_from: BTreeSet, + /// What the referenced type must say of the property (`when`): a property the condition + /// does not hold for does not fill the slot. `None` asks nothing. + pub when: Option, } /// Which property of the referenced type may fill a slot. @@ -229,8 +267,8 @@ impl SlotRequirement { impl RequiredPropertySlot { /// Whether `property`, a top-level property of the referenced type, matches the slot's - /// description, its name aside. A transient property is never stored, so it fills - /// nothing. + /// description, its name and its `when` aside. A transient property is never stored, so + /// it fills nothing. pub fn admits(&self, property: &DocumentProperty) -> bool { if property.transient { return false; @@ -271,10 +309,11 @@ impl DocumentTypeRequirements { /// Whether `document_type` meets the requirements. /// - /// A slot is filled by one top-level property of the type that its description admits: - /// any such property for `"$any"`, the named one for a literal name, and for a name read - /// from the referring document the property `name_from_property` gives for that - /// referring property (`None` when the document leaves it out, which fills nothing). + /// A slot is filled by one top-level property of the type that its description admits + /// and its `when` holds for: any such property for `"$any"`, the named one for a + /// literal name, and for a name read from the referring document the property + /// `name_from_property` gives for that referring property (`None` when the document + /// leaves it out, which fills nothing). /// Slots that are `distinctFrom` each other are filled by different properties. The /// requirements are met when some choice of properties fills a set of slots `require` /// holds for, which is every slot when there is no `require`. @@ -290,21 +329,35 @@ impl DocumentTypeRequirements { name_from_property: impl Fn(&str) -> Option, ) -> bool { let properties = document_type.properties(); + let kind = document_type.document_reference_kind(); + let deletion_kept_fields = document_type.moderator_deletion_kept_fields(); + let moderator_changeable_fields = document_type.moderator_changeable_fields(); let slots: Vec<(&str, &RequiredPropertySlot, Vec<&str>)> = self .slots .iter() .map(|(slot_name, slot)| { + let fills = |name: &str, property: &DocumentProperty| { + slot.admits(property) + && slot.when.as_ref().is_none_or(|when| { + when.holds(&SlotConditionSubject { + kind, + property_name: name, + deletion_kept_fields, + moderator_changeable_fields, + }) + }) + }; let named = |name: &str| { properties .get_key_value(name) - .filter(|(_, property)| slot.admits(property)) + .filter(|(name, property)| fills(name, property)) .map(|(name, _)| vec![name.as_str()]) .unwrap_or_default() }; let candidates = match &slot.name { SlotPropertyName::Any => properties .iter() - .filter(|(_, property)| slot.admits(property)) + .filter(|(name, property)| fills(name, property)) .map(|(name, _)| name.as_str()) .collect(), SlotPropertyName::Named(name) => named(name), 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 79616a8b3c0..56b92c8729f 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 @@ -48,6 +48,7 @@ pub mod lookup_preimage; pub mod reference_expression; pub mod reference_lookup; pub mod reference_where; +pub mod slot_condition; pub use dynamic_document_reference::{ DocumentTypeRequirements, DynamicDocumentReference, DynamicDocumentReferenceKind, @@ -76,6 +77,11 @@ pub use reference_where::{ WhereCondition, WhereContainsBound, WhereLiteral, WhereLiteralKind, WhereNotEqualBound, WhereOrdering, WhereOrderingBound, }; +pub use slot_condition::{ + SlotCondition, SlotConditionList, SlotConditionSubject, SlotConditionValue, SlotConditions, + MAX_SLOT_CONDITION_DECODE_DEPTH, SLOT_CONDITION_CHANGE_FIELDS, + SLOT_CONDITION_DELETE_KEEPS_FIELDS, SLOT_CONDITION_KIND, SLOT_CONDITION_SELF, +}; #[cfg(test)] mod byte_array_encoding_flip_tests; @@ -1723,6 +1729,7 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { match reference.kind { DynamicDocumentReferenceKind::Permanent => "permanent", DynamicDocumentReferenceKind::Moderated => "moderated", + DynamicDocumentReferenceKind::PermanentOrModerated => "permanent or moderated", }, reference.contract_id_property, reference.document_type_property @@ -11351,9 +11358,10 @@ mod tests { "deletableDocument" } DocumentPropertyReferenceTarget::ModeratedDocument { .. } => "moderatedDocument", - // The `type` it is declared with, permanent or moderated + // The `type` it is declared with, permanent or moderated (the first of + // the two when it lists both) DocumentPropertyReferenceTarget::DynamicDocument(reference) => { - reference.wire_name() + reference.wire_names()[0] } // Not a `type`: the schema declares them under their own keys DocumentPropertyReferenceTarget::ListElement(_) => "listElement", diff --git a/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs b/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs new file mode 100644 index 00000000000..538a395ad57 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs @@ -0,0 +1,534 @@ +//! The `when` condition of a `documentTypeRequirements` slot (protocol version 15). +//! +//! A slot describes a property the referenced document type must have by its name, its +//! value and its size. `when` adds a condition on what the type says about the property, +//! in the vocabulary of `propertyConstraints` conditions, read against the referenced +//! type's schema instead of a document: +//! +//! ```json +//! "body": { +//! "name": "$any", +//! "type": "string", +//! "when": { +//! "ifThen": [ +//! { "equal": ["$kind", { "const": "moderatedDocument" }] }, +//! { "allOf": [ +//! { "not": { "contains": ["$moderatorAbilities.deleteKeepsFields", "$self"] } }, +//! { "not": { "contains": ["$moderatorAbilities.changeFields", "$self"] } } +//! ] } +//! ] +//! } +//! } +//! ``` +//! +//! reads: when the type is moderated, the property filling the slot is one a moderator's +//! removal takes away and one its author writes. A property the condition does not hold for +//! does not fill the slot. +//! +//! The operands are `$kind` (the kind of the referenced type, by the `refersTo` `type` it +//! is referred to with), `$self` (the name of the property tried for the slot), +//! `$moderatorAbilities.deleteKeepsFields` and `$moderatorAbilities.changeFields` (the +//! type's lists, empty where it declares none) and `{ "const": }`. + +use crate::data_contract::document_type::property::DocumentReferenceKind; +use bincode::de::{BorrowDecoder, BorrowUntrustedDecoder, Decoder, UntrustedDecoder}; +use bincode::error::DecodeError; +use bincode::{BorrowDecode, BorrowDecodeUntrusted, Decode, DecodeUntrusted, Encode}; +use serde::Serialize; +use std::cell::Cell; +use std::collections::BTreeSet; + +/// The operand reading the kind of the referenced document type. +pub const SLOT_CONDITION_KIND: &str = "$kind"; +/// The operand reading the name of the property tried for the slot. +pub const SLOT_CONDITION_SELF: &str = "$self"; +/// The operand reading the referenced type's `moderatorAbilities.deleteKeepsFields`. +pub const SLOT_CONDITION_DELETE_KEEPS_FIELDS: &str = "$moderatorAbilities.deleteKeepsFields"; +/// The operand reading the referenced type's `moderatorAbilities.changeFields`. +pub const SLOT_CONDITION_CHANGE_FIELDS: &str = "$moderatorAbilities.changeFields"; + +/// The deepest a decoder accepts conditions inside one another. It only keeps crafted bytes +/// from driving the decoder, and the evaluation of what it decodes, into unbounded +/// recursion: a registered condition has at most +/// `SystemLimits::max_document_type_requirement_condition_nodes` conditions, so it nests +/// less deep than that (a test holds every protocol version's limit to it), and every +/// declaration a contract can carry decodes. A constant rather than a limit, like +/// `MAX_REFERENCE_EXPRESSION_DECODE_DEPTH`, so that no change to a limit can make a stored +/// contract undecodable. +pub const MAX_SLOT_CONDITION_DECODE_DEPTH: usize = 16; + +/// A string a slot condition compares. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +pub enum SlotConditionValue { + /// `$kind`: `permanentDocument`, `moderatedDocument` or `deletableDocument`, as the + /// referenced type's documents leave state. + Kind, + /// `$self`: the name of the property tried for the slot. + PropertyName, + /// `{ "const": }`. + Constant(String), +} + +/// A list of property paths of the referenced type a slot condition looks into. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Encode, Decode, DecodeUntrusted)] +pub enum SlotConditionList { + /// `$moderatorAbilities.deleteKeepsFields`: what a moderator's removal keeps public. + DeletionKeptFields, + /// `$moderatorAbilities.changeFields`: what only moderators write. + ModeratorChangeableFields, +} + +impl SlotConditionList { + /// The operand the list is written as. + pub const fn wire_name(self) -> &'static str { + match self { + SlotConditionList::DeletionKeptFields => SLOT_CONDITION_DELETE_KEEPS_FIELDS, + SlotConditionList::ModeratorChangeableFields => SLOT_CONDITION_CHANGE_FIELDS, + } + } +} + +/// A condition a property must meet to fill a slot. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +pub enum SlotCondition { + /// `equal`: the two strings are the same. + Equal(SlotConditionValue, SlotConditionValue), + /// `notEqual`: the two strings differ. + NotEqual(SlotConditionValue, SlotConditionValue), + /// `in`: the string is one of the listed ones. + In(SlotConditionValue, BTreeSet), + /// `notIn`: the string is none of the listed ones. + NotIn(SlotConditionValue, BTreeSet), + /// `contains`: the list holds the string, as a whole entry. + Contains(SlotConditionList, SlotConditionValue), + /// `not`: the one condition does not hold. + Not(SlotConditions), + /// `anyOf`: at least one of the conditions holds. + AnyOf(SlotConditions), + /// `allOf`: every one of the conditions holds. + AllOf(SlotConditions), + /// `ifThen`: two conditions; the second holds whenever the first does. + IfThen(SlotConditions), + /// `ifThenElse`: three conditions; the second holds when the first does, the third + /// when it does not. + IfThenElse(SlotConditions), +} + +/// What a slot condition is judged against: the referenced document type, and the one of +/// its properties tried for the slot. +#[derive(Debug, Clone, Copy)] +pub struct SlotConditionSubject<'a> { + /// How the referenced type's documents leave state. + pub kind: DocumentReferenceKind, + /// The name of the property tried for the slot. + pub property_name: &'a str, + /// The referenced type's `moderatorAbilities.deleteKeepsFields`. + pub deletion_kept_fields: &'a BTreeSet, + /// The referenced type's `moderatorAbilities.changeFields`. + pub moderator_changeable_fields: &'a BTreeSet, +} + +impl SlotConditionValue { + fn read<'a>(&'a self, subject: &SlotConditionSubject<'a>) -> &'a str { + match self { + SlotConditionValue::Kind => subject.kind.wire_name(), + SlotConditionValue::PropertyName => subject.property_name, + SlotConditionValue::Constant(constant) => constant, + } + } +} + +impl SlotCondition { + /// Whether the condition holds for `subject`. + /// + /// A `not`, `ifThen` or `ifThenElse` holding another number of conditions than it takes + /// is one no parse produces; it holds for nothing. + pub fn holds(&self, subject: &SlotConditionSubject) -> bool { + match self { + SlotCondition::Equal(left, right) => left.read(subject) == right.read(subject), + SlotCondition::NotEqual(left, right) => left.read(subject) != right.read(subject), + SlotCondition::In(value, among) => among.contains(value.read(subject)), + SlotCondition::NotIn(value, among) => !among.contains(value.read(subject)), + SlotCondition::Contains(list, value) => { + let list = match list { + SlotConditionList::DeletionKeptFields => subject.deletion_kept_fields, + SlotConditionList::ModeratorChangeableFields => { + subject.moderator_changeable_fields + } + }; + list.contains(value.read(subject)) + } + SlotCondition::Not(conditions) => match conditions.conditions() { + [condition] => !condition.holds(subject), + _ => false, + }, + SlotCondition::AnyOf(conditions) => conditions + .conditions() + .iter() + .any(|condition| condition.holds(subject)), + SlotCondition::AllOf(conditions) => conditions + .conditions() + .iter() + .all(|condition| condition.holds(subject)), + SlotCondition::IfThen(conditions) => match conditions.conditions() { + [condition, then] => !condition.holds(subject) || then.holds(subject), + _ => false, + }, + SlotCondition::IfThenElse(conditions) => match conditions.conditions() { + [condition, then, otherwise] => { + if condition.holds(subject) { + then.holds(subject) + } else { + otherwise.holds(subject) + } + } + _ => false, + }, + } + } + + /// How many conditions this one is made of, itself included: what + /// `SystemLimits::max_document_type_requirement_condition_nodes` bounds. + pub fn node_count(&self) -> usize { + match self { + SlotCondition::Equal(..) + | SlotCondition::NotEqual(..) + | SlotCondition::In(..) + | SlotCondition::NotIn(..) + | SlotCondition::Contains(..) => 1, + SlotCondition::Not(conditions) + | SlotCondition::AnyOf(conditions) + | SlotCondition::AllOf(conditions) + | SlotCondition::IfThen(conditions) + | SlotCondition::IfThenElse(conditions) => conditions + .conditions() + .iter() + .map(SlotCondition::node_count) + .fold(1usize, usize::saturating_add), + } + } +} + +/// The conditions a [`SlotCondition`] holds inside it. +/// +/// Decoding refuses conditions inside one another deeper than +/// [`MAX_SLOT_CONDITION_DECODE_DEPTH`]: the condition is embedded in a reference target, +/// which consensus errors carry and clients decode from bytes a node sends, and those bytes +/// must not drive the decoder into unbounded recursion. Encoding needs no guard, since what +/// it encodes was parsed. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode)] +#[serde(transparent)] +pub struct SlotConditions(Vec); + +impl SlotConditions { + /// Wraps conditions the caller has checked: as many as the condition holding them takes. + pub fn new(conditions: Vec) -> Self { + Self(conditions) + } + + /// The conditions, in declared order. + pub fn conditions(&self) -> &[SlotCondition] { + &self.0 + } +} + +std::thread_local! { + /// How many condition lists this thread is decoding inside one another. + static CONDITION_LIST_DECODE_DEPTH: Cell = const { Cell::new(0) }; +} + +/// Runs `decode`, the decoding of one list of conditions, one level deeper than the list +/// around it, refusing it past [`MAX_SLOT_CONDITION_DECODE_DEPTH`]. +fn decode_conditions( + decode: impl FnOnce() -> Result, DecodeError>, +) -> Result { + struct LeaveList; + + impl Drop for LeaveList { + fn drop(&mut self) { + CONDITION_LIST_DECODE_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1))); + } + } + + let depth = CONDITION_LIST_DECODE_DEPTH.with(|depth| depth.get()) + 1; + if depth > MAX_SLOT_CONDITION_DECODE_DEPTH { + return Err(DecodeError::OtherString(format!( + "slot condition nesting depth {depth} exceeds the maximum of \ + {MAX_SLOT_CONDITION_DECODE_DEPTH}" + ))); + } + CONDITION_LIST_DECODE_DEPTH.with(|current| current.set(depth)); + let _leave = LeaveList; + decode().map(SlotConditions) +} + +impl Decode for SlotConditions { + fn decode>(decoder: &mut D) -> Result { + decode_conditions(|| Vec::decode(decoder)) + } +} + +impl<'de, Context> BorrowDecode<'de, Context> for SlotConditions { + fn borrow_decode>( + decoder: &mut D, + ) -> Result { + decode_conditions(|| Vec::borrow_decode(decoder)) + } +} + +impl DecodeUntrusted for SlotConditions { + fn decode_untrusted>( + decoder: &mut D, + ) -> Result { + decode_conditions(|| Vec::decode_untrusted(decoder)) + } +} + +impl<'de, Context> BorrowDecodeUntrusted<'de, Context> for SlotConditions { + fn borrow_decode_untrusted>( + decoder: &mut D, + ) -> Result { + decode_conditions(|| Vec::borrow_decode_untrusted(decoder)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use platform_version::version::PLATFORM_VERSIONS; + + fn kept(names: &[&str]) -> BTreeSet { + names.iter().map(|name| name.to_string()).collect() + } + + fn not(condition: SlotCondition) -> SlotCondition { + SlotCondition::Not(SlotConditions::new(vec![condition])) + } + + fn is_moderated() -> SlotCondition { + SlotCondition::Equal( + SlotConditionValue::Kind, + SlotConditionValue::Constant("moderatedDocument".to_string()), + ) + } + + fn listed(list: SlotConditionList) -> SlotCondition { + SlotCondition::Contains(list, SlotConditionValue::PropertyName) + } + + /// "When the type is moderated, a removal takes the property and its author writes it." + fn removed_with_the_document() -> SlotCondition { + SlotCondition::IfThen(SlotConditions::new(vec![ + is_moderated(), + SlotCondition::AllOf(SlotConditions::new(vec![ + not(listed(SlotConditionList::DeletionKeptFields)), + not(listed(SlotConditionList::ModeratorChangeableFields)), + ])), + ])) + } + + /// `depth` lists of conditions inside one another. + fn nested_to(depth: usize) -> SlotCondition { + let mut condition = is_moderated(); + for _ in 0..depth { + condition = not(condition); + } + condition + } + + fn decode_every_way(bytes: &[u8]) -> [Result; 3] { + let config = bincode::config::standard(); + [ + bincode::decode_from_slice(bytes, config).map(|(condition, _)| condition), + bincode::borrow_decode_from_slice(bytes, config).map(|(condition, _)| condition), + bincode::decode_from_slice_untrusted(bytes, config).map(|(condition, _)| condition), + ] + } + + #[test] + fn should_judge_a_property_by_what_its_type_says_of_it() { + let condition = removed_with_the_document(); + let kept_fields = kept(&["tags", "$createdAt"]); + let changeable_fields = kept(&["label"]); + let none = BTreeSet::new(); + let holds = + |kind, property_name, kept: &BTreeSet, changeable: &BTreeSet| { + condition.holds(&SlotConditionSubject { + kind, + property_name, + deletion_kept_fields: kept, + moderator_changeable_fields: changeable, + }) + }; + + // A moderated type: only a property neither list names + let moderated = DocumentReferenceKind::Moderated; + assert!(holds( + moderated, + "content", + &kept_fields, + &changeable_fields + )); + assert!(!holds(moderated, "tags", &kept_fields, &changeable_fields)); + assert!(!holds(moderated, "label", &kept_fields, &changeable_fields)); + assert!(holds(moderated, "tags", &none, &none)); + // A whole entry, not a part of one + assert!(holds(moderated, "tag", &kept_fields, &changeable_fields)); + + // A permanent type: the condition asks nothing of it + let permanent = DocumentReferenceKind::Permanent; + assert!(holds(permanent, "tags", &kept_fields, &changeable_fields)); + assert!(holds(permanent, "label", &kept_fields, &changeable_fields)); + + assert_eq!(condition.node_count(), 7); + } + + #[test] + fn should_judge_every_form() { + let empty = BTreeSet::new(); + let subject = SlotConditionSubject { + kind: DocumentReferenceKind::Permanent, + property_name: "body", + deletion_kept_fields: &empty, + moderator_changeable_fields: &empty, + }; + let name_is = |name: &str| { + SlotCondition::Equal( + SlotConditionValue::PropertyName, + SlotConditionValue::Constant(name.to_string()), + ) + }; + let list = |conditions: Vec| SlotConditions::new(conditions); + + assert!(name_is("body").holds(&subject)); + assert!(!name_is("title").holds(&subject)); + assert!(SlotCondition::NotEqual( + SlotConditionValue::PropertyName, + SlotConditionValue::Constant("title".to_string()) + ) + .holds(&subject)); + assert!( + SlotCondition::In(SlotConditionValue::PropertyName, kept(&["body", "text"])) + .holds(&subject) + ); + assert!( + SlotCondition::NotIn(SlotConditionValue::PropertyName, kept(&["title", "text"])) + .holds(&subject) + ); + assert!( + SlotCondition::In(SlotConditionValue::Kind, kept(&["permanentDocument"])) + .holds(&subject) + ); + assert!( + SlotCondition::AnyOf(list(vec![name_is("title"), name_is("body")])).holds(&subject) + ); + assert!( + !SlotCondition::AllOf(list(vec![name_is("title"), name_is("body")])).holds(&subject) + ); + assert!(not(name_is("title")).holds(&subject)); + // The first holds, so the second decides; the first fails, so the third does + assert!(!SlotCondition::IfThenElse(list(vec![ + name_is("body"), + name_is("title"), + name_is("body") + ])) + .holds(&subject)); + assert!(SlotCondition::IfThenElse(list(vec![ + name_is("title"), + name_is("title"), + name_is("body") + ])) + .holds(&subject)); + + // A count of conditions no parse produces holds for nothing + assert!(!SlotCondition::Not(list(vec![])).holds(&subject)); + assert!( + !SlotCondition::Not(list(vec![name_is("title"), name_is("title")])).holds(&subject) + ); + assert!(!SlotCondition::IfThen(list(vec![name_is("title")])).holds(&subject)); + assert!( + !SlotCondition::IfThenElse(list(vec![name_is("body"), name_is("body")])) + .holds(&subject) + ); + } + + #[test] + fn should_round_trip_through_every_decoder() { + let condition = removed_with_the_document(); + let bytes = + bincode::encode_to_vec(&condition, bincode::config::standard()).expect("encodes"); + for decoded in decode_every_way(&bytes) { + assert_eq!(decoded.expect("decodes"), condition); + } + // The depth is back to zero: a second decode on this thread works + for decoded in decode_every_way(&bytes) { + assert_eq!(decoded.expect("decodes again"), condition); + } + } + + /// Nesting within the decode bound decodes; one level past it is refused, however deep + /// the bytes claim to go. A refused decode leaves the depth where it found it. + #[test] + fn should_refuse_to_decode_a_nesting_past_the_decode_bound() { + let deepest = nested_to(MAX_SLOT_CONDITION_DECODE_DEPTH); + let bytes = bincode::encode_to_vec(&deepest, bincode::config::standard()).expect("encodes"); + for decoded in decode_every_way(&bytes) { + assert_eq!(decoded.expect("the bound itself decodes"), deepest); + } + + let too_deep = nested_to(MAX_SLOT_CONDITION_DECODE_DEPTH + 1); + let bytes = + bincode::encode_to_vec(&too_deep, bincode::config::standard()).expect("encodes"); + for decoded in decode_every_way(&bytes) { + let error = decoded.expect_err("one level past the bound is refused"); + assert!( + error.to_string().contains(&format!( + "nesting depth {} exceeds the maximum of {MAX_SLOT_CONDITION_DECODE_DEPTH}", + MAX_SLOT_CONDITION_DECODE_DEPTH + 1 + )), + "unexpected error: {error}" + ); + } + + // Nesting claimed by hand far past the bound: variant 5 (`not`), one condition, ... + assert_eq!( + bincode::encode_to_vec(not(is_moderated()), bincode::config::standard()) + .expect("encodes")[..2], + [5, 1] + ); + let mut deep = Vec::new(); + for _ in 0..100_000 { + deep.extend_from_slice(&[5, 1]); + } + for decoded in decode_every_way(&deep) { + let error = decoded.expect_err("claimed nesting is refused at the bound"); + assert!( + error.to_string().contains("slot condition nesting depth"), + "unexpected error: {error}" + ); + } + + for decoded in decode_every_way( + &bincode::encode_to_vec(&deepest, bincode::config::standard()).expect("encodes"), + ) { + assert_eq!(decoded.expect("decodes after a refusal"), deepest); + } + } + + /// A condition of `n` conditions nests at most `n - 1` lists deep, so every condition + /// a contract can register decodes. + #[test] + fn should_keep_every_registration_limit_within_the_decode_bound() { + for platform_version in PLATFORM_VERSIONS.iter() { + assert!( + usize::from( + platform_version + .system_limits + .max_document_type_requirement_condition_nodes + ) <= MAX_SLOT_CONDITION_DECODE_DEPTH, + "protocol version {} admits a slot condition a decoder refuses", + platform_version.protocol_version + ); + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs index 2727633baa7..d89dafb25b3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs @@ -1174,7 +1174,9 @@ fn validate_reference_target_v1( document_type_name: &named_document_type, property_agreement: &no_agreement, where_conditions: &no_conditions, - kind: dynamic.kind.into(), + // Settled below, once the named type is known: a declaration + // listing both kinds is of the kind the type is + kind: dynamic.kind.checked_as(DocumentReferenceKind::Permanent), lookup: None, in_list: None, }) @@ -1278,7 +1280,17 @@ fn validate_reference_target_v1( // write that checks it: the type is not private, whose documents are encrypted // entries the chain never reads, and it has the properties the reference's // `documentTypeRequirements` ask for. A slot naming its property through the - // referring document reads the name there + // referring document reads the name there, and a slot's `when` is judged + // against what the type says of each property that could fill it + let kind = match reference_target.as_dynamic_document_reference() { + // The kind it declares or, declaring both, a moderated reference into a + // moderated type and a permanent one into any other, which the kind check + // below refuses for a type whose documents can be deleted + Some(dynamic) => dynamic + .kind + .checked_as(referenced_document_type.document_reference_kind()), + None => kind, + }; if let Some(dynamic) = reference_target.as_dynamic_document_reference() { if referenced_document_type.is_private() { return Ok(SimpleConsensusValidationResult::new_with_error( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs index 76e54f14d9d..7425c950acd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs @@ -3,14 +3,20 @@ //! 15): the document says which contract and which document type its value points into, so //! everything about the target is checked when the document is written. The target here is //! `post`, a type whose documents only the contract's moderators remove, each removal leaving -//! a record. +//! a record. A reference may also admit a type of either kind, permanent or moderated, and +//! ask of a property filling a slot what the type says of it (`when`). use super::*; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; +use dpp::data_contract::document_type::DocumentReferenceKind; const NOTE: &str = "note"; const BYTES_NOTE: &str = "bytesNote"; const NAMED_NOTE: &str = "namedNote"; const PLAIN: &str = "plain"; +const EITHER_NOTE: &str = "eitherNote"; +const KEPT_POST: &str = "keptPost"; +const ARTICLE: &str = "article"; const REFERENCED_ENTITY_NOT_FOUND: u32 = 40120; const REFERENCED_DOCUMENT_TYPE_NOT_FOUND: u32 = 40121; const REFERENCED_DOCUMENT_TYPE_NOT_MODERATED: u32 = 40143; @@ -34,12 +40,17 @@ fn identifier_property(position: u64) -> Value { /// A note describing itself with a moderated document of a type it names, which must meet /// `requirements`. `textProperty` is there for a slot to read its property name from. fn note_schema(requirements: Value) -> Value { + note_schema_of(platform_value!("moderatedDocument"), requirements) +} + +/// The same, describing itself with a document of the kind, or of the kinds, `kind` lists. +fn note_schema_of(kind: Value, requirements: Value) -> Value { let mut description_id = identifier_property(0); description_id .insert( "refersTo".to_string(), platform_value!({ - "type": "moderatedDocument", + "type": kind, "contractIdProperty": "descriptionContractId", "documentTypeProperty": "descriptionDocumentType", "documentTypeRequirements": requirements, @@ -399,3 +410,212 @@ async fn should_follow_a_dynamic_reference_into_another_contract() { let edit = replacement(&setup.stranger, &setup.contract, NOTE, &mut note).await; assert_success(&setup.process(&edit, &transaction)); } + +/// A string a removal takes with the document and its author writes, when the type is a +/// moderated one; any string of a permanent type. +fn text_removed_with_the_document() -> Value { + platform_value!({ + "properties": { + "body": { + "name": "$any", + "type": "string", + "when": { + "ifThen": [ + { "equal": ["$kind", { "const": "moderatedDocument" }] }, + { "allOf": [ + { "not": { "contains": ["$moderatorAbilities.deleteKeepsFields", "$self"] } }, + { "not": { "contains": ["$moderatorAbilities.changeFields", "$self"] } }, + ] }, + ], + }, + }, + }, + }) +} + +/// A reference listing both kinds admits a permanent type and a moderated one, and is +/// checked as the kind the named type is: a moderated type is held to the slot's `when`, +/// which asks that a removal take the text, a permanent one is not, and a type whose +/// documents their owner deletes is refused as it is for a permanent reference. Into a +/// moderated type it is kept as a moderated reference is, once the post is removed. +#[tokio::test] +async fn should_check_a_reference_of_either_kind_as_the_kind_the_named_type_is() { + let setup = Setup::new_at_with( + Some(moderators_without_lists()), + PlatformVersion::latest(), + |contract| { + add_document_type( + contract, + POST, + post_schema_with(platform_value!({ "canBeDeleted": false })), + ); + // A moderated type whose removal record keeps the text + add_document_type( + contract, + KEPT_POST, + post_schema_with(platform_value!({ + "canBeDeleted": false, + "moderatorAbilities": { "delete": true, "deleteKeepsFields": ["text"] }, + })), + ); + // A permanent type: nobody deletes or changes an article + add_document_type( + contract, + ARTICLE, + platform_value!({ + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "text": { "type": "string", "maxLength": 50, "position": 0 }, + }, + "required": ["text"], + "additionalProperties": false, + }), + ); + add_document_type(contract, PLAIN, plain_schema()); + add_document_type( + contract, + EITHER_NOTE, + note_schema_of( + platform_value!(["permanentDocument", "moderatedDocument"]), + text_removed_with_the_document(), + ), + ); + add_document_type(contract, NOTE, note_schema(text_or_bytes())); + }, + ) + .await; + let transaction = setup.platform.drive.grove.start_transaction(); + let contract_id = setup.contract.id(); + for (document_type_name, kind) in [ + (POST, DocumentReferenceKind::Moderated), + (KEPT_POST, DocumentReferenceKind::Moderated), + (ARTICLE, DocumentReferenceKind::Permanent), + (PLAIN, DocumentReferenceKind::Deletable), + ] { + assert_eq!( + setup + .contract + .document_type_for_name(document_type_name) + .expect("expected the type") + .document_reference_kind(), + kind, + "{document_type_name}" + ); + } + let (post, create_post) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create_post, &transaction)); + let (kept_post, create_kept_post) = setup.create_document_of_type(&setup.user, KEPT_POST).await; + assert_success(&setup.process(&create_kept_post, &transaction)); + let (article, create_article) = setup.create_document_of_type(&setup.user, ARTICLE).await; + assert_success(&setup.process(&create_article, &transaction)); + let (plain, create_plain) = setup.create_document_of_type(&setup.user, PLAIN).await; + assert_success(&setup.process(&create_plain, &transaction)); + + // A moderated type whose text a removal takes + let (mut note, names_a_post) = create_with( + &setup, + &setup.stranger, + EITHER_NOTE, + note_naming(contract_id, POST, post.id()), + ) + .await; + assert_success(&setup.process(&names_a_post, &transaction)); + // A moderated type whose removal record keeps its one string: nothing fills the slot + let (_, names_kept_text) = create_with( + &setup, + &setup.stranger, + EITHER_NOTE, + note_naming(contract_id, KEPT_POST, kept_post.id()), + ) + .await; + assert_paid_with_code( + &setup.process(&names_kept_text, &transaction), + REFERENCED_DOCUMENT_TYPE_REQUIREMENT_NOT_MET, + ); + // which a reference that asks only for text accepts + let (_, asks_for_text) = create_with( + &setup, + &setup.stranger, + NOTE, + note_naming(contract_id, KEPT_POST, kept_post.id()), + ) + .await; + assert_success(&setup.process(&asks_for_text, &transaction)); + + // A permanent type, which a moderated reference refuses and the condition asks + // nothing of + let (_, names_an_article) = create_with( + &setup, + &setup.stranger, + EITHER_NOTE, + note_naming(contract_id, ARTICLE, article.id()), + ) + .await; + assert_success(&setup.process(&names_an_article, &transaction)); + let (_, moderated_only) = create_with( + &setup, + &setup.stranger, + NOTE, + note_naming(contract_id, ARTICLE, article.id()), + ) + .await; + assert_paid_with_code( + &setup.process(&moderated_only, &transaction), + REFERENCED_DOCUMENT_TYPE_NOT_MODERATED, + ); + + // A type whose documents their owner deletes is neither + let (_, names_a_deletable) = create_with( + &setup, + &setup.stranger, + EITHER_NOTE, + note_naming(contract_id, PLAIN, plain.id()), + ) + .await; + assert_paid_with_code( + &setup.process(&names_a_deletable, &transaction), + REFERENCED_DOCUMENT_TYPE_DELETABLE, + ); + // and a document that is not there is missing, of either kind + for document_type_name in [POST, ARTICLE] { + let (_, no_document) = create_with( + &setup, + &setup.stranger, + EITHER_NOTE, + note_naming(contract_id, document_type_name, Identifier::random()), + ) + .await; + assert_paid_with_code( + &setup.process(&no_document, &transaction), + REFERENCED_ENTITY_NOT_FOUND, + ); + } + + // Into a moderated type the reference is a moderated one: the note outlives the + // post's removal, and no new note names the removed post + let remove = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + assert_success(&setup.process(&remove, &transaction)); + note.set("body", "an edit".into()); + let edit = replacement(&setup.stranger, &setup.contract, EITHER_NOTE, &mut note).await; + assert_success(&setup.process(&edit, &transaction)); + let (_, names_removed) = create_with( + &setup, + &setup.stranger, + EITHER_NOTE, + note_naming(contract_id, POST, post.id()), + ) + .await; + assert_paid_with_code( + &setup.process(&names_removed, &transaction), + REFERENCED_ENTITY_NOT_FOUND, + ); + // A replace pointing the note at the article checks it again, as a permanent reference + note.set("descriptionDocumentType", ARTICLE.into()); + note.set("descriptionId", id_value(article.id())); + let repoint = replacement(&setup.stranger, &setup.contract, EITHER_NOTE, &mut note).await; + assert_success(&setup.process(&repoint, &transaction)); +} 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 66926d91b06..31a4486a270 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -615,6 +615,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_reference_operands: 4, max_reference_expression_depth: 4, max_document_type_requirement_slots: 4, + max_document_type_requirement_condition_nodes: 16, max_property_constraints: 16, max_property_constraint_nodes: 32, max_property_constraint_aggregates: 4, 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 5e3ff3c6665..7fa835b0e3a 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -99,6 +99,15 @@ pub struct SystemLimits { /// (protocol version 15), the only version that parses such a reference, and never /// reached before. pub max_document_type_requirement_slots: u16, + /// Maximum number of conditions the `when` of one `documentTypeRequirements` slot may be + /// made of, `anyOf`, `allOf`, `not`, `ifThen` and `ifThenElse` counted with the + /// comparisons under them. Every write that checks the reference judges the condition + /// against each property of the referenced document type that could fill the slot. It + /// also bounds how deep conditions nest, which a decoder holds to + /// `MAX_SLOT_CONDITION_DECODE_DEPTH` (a test keeps this limit within it). Refused on + /// every parse. Read by `apply_property_reference` 1 (protocol version 15), the only + /// version that parses such a reference, and never reached before. + pub max_document_type_requirement_condition_nodes: u16, /// Maximum number of named rules one document type's `propertyConstraints` may /// declare. Every rule is evaluated on each create and replace of a document of the /// type, so this and `max_property_constraint_nodes` are what bound the arithmetic one 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 56cee421b75..7ae8a0de363 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -18,6 +18,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_reference_operands: 4, max_reference_expression_depth: 4, max_document_type_requirement_slots: 4, + max_document_type_requirement_condition_nodes: 16, max_property_constraints: 16, max_property_constraint_nodes: 32, max_property_constraint_aggregates: 4, 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 d85a2b327ef..f93068d4d17 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -24,6 +24,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_reference_operands: 4, max_reference_expression_depth: 4, max_document_type_requirement_slots: 4, + max_document_type_requirement_condition_nodes: 16, max_property_constraints: 16, max_property_constraint_nodes: 32, max_property_constraint_aggregates: 4, 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 2ff95a78862..3782b0ccafc 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -26,6 +26,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_reference_operands: 4, max_reference_expression_depth: 4, max_document_type_requirement_slots: 4, + max_document_type_requirement_condition_nodes: 16, max_property_constraints: 16, max_property_constraint_nodes: 32, max_property_constraint_aggregates: 4, 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 e89be1f9325..cf168439b33 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -147,6 +147,7 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_reference_operands: 4, // refersTo anyOf / allOf (new in v14): contract registration caps the operands one list holds max_reference_expression_depth: 4, // refersTo anyOf / allOf (new in v14): contract registration caps how deep they nest max_document_type_requirement_slots: 4, // backfilled: dynamic references parse from protocol version 15 + max_document_type_requirement_condition_nodes: 16, // backfilled: a slot `when` parses from protocol version 15 max_property_constraints: 16, // propertyConstraints (new in v14): contract registration caps the rules one document type declares max_property_constraint_nodes: 32, // propertyConstraints (new in v14): contract registration caps the nodes (comparison, operators, properties, values) of one rule max_property_constraint_aggregates: 4, // propertyConstraints countOf / sumOf (new in v14): contract registration caps the distinct totals one document type's rules read diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index ddd121666cf..a3abf029bf3 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -668,9 +668,20 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// slots, each a top-level property the named type must have (`name`: `"$any"`, a /// property name, or `{ "property": }`; /// `type`; `maxBytes`; `distinctFrom`, one or several other slots it is filled by a -/// different property than), and `require`, `anyOf` / `allOf` over slot names, every -/// slot when absent. The native schema check of note 7 admits the keys and meta-schema -/// v3 refuses them. It parses to `DocumentPropertyReferenceTarget::DynamicDocument`, +/// different property than; `when`, a condition on what the named type says of the +/// property), and `require`, `anyOf` / `allOf` over slot names, every slot when absent. +/// `type` may list both kinds, `["permanentDocument", "moderatedDocument"]`: the named +/// type is then either, and the reference is checked as one of the kind the type is (a +/// moderated reference into a moderated type, a permanent one into any other). A +/// slot's `when` is written like a `propertyConstraints` condition (`equal`, +/// `notEqual`, `in`, `notIn`, `contains`, `not`, `anyOf`, `allOf`, `ifThen`, +/// `ifThenElse`) over `$kind` (the kind of the named type), `$self` (the name of the +/// property tried for the slot), `$moderatorAbilities.deleteKeepsFields` and +/// `$moderatorAbilities.changeFields` (the type's lists) and `{ "const": }`, +/// of at most `max_document_type_requirement_condition_nodes` conditions (16, +/// `SYSTEM_LIMITS_V5`, backfilled); a property it does not hold for does not fill the +/// slot. The native schema check of note 7 admits the keys and meta-schema v3 refuses +/// them. It parses to `DocumentPropertyReferenceTarget::DynamicDocument`, /// appended. Such a declaration carries nothing else (no `contractId`, `documentType`, /// `findBy`, `where`, `inList`, `consume` or `constraints`), is no operand of a reference /// expression, sits on no typed array's elements and in no `ownerRefersTo` or diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index d3c3221b310..73c7f433971 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -18,14 +18,15 @@ use crate::utils::define_own_property; use dpp::data_contract::document_type::{ ANY_PROPERTY_NAME, DocumentPropertyReferenceTarget, DocumentReferenceLookup, DocumentTypeRef, DocumentTypeRequirements, IdentityKeyReferenceRequirements, KeyIdReference, LookupHashKey, - LookupKeyParam, LookupKeySource, PropertyReference, SlotPropertyName, SlotRequirement, + LookupKeyParam, LookupKeySource, PropertyReference, SLOT_CONDITION_KIND, SLOT_CONDITION_SELF, + SlotCondition, SlotConditionValue, SlotConditions, SlotPropertyName, SlotRequirement, SlotRequirementTerm, SlotValue, WhereCondition, WhereContainsBound, WhereLiteral, WhereNotEqualBound, WhereOrderingBound, }; use dpp::document::property_names::{ID, OWNER_ID}; use dpp::prelude::Identifier; use js_sys::{Array, Object, Reflect}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; @@ -279,9 +280,14 @@ export type DocumentPropertyReferenceTarget = * since the contract and the type are read from the referring * document when it is written, where consensus checks that both * exist, that the type is of this kind and that it meets - * `documentTypeRequirements` (code 40153 when it does not). + * `documentTypeRequirements` (code 40153 when it does not). A list + * of both kinds admits a type of either: the reference is then + * checked as one of the kind the named type is. */ - type: 'permanentDocument' | 'moderatedDocument'; + type: + | 'permanentDocument' + | 'moderatedDocument' + | ['permanentDocument', 'moderatedDocument']; /** * The top-level identifier property of the referring document * holding the id of the contract the referenced document type lives @@ -332,8 +338,56 @@ export type DocumentTypeRequirementSlot = { maxBytes?: number; /** The other slots this one is filled by a different property than. */ distinctFrom?: Array; + /** + * What the referenced type must say of the property: one the condition + * does not hold for does not fill the slot. + */ + when?: DocumentTypeSlotCondition; }; +/** + * A string a slot's `when` compares: `'$kind'`, the kind of the referenced + * document type (`'permanentDocument'` or `'moderatedDocument'`), `'$self'`, + * the name of the property tried for the slot, or a constant. + */ +export type DocumentTypeSlotConditionString = + | '$kind' + | '$self' + | { const: string }; + +/** + * A condition of a slot's `when`, in the forms of a `propertyConstraints` + * condition, read against the referenced document type instead of a + * document. `contains` looks into the type's + * `moderatorAbilities.deleteKeepsFields` or `moderatorAbilities.changeFields`, + * empty where the type declares none. + */ +export type DocumentTypeSlotCondition = + | { equal: [DocumentTypeSlotConditionString, DocumentTypeSlotConditionString] } + | { notEqual: [DocumentTypeSlotConditionString, DocumentTypeSlotConditionString] } + | { in: ['$kind' | '$self', Array] } + | { notIn: ['$kind' | '$self', Array] } + | { + contains: [ + ( + | '$moderatorAbilities.deleteKeepsFields' + | '$moderatorAbilities.changeFields' + ), + '$self' | { const: string }, + ]; + } + | { not: DocumentTypeSlotCondition } + | { anyOf: Array } + | { allOf: Array } + | { ifThen: [DocumentTypeSlotCondition, DocumentTypeSlotCondition] } + | { + ifThenElse: [ + DocumentTypeSlotCondition, + DocumentTypeSlotCondition, + DocumentTypeSlotCondition, + ]; + }; + export type DocumentTypeSlotRequirement = | { anyOf: Array }> } | { allOf: Array }> }; @@ -896,15 +950,32 @@ fn set_reference_target_fields( DocumentPropertyReferenceTarget::DeletableDocument { .. } | DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. } => "deletableDocument", DocumentPropertyReferenceTarget::ModeratedDocument { .. } => "moderatedDocument", - // Permanent or moderated, as declared - DocumentPropertyReferenceTarget::DynamicDocument(reference) => reference.wire_name(), + // Permanent or moderated, as declared; both are listed below when it admits either + DocumentPropertyReferenceTarget::DynamicDocument(reference) => reference + .wire_names() + .first() + .copied() + .unwrap_or("permanentDocument"), DocumentPropertyReferenceTarget::AnyOf(_) | DocumentPropertyReferenceTarget::AllOf(_) => { return Err(WasmDppError::generic(format!( "the reference expression declared at '{path}' has no single target kind" ))); } }; - set_field(object, "type", &JsValue::from_str(kind), path)?; + let declared_type: JsValue = match target { + DocumentPropertyReferenceTarget::DynamicDocument(reference) + if reference.wire_names().len() > 1 => + { + reference + .wire_names() + .iter() + .map(|name| JsValue::from_str(name)) + .collect::() + .into() + } + _ => JsValue::from_str(kind), + }; + set_field(object, "type", &declared_type, path)?; match target { DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Token => {} @@ -1131,6 +1202,9 @@ fn document_type_requirements_to_js( let others: Vec = slot.distinct_from.iter().cloned().collect(); set_field(&fields, "distinctFrom", &names(&others), path)?; } + if let Some(when) = &slot.when { + set_field(&fields, "when", &slot_condition_to_js(when, path)?, path)?; + } set_schema_field(&properties, slot_name, fields.into(), path)?; } let object = Object::new(); @@ -1181,6 +1255,61 @@ fn document_type_requirements_to_js( Ok(object) } +/// A condition of a slot's `when` as the schema declares it: an object of one key. +fn slot_condition_to_js(condition: &SlotCondition, path: &str) -> WasmDppResult { + let string = |value: &SlotConditionValue| -> WasmDppResult { + Ok(match value { + SlotConditionValue::Kind => JsValue::from_str(SLOT_CONDITION_KIND), + SlotConditionValue::PropertyName => JsValue::from_str(SLOT_CONDITION_SELF), + SlotConditionValue::Constant(constant) => { + let object = Object::new(); + set_field(&object, "const", &JsValue::from_str(constant), path)?; + object.into() + } + }) + }; + let pair = |left: JsValue, right: JsValue| -> JsValue { Array::of2(&left, &right).into() }; + let among = |values: &BTreeSet| -> JsValue { + values + .iter() + .map(|value| JsValue::from_str(value)) + .collect::() + .into() + }; + let listed = |conditions: &SlotConditions| -> WasmDppResult { + let operands = Array::new(); + for condition in conditions.conditions() { + operands.push(&slot_condition_to_js(condition, path)?); + } + Ok(operands.into()) + }; + let (key, operand) = match condition { + SlotCondition::Equal(left, right) => ("equal", pair(string(left)?, string(right)?)), + SlotCondition::NotEqual(left, right) => ("notEqual", pair(string(left)?, string(right)?)), + SlotCondition::In(value, values) => ("in", pair(string(value)?, among(values))), + SlotCondition::NotIn(value, values) => ("notIn", pair(string(value)?, among(values))), + SlotCondition::Contains(list, value) => ( + "contains", + pair(JsValue::from_str(list.wire_name()), string(value)?), + ), + // `not` holds its one condition bare, not in a list + SlotCondition::Not(conditions) => ( + "not", + match conditions.conditions() { + [negated] => slot_condition_to_js(negated, path)?, + _ => listed(conditions)?, + }, + ), + SlotCondition::AnyOf(conditions) => ("anyOf", listed(conditions)?), + SlotCondition::AllOf(conditions) => ("allOf", listed(conditions)?), + SlotCondition::IfThen(conditions) => ("ifThen", listed(conditions)?), + SlotCondition::IfThenElse(conditions) => ("ifThenElse", listed(conditions)?), + }; + let object = Object::new(); + set_field(&object, key, &operand, path)?; + Ok(object.into()) +} + /// Collect every reference declaration of one document type: its /// `ownerRefersTo` or `creatorRefersTo` first, listed at `$ownerId` or /// `$creatorId`, the path consensus names it by, then in schema property From d2e04ba8101acbec21667b14f6ec7afc098338c7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 06:19:13 +0700 Subject: [PATCH 07/20] feat(platform)!: multiple choice proposals take their description from a moderated text (PV15) A multipleChoiceProposal no longer holds a description. It points at a document saying what is being decided (descriptionId), of the contract and the document type it names (descriptionContractId, descriptionDocumentType): - the document type is a moderated one holding a string or a byte array that a moderator's removal takes away and that its author writes; - the contract declares moderators the masternodes elect. So a description can be taken down while the proposal, which is undeletable, and its vote stay. The three properties may change for two hours after the proposal is created, like its title and url. Co-Authored-By: Claude Fable 5.2 --- .../contract-keywords/refers-to-dynamic.md | 2 + docs/protocol/decision-proposals.md | 76 +++- .../decision-proposals-contract/README.md | 22 +- ...decision-proposals-contract-documents.json | 137 +++++- .../decision-proposals-contract/src/lib.rs | 77 +++- .../decision-proposals-contract/src/v1/mod.rs | 17 +- .../unit/decisionProposalsContract.spec.js | 98 ++++- .../token/shielded/nullifier_batch_guard.rs | 20 +- .../multiple_choice_proposal_tests.rs | 415 ++++++++++++++++-- .../rs-platform-version/src/version/v15.rs | 7 +- 10 files changed, 788 insertions(+), 83 deletions(-) diff --git a/book/src/contract-keywords/refers-to-dynamic.md b/book/src/contract-keywords/refers-to-dynamic.md index 792d79e92ff..e076e60e8eb 100644 --- a/book/src/contract-keywords/refers-to-dynamic.md +++ b/book/src/contract-keywords/refers-to-dynamic.md @@ -188,6 +188,8 @@ The requirements say what the target's type looks like. They do not say who runs or compare it with the contracts the application trusts in a [`propertyConstraints`](property-constraints.md) rule (`{ "in": ["descriptionContractId", ["", ""]] }`). +The decision proposals contract does the first for the description of a multiple choice proposal: a `moderatedDocument` whose text a removal takes away, in a contract whose moderators the masternodes elect (see `docs/protocol/decision-proposals.md`). `"moderation": "elected"` says the contract declares such a team, not that one is seated: until then the contract's interim moderators, if it names any, are the ones who can remove the document. + ## Errors | Error | Code | When | diff --git a/docs/protocol/decision-proposals.md b/docs/protocol/decision-proposals.md index 02f0ada2110..0feb8212f1a 100644 --- a/docs/protocol/decision-proposals.md +++ b/docs/protocol/decision-proposals.md @@ -134,8 +134,10 @@ is a contender of its own, so an identity may enter several. ### `multipleChoiceProposal` Undeletable (`canBeDeleted: false`), so a `choice` can refer to it as a -`permanentDocument`. The owner may edit `title`, `description` and `url` until -two hours after the proposal was created: each is an `immutable` entry whose +`permanentDocument`. The owner may edit `title` and `url`, and point the +proposal at another description (`descriptionId`, `descriptionContractId`, +`descriptionDocumentType`), until two hours after the proposal was created: +each is an `immutable` entry whose condition, `{ "greaterThan": [{ "subtract": ["$updatedAt", "$createdAt"] }, 7200000] }`, freezes it once that time has passed, and an edit after it is refused @@ -150,13 +152,81 @@ rejected. | Property | Type | Meaning | | --- | --- | --- | | `title` | string, 1 to 128 characters and at most 512 bytes, required | The question, in one line | -| `description` | string, 1 to 4096 characters and at most 4096 bytes, required | What is being decided | +| `descriptionId` | identifier, required | The document saying what is being decided: a [reference whose target the proposal names](../../book/src/contract-keywords/refers-to-dynamic.md) | +| `descriptionContractId` | identifier, required | The contract that document lives in: `refersTo` a `contract` with `contractRequirements: { "moderation": "elected" }` | +| `descriptionDocumentType` | string, 1 to 64 characters, required | That document's document type | | `url` | string, `format: uri`, 1 to 512 characters and at most 512 bytes | Where the proposal is discussed | | `open` | boolean, required | Whether anyone may enter choices. Only the proposal's owner may when it is `false` | Indexes: `byOwner` (`$ownerId`, `$createdAt`) and `byCreatedAt` (`$createdAt`), neither unique. +#### The description + +A proposal costs its storage and nothing else, so text held in it would be +cheap to fill with anything and could never be taken down: the proposal is +undeletable. It therefore holds no description. It points at a document that +does, hosted where moderators can remove it: + +```json +"descriptionId": { + "type": "identifier", + "refersTo": { + "type": "moderatedDocument", + "contractIdProperty": "descriptionContractId", + "documentTypeProperty": "descriptionDocumentType", + "documentTypeRequirements": { + "properties": { + "body": { "name": "$any", "type": "string", "when": }, + "bodyBytes": { "name": "$any", "type": "array", "byteArray": true, "when": } + }, + "require": { "anyOf": ["body", "bodyBytes"] } + } + } +}, +"descriptionContractId": { + "type": "identifier", + "refersTo": { "type": "contract", "contractRequirements": { "moderation": "elected" } } +} +``` + +where `` is + +```json +{ "allOf": [ + { "not": { "contains": ["$moderatorAbilities.deleteKeepsFields", "$self"] } }, + { "not": { "contains": ["$moderatorAbilities.changeFields", "$self"] } } +] } +``` + +Checked when the proposal is created, and again when an edit points it +elsewhere: + +| What the proposal names | Refused with | +| --- | --- | +| A contract that does not exist, or has no such document type | `ReferencedDocumentTypeNotFoundError` (40121) | +| A contract that does not declare elected moderation | `ReferencedContractRequirementNotMetError` (40135) | +| A document type whose documents their owner can delete, or that never leave state | `ReferencedDocumentTypeNotModeratedError` (40143) | +| A moderated type with no string or byte array a removal takes away: every one is kept in the removal record (`deleteKeepsFields`) or is the moderators' to write (`changeFields`) | `ReferencedDocumentTypeRequirementNotMetError` (40153) | +| A document that is not there | `ReferencedEntityNotFoundError` (40120) | + +What this does and does not give: + +- A moderator's removal of the description leaves the proposal in place, + pointing at the removal record: the question and its vote stay, the text is + gone. +- The contract declares a team the masternodes elect. It does not have to have + seated one yet: until it does, whoever the contract names for the interim + moderates, which may be its owner or nobody. +- The description's own edit rules are its document type's. A type that lets + its author replace a document lets the author change the description after + the two hours, and after choices were entered. The proposal's history keeps + which document it pointed at, not what that document said. +- Which property of the document is the description is the first, in the + type's `position` order, that fills the `body` slot, then `bodyBytes` + (see [the binding rule](../../book/src/contract-keywords/refers-to-dynamic.md#documenttyperequirements)). +- `title` and `url` are still text of the proposal. + ### `choice` Immutable and undeletable. Additional properties are rejected. diff --git a/packages/decision-proposals-contract/README.md b/packages/decision-proposals-contract/README.md index aba7509e048..2e7dc663667 100644 --- a/packages/decision-proposals-contract/README.md +++ b/packages/decision-proposals-contract/README.md @@ -54,16 +54,26 @@ limits, or the create is refused: ## `multipleChoiceProposal` A question whose answers are the choices entered for it. Any identity may -create one, and nobody may delete it. Its owner may edit the `title`, -`description` and `url` for two hours after creating it; after that they -freeze. Every version is kept (`documentsKeepHistory`), so a reader can see the -text as it was when a choice was entered. Whether it is `open` never changes. -Creating it opens no vote and states no fund: the first choice does. +create one, and nobody may delete it. Its owner may edit the `title` and `url`, +and point it at another description, for two hours after creating it; after +that they freeze. Every version is kept (`documentsKeepHistory`), so a reader +can see the proposal as it was when a choice was entered. Whether it is `open` +never changes. Creating it opens no vote and states no fund: the first choice +does. + +The proposal holds no description of its own. It points at a document saying +what is being decided, in another contract, so that the description can be +taken down: the document type is a moderated one (its documents leave state +only through a moderator's recorded removal) holding a string or a byte array +that the removal takes away and that the document's author writes, and the +contract is one whose moderators the masternodes elect. | Property | Type | Meaning | | --- | --- | --- | | `title` | string, 1 to 128 characters and at most 512 bytes, required | The question, in one line | -| `description` | string, 1 to 4096 characters and at most 4096 bytes, required | What is being decided | +| `descriptionId` | identifier, required | The document saying what is being decided | +| `descriptionContractId` | identifier, required | The contract that document lives in, one declaring elected moderation | +| `descriptionDocumentType` | string, 1 to 64 characters, required | That document's document type | | `url` | string, a URI of 1 to 512 characters and at most 512 bytes | Where the proposal is discussed | | `open` | boolean, required | Whether anyone may enter choices. Only the proposal's owner may when it is false | diff --git a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json index 5748d32630e..8f9c42ba182 100644 --- a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json +++ b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json @@ -222,13 +222,100 @@ "description": "The question, in one line", "position": 0 }, - "description": { + "descriptionId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "moderatedDocument", + "contractIdProperty": "descriptionContractId", + "documentTypeProperty": "descriptionDocumentType", + "documentTypeRequirements": { + "properties": { + "body": { + "name": "$any", + "type": "string", + "when": { + "allOf": [ + { + "not": { + "contains": [ + "$moderatorAbilities.deleteKeepsFields", + "$self" + ] + } + }, + { + "not": { + "contains": [ + "$moderatorAbilities.changeFields", + "$self" + ] + } + } + ] + } + }, + "bodyBytes": { + "name": "$any", + "type": "array", + "byteArray": true, + "when": { + "allOf": [ + { + "not": { + "contains": [ + "$moderatorAbilities.deleteKeepsFields", + "$self" + ] + } + }, + { + "not": { + "contains": [ + "$moderatorAbilities.changeFields", + "$self" + ] + } + } + ] + } + } + }, + "require": { + "anyOf": [ + "body", + "bodyBytes" + ] + } + } + }, + "description": "The document saying what is being decided: a document of a moderated type, holding text its author wrote that a moderator's removal takes away with it", + "position": 1 + }, + "descriptionContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "contract", + "contractRequirements": { + "moderation": "elected" + } + }, + "description": "The contract the description document lives in, one whose moderators the masternodes elect", + "position": 2 + }, + "descriptionDocumentType": { "type": "string", "minLength": 1, - "maxLength": 4096, - "maxBytes": 4096, - "description": "What is being decided", - "position": 1 + "maxLength": 64, + "description": "The document type of the description document", + "position": 3 }, "url": { "type": "string", @@ -237,19 +324,21 @@ "maxLength": 512, "maxBytes": 512, "description": "Where the proposal is discussed", - "position": 2 + "position": 4 }, "open": { "type": "boolean", "description": "Whether anyone may enter choices; only the proposal's owner may when it is false", - "position": 3 + "position": 5 } }, "required": [ "$createdAt", "$updatedAt", "title", - "description", + "descriptionId", + "descriptionContractId", + "descriptionDocumentType", "open" ], "immutable": [ @@ -269,7 +358,35 @@ } }, { - "property": "description", + "property": "descriptionId", + "when": { + "greaterThan": [ + { + "subtract": [ + "$updatedAt", + "$createdAt" + ] + }, + 7200000 + ] + } + }, + { + "property": "descriptionContractId", + "when": { + "greaterThan": [ + { + "subtract": [ + "$updatedAt", + "$createdAt" + ] + }, + 7200000 + ] + } + }, + { + "property": "descriptionDocumentType", "when": { "greaterThan": [ { @@ -298,7 +415,7 @@ } ], "additionalProperties": false, - "description": "A question put to the masternodes whose answers are the choices entered for it. Anyone may enter choices when it is open, and only its owner when it is not. Its title, description and url may be edited for two hours after it is created; whether it is open never changes, and it is never deleted" + "description": "A question put to the masternodes whose answers are the choices entered for it. What is being decided is said by a document the proposal points at, of a moderated type of a contract whose moderators the masternodes elect, so that they can take it down. Anyone may enter choices when it is open, and only its owner when it is not. Its title, url and the description it points at may be changed for two hours after it is created; whether it is open never changes, and it is never deleted" }, "choice": { "type": "object", diff --git a/packages/decision-proposals-contract/src/lib.rs b/packages/decision-proposals-contract/src/lib.rs index 37539f1fefb..bc66538af44 100644 --- a/packages/decision-proposals-contract/src/lib.rs +++ b/packages/decision-proposals-contract/src/lib.rs @@ -160,8 +160,9 @@ mod tests { let schema = load_documents_schemas(PlatformVersion::latest()).expect("schema loads"); - // The question's text may be edited for two hours, every version kept, and then - // freezes. It is never deleted, which is what lets a choice refer to it. + // The question's title and url may be edited, and the description it points at + // changed, for two hours, every version kept, and then they freeze. It is never + // deleted, which is what lets a choice refer to it. let question = &schema[multiple_choice_proposal::NAME]; assert_eq!(question["documentsMutable"], Value::Bool(true)); assert_eq!(question["documentsKeepHistory"], Value::Bool(true)); @@ -189,7 +190,15 @@ mod tests { &two_hours_after_creation ), ( - multiple_choice_proposal::properties::DESCRIPTION, + multiple_choice_proposal::properties::DESCRIPTION_ID, + &two_hours_after_creation + ), + ( + multiple_choice_proposal::properties::DESCRIPTION_CONTRACT_ID, + &two_hours_after_creation + ), + ( + multiple_choice_proposal::properties::DESCRIPTION_DOCUMENT_TYPE, &two_hours_after_creation ), ( @@ -208,11 +217,71 @@ mod tests { declared, BTreeSet::from([ multiple_choice_proposal::properties::TITLE, - multiple_choice_proposal::properties::DESCRIPTION, + multiple_choice_proposal::properties::DESCRIPTION_ID, + multiple_choice_proposal::properties::DESCRIPTION_CONTRACT_ID, + multiple_choice_proposal::properties::DESCRIPTION_DOCUMENT_TYPE, multiple_choice_proposal::properties::URL, multiple_choice_proposal::properties::OPEN, ]) ); + // The question holds no description of its own: it points at a document of a + // moderated type, of the contract and the type it names, holding a string or bytes + // that a moderator's removal takes away and that its author writes + let taken_with_the_document = serde_json::json!({ + "allOf": [ + { "not": { "contains": ["$moderatorAbilities.deleteKeepsFields", "$self"] } }, + { "not": { "contains": ["$moderatorAbilities.changeFields", "$self"] } } + ] + }); + assert_eq!( + question["properties"][multiple_choice_proposal::properties::DESCRIPTION_ID] + ["refersTo"], + serde_json::json!({ + "type": "moderatedDocument", + "contractIdProperty": + multiple_choice_proposal::properties::DESCRIPTION_CONTRACT_ID, + "documentTypeProperty": + multiple_choice_proposal::properties::DESCRIPTION_DOCUMENT_TYPE, + "documentTypeRequirements": { + "properties": { + "body": { + "name": "$any", + "type": "string", + "when": taken_with_the_document + }, + "bodyBytes": { + "name": "$any", + "type": "array", + "byteArray": true, + "when": taken_with_the_document + } + }, + "require": { "anyOf": ["body", "bodyBytes"] } + } + }) + ); + // and that contract is one whose moderators the masternodes elect + assert_eq!( + question["properties"][multiple_choice_proposal::properties::DESCRIPTION_CONTRACT_ID] + ["refersTo"], + serde_json::json!({ + "type": "contract", + "contractRequirements": { "moderation": "elected" } + }) + ); + let required: Vec<&str> = question["required"] + .as_array() + .expect("the question lists required properties") + .iter() + .filter_map(Value::as_str) + .collect(); + for property in [ + multiple_choice_proposal::properties::DESCRIPTION_ID, + multiple_choice_proposal::properties::DESCRIPTION_CONTRACT_ID, + multiple_choice_proposal::properties::DESCRIPTION_DOCUMENT_TYPE, + ] { + assert!(required.contains(&property), "{property} is required"); + } // Every question says whether it is open, for good assert_eq!( question["properties"][multiple_choice_proposal::properties::OPEN]["type"], diff --git a/packages/decision-proposals-contract/src/v1/mod.rs b/packages/decision-proposals-contract/src/v1/mod.rs index d1de1b2d5e8..69b7ba1216b 100644 --- a/packages/decision-proposals-contract/src/v1/mod.rs +++ b/packages/decision-proposals-contract/src/v1/mod.rs @@ -34,15 +34,26 @@ pub mod document_types { /// A question put to the masternodes whose answers are the choices entered for it. Any /// identity may enter [`choice`]s when it is open, and only its owner when it is not; whoever - /// may enter them may enter several. Its owner may edit the title, description and url for - /// two hours after creating it, and every version is kept; whether it is open never changes. + /// may enter them may enter several. What is being decided is not text of the proposal: + /// the proposal points at a document saying it, of a moderated document type of a contract + /// whose moderators the masternodes elect, so that a description can be taken down. Its + /// owner may edit the title and url, and point it at another description, for two hours + /// after creating it, and every version is kept; whether it is open never changes. /// Undeletable. pub mod multiple_choice_proposal { pub const NAME: &str = "multipleChoiceProposal"; pub mod properties { pub const TITLE: &str = "title"; - pub const DESCRIPTION: &str = "description"; + /// The id of the document saying what is being decided. Its document type is a + /// moderated one holding a string or a byte array that a moderator's removal + /// takes away and that its author writes. + pub const DESCRIPTION_ID: &str = "descriptionId"; + /// The id of the contract the description document lives in, one declaring + /// elected moderation. + pub const DESCRIPTION_CONTRACT_ID: &str = "descriptionContractId"; + /// The name of the description document's document type. + pub const DESCRIPTION_DOCUMENT_TYPE: &str = "descriptionDocumentType"; pub const URL: &str = "url"; /// Whether anyone may enter choices. Only the proposal's owner may when it is /// false. diff --git a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js index 33ca3ad5de9..d1f6f184766 100644 --- a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js +++ b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js @@ -235,25 +235,29 @@ describe('Decision Proposals Contract', () => { }); describe('multipleChoiceProposal', () => { - const rawQuestion = () => ({ + // The description is a document of another contract: its id, that contract's id and + // the name of its document type + const rawQuestion = async () => ({ title: 'Which block time should Platform target?', - description: 'Enter the answer you would have the network give.', + descriptionId: await generateRandomIdentifier(), + descriptionContractId: await generateRandomIdentifier(), + descriptionDocumentType: 'post', url: 'https://www.dash.org/forum/threads/block-time.1/', open: true, }); - it('should be valid', () => { - expect(validateType('multipleChoiceProposal', rawQuestion()).isValid()).to.be.true(); + it('should be valid', async () => { + expect(validateType('multipleChoiceProposal', await rawQuestion()).isValid()).to.be.true(); }); - it('should not need a url', () => { - const raw = rawQuestion(); + it('should not need a url', async () => { + const raw = await rawQuestion(); delete raw.url; expect(validateType('multipleChoiceProposal', raw).isValid()).to.be.true(); }); - it('should keep its text editable for two hours, every version, and never be deleted', () => { + it('should keep its text and its description editable for two hours, every version, and never be deleted', () => { const { multipleChoiceProposal } = decisionProposalsContractDocumentsSchema; expect(multipleChoiceProposal.documentsMutable).to.be.true(); @@ -261,28 +265,76 @@ describe('Decision Proposals Contract', () => { expect(multipleChoiceProposal.canBeDeleted).to.be.false(); expect(multipleChoiceProposal.immutable).to.deep.equal([ 'open', - ...['title', 'description', 'url'].map((property) => ({ + ...[ + 'title', + 'descriptionId', + 'descriptionContractId', + 'descriptionDocumentType', + 'url', + ].map((property) => ({ property, when: { greaterThan: [{ subtract: ['$updatedAt', '$createdAt'] }, 7200000] }, })), ]); }); - it('should say whether it is open', () => { + it('should take its description from a moderated document of an elected contract', () => { + const { properties } = decisionProposalsContractDocumentsSchema.multipleChoiceProposal; + + expect(properties.description).to.be.undefined(); + + const takenWithTheDocument = { + allOf: [ + { not: { contains: ['$moderatorAbilities.deleteKeepsFields', '$self'] } }, + { not: { contains: ['$moderatorAbilities.changeFields', '$self'] } }, + ], + }; + + expect(properties.descriptionId.refersTo).to.deep.equal({ + type: 'moderatedDocument', + contractIdProperty: 'descriptionContractId', + documentTypeProperty: 'descriptionDocumentType', + documentTypeRequirements: { + properties: { + body: { name: '$any', type: 'string', when: takenWithTheDocument }, + bodyBytes: { + name: '$any', type: 'array', byteArray: true, when: takenWithTheDocument, + }, + }, + require: { anyOf: ['body', 'bodyBytes'] }, + }, + }); + expect(properties.descriptionContractId.refersTo).to.deep.equal({ + type: 'contract', + contractRequirements: { moderation: 'elected' }, + }); + }); + + it('should refuse a description document type of 65 characters', async () => { + const raw = await rawQuestion(); + raw.descriptionDocumentType = 'a'.repeat(65); + + const error = expectJsonSchemaError(validateType('multipleChoiceProposal', raw)); + + expect(error.keyword).to.equal('maxLength'); + }); + + it('should say whether it is open', async () => { const { multipleChoiceProposal } = decisionProposalsContractDocumentsSchema; expect(multipleChoiceProposal.properties.open.type).to.equal('boolean'); + const question = await rawQuestion(); + [true, false].forEach((open) => { - const raw = rawQuestion(); - raw.open = open; + const raw = { ...question, open }; expect(validateType('multipleChoiceProposal', raw).isValid()).to.be.true(); }); }); - it('should refuse an open that is not a boolean', () => { - const raw = rawQuestion(); + it('should refuse an open that is not a boolean', async () => { + const raw = await rawQuestion(); raw.open = 'yes'; const error = expectJsonSchemaError(validateType('multipleChoiceProposal', raw)); @@ -297,9 +349,15 @@ describe('Decision Proposals Contract', () => { indices.forEach(({ unique }) => expect(unique).to.be.undefined()); }); - ['title', 'description', 'open'].forEach((property) => { - it(`should require ${property}`, () => { - const raw = rawQuestion(); + [ + 'title', + 'descriptionId', + 'descriptionContractId', + 'descriptionDocumentType', + 'open', + ].forEach((property) => { + it(`should require ${property}`, async () => { + const raw = await rawQuestion(); delete raw[property]; const error = expectJsonSchemaError(validateType('multipleChoiceProposal', raw)); @@ -309,8 +367,8 @@ describe('Decision Proposals Contract', () => { }); }); - it('should not have additional properties', () => { - const raw = rawQuestion(); + it('should not have additional properties', async () => { + const raw = await rawQuestion(); raw.choices = ['one', 'two']; const error = expectJsonSchemaError(validateType('multipleChoiceProposal', raw)); @@ -319,8 +377,8 @@ describe('Decision Proposals Contract', () => { expect(error.params.additionalProperties).to.deep.equal(['choices']); }); - it('should refuse a title of 129 characters', () => { - const raw = rawQuestion(); + it('should refuse a title of 129 characters', async () => { + const raw = await rawQuestion(); raw.title = 'a'.repeat(129); const error = expectJsonSchemaError(validateType('multipleChoiceProposal', raw)); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index 59be33d6614..ffdf50d8451 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -731,7 +731,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "773ae3ee2393ca47c64d31930dba6ce321958da979ea17802c78480a55bac06b" + "6967ac80ab98ca1e7f0aded1e29c9bc76cdcb69211683b61dde13be2e76fc5ad" ); assert_eq!( pool.platform @@ -2182,39 +2182,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "6a823c2db94612652926e6b9c1ae7feec9c2547dfd4cfc4e474318da1c16d59c", - "513572d885b2901a779c2c5cc11666f6230ddf2dd20bf6a35da46f55ba3f80a2", + "ee3d638c69d621aff8e044a279b4646b362b9605570e4333bd21d39f3a3ede3b", ), "valid-payment" => ( "9388d5cf63c4a571b9fb009679e2e34d0b681acc88f0f5195e7287c7efbd92f7", - "8eab3e7c4eb1668c5da778619e5b66ef1f053f8ff328cb4a1575af46d8d4d746", + "0006d1540ed58fef8680f9e3b51aa22ae4f97f849ab937787415676546d7278c", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "646f07c40aa13297dbcb0e19ffb1ceaf1646c20e11943a920ab45ffa6596b268", + "05f35e2891516f09cfbbcc842b127eee5c9846680acf38ade47fde3ae4aca854", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "a15dc66f817730014aa506d41f6d43442357e93326d2c48dbdf3bd0ba59e0ed6", + "9db829cff3be1b26e9fe479f36700884f0a7f947f20ce8dd7aed24e43bbc00da", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "d1e9cca1967acb3b0d54ea7338e2deafc2e1575b480595703299fd237c91087b", + "6ab3af6e1382c6e28b4ab2aa9c0f183612894e832efa0448a3121d99e50ad9bf", ), "open-mint-group" => ( "5c68381297c9bf68c5e85ebe6bb9e0f1289e2c07718eecfb5e6773d6009455bb", - "c5410f89e9cfeb6827b0a6e914f95572569263d99e49d39a3dccf0dc78a78ddd", + "120e852c50055ad35a68990845714c18afc2acad4cd447575ed20a241d8ff9f5", ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "62da8948faea086ba8f02f5061375e49bc0ba322df5b66434adb899089fd3c0a", + "5f29d07686d217e6774d5235308a5f05f335930b8c4228391abef80f06a02f09", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", - "68aea0f109f923d99dcdb31a18aff37084177766bf02224f8f40dce3fe522864", + "f52e14e4c6a408c9cf851e0abd3b28c2e3ae83fdc85b3b281a2754202123eb20", ), "closing-burn-group" => ( "93e1732286f168c4a91d2765931e181c5721bfca1bfd5f93b8baacca03e00b2b", - "0420d4f51db6dbc072889bfe8de82b318ac327576e753701188e88b01fa3aa98", + "27de67f8ab14c956d6484db8eb42a8e8a0800ad694420ec9a96e3538803295aa", ), _ => unreachable!("enumerated characterization case"), }; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs index a8af2e39aba..a09b13673a1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs @@ -5,6 +5,10 @@ //! as it wants. The choice with the most votes is kept, and a lock means none of them. Anyone //! enters choices for an `open` proposal, and only its owner for one that is not. The owner //! may edit the question's text for two hours after creating it. +//! +//! A proposal holds no description of its own. It points at a document saying what is being +//! decided, of the contract and the document type it names: a moderated type of a contract +//! whose moderators the masternodes elect, holding text a moderator's removal takes away. use crate::execution::validation::state_transition::state_transitions::tests::{ setup_identity, setup_masternode_voting_identity, @@ -28,7 +32,14 @@ use dpp::consensus::basic::BasicError; use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; use dpp::dash_to_credits; -use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; +use dpp::data_contract::config::moderation::{ + ContractModerationConfig, ContractModerators, ElectedModerators, InterimModerators, + ModerationAbility, DEFAULT_ELECTION_WINDOW_SECONDS, +}; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; +use dpp::data_contract::document_type::DocumentReferenceKind; +use dpp::data_contract::schema::DataContractSchemaMethodsV0; use dpp::data_contract::DataContract; use dpp::decision_proposal::DECISION_PROPOSALS_CONTRACT_ID; use dpp::document::document_methods::DocumentMethodsV0; @@ -38,7 +49,7 @@ use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::{Identity, IdentityPublicKey, TimestampMillis}; use dpp::platform_serialization::PLATFORM_BINCODE_CONFIG; -use dpp::platform_value::{Bytes32, Identifier, Value}; +use dpp::platform_value::{platform_value, Bytes32, Identifier, Value}; use dpp::prelude::IdentityNonce; use dpp::serialization::PlatformSerializable; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; @@ -48,6 +59,7 @@ use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; use dpp::system_data_contracts::decision_proposals_contract::v1::document_types::{ choice, multiple_choice_proposal, }; +use dpp::tests::fixtures::get_data_contract_fixture; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ ContestedDocumentVotePollStatus, ContestedDocumentVotePollStoredInfoV0Getters, @@ -68,11 +80,12 @@ use drive::query::vote_poll_vote_state_query::{ }; use drive::query::DriveDocumentQuery; use drive::util::object_size_info::DataContractResolvedInfo; +use drive::util::storage_flags::StorageFlags; use platform_version::version::PlatformVersion; use rand::prelude::StdRng; use rand::{Rng, SeedableRng}; use simple_signer::signer::SimpleSigner; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; type IdentityInfo = (Identity, SimpleSigner, IdentityPublicKey); @@ -99,6 +112,93 @@ fn block(time_ms: TimestampMillis, height: u64) -> BlockInfo { } } +/// A document type of the description hosts whose documents moderators delete, a removal +/// taking their `text` with it. +const TEXT: &str = "text"; +/// One whose removal record keeps the `text`. +const KEPT_TEXT: &str = "keptText"; +/// One whose documents their owner deletes. +const PLAIN_TEXT: &str = "plainText"; + +/// A document type holding a `text`, with `extra` merged into its schema. +fn text_schema(extra: Value) -> Value { + let mut schema = platform_value!({ + "type": "object", + "properties": { + "text": { "type": "string", "minLength": 1, "maxLength": 4096, "position": 0 }, + }, + "required": ["text"], + "additionalProperties": false, + }); + if let (Value::Map(schema_map), Value::Map(extra_map)) = (&mut schema, extra) { + schema_map.extend(extra_map); + } + schema +} + +/// A contract hosting the texts proposals describe themselves with. Its moderators are a +/// team the masternodes elect, its owner until one is seated, when `elected`; its owner +/// alone otherwise. The same contract every time it is built. +fn description_host(elected: bool, platform_version: &PlatformVersion) -> DataContract { + let owner_id = Identifier::new([if elected { 0xD5 } else { 0xD6 }; 32]); + let mut contract = + get_data_contract_fixture(Some(owner_id), 1, platform_version.protocol_version) + .data_contract_owned(); + let moderators = if elected { + ContractModerators::Elected(Box::new(ElectedModerators { + join_window: DEFAULT_ELECTION_WINDOW_SECONDS, + vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, + challenge_cool_down: Some(1_209_600), + moderated_document_types: [TEXT, KEPT_TEXT] + .into_iter() + .map(|name| { + ( + name.to_string(), + BTreeSet::from([ModerationAbility::DeleteDocuments]), + ) + }) + .collect(), + interim: InterimModerators::ContractOwner, + election_delay: None, + max_added_moderators: 0, + owner_protected: false, + })) + } else { + ContractModerators::ContractOwner + }; + contract.set_config(contract.config().clone().with_moderation(Some( + ContractModerationConfig { + banlist: false, + suspensions: false, + warnings: false, + moderators, + }, + ))); + // After the config: a type moderators delete from needs the contract to be moderated + for (name, schema) in [ + ( + TEXT, + text_schema(platform_value!({ + "canBeDeleted": false, + "moderatorAbilities": { "delete": true }, + })), + ), + ( + KEPT_TEXT, + text_schema(platform_value!({ + "canBeDeleted": false, + "moderatorAbilities": { "delete": true, "deleteKeepsFields": ["text"] }, + })), + ), + (PLAIN_TEXT, text_schema(platform_value!({}))), + ] { + contract + .set_document_schema(name, schema, true, &mut vec![], platform_version) + .expect("expected to add the document type"); + } + contract +} + fn setup() -> ( TempPlatform, &'static PlatformVersion, @@ -110,6 +210,19 @@ fn setup() -> ( .with_latest_protocol_version() .build_with_mock_rpc() .set_genesis_state(); + for elected in [true, false] { + platform + .drive + .apply_contract( + &description_host(elected, platform_version), + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the description host"); + } let contract = platform .drive .cache @@ -282,25 +395,49 @@ async fn ask_stating( rng: &mut StdRng, platform_version: &PlatformVersion, ) -> Identifier { - let properties = BTreeMap::from([ - ( - multiple_choice_proposal::properties::TITLE.to_string(), - Value::Text(question.to_string()), - ), - ( - multiple_choice_proposal::properties::DESCRIPTION.to_string(), - Value::Text("Enter the answer you would have the network give.".to_string()), - ), - ( - multiple_choice_proposal::properties::OPEN.to_string(), - Value::Bool(open), - ), - ]); + // The proposer first writes what is being decided where moderators can take it down + let host = description_host(true, platform_version); + let description_id = describe( + platform, + &host, + proposer, + TEXT, + time_ms, + rng, + platform_version, + ) + .await; let (transition, id) = create_transition( contract, proposer, multiple_choice_proposal::NAME, - properties, + question_properties(question, open, host.id(), TEXT, description_id), + rng, + platform_version, + ) + .await; + process_valid(platform, transition, time_ms, platform_version); + id +} + +/// `writer` writes a text of `document_type_name` in `host` at `time_ms`; returns its id. +async fn describe( + platform: &TempPlatform, + host: &DataContract, + writer: &mut Writer, + document_type_name: &str, + time_ms: TimestampMillis, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> Identifier { + let (transition, id) = create_transition( + host, + writer, + document_type_name, + BTreeMap::from([( + "text".to_string(), + Value::Text("Enter the answer you would have the network give.".to_string()), + )]), rng, platform_version, ) @@ -309,6 +446,39 @@ async fn ask_stating( id } +/// The properties of a multiple choice proposal asking `question`, described by the document +/// `description_id` of `description_document_type` in the contract `description_contract_id`. +fn question_properties( + question: &str, + open: bool, + description_contract_id: Identifier, + description_document_type: &str, + description_id: Identifier, +) -> BTreeMap { + BTreeMap::from([ + ( + multiple_choice_proposal::properties::TITLE.to_string(), + Value::Text(question.to_string()), + ), + ( + multiple_choice_proposal::properties::DESCRIPTION_ID.to_string(), + Value::Identifier(description_id.to_buffer()), + ), + ( + multiple_choice_proposal::properties::DESCRIPTION_CONTRACT_ID.to_string(), + Value::Identifier(description_contract_id.to_buffer()), + ), + ( + multiple_choice_proposal::properties::DESCRIPTION_DOCUMENT_TYPE.to_string(), + Value::Text(description_document_type.to_string()), + ), + ( + multiple_choice_proposal::properties::OPEN.to_string(), + Value::Bool(open), + ), + ]) +} + /// A serialized create of `writer`'s choice `answer` for `proposal_id`, and the choice's id. async fn choice_transition( contract: &DataContract, @@ -1371,6 +1541,29 @@ async fn should_let_the_proposer_edit_the_question_for_two_hours() { ) .await; + // Another text the proposer wrote, to describe the question with instead + let host = description_host(true, platform_version); + let rewritten = describe( + &platform, + &host, + &mut proposer, + TEXT, + created_at + 30_000, + &mut rng, + platform_version, + ) + .await; + let later_text = describe( + &platform, + &host, + &mut proposer, + TEXT, + created_at + 30_000, + &mut rng, + platform_version, + ) + .await; + // The title, the description and the url may change until two hours after the creation let edit = edit_transition( &contract, @@ -1382,8 +1575,8 @@ async fn should_let_the_proposer_edit_the_question_for_two_hours() { Value::Text("Which mascot for Platform?".to_string()), ), ( - multiple_choice_proposal::properties::DESCRIPTION, - Value::Text("One animal, in one word.".to_string()), + multiple_choice_proposal::properties::DESCRIPTION_ID, + Value::Identifier(rewritten.to_buffer()), ), ( multiple_choice_proposal::properties::URL, @@ -1403,6 +1596,10 @@ async fn should_let_the_proposer_edit_the_question_for_two_hours() { edited.get(multiple_choice_proposal::properties::URL), Some(&Value::Text("https://example.org/mascot".to_string())) ); + assert_eq!( + edited.get(multiple_choice_proposal::properties::DESCRIPTION_ID), + Some(&Value::Identifier(rewritten.to_buffer())) + ); // to the millisecond let edit = edit_transition( @@ -1420,21 +1617,32 @@ async fn should_let_the_proposer_edit_the_question_for_two_hours() { // and no longer after that, whichever of them the edit changes for (property, value) in [ - (multiple_choice_proposal::properties::TITLE, "Which bird?"), ( - multiple_choice_proposal::properties::DESCRIPTION, - "One bird, in one word.", + multiple_choice_proposal::properties::TITLE, + Value::Text("Which bird?".to_string()), + ), + ( + multiple_choice_proposal::properties::DESCRIPTION_ID, + Value::Identifier(later_text.to_buffer()), + ), + ( + multiple_choice_proposal::properties::DESCRIPTION_CONTRACT_ID, + Value::Identifier(description_host(false, platform_version).id().to_buffer()), + ), + ( + multiple_choice_proposal::properties::DESCRIPTION_DOCUMENT_TYPE, + Value::Text(KEPT_TEXT.to_string()), ), ( multiple_choice_proposal::properties::URL, - "https://example.org/bird", + Value::Text("https://example.org/bird".to_string()), ), ] { let edit = edit_transition( &contract, &mut proposer, question(&platform), - &[(property, Value::Text(value.to_string()))], + &[(property, value)], platform_version, ) .await; @@ -1620,3 +1828,160 @@ async fn should_never_let_a_proposal_change_whether_it_is_open() { ); } } + +/// What is being decided is said by a document the proposal points at. The contract it names +/// is one whose moderators the masternodes elect, the document type a moderated one whose +/// text a removal takes away, and the document is there. +#[tokio::test] +async fn should_take_the_description_from_a_moderated_text_of_an_elected_contract() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let mut proposer = writer(&mut platform, &mut rng); + let elected = description_host(true, platform_version); + let owner_moderated = description_host(false, platform_version); + for (document_type_name, kind) in [ + (TEXT, DocumentReferenceKind::Moderated), + (KEPT_TEXT, DocumentReferenceKind::Moderated), + (PLAIN_TEXT, DocumentReferenceKind::Deletable), + ] { + assert_eq!( + elected + .document_type_for_name(document_type_name) + .expect("expected the type") + .document_reference_kind(), + kind, + "{document_type_name}" + ); + } + + // A text in each place a proposal could point + let mut texts = BTreeMap::new(); + for (host, document_type_name) in [ + (&elected, TEXT), + (&elected, KEPT_TEXT), + (&elected, PLAIN_TEXT), + (&owner_moderated, TEXT), + ] { + let id = describe( + &platform, + host, + &mut proposer, + document_type_name, + 10_000, + &mut rng, + platform_version, + ) + .await; + texts.insert((host.id(), document_type_name), id); + } + let asking = |host: &DataContract, document_type_name: &'static str, id: Identifier| { + question_properties("Which mascot?", true, host.id(), document_type_name, id) + }; + let text_of = |host: &DataContract, document_type_name: &'static str| { + texts[&(host.id(), document_type_name)] + }; + + // A moderated text of the elected contract + let (accepted, _) = create_transition( + &contract, + &mut proposer, + multiple_choice_proposal::NAME, + asking(&elected, TEXT, text_of(&elected, TEXT)), + &mut rng, + platform_version, + ) + .await; + process_valid(&platform, accepted, 20_000, platform_version); + + // A type whose removal record keeps the text: taking the document down would leave it + let (kept, _) = create_transition( + &contract, + &mut proposer, + multiple_choice_proposal::NAME, + asking(&elected, KEPT_TEXT, text_of(&elected, KEPT_TEXT)), + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, kept, 20_000, platform_version); + assert!( + matches!( + error, + ConsensusError::StateError(StateError::ReferencedDocumentTypeRequirementNotMetError(_)) + ), + "{error:?}" + ); + + // A type whose documents their owner deletes is not a moderated one + let (plain, _) = create_transition( + &contract, + &mut proposer, + multiple_choice_proposal::NAME, + asking(&elected, PLAIN_TEXT, text_of(&elected, PLAIN_TEXT)), + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, plain, 20_000, platform_version); + assert!( + matches!( + error, + ConsensusError::StateError(StateError::ReferencedDocumentTypeNotModeratedError(_)) + ), + "{error:?}" + ); + + // A moderated text of a contract its owner moderates: the masternodes elect nobody there + let (unelected, _) = create_transition( + &contract, + &mut proposer, + multiple_choice_proposal::NAME, + asking(&owner_moderated, TEXT, text_of(&owner_moderated, TEXT)), + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, unelected, 20_000, platform_version); + assert!( + matches!( + error, + ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(_)) + ), + "{error:?}" + ); + + // A text that is not there, and a type the contract does not have + let (missing, _) = create_transition( + &contract, + &mut proposer, + multiple_choice_proposal::NAME, + asking(&elected, TEXT, Identifier::new([7; 32])), + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, missing, 20_000, platform_version); + assert!( + matches!( + error, + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(_)) + ), + "{error:?}" + ); + let (no_type, _) = create_transition( + &contract, + &mut proposer, + multiple_choice_proposal::NAME, + asking(&elected, "essay", text_of(&elected, TEXT)), + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, no_type, 20_000, platform_version); + assert!( + matches!( + error, + ConsensusError::StateError(StateError::ReferencedDocumentTypeNotFoundError(_)) + ), + "{error:?}" + ); +} diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index be3844589f3..d1317ccedb2 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -712,8 +712,11 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// the declared kind (40122, 40143, 40144), and the document exists (40120) or, for a /// moderated one a replace keeps, was removed on the record. A replace checks the /// reference again when its value or a property it reads the target from changed. -/// Protocol v14 and earlier keep `apply_property_reference` 0, which does not read the -/// keys, and `document_reference_validation` 0, which no dynamic reference reaches. +/// The decision proposals contract's `multipleChoiceProposal` type uses it for its +/// description: a moderated document, of a contract declaring elected moderation, whose +/// text a moderator's removal takes away. Protocol v14 and earlier keep +/// `apply_property_reference` 0, which does not read the keys, and +/// `document_reference_validation` 0, which no dynamic reference reaches. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V10, // changed: calculate_fee 1 prices a refund at the storing epoch's rate (note 27); apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10) and verify_private_document_entries Some(0) a page of a private type's store (note 13); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11); document method versions v5: insert_empty_private_document_store_operation Some(0) creates a private document type's store, and fetch_private_document_entries and prove_private_document_entries Some(0) read and prove a page of it (note 13); apply_drive_operations 2 resolves a batch's estimate, remove_reference_for_index_level_for_contract_operations 2, stateless_delete_of_non_tree_for_costs 1 and for_total_system_credits_update 1 describe their layers as they are (note 14); create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description (note 17); fetch_core_fork_activation_height and store_core_fork_activation_height Some(0) keep the Core fork activations (note 18); document method versions v5: append_private_document_entries_operations Some(0) appends a private document create's entries (note 25); grove_version GROVE_V5 (note 26); verify methods v4: verify_proof_keep_serialized 1 verifies a document that may be contested (note 29); document method versions v5: single_document_path_query 1 stops a single document lookup at a block time above the pointer to the latest version, and verify_proof_keep_serialized 1 verifies it (note 31); add_new_keys_to_masternode_identity 1 adds a masternode key whose hash the identity already holds a reference for (note 32) From c3485d2396f2e3885aa50bf9748683deb73c43aa Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 06:43:22 +0700 Subject: [PATCH 08/20] fix(platform): refuse index-only dynamic targets and moderator-written reference inputs Review of the references whose target the document names: - A document naming an indexOnly document type is refused, paid, with ReferencedDocumentTypeIndexOnlyError (40146) before anything is fetched. Drive refuses to fetch such a document by its id, which failed the transition with an internal error and no fee. - The properties a dynamic reference reads its target from (the contract id, the document type name, a slot's name property) can no longer be listed under moderatorAbilities.changeFields: a moderator's change checks no reference again. - A rule reading through a dynamic reference is refused with a message that says why. - The schema shape of documentTypeRequirements is written by rs-dpp (to_schema_value, which the parser reads back); wasm-dpp2 delegates. - A replace changing a slot's name property is covered by a test. Co-Authored-By: Claude Fable 5.2 --- .../contract-keywords/refers-to-dynamic.md | 7 +- .../try_from_schema/common/mod.rs | 10 +- .../class_methods/try_from_schema/mod.rs | 8 + .../v3/dynamic_reference_tests.rs | 146 +++++++++++++++++- .../property/dynamic_document_reference.rs | 94 +++++++++++ .../document_reference_validation/v1/mod.rs | 19 ++- .../tests/dynamic_document_reference.rs | 105 +++++++++++++ .../rs-platform-version/src/version/v15.rs | 6 +- .../data_contract/document_type_reference.rs | 118 ++++---------- 9 files changed, 413 insertions(+), 100 deletions(-) diff --git a/book/src/contract-keywords/refers-to-dynamic.md b/book/src/contract-keywords/refers-to-dynamic.md index 111c46a4150..1aee0a7fb8c 100644 --- a/book/src/contract-keywords/refers-to-dynamic.md +++ b/book/src/contract-keywords/refers-to-dynamic.md @@ -8,7 +8,7 @@ A document reference normally names its target in the schema: a `documentType`, | **Value** | `contractIdProperty` and `documentTypeProperty`, both required, and optionally `documentTypeRequirements`, in a `"type": "permanentDocument"` or `"type": "moderatedDocument"` declaration that carries nothing else. | | **Since** | protocol version 15 | | **On update** | Fixed, like the rest of `refersTo` (`IncompatibleDocumentTypeSchemaError`, 10246). | -| **Errors** | When a document is written: `ReferencedDocumentTypeNotFoundError` (40121), `ReferencedDocumentTypePrivateError` (40150), `ReferencedDocumentTypeRequirementNotMetError` (40153), `ReferencedDocumentTypeDeletableError` (40122), `ReferencedDocumentTypeNotModeratedError` (40143), `ReferencedDocumentTypeModeratedError` (40144), `ReferencedEntityNotFoundError` (40120). At registration: `InvalidContractStructure` (10231), `JsonSchemaError` (10101). | +| **Errors** | When a document is written: `ReferencedDocumentTypeNotFoundError` (40121), `ReferencedDocumentTypePrivateError` (40150), `ReferencedDocumentTypeIndexOnlyError` (40146), `ReferencedDocumentTypeRequirementNotMetError` (40153), `ReferencedDocumentTypeDeletableError` (40122), `ReferencedDocumentTypeNotModeratedError` (40143), `ReferencedDocumentTypeModeratedError` (40144), `ReferencedEntityNotFoundError` (40120). At registration: `InvalidContractStructure` (10231), `JsonSchemaError` (10101). | ## Example @@ -94,7 +94,7 @@ Nothing about the target is known when the contract is registered, so registrati 1. The document names a contract and a document type. A document that holds the reference but leaves out `contractIdProperty`'s or `documentTypeProperty`'s property points nowhere: `ReferencedEntityNotFoundError` (40120). 2. The contract exists and has a document type of that name: `ReferencedDocumentTypeNotFoundError` (40121) otherwise. Another contract costs a billed fetch, once per write. -3. The type is not [private](../contract-keywords.md#document-type): `ReferencedDocumentTypePrivateError` (40150). +3. The type is not [private](../contract-keywords.md#document-type), `ReferencedDocumentTypePrivateError` (40150), and not `indexOnly`, `ReferencedDocumentTypeIndexOnlyError` (40146): the documents of such a type are index entries, and none can be found by its id. 4. The type meets `documentTypeRequirements`: `ReferencedDocumentTypeRequirementNotMetError` (40153). 5. The type is of the declared kind, as a reference naming it in the schema is held to at registration: for `permanentDocument` its documents never disappear (40122 or 40144 otherwise), for `moderatedDocument` they disappear only through a moderator's recorded removal (40143). 6. The document exists: `ReferencedEntityNotFoundError` (40120). @@ -108,7 +108,7 @@ Nothing about the target is known when the contract is registered, so registrati - Both `contractIdProperty` and `documentTypeProperty` are present, name two different properties, and `type` is `permanentDocument` or `moderatedDocument`. A `deletableDocument` reference always names its type in the schema: a dynamic target is trusted for what its type promises about its documents staying, and a deletable type promises nothing. - The declaration carries nothing else: no `contractId`, `documentType`, `findBy`, `where`, `inList`, `consume` or `constraints`. Each needs the referenced type when the contract is registered. - It sits on a single identifier property: not on the elements of a typed array, in a reference expression, or in `ownerRefersTo` and `creatorRefersTo`. -- `contractIdProperty` names a top-level identifier property, and `documentTypeProperty` and every slot's `{ "property": ... }` a top-level string property. None is transient or the reference property itself, and each is required wherever the reference property is, so a document that must hold the reference always says where it points. +- `contractIdProperty` names a top-level identifier property, and `documentTypeProperty` and every slot's `{ "property": ... }` a top-level string property. None is transient or the reference property itself, and each is required wherever the reference property is, so a document that must hold the reference always says where it points. None is listed under [`moderatorAbilities.changeFields`](moderator-abilities.md), like the reference property itself: a moderator's change checks no reference again, so it could point a stored one somewhere nobody checked. - `documentTypeRequirements` holds 1 to 4 slots (`max_document_type_requirement_slots`), each well formed; `distinctFrom` names other slots of the same `properties`; `require` names slots, and all of them. - No [`propertyConstraints` rule](property-constraints.md#values-of-a-referenced-document) reads a value through the reference, no [index](derived-index-properties.md) holds one, and no chained query joins through it: each resolves the referenced type from the schema. @@ -135,6 +135,7 @@ or compare it with the contracts the application trusts in a [`propertyConstrain | `ReferencedDocumentTypeRequirementNotMetError` | 40153 | Write: the named document type does not meet the reference's `documentTypeRequirements`. | | `ReferencedDocumentTypeNotFoundError` | 40121 | Write: the named contract does not exist, or has no document type of that name. | | `ReferencedDocumentTypePrivateError` | 40150 | Write: the named document type is private. | +| `ReferencedDocumentTypeIndexOnlyError` | 40146 | Write: the named document type is `indexOnly`. | | `ReferencedDocumentTypeDeletableError`, `ReferencedDocumentTypeModeratedError`, `ReferencedDocumentTypeNotModeratedError` | 40122, 40144, 40143 | Write: the named document type is not of the declared kind. | | `ReferencedEntityNotFoundError` | 40120 | Write: the document does not say where the reference points, or no document with that id exists in the named type. | diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 9c9b33c5f72..b3d868c94f8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -3448,7 +3448,8 @@ fn apply_delete_keeps_fields( /// The top-level properties of `document_type` that a reference declared on it /// reads when a document is written: each property holding a reference, and /// the referring side of every `where`, `findBy` source (a function's params -/// included), `chooseFirst` function param and key id property, for every leaf of every reference expression, the +/// included), `chooseFirst` function param and key id property, and every property a +/// reference naming its target through the referring document reads it from, for every leaf of every reference expression, the /// `ownerRefersTo` and `creatorRefersTo` declarations included. System /// properties (`$ownerId`) are left out: they are no property a moderator can /// name. @@ -3476,6 +3477,13 @@ fn properties_read_by_references(document_type: DocumentTypeRef<'_>) -> BTreeSet { read.insert(top_level_property(key_id_property)); } + // A reference naming its target through the referring document reads where it + // points there: the contract id, the document type name, and every property a + // slot takes its property name from. A change to one of them outside a replace + // would leave the stored reference pointing at something nobody checked + if let Some(dynamic) = leaf.as_dynamic_document_reference() { + read.extend(dynamic.target_properties().map(top_level_property)); + } let Some(declaration) = leaf.as_any_document_reference() else { continue; }; 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 b6dc8982db0..7dac3bd6a80 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 @@ -4565,6 +4565,14 @@ fn referencing_property_error(document_type: DocumentTypeRef, reference: &str) - .to_string(), ); } + if target.as_dynamic_document_reference().is_some() { + return Some( + "whose refersTo names its target through the referring document: the document type \ + a rule reads through is one the schema names, known when the contract is \ + registered" + .to_string(), + ); + } let Some(declaration) = target.as_any_document_reference() else { return Some( "whose refersTo names an identity, a contract, a token or a key, not a document" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs index 15941394368..86642b6c0d7 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs @@ -8,9 +8,13 @@ use super::reference_test_helpers::{ assert_refused, assert_refused_by_the_parser, contract, contract_on, identifier, CONTRACT_ID, }; use crate::data_contract::accessors::v0::DataContractV0Getters; -use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::config::moderation::{ContractModerationConfig, ContractModerators}; +use crate::data_contract::config::DataContractConfig; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; use crate::data_contract::document_type::{ - DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRequirements, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, DocumentTypeRequirements, DynamicDocumentReference, DynamicDocumentReferenceKind, RequiredPropertySlot, SlotPropertyName, SlotRequirement, SlotRequirementTerm, SlotValue, }; @@ -748,7 +752,11 @@ fn should_refuse_a_rule_or_an_index_reading_through_a_dynamic_reference() { "equal": [{ "referenced": ["descriptionId", "$ownerId"] }, "$ownerId"] } }); - assert_refused(contract(with_a_rule), "descriptionId"); + // The refusal says why: the reference is to a document, of a type the schema does not name + assert_refused( + contract(with_a_rule), + "\"descriptionId\", whose refersTo names its target through the referring document", + ); // and an index holds a value of one let mut with_an_index = note_contract(text_or_bytes()); @@ -757,3 +765,135 @@ fn should_refuse_a_rule_or_an_index_reading_through_a_dynamic_reference() { ]); contract(with_an_index).expect_err("an index reads no value through a dynamic reference"); } + +/// A `note` of a contract its owner moderates, describing itself with a document of a type +/// it names whose text is the string property the note names. Nothing of the description +/// is required, so a moderator could be given any of its properties to write. `abilities` +/// is the type's `moderatorAbilities`. +fn moderated_note(abilities: serde_json::Value) -> Result { + let platform_version = PlatformVersion::latest(); + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config") + .with_moderation(Some(ContractModerationConfig { + banlist: false, + suspensions: false, + moderators: ContractModerators::ContractOwner, + warnings: false, + })); + let mut description_id = identifier(0); + description_id["refersTo"] = requiring(json!({ + "properties": { + "body": { "name": { "property": "bodyProperty" }, "type": "string" } + } + })); + let schema = json!({ + "type": "object", + "properties": { + "descriptionId": description_id, + "descriptionContractId": identifier(1), + "descriptionDocumentType": string(2), + "bodyProperty": string(3), + "label": string(4) + }, + "additionalProperties": false, + "moderatorAbilities": abilities + }); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "note", + platform_value::to_value(schema).expect("the schema should convert"), + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + ) +} + +/// A moderator's change of a field is not a replace: no reference is checked again. So a +/// moderator is given none of the properties a reference reads its target from, or it could +/// point a stored reference at a document type, or a property of one, nobody checked. +#[test] +fn should_not_let_a_moderator_write_what_a_dynamic_target_is_read_from() { + // A property no reference reads is the moderators' to write + let note = moderated_note(json!({ "changeFields": ["label"] })).expect("parses"); + assert_eq!( + note.moderator_changeable_fields(), + &BTreeSet::from(["label".to_string()]) + ); + + for source in [ + // The reference, the contract id, the document type name, and a slot's property name + "descriptionId", + "descriptionContractId", + "descriptionDocumentType", + "bodyProperty", + ] { + let error = moderated_note(json!({ "changeFields": [source] })) + .expect_err("a moderator must not write what the reference reads"); + let message = error.to_string(); + assert!( + message.contains(&format!("can not list \"{source}\"")), + "{source}: {message}" + ); + assert!( + message.contains("it holds a reference or is read by one"), + "{source}: {message}" + ); + } +} + +/// The requirements are written back as a schema declares them, in one place, and the +/// parser reads what is written back to the same requirements: what a client is shown of a +/// declaration is a declaration. +#[test] +fn should_write_requirements_as_a_schema_declares_them() { + let declared = json!({ + "properties": { + "title": { "name": "$any", "type": "string", "maxBytes": 256 }, + "body": { + "name": { "property": "bodyProperty" }, + "type": "string", + "distinctFrom": ["title"] + }, + "bytes": { "name": "blob", "type": "array", "byteArray": true }, + "count": { "name": "$any", "type": "integer" } + }, + "require": { "allOf": ["title", { "anyOf": ["body", "bytes"] }, "count"] } + }); + let parsed = requirements(declared.clone()); + let written = parsed + .to_schema_value() + .try_into_validating_json() + .expect("the schema value converts to JSON"); + assert_eq!(written, declared); + assert_eq!(requirements(written), parsed); + + // Every other form: one slot name, `anyOf` over groups, a `distinctFrom` written as + // one name (which is written back as a list), and no `require` + for declared in [ + json!({ + "properties": { "body": { "name": "$any", "type": "boolean" } }, + "require": "body" + }), + json!({ + "properties": { + "a": { "name": "$any", "type": "string" }, + "b": { "name": "$any", "type": "string", "distinctFrom": "a" }, + "c": { "name": "$any", "type": "identifier" } + }, + "require": { "anyOf": [{ "allOf": ["a", "b"] }, "c"] } + }), + json!({ "properties": { "body": { "name": "body", "type": "string" } } }), + ] { + let parsed = requirements(declared); + let written = parsed + .to_schema_value() + .try_into_validating_json() + .expect("the schema value converts to JSON"); + assert_eq!(requirements(written), parsed); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs index a1968a028b0..d7dd01a3178 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs @@ -37,6 +37,7 @@ use crate::data_contract::document_type::property::{ }; use crate::data_contract::document_type::DocumentTypeRef; use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::Value; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; @@ -160,6 +161,18 @@ pub enum SlotValue { } impl SlotValue { + /// The `type` a contract schema declares the slot with; a byte array is an `array` + /// declared beside `"byteArray": true`. + pub const fn schema_type(self) -> &'static str { + match self { + SlotValue::String => "string", + SlotValue::ByteArray => "array", + SlotValue::Identifier => "identifier", + SlotValue::Integer => "integer", + SlotValue::Boolean => "boolean", + } + } + /// The `type` the slot is written with, for messages. pub const fn wire_name(self) -> &'static str { match self { @@ -196,7 +209,43 @@ pub enum SlotRequirementTerm { Group(Vec), } +/// A schema object: its members in the order given. +fn schema_object(members: Vec<(&str, Value)>) -> Value { + Value::Map( + members + .into_iter() + .map(|(key, value)| (Value::Text(key.to_string()), value)) + .collect(), + ) +} + +/// A schema list of names. +fn schema_names<'a>(names: impl IntoIterator) -> Value { + Value::Array(names.into_iter().cloned().map(Value::Text).collect()) +} + impl SlotRequirement { + /// `require` as a contract schema declares it: a slot name, or `anyOf` / `allOf` over + /// slot names and groups, a group written as the other combinator. What the parser + /// reads back to this requirement. + pub fn to_schema_value(&self) -> Value { + let (combinator, group_combinator, terms) = match self { + SlotRequirement::Slot(slot) => return Value::Text(slot.clone()), + SlotRequirement::AnyOf(terms) => ("anyOf", "allOf", terms), + SlotRequirement::AllOf(terms) => ("allOf", "anyOf", terms), + }; + let terms = terms + .iter() + .map(|term| match term { + SlotRequirementTerm::Slot(slot) => Value::Text(slot.clone()), + SlotRequirementTerm::Group(slots) => { + schema_object(vec![(group_combinator, schema_names(slots))]) + } + }) + .collect(); + schema_object(vec![(combinator, Value::Array(terms))]) + } + /// Whether the requirement holds when exactly the slots `filled` reports are filled. pub fn holds(&self, filled: impl Fn(&str) -> bool) -> bool { match self { @@ -228,6 +277,33 @@ impl SlotRequirement { } impl RequiredPropertySlot { + /// The slot as a contract schema declares it: `name`, `type`, `byteArray` on a byte + /// array, and `maxBytes` and `distinctFrom` (always a list) when it has them. What the + /// parser reads back to this slot. + pub fn to_schema_value(&self) -> Value { + let name = match &self.name { + SlotPropertyName::Any => Value::Text(ANY_PROPERTY_NAME.to_string()), + SlotPropertyName::Named(name) => Value::Text(name.clone()), + SlotPropertyName::FromProperty(property) => { + schema_object(vec![("property", Value::Text(property.clone()))]) + } + }; + let mut members = vec![ + ("name", name), + ("type", Value::Text(self.value.schema_type().to_string())), + ]; + if self.value == SlotValue::ByteArray { + members.push(("byteArray", Value::Bool(true))); + } + if let Some(max_bytes) = self.max_bytes { + members.push(("maxBytes", Value::U32(max_bytes))); + } + if !self.distinct_from.is_empty() { + members.push(("distinctFrom", schema_names(&self.distinct_from))); + } + schema_object(members) + } + /// Whether `property`, a top-level property of the referenced type, matches the slot's /// description, its name aside. A transient property is never stored, so it fills /// nothing. @@ -261,6 +337,24 @@ impl RequiredPropertySlot { } impl DocumentTypeRequirements { + /// `documentTypeRequirements` as a contract schema declares it: the slots under + /// `properties`, each as [`RequiredPropertySlot::to_schema_value`] gives it, and + /// `require` when not every slot is required. The one place the declared shape is + /// written out of the parsed one; the parser reads it back to these requirements. + pub fn to_schema_value(&self) -> Value { + let slots = Value::Map( + self.slots + .iter() + .map(|(name, slot)| (Value::Text(name.clone()), slot.to_schema_value())) + .collect(), + ); + let mut members = vec![("properties", slots)]; + if let Some(require) = &self.require { + members.push(("require", require.to_schema_value())); + } + schema_object(members) + } + /// The properties of the declaring type a slot takes its property name from. pub fn name_properties(&self) -> impl Iterator { self.slots.values().filter_map(|slot| match &slot.name { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs index 2727633baa7..9e433122b71 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs @@ -36,6 +36,7 @@ use dpp::errors::consensus::state::document::referenced_document_preceded_error: use dpp::errors::consensus::state::document::referenced_document_property_mismatch_error::ReferencedDocumentPropertyMismatchError; use dpp::errors::consensus::state::document::referenced_document_removed_error::ReferencedDocumentRemovedError; use dpp::errors::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; +use dpp::errors::consensus::state::document::referenced_document_type_index_only_error::ReferencedDocumentTypeIndexOnlyError; use dpp::errors::consensus::state::document::referenced_document_type_private_error::ReferencedDocumentTypePrivateError; use dpp::errors::consensus::state::document::referenced_document_type_requirement_not_met_error::ReferencedDocumentTypeRequirementNotMetError; use dpp::errors::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; @@ -1276,9 +1277,11 @@ fn validate_reference_target_v1( // What registration checks of a reference naming its target in the schema, a // reference naming it through the referring document meets here, on every // write that checks it: the type is not private, whose documents are encrypted - // entries the chain never reads, and it has the properties the reference's - // `documentTypeRequirements` ask for. A slot naming its property through the - // referring document reads the name there + // entries the chain never reads, it is not indexOnly, whose documents are index + // entries Drive refuses to fetch by id (the read below would fail the block's + // processing of the transition instead of refusing it, paid), and it has the + // properties the reference's `documentTypeRequirements` ask for. A slot naming + // its property through the referring document reads the name there if let Some(dynamic) = reference_target.as_dynamic_document_reference() { if referenced_document_type.is_private() { return Ok(SimpleConsensusValidationResult::new_with_error( @@ -1290,6 +1293,16 @@ fn validate_reference_target_v1( .into(), )); } + if referenced_document_type.index_only() { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentTypeIndexOnlyError::new( + effective_contract_id, + document_type_name.to_string(), + path.to_string(), + ) + .into(), + )); + } let meets_requirements = dynamic.requirements.as_ref().is_none_or(|requirements| { requirements.met_by(referenced_document_type, |referring_property| { document_data diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs index 76e54f14d9d..c91b2143611 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs @@ -6,14 +6,18 @@ //! a record. use super::*; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; const NOTE: &str = "note"; const BYTES_NOTE: &str = "bytesNote"; const NAMED_NOTE: &str = "namedNote"; const PLAIN: &str = "plain"; +const PERMANENT_NOTE: &str = "permanentNote"; +const TALLY: &str = "tally"; const REFERENCED_ENTITY_NOT_FOUND: u32 = 40120; const REFERENCED_DOCUMENT_TYPE_NOT_FOUND: u32 = 40121; const REFERENCED_DOCUMENT_TYPE_NOT_MODERATED: u32 = 40143; +const REFERENCED_DOCUMENT_TYPE_INDEX_ONLY: u32 = 40146; const REFERENCED_DOCUMENT_TYPE_REQUIREMENT_NOT_MET: u32 = 40153; fn id_value(id: Identifier) -> Value { @@ -89,6 +93,48 @@ fn plain_schema() -> Value { }) } +/// A document type kept only as index entries, which nobody deletes: its documents never leave +/// state, and none can be fetched by its id +fn tally_schema() -> Value { + platform_value!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "label": { "type": "string", "minLength": 1, "maxLength": 63, "position": 0 }, + }, + "indices": [{ + "name": "byLabel", + "properties": [{ "label": "asc" }], + "terminal": "$ownerId", + }], + "required": ["label"], + "additionalProperties": false, + }) +} + +/// A note describing itself with a permanent document of a type it names, of which it asks +/// nothing else. +fn permanent_note_schema() -> Value { + let mut schema = note_schema(text_or_bytes()); + let refers_to = platform_value!({ + "type": "permanentDocument", + "contractIdProperty": "descriptionContractId", + "documentTypeProperty": "descriptionDocumentType", + }); + schema + .get_mut("properties") + .expect("expected properties") + .expect("expected properties") + .get_mut("descriptionId") + .expect("expected the reference property") + .expect("expected the reference property") + .insert("refersTo".to_string(), refers_to) + .expect("expected to declare the reference"); + schema +} + /// A contract whose named moderator removes posts, with notes referring to a document of a /// type they name: `note` asks for text or bytes, `bytesNote` for bytes, and `namedNote` for /// a string property the note names. @@ -103,7 +149,9 @@ async fn setup() -> Setup { post_schema_with(platform_value!({ "canBeDeleted": false })), ); add_document_type(contract, PLAIN, plain_schema()); + add_document_type(contract, TALLY, tally_schema()); add_document_type(contract, NOTE, note_schema(text_or_bytes())); + add_document_type(contract, PERMANENT_NOTE, permanent_note_schema()); add_document_type( contract, BYTES_NOTE, @@ -213,6 +261,28 @@ async fn should_check_a_reference_into_the_document_type_the_document_names() { &setup.process(&no_document, &transaction), REFERENCED_ENTITY_NOT_FOUND, ); + + // A type kept only as index entries has no document to find by its id. Its documents + // never leave state, so it is of the kind a permanent reference admits: it is refused, + // paid, before anything is fetched, whatever the reference asks of the type + assert!(setup + .contract + .document_type_for_name(TALLY) + .expect("expected the tally type") + .index_only()); + for note_type in [PERMANENT_NOTE, NOTE] { + let (_, index_only) = create_with( + &setup, + &setup.stranger, + note_type, + note_naming(contract_id, TALLY, Identifier::random()), + ) + .await; + assert_paid_with_code( + &setup.process(&index_only, &transaction), + REFERENCED_DOCUMENT_TYPE_INDEX_ONLY, + ); + } } /// The named type must have the properties the reference's requirements ask for: `post` @@ -326,6 +396,29 @@ async fn should_keep_a_dynamic_reference_until_a_replace_points_it_elsewhere() { note.set("descriptionId", id_value(other_post.id())); let repoint = replacement(&setup.stranger, &setup.contract, NOTE, &mut note).await; assert_success(&setup.process(&repoint, &transaction)); + + // A slot reading its property name from the note is part of where the reference points: + // naming another property checks the reference again, though its id, its contract and + // its document type are the ones it was created with + let (mut named, create_named) = create_with( + &setup, + &setup.stranger, + NAMED_NOTE, + note_naming(contract_id, POST, other_post.id()), + ) + .await; + assert_success(&setup.process(&create_named, &transaction)); + let mut renamed = named.clone(); + renamed.set("textProperty", "summary".into()); + let rename = replacement(&setup.stranger, &setup.contract, NAMED_NOTE, &mut renamed).await; + assert_paid_with_code( + &setup.process(&rename, &transaction), + REFERENCED_DOCUMENT_TYPE_REQUIREMENT_NOT_MET, + ); + // while an edit of another property keeps it + named.set("body", "an edit".into()); + let edit = replacement(&setup.stranger, &setup.contract, NAMED_NOTE, &mut named).await; + assert_success(&setup.process(&edit, &transaction)); } /// The contract a note names need not be its own: a note of another contract describes @@ -373,6 +466,18 @@ async fn should_follow_a_dynamic_reference_into_another_contract() { ) .await; assert_success(&setup.process(&create_note, &transaction)); + // An index-only type of the posts contract is refused there as it is at home + let (_, index_only) = create_with( + &setup, + &setup.stranger, + NOTE, + note_naming(posts_contract.id(), TALLY, Identifier::random()), + ) + .await; + assert_paid_with_code( + &setup.process(&index_only, &transaction), + REFERENCED_DOCUMENT_TYPE_INDEX_ONLY, + ); // The notes contract has no `post` type of its own to find the post in let (_, names_itself) = create_with( &setup, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index ddd121666cf..0485c6e1d36 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -676,11 +676,13 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// expression, sits on no typed array's elements and in no `ownerRefersTo` or /// `creatorRefersTo`, and no rule, derived index property or chained query reads through /// it, each of which resolves the referenced type from the schema. Registration checks -/// the declaration and the properties it reads alone. `document_reference_validation` 1 +/// the declaration and the properties it reads alone, none of which a moderator may be +/// given to write (`moderatorAbilities.changeFields`). `document_reference_validation` 1 /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) checks the target when a document is written: /// the document names a contract and a document type (`ReferencedEntityNotFoundError`, /// 40120, when it does not), they exist (`ReferencedDocumentTypeNotFoundError`, 40121), -/// the type is not private (`ReferencedDocumentTypePrivateError`, 40150), meets the +/// the type is not private (`ReferencedDocumentTypePrivateError`, 40150) and not +/// indexOnly (`ReferencedDocumentTypeIndexOnlyError`, 40146), meets the /// requirements (`ReferencedDocumentTypeRequirementNotMetError`, 40153, new) and is of /// the declared kind (40122, 40143, 40144), and the document exists (40120) or, for a /// moderated one a replace keeps, was removed on the record. A replace checks the diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index d3c3221b310..0fcc5fd2e2f 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -14,13 +14,13 @@ use crate::data_contract::document_type_property_constraints::integer_to_js; use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::IdentifierWasm; +use crate::serialization::{object_with_own_properties, platform_value_to_object}; use crate::utils::define_own_property; use dpp::data_contract::document_type::{ - ANY_PROPERTY_NAME, DocumentPropertyReferenceTarget, DocumentReferenceLookup, DocumentTypeRef, + DocumentPropertyReferenceTarget, DocumentReferenceLookup, DocumentTypeRef, DocumentTypeRequirements, IdentityKeyReferenceRequirements, KeyIdReference, LookupHashKey, - LookupKeyParam, LookupKeySource, PropertyReference, SlotPropertyName, SlotRequirement, - SlotRequirementTerm, SlotValue, WhereCondition, WhereContainsBound, WhereLiteral, - WhereNotEqualBound, WhereOrderingBound, + LookupKeyParam, LookupKeySource, PropertyReference, WhereCondition, WhereContainsBound, + WhereLiteral, WhereNotEqualBound, WhereOrderingBound, }; use dpp::document::property_names::{ID, OWNER_ID}; use dpp::prelude::Identifier; @@ -1087,96 +1087,38 @@ fn set_reference_target_fields( Ok(()) } -/// `documentTypeRequirements` as the schema declares it: the slots under `properties`, and -/// `require` when the declaration has one. +/// `documentTypeRequirements` as the schema declares it. The shape is rs-dpp's +/// (`DocumentTypeRequirements::to_schema_value`, which the parser reads back); only the +/// slots are placed here, each an own property, since a slot may be named like an +/// accessor every object inherits (`__proto__`). fn document_type_requirements_to_js( requirements: &DocumentTypeRequirements, path: &str, ) -> WasmDppResult { - let names = - |slots: &[String]| -> Array { slots.iter().map(|slot| JsValue::from_str(slot)).collect() }; - let properties = Object::new(); - for (slot_name, slot) in &requirements.slots { - let fields = Object::new(); - let name = match &slot.name { - SlotPropertyName::Any => JsValue::from_str(ANY_PROPERTY_NAME), - SlotPropertyName::Named(name) => JsValue::from_str(name), - SlotPropertyName::FromProperty(property) => { - let from_property = Object::new(); - set_field( - &from_property, - "property", - &JsValue::from_str(property), - path, - )?; - from_property.into() - } - }; - set_field(&fields, "name", &name, path)?; - let value_type = match slot.value { - SlotValue::String => "string", - SlotValue::Identifier => "identifier", - SlotValue::Integer => "integer", - SlotValue::Boolean => "boolean", - SlotValue::ByteArray => "array", - }; - set_field(&fields, "type", &JsValue::from_str(value_type), path)?; - if slot.value == SlotValue::ByteArray { - set_field(&fields, "byteArray", &JsValue::TRUE, path)?; - } - if let Some(max_bytes) = slot.max_bytes { - set_field(&fields, "maxBytes", &JsValue::from(max_bytes), path)?; - } - if !slot.distinct_from.is_empty() { - let others: Vec = slot.distinct_from.iter().cloned().collect(); - set_field(&fields, "distinctFrom", &names(&others), path)?; - } - set_schema_field(&properties, slot_name, fields.into(), path)?; - } + let slots = requirements + .slots + .iter() + .map(|(slot_name, slot)| { + Ok(( + slot_name.as_str(), + platform_value_to_object(&slot.to_schema_value())?, + )) + }) + .collect::>>()?; let object = Object::new(); - set_field(&object, "properties", &properties, path)?; - + set_field( + &object, + "properties", + &object_with_own_properties(slots)?.into(), + path, + )?; if let Some(require) = &requirements.require { - let terms_to_js = |terms: &[SlotRequirementTerm], inner: &str| -> WasmDppResult { - let operands = Array::new(); - for term in terms { - match term { - SlotRequirementTerm::Slot(slot) => { - operands.push(&JsValue::from_str(slot)); - } - SlotRequirementTerm::Group(slots) => { - let group = Object::new(); - set_field(&group, inner, &names(slots), path)?; - operands.push(&group); - } - } - } - Ok(operands) - }; - let require: JsValue = match require { - SlotRequirement::Slot(slot) => JsValue::from_str(slot), - SlotRequirement::AnyOf(terms) => { - let combinator = Object::new(); - set_field( - &combinator, - "anyOf", - &terms_to_js(terms, "allOf")?.into(), - path, - )?; - combinator.into() - } - SlotRequirement::AllOf(terms) => { - let combinator = Object::new(); - set_field( - &combinator, - "allOf", - &terms_to_js(terms, "anyOf")?.into(), - path, - )?; - combinator.into() - } - }; - set_field(&object, "require", &require, path)?; + set_field( + &object, + "require", + &platform_value_to_object(&require.to_schema_value())?, + path, + )?; } Ok(object) } From d734f2b39a26bc7dc274c1898ece8dfdf4bfd5d4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 07:02:16 +0700 Subject: [PATCH 09/20] fix(dpp): restore the line continuation in a dynamic reference refusal The message refusing a refersTo that names its target through the referring document on a property that is not an identifier held a run of spaces where its two lines join. It travels in a consensus error, so it is fixed before protocol version 15 ships, and its test now holds the whole sentence. Co-Authored-By: Claude Fable 5.2 --- .../document_type/class_methods/try_from_schema/mod.rs | 3 ++- .../try_from_schema/v3/dynamic_reference_tests.rs | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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 7dac3bd6a80..a7ad0fdabd8 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 @@ -789,7 +789,8 @@ fn apply_property_reference_v1( DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) ) { return Err(DataContractError::InvalidContractStructure( - "a refersTo naming its target through contractIdProperty and documentTypeProperty is only allowed on identifier properties: its value is the referenced document's id" + "a refersTo naming its target through contractIdProperty and documentTypeProperty \ + is only allowed on identifier properties: its value is the referenced document's id" .to_string(), )); } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs index 86642b6c0d7..9e8c1715ec2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs @@ -368,7 +368,12 @@ fn should_refuse_a_dynamic_target_anywhere_but_on_an_identifier_property() { let mut on_a_string = note_contract(text_or_bytes()); on_a_string["documentSchemas"]["note"]["properties"]["descriptionId"] = json!({ "type": "string", "maxLength": 64, "position": 0, "refersTo": text_or_bytes() }); - assert_refused_by_the_parser(on_a_string, "only allowed on identifier properties"); + // The whole sentence, which travels in a consensus error: one space between its lines + assert_refused_by_the_parser( + on_a_string, + "a refersTo naming its target through contractIdProperty and documentTypeProperty is \ + only allowed on identifier properties: its value is the referenced document's id", + ); // On the elements of a typed array let mut on_elements = note_contract(text_or_bytes()); From a6d4c4416e86b1799441a5ef7c76e7e0df62c4e3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 15:35:55 +0700 Subject: [PATCH 10/20] fix(dpp): hold a dynamic reference's require to terms named once Second review of the references whose target the document names: - `require` is judged on every write that checks the reference, and only the number of slots was bounded. It now names nothing twice: no term, no slot within a group, no group with its slots in another order. With at most four slots that is at most fifteen terms, where a contract could repeat one slot thousands of times. - The book's rule for which property fills which slot now starts by choosing the set of slots, as consensus does, so a client reads every reference Platform accepts. - A test makes the assignment search take a property back and then succeed. Co-Authored-By: Claude Fable 5.2 --- .../contract-keywords/refers-to-dynamic.md | 9 ++- .../try_from_schema/dynamic_reference.rs | 34 ++++++++--- .../native_schema/references.rs | 4 +- .../v3/dynamic_reference_tests.rs | 58 +++++++++++++++++++ 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/book/src/contract-keywords/refers-to-dynamic.md b/book/src/contract-keywords/refers-to-dynamic.md index 1aee0a7fb8c..6a6d2757d04 100644 --- a/book/src/contract-keywords/refers-to-dynamic.md +++ b/book/src/contract-keywords/refers-to-dynamic.md @@ -69,7 +69,7 @@ A slot name is 1 to 64 letters, digits or underscores. A transient property fill - absent, every slot; - a slot name, that slot; -- `{ "anyOf": [...] }` or `{ "allOf": [...] }` over two or more terms, each a slot name or the other combinator over two or more slot names. Two levels say everything `anyOf` and `allOf` can say of a set of slots. +- `{ "anyOf": [...] }` or `{ "allOf": [...] }` over two or more terms, each a slot name or the other combinator over two or more slot names. Two levels say everything `anyOf` and `allOf` can say of a set of slots. Nothing is named twice: no term, no slot within a group, no group with its slots in another order. Every slot is named by `require` when there is one: a slot it never names could not matter. @@ -86,7 +86,12 @@ Every slot is named by `require` when there is one: a slot it never names could A short text property, and either the string property the document names in its `bodyProperty`, other than the one taken as the title, or a byte array. -**When the requirements are met.** They are met when some choice of properties fills a set of slots `require` holds for, with slots that are `distinctFrom` each other filled by different properties. Consensus only asks whether such a choice exists. A client that needs to know which property is the title and which the body can settle it the same way every time: take the slots in the order of their names and give each the first property, in the type's `position` order, that matches it and that no slot it must differ from has taken, backing up when a later slot is left with nothing. Naming the property outright, or through the referring document, leaves nothing to settle. +**When the requirements are met.** They are met when some choice of properties fills a set of slots `require` holds for, with slots that are `distinctFrom` each other filled by different properties. Consensus only asks whether such a choice exists. A client that needs to know which property is the title and which the body can settle it the same way every time, in two steps: + +1. **Which slots.** Not every slot need be filled: `require` may hold for several sets of slots (every slot is the only set when there is no `require`). Take the sets it holds for with the most slots first and, between sets of the same size, the one whose slot names, listed in order, come first. Use the first set that step 2 can fill. +2. **Which property for each.** Take the set's slots in the order of their names and give each the first property, in the type's `position` order, that matches it and that no slot it must differ from has taken, backing up to the previous slot's next property when a slot is left with nothing. + +Take the first example of this page, `body` or `bodyBytes`: a type holding a string and a byte array fills both slots. A type holding only a string cannot fill that set, so the next ones are tried: `body` alone, which the string fills (`bodyBytes` alone would come after it). Naming the property outright, or through the referring document, leaves nothing to settle. ## How it works diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs index 33aeea9d39d..217edb3da25 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs @@ -392,27 +392,47 @@ fn parse_require( Ok(name.to_string()) }; + // The condition is judged on every write that checks the reference, so it stays as + // small as the slots are few: a slot named twice in a group, or a term given twice + // (a group whatever the order of its slots), says nothing the first did not + let repeated = |what: &str| { + refused(format!( + "documentTypeRequirements require names {what} twice: each slot is named once in \ + a group, and each term once" + )) + }; let requirement = if value.is_text() { SlotRequirement::Slot(slot(value)?) } else { let (combinator, operands) = combinator_operands(value).ok_or_else(shape)?; + let mut seen: BTreeSet> = BTreeSet::new(); let terms = operands .iter() .map(|operand| { if operand.is_text() { - return Ok(SlotRequirementTerm::Slot(slot(operand)?)); + let name = slot(operand)?; + if !seen.insert(BTreeSet::from([name.clone()])) { + return Err(repeated(&format!("the slot \"{name}\""))); + } + return Ok(SlotRequirementTerm::Slot(name)); } let (inner, inner_operands) = combinator_operands(operand).ok_or_else(shape)?; // A combinator directly inside itself says what one flat list says if inner == combinator { return Err(shape()); } - Ok(SlotRequirementTerm::Group( - inner_operands - .iter() - .map(&slot) - .collect::, _>>()?, - )) + let group = inner_operands + .iter() + .map(&slot) + .collect::, _>>()?; + let members: BTreeSet = group.iter().cloned().collect(); + if members.len() != group.len() { + return Err(repeated("a slot in one group")); + } + if !seen.insert(members) { + return Err(repeated("a group of slots")); + } + Ok(SlotRequirementTerm::Group(group)) }) .collect::, DataContractError>>()?; match combinator { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs index 07566c7c923..34b6db328b8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs @@ -375,7 +375,9 @@ impl Checker<'_> { c.slot_combinator(term, false) })?; } - Ok(()) + // No term twice. A group repeated with its slots in another order is the + // parser's to refuse + c.unique(terms) })?; } Ok(()) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs index 9e8c1715ec2..81c40f96103 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs @@ -553,6 +553,38 @@ fn should_refuse_malformed_document_type_requirements() { "require": { "anyOf": ["title", { "anyOf": ["title", "body"] }] } }), "require must be a slot name", ); + + // require is judged on every write that checks the reference, so it names nothing + // twice: a term, a slot within a group, or a group with its slots in another order. + // With at most four slots that leaves it at most fifteen terms + let three = || json!({ "a": any_string(), "b": any_string(), "c": any_string() }); + refused( + json!({ "properties": three(), "require": { "allOf": ["a", "b", "c", "a"] } }), + "names the slot \"a\" twice", + ); + refused( + json!({ "properties": three(), + "require": { "anyOf": ["c", { "allOf": ["a", "b", "a"] }] } }), + "names a slot in one group twice", + ); + refused( + json!({ "properties": three(), + "require": { "anyOf": ["c", { "allOf": ["a", "b"] }, { "allOf": ["b", "a"] }] } }), + "names a group of slots twice", + ); + // and one repeated thousands of times is refused like one repeated once + let mut many = vec![json!("b"), json!("c")]; + many.extend(std::iter::repeat_n(json!("a"), 5_000)); + refused( + json!({ "properties": three(), "require": { "allOf": many } }), + "names the slot \"a\" twice", + ); + // A slot may still stand alone and in a group + contract(note_contract(requiring(json!({ + "properties": three(), + "require": { "anyOf": ["a", { "allOf": ["a", "b"] }, { "allOf": ["b", "c"] }] } + })))) + .expect("a slot named alone and in a group parses"); } #[test] @@ -665,6 +697,32 @@ fn should_fill_distinct_slots_with_different_properties() { } }), None )); + + // Slots that differ from some of the others, not all: `title` and `body` are the two + // strings of at most 4096 bytes, so each of four slots takes one of them. With `a` + // differing from `b`, and `d` from `b` and from `c`, giving them out in turn (`title`, + // `body`, `title`) leaves `d` nothing, and only taking `c`'s back finds what fills all + // four: `title`, `body`, `body`, `title` + let bounded = |distinct_from: serde_json::Value| json!({ "name": "$any", "type": "string", "maxBytes": 4096, "distinctFrom": distinct_from }); + assert!(post_meets( + json!({ "properties": { + "a": { "name": "$any", "type": "string", "maxBytes": 4096 }, + "b": bounded(json!("a")), + "c": { "name": "$any", "type": "string", "maxBytes": 4096 }, + "d": bounded(json!(["b", "c"])) + } }), + None + )); + // while one more edge, `a` from `d`, leaves three slots that all differ and two strings + assert!(!post_meets( + json!({ "properties": { + "a": bounded(json!("d")), + "b": bounded(json!("a")), + "c": { "name": "$any", "type": "string", "maxBytes": 4096 }, + "d": bounded(json!(["b", "c"])) + } }), + None + )); } #[test] From decfcc488aedd55740a5b5f8db040dcdda32fd0b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 16:42:23 +0700 Subject: [PATCH 11/20] feat(platform)!: name a document-named target with $self. on contractId and documentType A refersTo whose target the referring document names no longer has keys of its own for it. It writes the keys every document reference has, read from the document: "contractId": "$self.descriptionContractId", "documentType": "$self.descriptionDocumentType" in place of contractIdProperty and documentTypeProperty. `$self` is the document being written, as `$old` is the stored one in a rule. A slot's name read from the document follows: "$self.bodyProperty" in place of { "property": "bodyProperty" }. Both are read from the document or neither: one of them literal, or left out, beside the other is refused, as is documentTypeRequirements beside a target the schema names. Protocol version 14 gives the prefix no meaning and refuses such a declaration. What is stored is unchanged. Co-Authored-By: Claude Fable 5.2 --- book/src/contract-keywords.md | 5 +- .../contract-keywords/refers-to-dynamic.md | 26 +-- book/src/contract-keywords/refers-to.md | 4 +- book/src/data-model/documents.md | 4 +- .../try_from_schema/dynamic_reference.rs | 151 +++++++++--------- .../class_methods/try_from_schema/mod.rs | 4 +- .../native_schema/references.rs | 105 +++++++----- .../v3/dynamic_reference_tests.rs | 125 ++++++++------- .../src/data_contract/document_type/mod.rs | 9 -- .../property/dynamic_document_reference.rs | 42 +++-- .../document_type/property/mod.rs | 4 +- ...document_type_requirement_not_met_error.rs | 2 +- .../tests/dynamic_document_reference.rs | 14 +- .../dpp_versions/dpp_contract_versions/mod.rs | 5 +- .../dpp_versions/dpp_contract_versions/v7.rs | 2 +- .../src/version/system_limits/mod.rs | 2 +- .../rs-platform-version/src/version/v15.rs | 8 +- packages/wasm-dpp2/src/consensus_error.rs | 2 +- .../data_contract/document_type_reference.rs | 43 ++--- 19 files changed, 312 insertions(+), 245 deletions(-) diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md index 63ced2e968b..3caa1f2f6cf 100644 --- a/book/src/contract-keywords.md +++ b/book/src/contract-keywords.md @@ -194,9 +194,8 @@ A typed array's element (`items`) takes `type` (the `identifier` and `bytes` sho | `type: "identityPublicKey"` | | The value names an identity key that exists and is not disabled. | 14 | [identityPublicKey](contract-keywords/refers-to.md#identitypublickey) | | `documentType` | document type name | The referenced document type. | 14 | [documentType](contract-keywords/refers-to.md#documenttype) | | `contractId` | identifier | The contract holding `documentType`, when it is not this one. | 14 | [contractId](contract-keywords/refers-to.md#contractid) | -| `contractIdProperty` | top-level identifier property name | On a `permanentDocument` or `moderatedDocument`: the property holding the id of the contract the referenced type lives in, in place of `contractId`. Comes with `documentTypeProperty`. | 15 | [A Target the Document Names](contract-keywords/refers-to-dynamic.md) | -| `documentTypeProperty` | top-level string property name | The property holding the name of the referenced document type, in place of `documentType`. | 15 | [A Target the Document Names](contract-keywords/refers-to-dynamic.md) | -| `documentTypeRequirements` | `{ "properties", "require"? }` | Beside the two above: 1 to 4 named slots, each a property the named type must have (`name`, `type`, `maxBytes`, `distinctFrom`), and which of them must be filled. | 15 | [documentTypeRequirements](contract-keywords/refers-to-dynamic.md#documenttyperequirements) | +| `contractId`, `documentType` as `"$self."` | `$self.` and a top-level property name | On a `permanentDocument` or `moderatedDocument`: both read from the referring document, an identifier property holding the id of the contract and a string property holding the name of the document type. Both or neither. | 15 | [A Target the Document Names](contract-keywords/refers-to-dynamic.md) | +| `documentTypeRequirements` | `{ "properties", "require"? }` | Beside a target read from the document: 1 to 4 named slots, each a property the named type must have (`name`, `type`, `maxBytes`, `distinctFrom`), and which of them must be filled. | 15 | [documentTypeRequirements](contract-keywords/refers-to-dynamic.md#documenttyperequirements) | | `findBy` | 1 to 10 entries: referenced property → `"."`, `"$ownerId"`, a path or a function | Finds the document through the unique index of `documentType` over exactly these properties, in any order; `"."` is the value itself. Without it the value is the document's `$id`. | 14 | [findBy](contract-keywords/refers-to-lookup.md) · [internals](data-model/documents.md#found-by-a-unique-index-findby) | | `findBy.` | `{ "function": "sys.hash.sha256d", "params" }` | A function entry: the referenced property holds the hash of `params` (paths, `{ "const": text }`, `"."` for a value without a path), which fills that part of the key, finding a commitment made earlier. At most one. | 14 | [Commit and reveal](contract-keywords/refers-to-lookup.md#commit-and-reveal) · [internals](data-model/documents.md#commit-and-reveal-a-findby-function) | | `where` | 1 to 10 entries `{ "": "" }` | Checked on the document found: each referenced property (or `$ownerId`, `$creatorId`, `$id`) must equal the referring property. A value of `"$ownerId"`, the writer, makes a write gate. Never finds the document. | 14 | [where](contract-keywords/refers-to.md#where) | diff --git a/book/src/contract-keywords/refers-to-dynamic.md b/book/src/contract-keywords/refers-to-dynamic.md index 6a6d2757d04..0a76c7074a0 100644 --- a/book/src/contract-keywords/refers-to-dynamic.md +++ b/book/src/contract-keywords/refers-to-dynamic.md @@ -1,11 +1,11 @@ # A Target the Document Names -A document reference normally names its target in the schema: a `documentType`, and a `contractId` when the type is in another contract. A reference with `contractIdProperty` and `documentTypeProperty` leaves both to the document: two of its own properties say which contract and which document type the value points into. `documentTypeRequirements` then says what that document type must look like. Reach for it when a contract should accept a document of any contract that qualifies: a proposal described by a moderated text someone else hosts, a listing attached to a record of whichever registry issued it. +A document reference normally names its target in the schema: a `documentType`, and a `contractId` when the type is in another contract. A reference whose `contractId` and `documentType` are written `"$self."` leaves both to the document: two of its own properties say which contract and which document type the value points into. `$self` is the document being written, as [`$old`](property-constraints.md) is the stored one in a rule. `documentTypeRequirements` then says what that document type must look like. Reach for it when a contract should accept a document of any contract that qualifies: a proposal described by a moderated text someone else hosts, a listing attached to a record of whichever registry issued it. | | | |---|---| | **Where** | `refersTo` on an identifier property, at the top level or inside an object. Not on the `items` of a typed array, not an operand of an [expression](refers-to-expressions.md), not [`ownerRefersTo` or `creatorRefersTo`](owner-refers-to.md). | -| **Value** | `contractIdProperty` and `documentTypeProperty`, both required, and optionally `documentTypeRequirements`, in a `"type": "permanentDocument"` or `"type": "moderatedDocument"` declaration that carries nothing else. | +| **Value** | `"contractId": "$self."` and `"documentType": "$self."`, both required, and optionally `documentTypeRequirements`, in a `"type": "permanentDocument"` or `"type": "moderatedDocument"` declaration that carries nothing else. | | **Since** | protocol version 15 | | **On update** | Fixed, like the rest of `refersTo` (`IncompatibleDocumentTypeSchemaError`, 10246). | | **Errors** | When a document is written: `ReferencedDocumentTypeNotFoundError` (40121), `ReferencedDocumentTypePrivateError` (40150), `ReferencedDocumentTypeIndexOnlyError` (40146), `ReferencedDocumentTypeRequirementNotMetError` (40153), `ReferencedDocumentTypeDeletableError` (40122), `ReferencedDocumentTypeNotModeratedError` (40143), `ReferencedDocumentTypeModeratedError` (40144), `ReferencedEntityNotFoundError` (40120). At registration: `InvalidContractStructure` (10231), `JsonSchemaError` (10101). | @@ -20,8 +20,8 @@ A document reference normally names its target in the schema: a `documentType`, "type": "identifier", "refersTo": { "type": "moderatedDocument", - "contractIdProperty": "descriptionContractId", - "documentTypeProperty": "descriptionDocumentType", + "contractId": "$self.descriptionContractId", + "documentType": "$self.descriptionDocumentType", "documentTypeRequirements": { "properties": { "body": { "name": "$any", "type": "string" }, @@ -46,11 +46,11 @@ A note is described by a document its author points at: `descriptionContractId` | Key | Value | What it does | |---|---|---| -| `contractIdProperty` | the name of a top-level identifier property of the document type, 1 to 64 letters, digits or underscores | The property holding the id of the contract the referenced document type lives in. Takes the place of `contractId`. | -| `documentTypeProperty` | the name of a top-level string property | The property holding the name of the referenced document type. Takes the place of `documentType`. | +| `contractId` | `"$self."` followed by the name of a top-level identifier property of the document type, 1 to 64 letters, digits or underscores | The property holding the id of the contract the referenced document type lives in, where a reference naming its target in the schema writes the id itself. | +| `documentType` | `"$self."` followed by the name of a top-level string property | The property holding the name of the referenced document type, where a reference naming its target in the schema writes the name itself. | | `documentTypeRequirements` | object: `properties` (required), `require` | What the named document type must look like. Absent, the type is held to its kind alone. | -The two properties come together: a declaration names its target in the schema or through the document, never half of each. The declaring contract is named like any other: a document pointing into its own contract writes that contract's id. +The two come together: a declaration names its target in the schema or through the document, never half of each, so a `$self.` `contractId` beside a literal `documentType` is refused, as is one beside none. The declaring contract is named like any other: a document pointing into its own contract writes that contract's id. Nothing a schema could write literally starts with `$self.`: a contract id is base58 and a document type name holds no `$`. ## documentTypeRequirements @@ -58,7 +58,7 @@ The two properties come together: a declaration names its target in the schema o | Slot key | Value | Meaning | |---|---|---| -| `name` | `"$any"`, a property name, or `{ "property": "" }` | Which property may fill the slot: any property matching the rest; the one called this; or the one the referring document names in that property of its own. | +| `name` | `"$any"`, a property name, or `"$self."` | Which property may fill the slot: any property matching the rest; the one called this; or the one the referring document names in that property of its own. | | `type` | `"string"`, `"identifier"`, `"integer"`, `"boolean"`, or `"array"` with `"byteArray": true` | What the property holds. A byte array slot is not filled by an identifier, nor an identifier slot by a byte array. | | `maxBytes` | integer, 1 to 4294967295 | On a string or byte array slot: the property can never hold more bytes than this. A string's bound is its `maxBytes`, or four times its `maxLength`; a byte array's is its `maxItems`. A property declaring no bound meets none. | | `distinctFrom` | a slot name, or a list of slot names | This slot is filled by a different property than each of them. Saying it on one side is enough. | @@ -77,7 +77,7 @@ Every slot is named by `require` when there is one: a slot it never names could "documentTypeRequirements": { "properties": { "title": { "name": "$any", "type": "string", "maxBytes": 512 }, - "body": { "name": { "property": "bodyProperty" }, "type": "string", "distinctFrom": "title" }, + "body": { "name": "$self.bodyProperty", "type": "string", "distinctFrom": "title" }, "bodyBytes": { "name": "$any", "type": "array", "byteArray": true } }, "require": { "allOf": ["title", { "anyOf": ["body", "bodyBytes"] }] } @@ -97,7 +97,7 @@ Take the first example of this page, `body` or `bodyBytes`: a type holding a str Nothing about the target is known when the contract is registered, so registration checks the declaration alone. Everything about the target is checked when a document is written, in this order, the first failure refusing the write: -1. The document names a contract and a document type. A document that holds the reference but leaves out `contractIdProperty`'s or `documentTypeProperty`'s property points nowhere: `ReferencedEntityNotFoundError` (40120). +1. The document names a contract and a document type. A document that holds the reference but leaves out the property its `contractId` or its `documentType` is read from points nowhere: `ReferencedEntityNotFoundError` (40120). 2. The contract exists and has a document type of that name: `ReferencedDocumentTypeNotFoundError` (40121) otherwise. Another contract costs a billed fetch, once per write. 3. The type is not [private](../contract-keywords.md#document-type), `ReferencedDocumentTypePrivateError` (40150), and not `indexOnly`, `ReferencedDocumentTypeIndexOnlyError` (40146): the documents of such a type are index entries, and none can be found by its id. 4. The type meets `documentTypeRequirements`: `ReferencedDocumentTypeRequirementNotMetError` (40153). @@ -110,10 +110,10 @@ Nothing about the target is known when the contract is registered, so registrati ## Rules at registration -- Both `contractIdProperty` and `documentTypeProperty` are present, name two different properties, and `type` is `permanentDocument` or `moderatedDocument`. A `deletableDocument` reference always names its type in the schema: a dynamic target is trusted for what its type promises about its documents staying, and a deletable type promises nothing. -- The declaration carries nothing else: no `contractId`, `documentType`, `findBy`, `where`, `inList`, `consume` or `constraints`. Each needs the referenced type when the contract is registered. +- Both `contractId` and `documentType` are present and read from the document (`"$self."`), from two different properties, and `type` is `permanentDocument` or `moderatedDocument`. A `deletableDocument` reference always names its type in the schema: a dynamic target is trusted for what its type promises about its documents staying, and a deletable type promises nothing. +- The declaration carries nothing else: no `findBy`, `where`, `inList`, `consume` or `constraints`. Each needs the referenced type when the contract is registered. `documentTypeRequirements` comes only with a target read from the document: a type the schema names needs no description. - It sits on a single identifier property: not on the elements of a typed array, in a reference expression, or in `ownerRefersTo` and `creatorRefersTo`. -- `contractIdProperty` names a top-level identifier property, and `documentTypeProperty` and every slot's `{ "property": ... }` a top-level string property. None is transient or the reference property itself, and each is required wherever the reference property is, so a document that must hold the reference always says where it points. None is listed under [`moderatorAbilities.changeFields`](moderator-abilities.md), like the reference property itself: a moderator's change checks no reference again, so it could point a stored one somewhere nobody checked. +- `contractId` reads a top-level identifier property, and `documentType` and every slot's `"$self."` name a top-level string property. None is transient or the reference property itself, and each is required wherever the reference property is, so a document that must hold the reference always says where it points. None is listed under [`moderatorAbilities.changeFields`](moderator-abilities.md), like the reference property itself: a moderator's change checks no reference again, so it could point a stored one somewhere nobody checked. - `documentTypeRequirements` holds 1 to 4 slots (`max_document_type_requirement_slots`), each well formed; `distinctFrom` names other slots of the same `properties`; `require` names slots, and all of them. - No [`propertyConstraints` rule](property-constraints.md#values-of-a-referenced-document) reads a value through the reference, no [index](derived-index-properties.md) holds one, and no chained query joins through it: each resolves the referenced type from the schema. diff --git a/book/src/contract-keywords/refers-to.md b/book/src/contract-keywords/refers-to.md index ca8955a6ff9..15ae9ac3ee2 100644 --- a/book/src/contract-keywords/refers-to.md +++ b/book/src/contract-keywords/refers-to.md @@ -57,7 +57,7 @@ An identifier (a 32-byte id) can hold any value. `refersTo` says what it points A key that belongs to another target is refused when the contract is registered. -From protocol version 15 a `permanentDocument` or `moderatedDocument` reference may leave its target to the document: `contractIdProperty` and `documentTypeProperty` name the properties holding the contract and the document type, in place of `contractId` and `documentType`, and `documentTypeRequirements` says what that type must look like. See [A Target the Document Names](refers-to-dynamic.md). +From protocol version 15 a `permanentDocument` or `moderatedDocument` reference may leave its target to the document: a `contractId` and a `documentType` written `"$self."` are read from the properties of the referring document that hold the contract and the document type, and `documentTypeRequirements` says what that type must look like. See [A Target the Document Names](refers-to-dynamic.md). ### `identity` @@ -424,7 +424,7 @@ The registration errors name the declaration as `.`, `"`) on a `permanentDocument` or `moderatedDocument`, a document of whichever contract and document type the referring document names, held to `documentTypeRequirements` (protocol version 15). - [Writer and Creator References](owner-refers-to.md): `ownerRefersTo` and `creatorRefersTo`, the same declaration applied to the document's writer or creator instead of a property. - [References on the elements](../data-model/documents.md#references-on-the-elements) of a typed array: a declaration on `items`, checked for every element. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index ccab4b9a93c..ee74ca8b6af 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -630,11 +630,11 @@ A create missing a param of `validWhen` is refused before any read, as for `find In Rust the parser holds the function entry in the `DocumentReferenceLookup` as the source of the property it names: `LookupKeySource::Hash(LookupHashKey)`, appended to the key sources, with its `function` a `HashFunction` (the `SystemFunction::Hash` namespace, beside the string transformations `generatedFrom` takes) and its `params` `LookupKeyParam`s. The parsed `property_agreement` holds the `where` entries alone. A string or byte array property's declaration is `DocumentProperty::revealed_reference`, listed by `reference_declarations` as `PropertyReference::Revealed`. `DocumentReferenceLookup::is_checked_on_create_only` tells a lookup holding a computed key, and the parser holds the reference's `consume` and its `constraints`, `minimumAgeBlocks` and `chooseFirst`, on the lookup as `consume`, `minimum_age_blocks` and `choose_first`, a `LookupChooseFirst` (`among`, `valid_when_property`, `valid_when`, a `LookupHashKey`, and `by`, a list of `ChooseFirstOrder`: a property and whether it ascends), the lookup serializing them back inside `constraints`; a `"$ownerId"` param is `LookupKeyParam::Writer`. `first_unrevealable_lookup_key` is the structure check. A create that consumes carries its commitments in `DocumentCreateTransitionAction::consumed_documents`, turned into delete operations with the create's own. -### A target the document names (`contractIdProperty`, `documentTypeProperty`) +### A target the document names (`$self.`) From protocol version 15 a `permanentDocument` or `moderatedDocument` reference on an identifier property may name its target through the referring document instead of the schema (see [A Target the Document Names](../contract-keywords/refers-to-dynamic.md)). The declaration parses to its own variant, `DocumentPropertyReferenceTarget::DynamicDocument(DynamicDocumentReference)`, appended to the enum: the kind, the two property names and the optional `DocumentTypeRequirements` (`property/dynamic_document_reference.rs`). `as_any_document_reference` gives `None` for it, since it names no contract and no document type, so everything that resolves a reference's target from the schema (registration's target checks, rule reads, derived index properties, chained joins) does not see it; `as_dynamic_document_reference` gives the declaration to the code that does. -Version 1 of `apply_property_reference` reads the declaration (`try_from_schema/dynamic_reference.rs`), and refuses its keys wherever else a reference sits; version 0, protocol version 14, does not read them. Once a type's properties are parsed, `validate_dynamic_reference_sources` checks the properties the target is read from. +Version 1 of `apply_property_reference` reads the declaration (`try_from_schema/dynamic_reference.rs`), told by a `contractId` or a `documentType` written with `REFERRING_DOCUMENT_PREFIX` (`$self.`) or by `documentTypeRequirements`, and refuses it wherever else a reference sits; version 0, protocol version 14, gives the prefix no meaning, so a `contractId` written with it is no contract id there and the declaration is refused. Once a type's properties are parsed, `validate_dynamic_reference_sources` checks the properties the target is read from. The write-time check, `document_reference_validation` 1, builds the declaration a by-id reference would carry from the document's own values (the contract id and the type name, no `where`) and runs the flow every document reference runs: contract, document type, kind, document, removal record. Between the document type and the kind it does what registration does for a reference naming its type in the schema, refusing a private type, and judges the requirements with `DocumentTypeRequirements::met_by`. That search is over sets of slots, fullest first, and within a set fills the slot with the fewest candidates first; with at most `max_document_type_requirement_slots` slots it stays small whatever the referenced type declares. A replace checks the reference again when one of `DynamicDocumentReference::target_properties` changed. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs index 217edb3da25..db94af0e8a9 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs @@ -1,5 +1,5 @@ -//! Parsing of a `refersTo` whose target the referring document names -//! (`contractIdProperty`, `documentTypeProperty`, `documentTypeRequirements`), which +//! Parsing of a `refersTo` whose target the referring document names (`contractId` and +//! `documentType` written `"$self."`, with `documentTypeRequirements`), which //! version 1 of `apply_property_reference` reads (protocol version 15). See //! [`DynamicDocumentReference`]. @@ -8,6 +8,7 @@ use crate::data_contract::document_type::property::{ DocumentPropertyType, DocumentTypeRequirements, DynamicDocumentReference, DynamicDocumentReferenceKind, PropertyReference, ReferenceHolder, RequiredPropertySlot, SlotPropertyName, SlotRequirement, SlotRequirementTerm, SlotValue, ANY_PROPERTY_NAME, + REFERRING_DOCUMENT_PREFIX, }; use crate::data_contract::document_type::{property_names, DocumentTypeRef}; use crate::data_contract::errors::DataContractError; @@ -16,13 +17,22 @@ use platform_value::Value; use platform_version::version::PlatformVersion; use std::collections::{BTreeMap, BTreeSet}; -/// The keys only a dynamic reference carries. +/// The keys of a dynamic reference beside its `type`. const DYNAMIC_KEYS: [&str; 3] = [ - property_names::CONTRACT_ID_PROPERTY, - property_names::DOCUMENT_TYPE_PROPERTY, + property_names::CONTRACT_ID, + property_names::DOCUMENT_TYPE, property_names::DOCUMENT_TYPE_REQUIREMENTS, ]; +/// The two keys a dynamic reference reads from the referring document. +const TARGET_KEYS: [&str; 2] = [property_names::CONTRACT_ID, property_names::DOCUMENT_TYPE]; + +/// What `"$self."` reads from the referring document: ``. `None` for a value +/// that is not written with the prefix, a literal contract id or document type among them. +fn read_from_referring_document(value: &Value) -> Option<&str> { + value.as_text()?.strip_prefix(REFERRING_DOCUMENT_PREFIX) +} + /// The keys of a slot of `documentTypeRequirements.properties`. const SLOT_KEYS: [&str; 5] = ["name", "type", "byteArray", "maxBytes", "distinctFrom"]; @@ -30,15 +40,19 @@ fn refused(message: String) -> DataContractError { DataContractError::InvalidContractStructure(message) } -/// Whether a `refersTo` declaration names its target through the referring document: it -/// carries one of the keys only a dynamic reference does. +/// Whether a `refersTo` declaration names its target through the referring document: its +/// `contractId` or its `documentType` is read from it (`"$self."`), or it +/// carries `documentTypeRequirements`, which describe a type the schema does not name. pub(super) fn declares_dynamic_target(refers_to_value: &Value) -> bool { refers_to_value .to_btree_ref_string_map() .is_ok_and(|declaration| { - DYNAMIC_KEYS - .iter() - .any(|key| declaration.contains_key(*key)) + declaration.contains_key(property_names::DOCUMENT_TYPE_REQUIREMENTS) + || TARGET_KEYS.iter().any(|key| { + declaration + .get(*key) + .is_some_and(|value| read_from_referring_document(value).is_some()) + }) }) } @@ -68,7 +82,7 @@ pub(super) fn refuse_dynamic_target( ) -> Result<(), DataContractError> { if refers_to_value.is_some_and(declares_dynamic_target_anywhere) { return Err(refused(format!( - "a refersTo naming its target through contractIdProperty and documentTypeProperty \ + "a refersTo reading its contractId and documentType from the referring document \ is not allowed {place}: it sits on an identifier property, alone" ))); } @@ -84,18 +98,6 @@ fn is_word_name(name: &str) -> bool { .all(|character| character.is_ascii_alphanumeric() || character == '_') } -fn word_name(value: &Value, what: &str) -> Result { - value - .as_text() - .filter(|name| is_word_name(name)) - .map(str::to_string) - .ok_or_else(|| { - refused(format!( - "{what} must be a name of 1 to 64 letters, digits or underscores" - )) - }) -} - /// Parses a declaration [`declares_dynamic_target`] holds for, on an identifier property. pub(super) fn parse_dynamic_document_reference( refers_to_map: &BTreeMap, @@ -109,10 +111,10 @@ pub(super) fn parse_dynamic_document_reference( "moderatedDocument" => DynamicDocumentReferenceKind::Moderated, other => { return Err(refused(format!( - "{other} refersTo does not take contractIdProperty, documentTypeProperty or \ - documentTypeRequirements: only a permanentDocument or a moderatedDocument \ - reference names its target through the referring document, since only those \ - document types promise their documents stay" + "{other} refersTo reads no contractId or documentType from the referring \ + document and takes no documentTypeRequirements: only a permanentDocument or \ + a moderatedDocument reference names its target through the referring \ + document, since only those document types promise their documents stay" ))) } }; @@ -124,27 +126,40 @@ pub(super) fn parse_dynamic_document_reference( .find(|key| key.as_str() != property_names::TYPE && !DYNAMIC_KEYS.contains(&key.as_str())) { return Err(refused(format!( - "{reference_type} refersTo naming its target through contractIdProperty and \ - documentTypeProperty does not take {key}: the referenced document type is not \ - known until a document is written" + "{reference_type} refersTo naming its target through the referring document does \ + not take {key}: the referenced document type is not known until a document is \ + written" ))); } + // Both come from the document: a declaration names its target in the schema or + // through the referring document, not half of each let property = |key: &str| -> Result { - let value = refers_to_map.get(key).ok_or_else(|| { - refused(format!( - "{reference_type} refersTo naming its target through the referring document \ - needs both contractIdProperty and documentTypeProperty: {key} is missing" - )) - })?; - word_name(value, &format!("{reference_type} refersTo {key}")) + let read = refers_to_map + .get(key) + .and_then(|value| read_from_referring_document(value)) + .ok_or_else(|| { + refused(format!( + "{reference_type} refersTo naming its target through the referring \ + document reads both contractId and documentType from it: {key} must be \ + \"{REFERRING_DOCUMENT_PREFIX}\"" + )) + })?; + if !is_word_name(read) { + return Err(refused(format!( + "{reference_type} refersTo {key} must read a top-level property, \ + \"{REFERRING_DOCUMENT_PREFIX}\" followed by a name of 1 to 64 letters, digits \ + or underscores" + ))); + } + Ok(read.to_string()) }; - let contract_id_property = property(property_names::CONTRACT_ID_PROPERTY)?; - let document_type_property = property(property_names::DOCUMENT_TYPE_PROPERTY)?; + let contract_id_property = property(property_names::CONTRACT_ID)?; + let document_type_property = property(property_names::DOCUMENT_TYPE)?; if contract_id_property == document_type_property { return Err(refused(format!( - "{reference_type} refersTo contractIdProperty and documentTypeProperty name the \ - same property, \"{contract_id_property}\"" + "{reference_type} refersTo contractId and documentType read the same property, \ + \"{contract_id_property}\"" ))); } @@ -237,35 +252,25 @@ fn parse_slot(slot_name: &str, value: &Value) -> Result SlotPropertyName::Any, - Some(Value::Text(name)) if is_word_name(name) => SlotPropertyName::Named(name.clone()), - Some(from_property @ Value::Map(_)) => { - let from_property = from_property - .to_btree_ref_string_map() - .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; - match (from_property.len(), from_property.get("property")) { - (1, Some(property)) => SlotPropertyName::FromProperty(word_name( - property, - &format!("{} name property", at()), - )?), - _ => { - return Err(refused(format!( - "{} name must be \"$any\", a property name, or {{ \"property\": }}", - at() - ))) - } - } - } - _ => { - return Err(refused(format!( - "{} needs a name: \"$any\", a property name of 1 to 64 letters, digits or \ - underscores, or {{ \"property\": }}", - at() - ))) - } + let named_wrongly = || { + refused(format!( + "{} needs a name: \"$any\", a property name of 1 to 64 letters, digits or \ + underscores, or \"{REFERRING_DOCUMENT_PREFIX}\"", + at() + )) + }; + let name = match slot.get("name").and_then(|name| name.as_text()) { + Some(ANY_PROPERTY_NAME) => SlotPropertyName::Any, + Some(name) if is_word_name(name) => SlotPropertyName::Named(name.to_string()), + // The name the referring document gives in a string property of its own + Some(name) => SlotPropertyName::FromProperty( + name.strip_prefix(REFERRING_DOCUMENT_PREFIX) + .filter(|property| is_word_name(property)) + .ok_or_else(named_wrongly)? + .to_string(), + ), + None => return Err(named_wrongly()), }; let byte_array = match slot.get("byteArray") { @@ -530,7 +535,7 @@ pub(super) fn validate_dynamic_reference_sources( }; source( - property_names::CONTRACT_ID_PROPERTY, + property_names::CONTRACT_ID, &dynamic.contract_id_property, "an identifier", |property_type| { @@ -542,7 +547,7 @@ pub(super) fn validate_dynamic_reference_sources( }, )?; let string_sources = std::iter::once(( - property_names::DOCUMENT_TYPE_PROPERTY, + property_names::DOCUMENT_TYPE, dynamic.document_type_property.as_str(), )) .chain( @@ -550,7 +555,7 @@ pub(super) fn validate_dynamic_reference_sources( .requirements .iter() .flat_map(|requirements| requirements.name_properties()) - .map(|name| ("documentTypeRequirements slot name property", name)), + .map(|name| ("documentTypeRequirements slot name", name)), ); for (key, name) in string_sources { source(key, name, "a string", |property_type| { 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 a7ad0fdabd8..c1a1c0dfc9e 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 @@ -765,7 +765,7 @@ pub(in crate::data_contract::document_type::class_methods) fn apply_property_ref } /// Version 1 (protocol version 15): also reads a reference whose target the referring -/// document names (`contractIdProperty`, `documentTypeProperty`, +/// document names (`contractId` and `documentType` written `"$self."`, /// `documentTypeRequirements`), on an identifier property. Every other declaration is /// version 0's, except that an operand of a reference expression naming its target that way /// is refused, where version 0 does not read those keys. @@ -789,7 +789,7 @@ fn apply_property_reference_v1( DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) ) { return Err(DataContractError::InvalidContractStructure( - "a refersTo naming its target through contractIdProperty and documentTypeProperty \ + "a refersTo reading its contractId and documentType from the referring document \ is only allowed on identifier properties: its value is the referenced document's id" .to_string(), )); diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs index 34b6db328b8..d4b03b243f1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs @@ -3,23 +3,35 @@ use super::*; -/// The keys only a `refersTo` naming its target through the referring document carries +/// The prefix a `refersTo` reads a value from the referring document with: a `contractId` +/// and a `documentType` written `"$self."` name the target through the document /// (protocol version 15). -const DYNAMIC_TARGET_KEYS: [&str; 3] = [ - "contractIdProperty", - "documentTypeProperty", - "documentTypeRequirements", -]; +const REFERRING_DOCUMENT_PREFIX: &str = "$self."; + +/// Whether `value` is written with the prefix that reads from the referring document. +fn is_read_from_referring_document(value: &JsonValue) -> bool { + value + .as_str() + .is_some_and(|text| text.starts_with(REFERRING_DOCUMENT_PREFIX)) +} + +/// Whether a `refersTo` declaration names its target through the referring document: its +/// `contractId` or its `documentType` is read from it, or it carries +/// `documentTypeRequirements`. +fn names_its_target(map: &JsonMap) -> bool { + map.contains_key("documentTypeRequirements") + || ["contractId", "documentType"] + .iter() + .any(|key| map.get(*key).is_some_and(is_read_from_referring_document)) +} /// The keys of a `refersTo` declaration. -const REFERS_TO_KEYS: [&str; 17] = [ +const REFERS_TO_KEYS: [&str; 15] = [ "type", "anyOf", "allOf", "contractId", "documentType", - "contractIdProperty", - "documentTypeProperty", "documentTypeRequirements", "contractRequirements", "keyIdProperty", @@ -67,20 +79,16 @@ impl Checker<'_> { // A reference naming its target through the referring document: a permanent or // moderated document found by its id, with nothing beside it that needs the // referenced type at registration - if DYNAMIC_TARGET_KEYS.iter().any(|key| has(key)) { - self.required(map, &["type", "contractIdProperty", "documentTypeProperty"])?; + if names_its_target(map) { + self.required(map, &["type", "contractId", "documentType"])?; self.member(map, "type", |c, target| { c.one_of_strings(target, &["permanentDocument", "moderatedDocument"]) })?; - for name in [ - "contractId", - "documentType", - "findBy", - "where", - "inList", - "consume", - "constraints", - ] { + // Both are read from the referring document, or neither + for key in ["contractId", "documentType"] { + self.member(map, key, |c, value| c.read_from_referring_document(value))?; + } + for name in ["findBy", "where", "inList", "consume", "constraints"] { self.absent(map, name)?; } } else if matches!( @@ -135,6 +143,33 @@ impl Checker<'_> { Ok(()) } + /// Refuses a declaration naming its target through the referring document where a + /// reference names it in the schema: an operand of an expression, `ownerRefersTo` and + /// `creatorRefersTo`. + fn names_its_target_in_the_schema(&self, map: &JsonMap) -> Checked { + self.absent(map, "documentTypeRequirements")?; + if names_its_target(map) { + return Err(self.fail( + "not", + "a refersTo here names its contract and document type in the schema, not \ + through the referring document", + )); + } + Ok(()) + } + + /// `"$self."`: a top-level property of the referring document, a name of 1 to + /// 64 letters, digits or underscores after the prefix. + fn read_from_referring_document(&self, value: &JsonValue) -> Checked { + let property = self + .string(value)? + .strip_prefix(REFERRING_DOCUMENT_PREFIX) + .map(|property| json!(property)) + .ok_or_else(|| self.no_branch(value))?; + self.string_of_length(&property, 1, Some(64))?; + self.word(&property) + } + fn refers_to_keyword(&self, key: &str, value: &JsonValue) -> Checked { match key { "type" => self.one_of_strings( @@ -151,6 +186,9 @@ impl Checker<'_> { ), "anyOf" | "allOf" => self.reference_operands(key, value), "contractId" => { + if is_read_from_referring_document(value) { + return self.read_from_referring_document(value); + } if let Some(text) = value.as_str() { let length = text.chars().count(); let base58 = text @@ -174,7 +212,10 @@ impl Checker<'_> { } Err(self.fail("oneOfNotValid", format!("{value} is not a contract id"))) } - "documentType" | "contractIdProperty" | "documentTypeProperty" => { + "documentType" => { + if is_read_from_referring_document(value) { + return self.read_from_referring_document(value); + } self.string_of_length(value, 1, Some(64))?; self.word(value) } @@ -313,17 +354,13 @@ impl Checker<'_> { &["name", "type", "byteArray", "maxBytes", "distinctFrom"], )?; self.required(map, &["name", "type"])?; + // `$any`, a property name, or `$self.` for the name the referring + // document gives: which of the three a string is, the parser tells self.member(map, "name", |c, name| { - if name.is_string() { - return c.string_of_length(name, 1, Some(64)).map(drop); + if is_read_from_referring_document(name) { + return c.read_from_referring_document(name); } - let from_property = c.object(name)?; - c.closed(from_property, &["property"])?; - c.required(from_property, &["property"])?; - c.member(from_property, "property", |c, property| { - c.string_of_length(property, 1, Some(64))?; - c.word(property) - }) + c.string_of_length(name, 1, Some(64)).map(drop) })?; self.member(map, "type", |c, value_type| { c.one_of_strings( @@ -394,9 +431,7 @@ impl Checker<'_> { let map = c.object(operand)?; c.absent(map, combinator)?; c.absent(map, "identityProperty")?; - for key in DYNAMIC_TARGET_KEYS { - c.absent(map, key)?; - } + c.names_its_target_in_the_schema(map)?; c.member(map, "type", |c, target| { c.one_of_strings( target, @@ -417,9 +452,7 @@ impl Checker<'_> { pub(super) fn owner_refers_to(&self, value: &JsonValue) -> Checked { self.refers_to(value)?; let map = self.object(value)?; - for key in DYNAMIC_TARGET_KEYS { - self.absent(map, key)?; - } + self.names_its_target_in_the_schema(map)?; self.member(map, "type", |c, target| { c.one_of_strings( target, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs index 81c40f96103..b6e56599cef 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs @@ -1,5 +1,5 @@ -//! References whose target the referring document names (`contractIdProperty`, -//! `documentTypeProperty`, `documentTypeRequirements`, protocol version 15): the parse of +//! References whose target the referring document names (`contractId` and `documentType` +//! written `"$self."`, `documentTypeRequirements`, protocol version 15): the parse of //! the declaration, where it may sit, the checks of the properties it reads, the protocol //! version gate, what a document type must look like to meet the requirements, and the //! platform serialization round trip. @@ -38,8 +38,8 @@ fn string(position: u32) -> serde_json::Value { fn text_or_bytes() -> serde_json::Value { json!({ "type": "moderatedDocument", - "contractIdProperty": "descriptionContractId", - "documentTypeProperty": "descriptionDocumentType", + "contractId": "$self.descriptionContractId", + "documentType": "$self.descriptionDocumentType", "documentTypeRequirements": { "properties": { "body": { "name": "$any", "type": "string" }, @@ -54,8 +54,8 @@ fn text_or_bytes() -> serde_json::Value { fn requiring(requirements: serde_json::Value) -> serde_json::Value { json!({ "type": "moderatedDocument", - "contractIdProperty": "descriptionContractId", - "documentTypeProperty": "descriptionDocumentType", + "contractId": "$self.descriptionContractId", + "documentType": "$self.descriptionDocumentType", "documentTypeRequirements": requirements }) } @@ -205,8 +205,8 @@ fn should_parse_a_reference_naming_its_target_through_the_referring_document() { // A permanent document, with no requirements let parsed = contract(note_contract(json!({ "type": "permanentDocument", - "contractIdProperty": "descriptionContractId", - "documentTypeProperty": "descriptionDocumentType" + "contractId": "$self.descriptionContractId", + "documentType": "$self.descriptionDocumentType" }))) .expect("parses"); let reference = dynamic_reference(&parsed); @@ -220,7 +220,7 @@ fn should_parse_every_form_of_a_slot_and_of_require() { "properties": { "title": { "name": "$any", "type": "string", "maxBytes": 256 }, "body": { - "name": { "property": "bodyProperty" }, + "name": "$self.bodyProperty", "type": "string", "distinctFrom": "title" }, @@ -294,8 +294,8 @@ fn should_parse_every_form_of_a_slot_and_of_require() { #[test] fn should_not_read_a_dynamic_target_before_protocol_version_15() { let platform_version_14 = PlatformVersion::get(14).expect("protocol version 14"); - // Registration refuses the keys it does not know, and the parser of version 14, which - // does not read them, refuses the document reference they leave without a documentType + // Registration refuses `documentTypeRequirements`, a key it does not know, and the parser + // of version 14 a `contractId` that is no contract id: `$self.` reads nothing there for full_validation in [true, false] { contract_on( note_contract(text_or_bytes()), @@ -304,20 +304,8 @@ fn should_not_read_a_dynamic_target_before_protocol_version_15() { ) .expect_err("protocol version 14 has no dynamic target"); } - // Beside a documentType, version 14's parser reads the reference it always read - let mut refers_to = text_or_bytes(); - refers_to["documentType"] = json!("post"); - let parsed = contract_on(note_contract(refers_to.clone()), false, platform_version_14) - .expect("version 14 does not read the keys"); - let note = parsed.document_type_for_name("note").expect("note"); - assert!(matches!( - note.properties()["descriptionId"].property_type, - DocumentPropertyType::IdentifierWithReference( - DocumentPropertyReferenceTarget::ModeratedDocument { .. } - ) - )); - // where protocol version 15 refuses the two together - assert_refused_by_the_parser(note_contract(refers_to), "does not take documentType"); + // which protocol version 15 reads + contract(note_contract(text_or_bytes())).expect("protocol version 15 reads it"); } #[test] @@ -328,11 +316,6 @@ fn should_refuse_what_cannot_sit_beside_a_dynamic_target() { note_contract(refers_to) }; for (key, value) in [ - ("documentType", json!("post")), - ( - "contractId", - json!(Identifier::from(CONTRACT_ID).to_string(Encoding::Base58)), - ), ("where", json!({ "$ownerId": "$ownerId" })), ("findBy", json!({ "title": "." })), ] { @@ -347,19 +330,53 @@ fn should_refuse_what_cannot_sit_beside_a_dynamic_target() { "only a permanentDocument or a moderatedDocument reference names its target", ); - // Both properties, and two different ones - let mut missing = text_or_bytes(); - missing - .as_object_mut() - .expect("an object") - .remove("documentTypeProperty"); + // The contract and the document type both come from the document: not one of them + // from the schema, whether it is given there or left out + for (key, value) in [ + ("documentType", json!("post")), + ( + "contractId", + json!(Identifier::from(CONTRACT_ID).to_string(Encoding::Base58)), + ), + ] { + assert_refused_by_the_parser( + with(key, value), + &format!("reads both contractId and documentType from it: {key} must be"), + ); + let mut missing = text_or_bytes(); + missing.as_object_mut().expect("an object").remove(key); + assert_refused_by_the_parser( + note_contract(missing), + &format!("reads both contractId and documentType from it: {key} must be"), + ); + } + // Requirements describe a type the schema does not name + assert_refused_by_the_parser( + note_contract(json!({ + "type": "moderatedDocument", + "documentType": "post", + "documentTypeRequirements": { + "properties": { "body": { "name": "$any", "type": "string" } } + } + })), + "reads both contractId and documentType from it", + ); + // What is read is a top-level property, and two different ones + for read in [ + "$self.", + "$self.description.type", + "$self.$ownerId", + "$self.a-b", + ] { + assert_refused_by_the_parser( + with("documentType", json!(read)), + "documentType must read a top-level property", + ); + } assert_refused_by_the_parser( - note_contract(missing), - "needs both contractIdProperty and documentTypeProperty", + with("documentType", json!("$self.descriptionContractId")), + "read the same property", ); - let mut same = text_or_bytes(); - same["documentTypeProperty"] = json!("descriptionContractId"); - assert_refused_by_the_parser(note_contract(same), "name the same property"); } #[test] @@ -371,7 +388,7 @@ fn should_refuse_a_dynamic_target_anywhere_but_on_an_identifier_property() { // The whole sentence, which travels in a consensus error: one space between its lines assert_refused_by_the_parser( on_a_string, - "a refersTo naming its target through contractIdProperty and documentTypeProperty is \ + "a refersTo reading its contractId and documentType from the referring document is \ only allowed on identifier properties: its value is the referenced document's id", ); @@ -400,25 +417,25 @@ fn should_refuse_a_dynamic_target_anywhere_but_on_an_identifier_property() { fn should_hold_the_properties_a_dynamic_target_reads_to_the_declaring_type() { let naming = |key: &str, property: &str| { let mut refers_to = text_or_bytes(); - refers_to[key] = json!(property); + refers_to[key] = json!(format!("$self.{property}")); note_contract(refers_to) }; // The contract id is a top-level identifier property, other than the reference assert_refused_by_the_parser( - naming("contractIdProperty", "label"), + naming("contractId", "label"), "which is not an identifier property", ); assert_refused_by_the_parser( - naming("contractIdProperty", "missing"), + naming("contractId", "missing"), "which is not a top-level property", ); assert_refused_by_the_parser( - naming("contractIdProperty", "descriptionId"), + naming("contractId", "descriptionId"), "the property carrying the reference", ); // The document type name is a string property assert_refused_by_the_parser( - naming("documentTypeProperty", "descriptionId"), + naming("documentType", "descriptionId"), "the property carrying the reference", ); let mut type_from_an_identifier = note_contract(text_or_bytes()); @@ -427,14 +444,14 @@ fn should_hold_the_properties_a_dynamic_target_reads_to_the_declaring_type() { assert_refused_by_the_parser(type_from_an_identifier, "which is not a string property"); // and each is required where the reference is assert_refused_by_the_parser( - naming("documentTypeProperty", "label"), + naming("documentType", "label"), "which is not required while the reference is", ); // A slot's name property too let named_by = |property: &str| { note_contract(requiring(json!({ "properties": { - "body": { "name": { "property": property }, "type": "string" } + "body": { "name": format!("$self.{property}"), "type": "string" } } }))) }; @@ -449,7 +466,7 @@ fn should_hold_the_properties_a_dynamic_target_reads_to_the_declaring_type() { contract(named_by("bodyProperty")).expect("a required string property names the slot's"); // An optional reference reads optional properties - let mut optional = naming("documentTypeProperty", "label"); + let mut optional = naming("documentType", "label"); optional["documentSchemas"]["note"]["required"] = json!(["descriptionContractId"]); contract(optional).expect("nothing is required where the reference is not"); } @@ -624,7 +641,7 @@ fn should_fill_a_slot_with_a_property_of_the_described_kind() { None )); // One the referring document names - let named = one(json!({ "name": { "property": "bodyProperty" }, "type": "string" })); + let named = one(json!({ "name": "$self.bodyProperty", "type": "string" })); assert!(post_meets(named.clone(), Some("body"))); assert!(!post_meets(named.clone(), Some("count"))); assert!(!post_meets(named.clone(), Some("missing"))); @@ -788,7 +805,7 @@ fn should_round_trip_a_contract_through_platform_serialization_with_a_dynamic_re "properties": { "title": { "name": "$any", "type": "string", "maxBytes": 256 }, "body": { - "name": { "property": "bodyProperty" }, + "name": "$self.bodyProperty", "type": "string", "distinctFrom": ["title"] }, @@ -846,7 +863,7 @@ fn moderated_note(abilities: serde_json::Value) -> Result"`, +//! `"documentType": "$self."`, `$self` being the document that is written, as +//! `$old` is the stored one in a rule), and the document says, when it is written, which +//! contract and which document type its value points into: //! //! ```json //! "descriptionId": { //! "type": "identifier", //! "refersTo": { //! "type": "moderatedDocument", -//! "contractIdProperty": "descriptionContractId", -//! "documentTypeProperty": "descriptionDocumentType", +//! "contractId": "$self.descriptionContractId", +//! "documentType": "$self.descriptionDocumentType", //! "documentTypeRequirements": { //! "properties": { //! "body": { "name": "$any", "type": "string" }, @@ -44,6 +45,14 @@ use std::collections::{BTreeMap, BTreeSet}; /// The name a slot takes for "any property": `"name": "$any"`. pub const ANY_PROPERTY_NAME: &str = "$any"; +/// The prefix through which a `refersTo` reads a value from the referring document, the +/// document being written: `"$self.descriptionContractId"` is its top-level property +/// `descriptionContractId`. A `contractId` and a `documentType` written with it name the +/// target through the document, and a slot's `name` written with it names the property the +/// document says. No contract id, document type or property can take such a name: none +/// holds a `$` or a dot. +pub const REFERRING_DOCUMENT_PREFIX: &str = "$self."; + /// Which kind of document a dynamic reference points at. A `deletableDocument` reference is /// never dynamic: a dynamic target is trusted for what its type promises about its /// documents staying, and a deletable type promises nothing. @@ -65,8 +74,9 @@ impl From for DocumentReferenceKind { } } -/// A `refersTo` with `contractIdProperty` and `documentTypeProperty`: the value is the id of -/// a document of the document type the referring document names. +/// A `refersTo` whose `contractId` and `documentType` are read from the referring document +/// (`"$self."`): the value is the id of a document of the document type the +/// referring document names. #[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] pub struct DynamicDocumentReference { /// What the named document type must promise about its documents leaving state. @@ -83,6 +93,16 @@ pub struct DynamicDocumentReference { } impl DynamicDocumentReference { + /// `contractId` as a contract schema declares it: `"$self."`. + pub fn contract_id_schema_value(&self) -> String { + format!("{REFERRING_DOCUMENT_PREFIX}{}", self.contract_id_property) + } + + /// `documentType` as a contract schema declares it: `"$self."`. + pub fn document_type_schema_value(&self) -> String { + format!("{REFERRING_DOCUMENT_PREFIX}{}", self.document_type_property) + } + /// The `refersTo` `type` the declaration is written with. pub fn wire_name(&self) -> &'static str { DocumentReferenceKind::from(self.kind).wire_name() @@ -140,8 +160,8 @@ pub enum SlotPropertyName { Any, /// A literal name: the property called this. Named(String), - /// `{ "property": "" }`: the property the referring document names in this - /// top-level string property of its own. + /// `"$self."`: the property the referring document names in this top-level + /// string property of its own. FromProperty(String), } @@ -285,7 +305,7 @@ impl RequiredPropertySlot { SlotPropertyName::Any => Value::Text(ANY_PROPERTY_NAME.to_string()), SlotPropertyName::Named(name) => Value::Text(name.clone()), SlotPropertyName::FromProperty(property) => { - schema_object(vec![("property", Value::Text(property.clone()))]) + Value::Text(format!("{REFERRING_DOCUMENT_PREFIX}{property}")) } }; let mut members = vec![ 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 79616a8b3c0..109ab568a21 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 @@ -52,7 +52,7 @@ pub mod reference_where; pub use dynamic_document_reference::{ DocumentTypeRequirements, DynamicDocumentReference, DynamicDocumentReferenceKind, RequiredPropertySlot, SlotPropertyName, SlotRequirement, SlotRequirementTerm, SlotValue, - ANY_PROPERTY_NAME, + ANY_PROPERTY_NAME, REFERRING_DOCUMENT_PREFIX, }; pub use encrypted_for::{EncryptedFor, EncryptedForRecipient, EncryptionScheme}; pub use generated_from::{ @@ -1059,7 +1059,7 @@ pub enum DocumentPropertyReferenceTarget { where_conditions: BTreeMap, }, /// A document of a document type the referring document names - /// (`contractIdProperty`, `documentTypeProperty`), permanent or moderated, found by the + /// (`contractId` and `documentType` read from it with `$self.`), permanent or moderated, found by the /// value as its id (protocol version 15). The contract and the document type are read /// from the referring document when it is written, so nothing about the target is /// checked at contract registration: that the type exists, is of the declared kind and diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_type_requirement_not_met_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_type_requirement_not_met_error.rs index 68c850f7ec5..40cbde0218b 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_type_requirement_not_met_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_type_requirement_not_met_error.rs @@ -9,7 +9,7 @@ use platform_value::Identifier; use thiserror::Error; /// A document reference naming its target through the referring document -/// (`contractIdProperty`, `documentTypeProperty`, protocol version 15) points at a document +/// (`contractId` and `documentType` written `$self.`, protocol version 15) points at a document /// type that does not meet the reference's `documentTypeRequirements`: no choice of its /// properties fills the slots `require` asks for. #[derive( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs index c91b2143611..9fafdbbbd58 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs @@ -1,6 +1,6 @@ //! A document reference naming its target through the referring document -//! (`contractIdProperty`, `documentTypeProperty`, `documentTypeRequirements`, protocol version -//! 15): the document says which contract and which document type its value points into, so +//! (`contractId` and `documentType` written `"$self."`, `documentTypeRequirements`, +//! protocol version 15): the document says which contract and which document type its value points into, so //! everything about the target is checked when the document is written. The target here is //! `post`, a type whose documents only the contract's moderators remove, each removal leaving //! a record. @@ -44,8 +44,8 @@ fn note_schema(requirements: Value) -> Value { "refersTo".to_string(), platform_value!({ "type": "moderatedDocument", - "contractIdProperty": "descriptionContractId", - "documentTypeProperty": "descriptionDocumentType", + "contractId": "$self.descriptionContractId", + "documentType": "$self.descriptionDocumentType", "documentTypeRequirements": requirements, }), ) @@ -120,8 +120,8 @@ fn permanent_note_schema() -> Value { let mut schema = note_schema(text_or_bytes()); let refers_to = platform_value!({ "type": "permanentDocument", - "contractIdProperty": "descriptionContractId", - "documentTypeProperty": "descriptionDocumentType", + "contractId": "$self.descriptionContractId", + "documentType": "$self.descriptionDocumentType", }); schema .get_mut("properties") @@ -166,7 +166,7 @@ async fn setup() -> Setup { NAMED_NOTE, note_schema(platform_value!({ "properties": { - "text": { "name": { "property": "textProperty" }, "type": "string" }, + "text": { "name": "$self.textProperty", "type": "string" }, }, })), ); 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 cac0f0eb029..f0e8edf2ce7 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 @@ -155,8 +155,9 @@ pub struct DocumentTypeSchemaVersions { /// exactly as they parsed before it existed. 0 (protocol version 14) reads every /// reference that names its target in the schema. 1 (protocol version 15) also reads, /// on an identifier property, a permanent or moderated document reference whose target - /// the referring document names (`contractIdProperty`, `documentTypeProperty`, with - /// `documentTypeRequirements`), and refuses those keys wherever else a reference sits. + /// the referring document names (`contractId` and `documentType` written + /// `"$self."`, with `documentTypeRequirements`), and refuses such a + /// declaration wherever else a reference sits. pub apply_property_reference: OptionalFeatureVersion, /// Parses the `requiredSince` property keyword (the contract version from /// which a property is required). `None` on versions that predate the diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs index 7dd882321cd..8e41378b630 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs @@ -22,7 +22,7 @@ use crate::version::dpp_versions::dpp_contract_versions::{ /// private document type, which only the native schema check of registration admits; /// - `apply_property_reference`, `Some(1)` rather than `Some(0)`: a permanent or moderated /// document reference on an identifier property may name its target through the -/// referring document (`contractIdProperty`, `documentTypeProperty`), with what the named +/// referring document (`contractId` and `documentType` written `"$self."`), with what the named /// document type must look like (`documentTypeRequirements`); /// - `apply_generated_from`, `Some(1)` rather than `Some(0)`: a `generatedFrom` property may /// also be generated with `sys.tokens.decimals`, the decimals of the token an identifier 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 5e3ff3c6665..59a9fcea349 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -92,7 +92,7 @@ pub struct SystemLimits { /// before. pub max_reference_expression_depth: u16, /// Maximum number of slots the `documentTypeRequirements` of a `refersTo` naming its - /// target through `contractIdProperty` and `documentTypeProperty` may declare (it holds + /// target through the referring document (`"$self."`) may declare (it holds /// at least one). Every write that checks such a reference looks for properties of the /// referenced document type to fill them with, a search that stays small only while /// the slots are few. Refused on every parse. Read by `apply_property_reference` 1 diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 0485c6e1d36..d95e5e9be22 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -661,12 +661,12 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// /// 33. **A reference whose target the document names**: `CONTRACT_VERSIONS_V7` selects /// `apply_property_reference` 1: a `permanentDocument` or `moderatedDocument` `refersTo` -/// on an identifier property may name `contractIdProperty` and `documentTypeProperty`, -/// two top-level properties of the referring document (an identifier and a string), in -/// place of `contractId` and `documentType`, with `documentTypeRequirements`: up to +/// on an identifier property may read its `contractId` and its `documentType` from the +/// referring document, each written `"$self."` for a top-level property of +/// it (an identifier and a string), with `documentTypeRequirements`: up to /// `max_document_type_requirement_slots` (4, `SYSTEM_LIMITS_V5`, backfilled) named /// slots, each a top-level property the named type must have (`name`: `"$any"`, a -/// property name, or `{ "property": }`; +/// property name, or `"$self."`; /// `type`; `maxBytes`; `distinctFrom`, one or several other slots it is filled by a /// different property than), and `require`, `anyOf` / `allOf` over slot names, every /// slot when absent. The native schema check of note 7 admits the keys and meta-schema diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 2f4044c89ba..58d36688a12 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -131,7 +131,7 @@ pub enum DocumentReferenceErrorCodeWasm { /// reads, so no write could check the reference. ReferencedDocumentTypePrivate = 40150, /// A reference naming its target through the referring document - /// (`contractIdProperty`, `documentTypeProperty`, protocol version 15) + /// (`contractId` and `documentType` written `$self.`, protocol version 15) /// points at a document type that does not meet the reference's /// `documentTypeRequirements`: its properties do not fill the slots the /// reference requires. diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index 0fcc5fd2e2f..5abb85fecce 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -275,24 +275,25 @@ export type DocumentPropertyReferenceTarget = * A document of a document type the referring document names * (protocol version 15): permanent or moderated, found by the value * as its id. Told from the other members of the same `type` by its - * `contractIdProperty`: it has no `contractId` and no `documentType`, - * since the contract and the type are read from the referring - * document when it is written, where consensus checks that both - * exist, that the type is of this kind and that it meets - * `documentTypeRequirements` (code 40153 when it does not). + * `contractId` and `documentType`, which start with `$self.`: the + * contract and the type are read from the referring document (`$self`, + * as `$old` is the stored one in a rule) when it is written, where + * consensus checks that both exist, that the type is of this kind and + * that it meets `documentTypeRequirements` (code 40153 when it does + * not). */ type: 'permanentDocument' | 'moderatedDocument'; /** - * The top-level identifier property of the referring document - * holding the id of the contract the referenced document type lives - * in. + * `$self.` followed by the top-level identifier property of the + * referring document holding the id of the contract the referenced + * document type lives in. */ - contractIdProperty: string; + contractId: `$self.${string}`; /** - * The top-level string property of the referring document holding - * the name of the referenced document type. + * `$self.` followed by the top-level string property of the referring + * document holding the name of the referenced document type. */ - documentTypeProperty: string; + documentType: `$self.${string}`; /** * What the named document type must look like. Absent when the * declaration asks nothing of it beyond its kind. @@ -321,10 +322,10 @@ export type DocumentTypeRequirements = { export type DocumentTypeRequirementSlot = { /** * Which property may fill the slot: `'$any'` for any property matching - * the rest, a property name, or `{ property }` for the property the - * referring document names in that string property of its own. + * the rest, a property name, or `$self.` followed by the string property + * of the referring document that names it. */ - name: string | { property: string }; + name: string; /** What the property holds; `'array'` comes with `byteArray: true`. */ type: 'string' | 'identifier' | 'integer' | 'boolean' | 'array'; byteArray?: true; @@ -1058,19 +1059,19 @@ fn set_reference_target_fields( )?; set_key_requirements_field(object, key_requirements, path)?; } - // The keys the schema declares it with: no contract and no document type, which - // the referring document names when it is written + // As the schema declares it: the contract and the document type read from the + // referring document, which names them when it is written DocumentPropertyReferenceTarget::DynamicDocument(reference) => { set_field( object, - "contractIdProperty", - &JsValue::from_str(&reference.contract_id_property), + "contractId", + &JsValue::from_str(&reference.contract_id_schema_value()), path, )?; set_field( object, - "documentTypeProperty", - &JsValue::from_str(&reference.document_type_property), + "documentType", + &JsValue::from_str(&reference.document_type_schema_value()), path, )?; if let Some(requirements) = &reference.requirements { From 0216863ccc1f0757ff8aee08f94f21496b7b6f60 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 17:06:04 +0700 Subject: [PATCH 12/20] fix(dpp): hold a string slot to the tighter of a property's two bounds A string property declaring both maxLength and maxBytes was held to its maxBytes alone when a slot's maxBytes was checked, so one of at most two characters (eight bytes) with a maxBytes of 100 failed a slot of eight bytes it can never exceed. The bound is now the smaller of maxBytes and four bytes a character of maxLength. Co-Authored-By: Claude Fable 5.2 --- .../contract-keywords/refers-to-dynamic.md | 2 +- .../v3/dynamic_reference_tests.rs | 22 +++++++++++++++++++ .../property/dynamic_document_reference.rs | 16 +++++++++----- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/book/src/contract-keywords/refers-to-dynamic.md b/book/src/contract-keywords/refers-to-dynamic.md index 0a76c7074a0..e0ec93dc636 100644 --- a/book/src/contract-keywords/refers-to-dynamic.md +++ b/book/src/contract-keywords/refers-to-dynamic.md @@ -60,7 +60,7 @@ The two come together: a declaration names its target in the schema or through t |---|---|---| | `name` | `"$any"`, a property name, or `"$self."` | Which property may fill the slot: any property matching the rest; the one called this; or the one the referring document names in that property of its own. | | `type` | `"string"`, `"identifier"`, `"integer"`, `"boolean"`, or `"array"` with `"byteArray": true` | What the property holds. A byte array slot is not filled by an identifier, nor an identifier slot by a byte array. | -| `maxBytes` | integer, 1 to 4294967295 | On a string or byte array slot: the property can never hold more bytes than this. A string's bound is its `maxBytes`, or four times its `maxLength`; a byte array's is its `maxItems`. A property declaring no bound meets none. | +| `maxBytes` | integer, 1 to 4294967295 | On a string or byte array slot: the property can never hold more bytes than this. A string's bound is its `maxBytes` or four times its `maxLength`, the smaller of the two when it declares both; a byte array's is its `maxItems`. A property declaring no bound meets none. | | `distinctFrom` | a slot name, or a list of slot names | This slot is filled by a different property than each of them. Saying it on one side is enough. | A slot name is 1 to 64 letters, digits or underscores. A transient property fills nothing: it is never stored. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs index b6e56599cef..a549bd4fb98 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs @@ -670,6 +670,28 @@ fn should_hold_a_slot_to_its_byte_bound() { }; assert!(post_meets(bytes_of(1024), None)); assert!(!post_meets(bytes_of(1023), None)); + + // A string bounded both ways is held to the tighter bound, whichever it is: `code` + // holds at most two characters, eight bytes, though its `maxBytes` allows a hundred, + // and `tag` at most six bytes, though its `maxLength` allows a hundred characters + let bounded_both_ways = |name: &str, max_bytes: u32| { + let mut posts = note_contract(requiring(named(name, max_bytes))); + let properties = &mut posts["documentSchemas"]["post"]["properties"]; + properties["code"] = + json!({ "type": "string", "maxLength": 2, "maxBytes": 100, "position": 7 }); + properties["tag"] = + json!({ "type": "string", "maxLength": 100, "maxBytes": 6, "position": 8 }); + let parsed = contract(posts).expect("parses"); + let post = parsed.document_type_for_name("post").expect("post"); + dynamic_reference(&parsed) + .requirements + .expect("requirements") + .met_by(post, |_| None) + }; + assert!(bounded_both_ways("code", 8)); + assert!(!bounded_both_ways("code", 7)); + assert!(bounded_both_ways("tag", 6)); + assert!(!bounded_both_ways("tag", 5)); } #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs index a8b2b432c42..69bf50e2739 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs @@ -336,12 +336,18 @@ impl RequiredPropertySlot { .is_none_or(|bound| declared.is_some_and(|declared| declared <= bound)) }; match (self.value, &property.property_type) { - // A string that declares no `maxBytes` holds at most four bytes a character - (SlotValue::String, DocumentPropertyType::String(sizes)) => within( - sizes.max_bytes.map(u32::from).or(sizes + // A string holds at most its `maxBytes`, and at most four bytes a character of + // its `maxLength`: the smaller of the two when it declares both + (SlotValue::String, DocumentPropertyType::String(sizes)) => { + let by_bytes = sizes.max_bytes.map(u32::from); + let by_length = sizes .max_length - .map(|characters| u32::from(characters).saturating_mul(4))), - ), + .map(|characters| u32::from(characters).saturating_mul(4)); + within(match (by_bytes, by_length) { + (Some(by_bytes), Some(by_length)) => Some(by_bytes.min(by_length)), + (by_bytes, by_length) => by_bytes.or(by_length), + }) + } (SlotValue::ByteArray, DocumentPropertyType::ByteArray(sizes)) => { within(sizes.max_size.map(u32::from)) } From 31697dfd5a626915c83b16746eb961da3cf667b2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 18:11:20 +0700 Subject: [PATCH 13/20] fix(platform): read stored references as registered and keep their errors decodable Review of the references whose target the document names: - Only a contractId or documentType written "$self." selects the reading this version adds. A contract stored under an earlier protocol version is parsed again whenever it is loaded, and a declaration naming its target in the schema could carry a documentTypeRequirements key that version never read: it made the stored contract fail to load. The key stays unread on a load and is refused at registration, by the native schema check. - ReferencedEntityNotFoundError carries such a target without its requirements. A declaration can be larger than the 2000 bytes a client decodes a consensus error within, and the requirements say nothing of a missing document. - The wasm description of a document-named target, its requirements and a slot named __proto__ are covered at the JS boundary. Co-Authored-By: Claude Fable 5.2 --- book/src/data-model/documents.md | 2 +- .../try_from_schema/dynamic_reference.rs | 23 ++- .../v3/dynamic_reference_tests.rs | 130 ++++++++++++-- .../document_type/property/mod.rs | 18 ++ .../referenced_entity_not_found_error.rs | 125 +++++++++++++- packages/wasm-dpp2/src/consensus_error.rs | 3 +- .../unit/DocumentPropertyReference.spec.ts | 160 ++++++++++++++++++ 7 files changed, 439 insertions(+), 22 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index ee74ca8b6af..5a157256d53 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -634,7 +634,7 @@ In Rust the parser holds the function entry in the `DocumentReferenceLookup` as From protocol version 15 a `permanentDocument` or `moderatedDocument` reference on an identifier property may name its target through the referring document instead of the schema (see [A Target the Document Names](../contract-keywords/refers-to-dynamic.md)). The declaration parses to its own variant, `DocumentPropertyReferenceTarget::DynamicDocument(DynamicDocumentReference)`, appended to the enum: the kind, the two property names and the optional `DocumentTypeRequirements` (`property/dynamic_document_reference.rs`). `as_any_document_reference` gives `None` for it, since it names no contract and no document type, so everything that resolves a reference's target from the schema (registration's target checks, rule reads, derived index properties, chained joins) does not see it; `as_dynamic_document_reference` gives the declaration to the code that does. -Version 1 of `apply_property_reference` reads the declaration (`try_from_schema/dynamic_reference.rs`), told by a `contractId` or a `documentType` written with `REFERRING_DOCUMENT_PREFIX` (`$self.`) or by `documentTypeRequirements`, and refuses it wherever else a reference sits; version 0, protocol version 14, gives the prefix no meaning, so a `contractId` written with it is no contract id there and the declaration is refused. Once a type's properties are parsed, `validate_dynamic_reference_sources` checks the properties the target is read from. +Version 1 of `apply_property_reference` reads the declaration (`try_from_schema/dynamic_reference.rs`), told by a `contractId` or a `documentType` written with `REFERRING_DOCUMENT_PREFIX` (`$self.`) or by `documentTypeRequirements`, and refuses it wherever else a reference sits; version 0, protocol version 14, gives the prefix no meaning, so a `contractId` written with it is no contract id there and the declaration is refused. Only the prefix selects the new reading: a contract stored under an earlier version is parsed again by this one whenever it is loaded, and must read as it was registered, so a key that version never read (`documentTypeRequirements` beside a target the schema names, which only an unchecked part of a stored schema could carry) stays unread on a load and is refused at registration alone. A `ReferencedEntityNotFoundError` carries such a target without its requirements (`named_in_an_error`): they say nothing of a missing document, and a declaration can be larger than the 2000 bytes a client decodes an error within. Once a type's properties are parsed, `validate_dynamic_reference_sources` checks the properties the target is read from. The write-time check, `document_reference_validation` 1, builds the declaration a by-id reference would carry from the document's own values (the contract id and the type name, no `where`) and runs the flow every document reference runs: contract, document type, kind, document, removal record. Between the document type and the kind it does what registration does for a reference naming its type in the schema, refusing a private type, and judges the requirements with `DocumentTypeRequirements::met_by`. That search is over sets of slots, fullest first, and within a set fills the slot with the fewest candidates first; with at most `max_document_type_requirement_slots` slots it stays small whatever the referenced type declares. A replace checks the reference again when one of `DynamicDocumentReference::target_properties` changed. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs index db94af0e8a9..d78ec31c8a6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs @@ -41,18 +41,25 @@ fn refused(message: String) -> DataContractError { } /// Whether a `refersTo` declaration names its target through the referring document: its -/// `contractId` or its `documentType` is read from it (`"$self."`), or it -/// carries `documentTypeRequirements`, which describe a type the schema does not name. +/// `contractId` or its `documentType` is read from it (`"$self."`). +/// +/// Nothing else tells one. This runs on every parse, the reading of a contract stored +/// under an earlier protocol version included, and must read such a contract as that +/// version did. No stored declaration is written this way: before protocol version 15 a +/// `contractId` that is no identifier does not parse, and a `documentType` names a document +/// type that exists, whose name holds no `$` and no dot. `documentTypeRequirements` beside +/// a target the schema names is another matter: earlier versions did not read the key, so +/// a stored declaration may carry it, and it stays unread here. Registration refuses it +/// (the native schema check), like every key a declaration does not take. pub(super) fn declares_dynamic_target(refers_to_value: &Value) -> bool { refers_to_value .to_btree_ref_string_map() .is_ok_and(|declaration| { - declaration.contains_key(property_names::DOCUMENT_TYPE_REQUIREMENTS) - || TARGET_KEYS.iter().any(|key| { - declaration - .get(*key) - .is_some_and(|value| read_from_referring_document(value).is_some()) - }) + TARGET_KEYS.iter().any(|key| { + declaration + .get(*key) + .is_some_and(|value| read_from_referring_document(value).is_some()) + }) }) } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs index a549bd4fb98..00bdb1e33d3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs @@ -350,17 +350,16 @@ fn should_refuse_what_cannot_sit_beside_a_dynamic_target() { &format!("reads both contractId and documentType from it: {key} must be"), ); } - // Requirements describe a type the schema does not name - assert_refused_by_the_parser( - note_contract(json!({ - "type": "moderatedDocument", - "documentType": "post", - "documentTypeRequirements": { - "properties": { "body": { "name": "$any", "type": "string" } } - } - })), - "reads both contractId and documentType from it", - ); + // Requirements describe a type the schema does not name: registration refuses them + // beside one it names (what a stored contract carrying them reads as is tested apart) + contract(note_contract(json!({ + "type": "permanentDocument", + "documentType": "post", + "documentTypeRequirements": { + "properties": { "body": { "name": "$any", "type": "string" } } + } + }))) + .expect_err("documentTypeRequirements beside a target the schema names"); // What is read is a top-level property, and two different ones for read in [ "$self.", @@ -999,3 +998,112 @@ fn should_write_requirements_as_a_schema_declares_them() { assert_eq!(requirements(written), parsed); } } + +/// A contract stored before protocol version 15 is read as the version that stored it read +/// it. That version did not read `documentTypeRequirements`, and through a key its schema +/// check did not see (a root `properties` given twice: the check read the last entry, the +/// parser the first) a stored declaration naming its target in the schema may carry one. +/// It is still that reference: only `$self.` selects the reading this version adds, and no +/// stored declaration is written with it. +#[test] +fn should_read_a_stored_reference_naming_its_target_in_the_schema_as_it_was_registered() { + let platform_version_14 = PlatformVersion::get(14).expect("protocol version 14"); + let text = |value: &str| platform_value::Value::Text(value.to_string()); + let reference = |stray: bool| { + let mut refers_to = json!({ "type": "permanentDocument", "documentType": "post" }); + if stray { + refers_to["documentTypeRequirements"] = json!({ + "properties": { "body": { "name": "$any", "type": "string" } } + }); + } + let mut description_id = identifier(0); + description_id["refersTo"] = refers_to; + platform_value::to_value(json!({ "descriptionId": description_id })) + .expect("the properties should convert") + }; + // `properties` twice: the first entry carries the stray key, the last does not + let schema = platform_value::Value::Map(vec![ + (text("type"), text("object")), + (text("properties"), reference(true)), + (text("properties"), reference(false)), + ( + text("additionalProperties"), + platform_value::Value::Bool(false), + ), + ]); + let parse = |full_validation: bool, platform_version: &PlatformVersion| { + let config = + DataContractConfig::default_for_version(platform_version).expect("default config"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "note", + schema.clone(), + None, + &BTreeMap::new(), + &config, + full_validation, + &mut vec![], + platform_version, + ) + }; + let target = |document_type: &DocumentType| { + document_type + .properties() + .get("descriptionId") + .expect("descriptionId") + .property_type + .clone() + }; + + // Protocol version 14 registers it, reading the reference of the first entry + let registered = parse(true, platform_version_14).expect("protocol version 14 registers it"); + assert!(matches!( + target(®istered), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocument { .. } + ) + )); + // and this version, loading what was stored, reads the same reference + let loaded = parse(false, PlatformVersion::latest()).expect("the stored schema still loads"); + assert_eq!(target(&loaded), target(®istered)); + // though it registers no such schema itself + parse(true, PlatformVersion::latest()) + .expect_err("registration refuses the repeated key and the stray one"); + + // A whole contract stored that way loads too + let stored = contract_on( + note_contract(json!({ + "type": "permanentDocument", + "documentType": "post", + "documentTypeRequirements": { + "properties": { "body": { "name": "$any", "type": "string" } } + } + })), + false, + platform_version_14, + ) + .expect("parsed as protocol version 14 stored it"); + let bytes = stored + .serialize_to_bytes_with_platform_version(platform_version_14) + .expect("the contract should serialize"); + let recovered = + DataContract::versioned_deserialize_untrusted(&bytes, false, PlatformVersion::latest()) + .expect("the stored contract loads at the latest protocol version"); + let note = |contract: &DataContract| { + contract + .document_type_for_name("note") + .expect("note") + .properties()["descriptionId"] + .property_type + .clone() + }; + assert_eq!(note(&recovered), note(&stored)); + assert!(matches!( + note(&recovered), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocument { .. } + ) + )); +} 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 109ab568a21..dfbab47a25e 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 @@ -1295,6 +1295,24 @@ impl DocumentPropertyReferenceTarget { } } + /// The target as a consensus error carries it. An error names what was referenced and + /// was not there, which a reference naming its target through the referring document + /// says with its kind and the two properties it reads: its `documentTypeRequirements` + /// describe a document type, say nothing of a missing document, and are as large as a + /// contract declared them, where a client decodes an error within a fixed budget. They + /// are left out. Every other target is carried whole. + pub fn named_in_an_error(self) -> Self { + match self { + DocumentPropertyReferenceTarget::DynamicDocument(reference) => { + DocumentPropertyReferenceTarget::DynamicDocument(DynamicDocumentReference { + requirements: None, + ..reference + }) + } + other => other, + } + } + /// The declaration of a reference whose target the referring document names /// ([`Self::DynamicDocument`]); `None` for every other target. pub fn as_dynamic_document_reference(&self) -> Option<&DynamicDocumentReference> { diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_entity_not_found_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_entity_not_found_error.rs index 3e8d5de36b6..93c5785b137 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/referenced_entity_not_found_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_entity_not_found_error.rs @@ -43,7 +43,9 @@ impl ReferencedEntityNotFoundError { ) -> Self { Self { entity_id, - entity_type, + // Without the requirements of a reference naming its target through the + // referring document, which could outgrow the budget an error is decoded in + entity_type: entity_type.named_in_an_error(), path, } } @@ -66,3 +68,124 @@ impl From for ConsensusError { Self::StateError(StateError::ReferencedEntityNotFoundError(err)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_contract::document_type::{ + DocumentTypeRequirements, DynamicDocumentReference, DynamicDocumentReferenceKind, + RequiredPropertySlot, SlotPropertyName, SlotRequirement, SlotRequirementTerm, SlotValue, + }; + use crate::serialization::{ + PlatformDeserializableUntrusted, PlatformSerializableWithPlatformVersion, + }; + use platform_version::version::LATEST_PLATFORM_VERSION; + use std::collections::{BTreeMap, BTreeSet}; + + /// A reference naming its target through the referring document, with requirements as + /// large as a contract may declare them: four slots of 64-character names, each named + /// property as long, every slot distinct from the others, and a `require` of groups. + fn largest_dynamic_reference() -> DynamicDocumentReference { + let name = |letter: char| letter.to_string().repeat(64); + let slots: Vec = ['a', 'b', 'c', 'd'].into_iter().map(name).collect(); + DynamicDocumentReference { + kind: DynamicDocumentReferenceKind::Moderated, + contract_id_property: name('x'), + document_type_property: name('y'), + requirements: Some(DocumentTypeRequirements { + slots: slots + .iter() + .map(|slot| { + ( + slot.clone(), + RequiredPropertySlot { + name: SlotPropertyName::Named(name('p')), + value: SlotValue::String, + max_bytes: Some(u32::MAX), + distinct_from: slots + .iter() + .filter(|other| *other != slot) + .cloned() + .collect::>(), + }, + ) + }) + .collect::>(), + require: Some(SlotRequirement::AnyOf( + (0..slots.len()) + .flat_map(|first| { + ((first + 1)..slots.len()).map(move |second| (first, second)) + }) + .map(|(first, second)| { + SlotRequirementTerm::Group(vec![ + slots[first].clone(), + slots[second].clone(), + ]) + }) + .collect(), + )), + }), + } + } + + /// A client decodes a consensus error within a fixed budget. The declaration of a + /// reference naming its target through the referring document can outgrow it, so the + /// error carries the target without its requirements and always decodes. + #[test] + fn should_carry_a_dynamic_target_without_its_requirements() { + let reference = largest_dynamic_reference(); + let declared = DocumentPropertyReferenceTarget::DynamicDocument(reference.clone()); + let declared_size = bincode::encode_to_vec(&declared, bincode::config::standard()) + .expect("encodes") + .len(); + assert!( + declared_size > 2000, + "the declaration alone, {declared_size} bytes, outgrows the budget" + ); + + let error = ReferencedEntityNotFoundError::new( + Identifier::new([7; 32]), + declared, + "descriptionId".to_string(), + ); + assert_eq!( + error.entity_type(), + &DocumentPropertyReferenceTarget::DynamicDocument(DynamicDocumentReference { + requirements: None, + ..reference + }) + ); + // The message names the kind and the properties read, as before + assert!(error.to_string().starts_with(&format!( + "referenced moderated document (contract named by {}", + "x".repeat(64) + ))); + + let consensus_error: ConsensusError = error.into(); + let bytes = consensus_error + .serialize_to_bytes_with_platform_version(LATEST_PLATFORM_VERSION) + .expect("the error serializes"); + assert!(bytes.len() < 2000, "{} bytes", bytes.len()); + assert_eq!( + ConsensusError::deserialize_from_bytes_untrusted(&bytes) + .expect("the error decodes within the budget"), + consensus_error + ); + } + + #[test] + fn should_carry_every_other_target_whole() { + let target = DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "post".to_string(), + where_conditions: Default::default(), + property_agreement: Default::default(), + }; + let error = ReferencedEntityNotFoundError::new( + Identifier::new([7; 32]), + target.clone(), + "postId".to_string(), + ); + assert_eq!(error.entity_type(), &target); + } +} diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 58d36688a12..58ee6b49a15 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -104,7 +104,8 @@ pub enum DocumentReferenceErrorCodeWasm { /// `permanentDocument`, `deletableDocument` or `moderatedDocument` /// reference without `findBy`, or one with `inList`, whose list's /// document is read by its id) names an `indexOnly` document type, - /// reported at contract registration. Such a type's documents exist only + /// reported at contract registration, or when a document is written for a + /// reference whose target the document names. Such a type's documents exist only /// as index entries and cannot be fetched by id, so no write could check /// the reference. ReferencedDocumentTypeIndexOnly = 40146, diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index a815a32b81a..7ef508dd856 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -1049,6 +1049,165 @@ describe('DataContract — refersTo declarations (v14)', () => { }); }); + describe('a target the document names', () => { + /** + * A note described by a document of whichever contract and document type + * the note itself names (protocol version 15): `contractId` and + * `documentType` are read from the note, written `$self.`, and + * `documentTypeRequirements` says what the named type must look like. + */ + type NamedReference = { + path: string; + type: string | string[]; + contractId: string; + documentType: string; + documentTypeRequirements?: { + properties: Record>; + require?: unknown; + }; + }; + + const namedSchemas = { + note: { + type: 'object', + properties: { + descriptionId: identifierProperty(0, { + type: 'moderatedDocument', + contractId: '$self.descriptionContractId', + documentType: '$self.descriptionDocumentType', + documentTypeRequirements: { + properties: { + title: { name: '$any', type: 'string', maxBytes: 256 }, + body: { name: '$self.bodyProperty', type: 'string', distinctFrom: 'title' }, + bodyBytes: { name: '$any', type: 'array', byteArray: true }, + }, + require: { allOf: ['title', { anyOf: ['body', 'bodyBytes'] }] }, + }, + }), + descriptionContractId: { ...plainIdentifier, position: 1 }, + descriptionDocumentType: { + type: 'string', minLength: 1, maxLength: 64, position: 2, + }, + bodyProperty: { + type: 'string', minLength: 1, maxLength: 64, position: 3, + }, + }, + required: [ + 'descriptionId', + 'descriptionContractId', + 'descriptionDocumentType', + 'bodyProperty', + ], + additionalProperties: false, + }, + }; + + function buildNamedContract( + documentSchemas: object, + platformVersion = 15, + fullValidation = true, + ) { + return new wasm.DataContract({ + ownerId, + identityNonce: BigInt(4), + schemas: documentSchemas, + definitions: null, + fullValidation, + platformVersion: new PlatformVersion(platformVersion), + }); + } + + it('should report the target and its requirements as the schema declares them', () => { + const references = buildNamedContract(namedSchemas).documentTypeReferences( + 'note', + ) as unknown as NamedReference[]; + + expect(references.map((reference) => reference.path)).to.deep.equal(['descriptionId']); + const [descriptionId] = references; + expect(descriptionId.type).to.equal('moderatedDocument'); + // Read from the note, not literals: no contract id object, no type name + expect(descriptionId.contractId).to.equal('$self.descriptionContractId'); + expect(descriptionId.documentType).to.equal('$self.descriptionDocumentType'); + expect(descriptionId.documentTypeRequirements).to.deep.equal({ + properties: { + title: { name: '$any', type: 'string', maxBytes: 256 }, + // `distinctFrom` is always reported as a list + body: { name: '$self.bodyProperty', type: 'string', distinctFrom: ['title'] }, + bodyBytes: { name: '$any', type: 'array', byteArray: true }, + }, + require: { allOf: ['title', { anyOf: ['body', 'bodyBytes'] }] }, + }); + expect(descriptionId).to.not.have.property('where'); + expect(descriptionId).to.not.have.property('findBy'); + }); + + it('should leave out requirements a declaration does not have', () => { + const withoutRequirements = structuredClone(namedSchemas); + delete ( + withoutRequirements.note.properties.descriptionId as { + refersTo: { documentTypeRequirements?: object }; + } + ).refersTo.documentTypeRequirements; + const [descriptionId] = buildNamedContract(withoutRequirements).documentTypeReferences( + 'note', + ) as unknown as NamedReference[]; + + expect(descriptionId.type).to.equal('moderatedDocument'); + expect(descriptionId).to.not.have.property('documentTypeRequirements'); + }); + + /** + * A slot may be named `__proto__`: it is an own key of `properties`, not + * its prototype. The schemas come from JSON, where `__proto__` is an + * ordinary key. + */ + it('should keep a slot named __proto__ as an own key', () => { + const declared = JSON.stringify(namedSchemas.note.properties.descriptionId.refersTo); + // A replacer function: the text holds `$`, which a replacement string interprets + const protoSchemas = JSON.parse(JSON.stringify(namedSchemas).replace( + declared, + () => `{ + "type": "moderatedDocument", + "contractId": "$self.descriptionContractId", + "documentType": "$self.descriptionDocumentType", + "documentTypeRequirements": { + "properties": { "__proto__": { "name": "$any", "type": "string" } } + } + }`, + )); + const [descriptionId] = buildNamedContract(protoSchemas).documentTypeReferences( + 'note', + ) as unknown as NamedReference[]; + const slots = descriptionId.documentTypeRequirements!.properties; + + expect(Object.keys(slots)).to.deep.equal(['__proto__']); + expect(Object.getPrototypeOf(slots)).to.equal(Object.prototype); + expect(JSON.stringify(slots)).to.equal('{"__proto__":{"name":"$any","type":"string"}}'); + }); + + it('should not read a target through the document before protocol version 15', () => { + for (const fullValidation of [true, false]) { + expect( + () => buildNamedContract(namedSchemas, 14, fullValidation), + `full validation ${fullValidation}`, + ).to.throw(); + } + }); + + it('should refuse a target named half in the schema and half through the document', () => { + const halfNamed = structuredClone(namedSchemas); + ( + halfNamed.note.properties.descriptionId as { refersTo: { documentType: string } } + ).refersTo.documentType = 'post'; + for (const fullValidation of [true, false]) { + expect( + () => buildNamedContract(halfNamed, 15, fullValidation), + `full validation ${fullValidation}`, + ).to.throw(); + } + }); + }); + describe('where over typed arrays', () => { const tags = (position: number) => ({ type: 'array', @@ -1316,6 +1475,7 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentRuleReadInvalid).to.equal(40148); expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentPreceded).to.equal(40149); expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentTypePrivate).to.equal(40150); + expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentTypeRequirementNotMet).to.equal(40153); }); it('should resolve a code back to its name', () => { From ee561af4ca8d190edf6e3f66109235c74d30b89b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 18:35:59 +0700 Subject: [PATCH 14/20] fix(platform): bill and bound the judging of a slot's when Review of the either-kind targets and slot conditions: - Judging the document type a reference names against its documentTypeRequirements is billed by the comparisons it may take (each property a slot tries, and each node of the slot's `when` for it), 100 credits each, before the type is judged, so a write refused for a type that does not meet them pays for it too. The work grows with the type's properties, the slots and their conditions, and nothing was charged for it. - Each string an `in` or a `notIn` lists counts toward the node limit of a `when`, checked before the list is read: one condition could otherwise hold any number of them. - The length of a string a `when` is written with is a SystemLimits value, read by the parser and by the native schema check, in place of a literal repeated in both. - The kind list and nested conditions are covered at the JS boundary, and the spec casts a property before reading its reference, which the type-checked run of the base's spec needs. Co-Authored-By: Claude Fable 5.2 --- book/src/contract-keywords.md | 3 +- .../contract-keywords/refers-to-dynamic.md | 6 +- book/src/data-model/documents.md | 2 +- .../try_from_schema/dynamic_reference.rs | 94 ++++++++----- .../try_from_schema/native_schema/mod.rs | 9 ++ .../native_schema/references.rs | 10 +- .../v3/dynamic_reference_tests.rs | 101 ++++++++++++++ .../property/dynamic_document_reference.rs | 29 ++++ .../document_type/property/slot_condition.rs | 11 +- .../document_reference_validation/v1/mod.rs | 42 ++++++ .../tests/dynamic_document_reference.rs | 131 ++++++++++++++++++ .../src/version/fee/document_reference/mod.rs | 14 +- .../src/version/fee/document_reference/v1.rs | 8 ++ .../src/version/mocks/v2_test.rs | 1 + .../src/version/system_limits/mod.rs | 10 +- .../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 | 1 + .../rs-platform-version/src/version/v15.rs | 8 +- .../unit/DocumentPropertyReference.spec.ts | 95 ++++++++++++- 21 files changed, 527 insertions(+), 51 deletions(-) diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md index 44ab7ab16b0..247beea63a7 100644 --- a/book/src/contract-keywords.md +++ b/book/src/contract-keywords.md @@ -331,7 +331,8 @@ The first three limits come from the meta-schema, the rest from protocol version | `max_property_constraint_nodes` | 32 | nodes in one rule | | `max_choose_first_candidates` | 1000 | commitments a create reads for a [`chooseFirst`](contract-keywords/refers-to-lookup.md#choosing-the-first-commitment-choosefirst) | | `max_document_type_requirement_slots` | 4 | slots a [`documentTypeRequirements`](contract-keywords/refers-to-dynamic.md#documenttyperequirements) may declare | -| `max_document_type_requirement_condition_nodes` | 16 | conditions in one slot's [`when`](contract-keywords/refers-to-dynamic.md#a-condition-on-the-property-when) | +| `max_document_type_requirement_condition_nodes` | 16 | conditions in one slot's [`when`](contract-keywords/refers-to-dynamic.md#a-condition-on-the-property-when), each string an `in` or a `notIn` lists counted | +| `max_document_type_requirement_condition_text_length` | 256 | characters of a string a slot's `when` is written with | | `min_document_ttl_seconds`, `max_document_ttl_seconds` | 3600, 31536000 | `ttl` | | `max_time_range_ttl_seconds` | 604800 | a `timeRange` index's `ttl` | | `max_contested_summed_value_magnitude` | 134217728 (2^27) | the `minimum` and `maximum` of a summed property on a type with a contested index | diff --git a/book/src/contract-keywords/refers-to-dynamic.md b/book/src/contract-keywords/refers-to-dynamic.md index 7add66171b8..fb03c340bc3 100644 --- a/book/src/contract-keywords/refers-to-dynamic.md +++ b/book/src/contract-keywords/refers-to-dynamic.md @@ -150,7 +150,7 @@ When the type is a moderated one, the string filling `body` is one a moderator's | `{ "not": c }`, `{ "anyOf": [...] }`, `{ "allOf": [...] }` | As in `propertyConstraints`: `anyOf` and `allOf` take two or more conditions, none listed twice and none the same combinator; `not` holds no `not` and no `notIn`. | | `{ "ifThen": [c, then] }`, `{ "ifThenElse": [c, then, else] }` | `then` holds whenever `c` does; with an `else`, it holds when `c` does not. | -One `when` is made of at most 16 conditions (`max_document_type_requirement_condition_nodes`), combinators counted. It reads the type, never a document: no property path, no arithmetic, and no value of the referenced document. `distinctFrom`, `require` and the search for an assignment work as before, over the properties each slot's `when` leaves it. +One `when` is made of at most 16 conditions (`max_document_type_requirement_condition_nodes`), combinators counted and each string an `in` or a `notIn` lists counted too, and no string in it is longer than 256 characters (`max_document_type_requirement_condition_text_length`), so what it adds to a stored contract is bounded. It reads the type, never a document: no property path, no arithmetic, and no value of the referenced document. `distinctFrom`, `require` and the search for an assignment work as before, over the properties each slot's `when` leaves it. ## How it works @@ -159,7 +159,7 @@ Nothing about the target is known when the contract is registered, so registrati 1. The document names a contract and a document type. A document that holds the reference but leaves out the property its `contractId` or its `documentType` is read from points nowhere: `ReferencedEntityNotFoundError` (40120). 2. The contract exists and has a document type of that name: `ReferencedDocumentTypeNotFoundError` (40121) otherwise. Another contract costs a billed fetch, once per write. 3. The type is not [private](../contract-keywords.md#document-type), `ReferencedDocumentTypePrivateError` (40150), and not `indexOnly`, `ReferencedDocumentTypeIndexOnlyError` (40146): the documents of such a type are index entries, and none can be found by its id. -4. The type meets `documentTypeRequirements`, each slot filled by a property its `when` holds for: `ReferencedDocumentTypeRequirementNotMetError` (40153). +4. The type meets `documentTypeRequirements`, each slot filled by a property its `when` holds for: `ReferencedDocumentTypeRequirementNotMetError` (40153). Judging it reads nothing from state but is billed by the comparisons it may take, before it is judged, so a refused write pays for it too: 100 credits (`requirement_credits_per_comparison`) for each property a slot tries (every property of the type for `"$any"`, one for a name) and for each node of the slot's `when` for that property. A type of 20 properties under one `"$any"` slot with the seven-node condition above is 160 comparisons, 16,000 credits. 5. The type is of the declared kind, as a reference naming it in the schema is held to at registration: for `permanentDocument` its documents never disappear (40122 or 40144 otherwise), for `moderatedDocument` they disappear only through a moderator's recorded removal (40143). A declaration listing both kinds is a moderated reference into a moderated type and a permanent one into any other, so it refuses only a type whose documents can be deleted (40122). 6. The document exists: `ReferencedEntityNotFoundError` (40120). @@ -174,7 +174,7 @@ Nothing about the target is known when the contract is registered, so registrati - It sits on a single identifier property: not on the elements of a typed array, in a reference expression, or in `ownerRefersTo` and `creatorRefersTo`. - `contractId` reads a top-level identifier property, and `documentType` and every slot's `"$self."` name a top-level string property. None is transient or the reference property itself, and each is required wherever the reference property is, so a document that must hold the reference always says where it points. None is listed under [`moderatorAbilities.changeFields`](moderator-abilities.md), like the reference property itself: a moderator's change checks no reference again, so it could point a stored one somewhere nobody checked. - `documentTypeRequirements` holds 1 to 4 slots (`max_document_type_requirement_slots`), each well formed; `distinctFrom` names other slots of the same `properties`; `require` names slots, and all of them. -- A slot's `when` is a condition of at most 16 conditions (`max_document_type_requirement_condition_nodes`) over the operands above, each comparison pairing what it may pair. +- A slot's `when` is a condition of at most 16 conditions, listed strings counted (`max_document_type_requirement_condition_nodes`), over the operands above, each comparison pairing what it may pair, with no string longer than 256 characters (`max_document_type_requirement_condition_text_length`). - No [`propertyConstraints` rule](property-constraints.md#values-of-a-referenced-document) reads a value through the reference, no [index](derived-index-properties.md) holds one, and no chained query joins through it: each resolves the referenced type from the schema. Before protocol version 15 the keys are not part of `refersTo`: registration refuses them. diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 72cf13ef5c0..4d503d61ee0 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -638,7 +638,7 @@ Version 1 of `apply_property_reference` reads the declaration (`try_from_schema/ The write-time check, `document_reference_validation` 1, builds the declaration a by-id reference would carry from the document's own values (the contract id and the type name, no `where`) and runs the flow every document reference runs: contract, document type, kind, document, removal record. Between the document type and the kind it does what registration does for a reference naming its type in the schema, refusing a private type, and judges the requirements with `DocumentTypeRequirements::met_by`. That search is over sets of slots, fullest first, and within a set fills the slot with the fewest candidates first; with at most `max_document_type_requirement_slots` slots it stays small whatever the referenced type declares. A replace checks the reference again when one of `DynamicDocumentReference::target_properties` changed. -A `type` listing both kinds parses to `DynamicDocumentReferenceKind::PermanentOrModerated`, and the write-time check takes the kind the reference is checked as from the named type (`checked_as`): moderated into a moderated type, permanent into any other. A slot's `when` is a `SlotCondition` (`property/slot_condition.rs`), judged by `met_by` for each property that could fill the slot against a `SlotConditionSubject`: the type's kind, the property's name, and the type's `moderator_deletion_kept_fields` and `moderator_changeable_fields`. The condition is stored in the compiled contract and travels in the consensus errors that carry a reference target, so the conditions it holds inside it sit behind `SlotConditions`, whose decoders refuse a nesting past `MAX_SLOT_CONDITION_DECODE_DEPTH` (16), as `ReferenceOperands` does for reference expressions. The parser holds one `when` to `max_document_type_requirement_condition_nodes` conditions (16), which keeps every registered condition within that depth. +A `type` listing both kinds parses to `DynamicDocumentReferenceKind::PermanentOrModerated`, and the write-time check takes the kind the reference is checked as from the named type (`checked_as`): moderated into a moderated type, permanent into any other. A slot's `when` is a `SlotCondition` (`property/slot_condition.rs`), judged by `met_by` for each property that could fill the slot against a `SlotConditionSubject`: the type's kind, the property's name, and the type's `moderator_deletion_kept_fields` and `moderator_changeable_fields`. The condition is stored in the compiled contract and travels in the consensus errors that carry a reference target, so the conditions it holds inside it sit behind `SlotConditions`, whose decoders refuse a nesting past `MAX_SLOT_CONDITION_DECODE_DEPTH` (16), as `ReferenceOperands` does for reference expressions. The parser holds one `when` to `max_document_type_requirement_condition_nodes` conditions (16), each string an `in` or a `notIn` lists counted, which keeps every registered condition within that depth and bounds its size with `max_document_type_requirement_condition_text_length`. `DocumentTypeRequirements::comparisons` counts what judging a type may take (each property a slot tries, times one plus the nodes of its `when`), and `document_reference_validation` 1 bills it through `bill_requirement_comparisons` before calling `met_by`, as `bill_where_list_elements` bills a `where` over lists. ## Immutable Properties on Mutable Document Types diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs index f6975b7baf4..406387e5464 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs @@ -50,9 +50,14 @@ const SLOT_KEYS: [&str; 6] = [ /// slot's `when` for a type the reference admits. const DYNAMIC_KIND_NAMES: [&str; 2] = ["permanentDocument", "moderatedDocument"]; -/// The most characters a `{ "const": ... }` of a slot's `when` holds: a property path, or -/// the name of a kind. -const MAX_SLOT_CONDITION_CONSTANT_LENGTH: usize = 256; +/// The limits a slot's `when` is parsed under, of `SystemLimits`. +#[derive(Clone, Copy)] +struct SlotConditionLimits { + /// `max_document_type_requirement_condition_nodes`. + nodes: usize, + /// `max_document_type_requirement_condition_text_length`. + text_length: usize, +} fn refused(message: String) -> DataContractError { DataContractError::InvalidContractStructure(message) @@ -412,13 +417,20 @@ fn parse_slot( let when = slot .get("when") .map(|when| { - let max_nodes = usize::from( - platform_version - .system_limits - .max_document_type_requirement_condition_nodes, - ); + let limits = SlotConditionLimits { + nodes: usize::from( + platform_version + .system_limits + .max_document_type_requirement_condition_nodes, + ), + text_length: usize::from( + platform_version + .system_limits + .max_document_type_requirement_condition_text_length, + ), + }; let mut nodes = 0; - parse_slot_condition(when, &at(), &mut nodes, max_nodes) + parse_slot_condition(when, &at(), &mut nodes, limits) }) .transpose()?; @@ -432,11 +444,15 @@ fn parse_slot( } /// A string a slot's `when` compares: `$kind`, `$property` or `{ "const": }`. -fn slot_condition_value(value: &Value, at: &str) -> Result { +fn slot_condition_value( + value: &Value, + at: &str, + max_length: usize, +) -> Result { let shape = || { refused(format!( "{at} when compares \"{SLOT_CONDITION_KIND}\", \"{SLOT_CONDITION_PROPERTY}\" or {{ \ - \"const\": }}, \ + \"const\": }}, \ got {value}" )) }; @@ -449,8 +465,7 @@ fn slot_condition_value(value: &Value, at: &str) -> Result + if (1..=max_length).contains(&constant.chars().count()) => { Ok(SlotConditionValue::Constant(constant.clone())) } @@ -478,30 +493,35 @@ fn parse_slot_conditions( values: &[Value], at: &str, nodes: &mut usize, - max_nodes: usize, + limits: SlotConditionLimits, ) -> Result { values .iter() - .map(|value| parse_slot_condition(value, at, nodes, max_nodes)) + .map(|value| parse_slot_condition(value, at, nodes, limits)) .collect::, _>>() .map(SlotConditions::new) } /// Parses a slot's `when`, or a condition inside one. `nodes` counts the conditions read so -/// far, refused past `max_nodes` +/// far, and each string an `in` or a `notIn` lists, refused past `limits.nodes` /// (`SystemLimits::max_document_type_requirement_condition_nodes`) before anything is read /// of the one that passes it, so the count also bounds how deep this recurses. fn parse_slot_condition( value: &Value, at: &str, nodes: &mut usize, - max_nodes: usize, + limits: SlotConditionLimits, ) -> Result { *nodes += 1; - if *nodes > max_nodes { - return Err(refused(format!( - "{at} when is made of more than {max_nodes} conditions" - ))); + let too_large = || { + refused(format!( + "{at} when is made of more than {} conditions, counting each string an in or a \ + notIn lists", + limits.nodes + )) + }; + if *nodes > limits.nodes { + return Err(too_large()); } let condition = value .to_btree_ref_string_map() @@ -528,8 +548,8 @@ fn parse_slot_condition( match key { "equal" | "notEqual" => { let pair = items(2..=2, "two strings")?; - let left = slot_condition_value(&pair[0], at)?; - let right = slot_condition_value(&pair[1], at)?; + let left = slot_condition_value(&pair[0], at, limits.text_length)?; + let right = slot_condition_value(&pair[1], at, limits.text_length)?; match (&left, &right) { (SlotConditionValue::Kind, SlotConditionValue::Constant(constant)) | (SlotConditionValue::Constant(constant), SlotConditionValue::Kind) => { @@ -552,7 +572,13 @@ fn parse_slot_condition( } "in" | "notIn" => { let pair = items(2..=2, "a string and a list of two or more distinct strings")?; - let value = slot_condition_value(&pair[0], at)?; + let value = slot_condition_value(&pair[0], at, limits.text_length)?; + // Each string listed counts toward the limit, before any of them is read: the + // list is what one condition could otherwise grow without bound by + *nodes = nodes.saturating_add(pair[1].as_array().map_or(0, Vec::len)); + if *nodes > limits.nodes { + return Err(too_large()); + } let among = pair[1] .as_array() .filter(|among| among.len() >= 2) @@ -562,8 +588,7 @@ fn parse_slot_condition( .map(|text| { text.as_text() .filter(|text| { - (1..=MAX_SLOT_CONDITION_CONSTANT_LENGTH) - .contains(&text.chars().count()) + (1..=limits.text_length).contains(&text.chars().count()) }) .map(str::to_string) }) @@ -573,7 +598,8 @@ fn parse_slot_condition( .ok_or_else(|| { refused(format!( "{at} when {key} takes a string and a list of two or more distinct \ - strings of 1 to {MAX_SLOT_CONDITION_CONSTANT_LENGTH} characters" + strings of 1 to {} characters", + limits.text_length )) })?; match value { @@ -609,7 +635,7 @@ fn parse_slot_condition( ))) } }; - let needle = slot_condition_value(&pair[1], at)?; + let needle = slot_condition_value(&pair[1], at, limits.text_length)?; if needle == SlotConditionValue::Kind { return Err(refused(format!( "{at} when contains looks for \"{SLOT_CONDITION_PROPERTY}\" or a {{ \"const\": \ @@ -630,7 +656,7 @@ fn parse_slot_condition( "{at} when not holds a not or a notIn: write the condition it negates, or in" ))); } - parse_slot_conditions(std::slice::from_ref(operand), at, nodes, max_nodes) + parse_slot_conditions(std::slice::from_ref(operand), at, nodes, limits) .map(SlotCondition::Not) } "anyOf" | "allOf" => { @@ -645,7 +671,7 @@ fn parse_slot_condition( "{at} when {key} holds another {key}: list its conditions in the outer one" ))); } - let conditions = parse_slot_conditions(operands, at, nodes, max_nodes)?; + let conditions = parse_slot_conditions(operands, at, nodes, limits)?; let listed = conditions.conditions(); if listed .iter() @@ -662,12 +688,10 @@ fn parse_slot_condition( SlotCondition::AllOf(conditions) }) } - "ifThen" => parse_slot_conditions(items(2..=2, "two conditions")?, at, nodes, max_nodes) + "ifThen" => parse_slot_conditions(items(2..=2, "two conditions")?, at, nodes, limits) .map(SlotCondition::IfThen), - "ifThenElse" => { - parse_slot_conditions(items(3..=3, "three conditions")?, at, nodes, max_nodes) - .map(SlotCondition::IfThenElse) - } + "ifThenElse" => parse_slot_conditions(items(3..=3, "three conditions")?, at, nodes, limits) + .map(SlotCondition::IfThenElse), other => Err(refused(format!( "{at} when does not take {other}: a condition is equal, notEqual, in, notIn, \ contains, not, anyOf, allOf, ifThen or ifThenElse" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/mod.rs index 7bd3848d1e2..df0524c05d8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/mod.rs @@ -219,6 +219,9 @@ struct Checker<'a> { parent: Option<(&'a Checker<'a>, String)>, /// The longest index property name, in bytes (`max_index_property_name_bytes`). max_index_property_name_bytes: usize, + /// The longest string a slot's `when` is written with, in characters + /// (`max_document_type_requirement_condition_text_length`). + max_requirement_condition_text_length: usize, } type Checked = Result<(), ProtocolError>; @@ -231,6 +234,11 @@ impl Checker<'_> { max_index_property_name_bytes: usize::from( platform_version.system_limits.max_index_property_name_bytes, ), + max_requirement_condition_text_length: usize::from( + platform_version + .system_limits + .max_document_type_requirement_condition_text_length, + ), } } } @@ -245,6 +253,7 @@ impl Checker<'_> { check(&Checker { parent: Some((self, segment.to_string())), max_index_property_name_bytes: self.max_index_property_name_bytes, + max_requirement_condition_text_length: self.max_requirement_condition_text_length, }) } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs index f3052cfb02f..baede1fb6bb 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs @@ -467,7 +467,12 @@ impl Checker<'_> { let members = c.array(item)?; for (index, member) in members.iter().enumerate() { c.at(index, |c| { - c.string_of_length(member, 1, Some(256)).map(drop) + c.string_of_length( + member, + 1, + Some(c.max_requirement_condition_text_length), + ) + .map(drop) })?; } c.item_count(members, Some(2), None)?; @@ -521,7 +526,8 @@ impl Checker<'_> { self.closed(constant, &["const"])?; self.required(constant, &["const"])?; self.member(constant, "const", |c, text| { - c.string_of_length(text, 1, Some(256)).map(drop) + c.string_of_length(text, 1, Some(c.max_requirement_condition_text_length)) + .map(drop) }) } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs index e3631fa168c..f238739d284 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs @@ -1698,3 +1698,104 @@ fn should_read_a_stored_reference_naming_its_target_in_the_schema_as_it_was_regi ) )); } + +/// A `when` is held to its nodes and to the length of its strings, so what one adds to a +/// stored contract is bounded: each string an `in` or a `notIn` lists counts as a node, and +/// no string is longer than a property path. +#[test] +fn should_bound_what_a_slot_condition_lists() { + let limits = &PlatformVersion::latest().system_limits; + let max_nodes = usize::from(limits.max_document_type_requirement_condition_nodes); + let max_length = usize::from(limits.max_document_type_requirement_condition_text_length); + let listing = |key: &str, count: usize| json!({ key: ["$property", (0..count).map(|index| format!("p{index}")).collect::>()] }); + let refused = |when: serde_json::Value, fragment: &str| { + assert_refused_by_the_parser(note_contract(requiring(body_when(when))), fragment); + }; + + // The list and the condition holding it: as many strings as the limit less one + for key in ["in", "notIn"] { + assert_eq!( + parsed_when(listing(key, max_nodes - 1)).node_count(), + max_nodes + ); + refused( + listing(key, max_nodes), + &format!("when is made of more than {max_nodes} conditions, counting each string"), + ); + // however many: the list is not read past the limit + refused( + listing(key, 50_000), + &format!("when is made of more than {max_nodes} conditions, counting each string"), + ); + } + // The strings count with the conditions around them + refused( + json!({ "allOf": [ + listing("in", max_nodes / 2), + listing("notIn", max_nodes / 2) + ] }), + &format!("when is made of more than {max_nodes} conditions"), + ); + + // A string is as long as a property path may be, and no longer + let long = |length: usize| "p".repeat(length); + parsed_when(json!({ "equal": ["$property", { "const": long(max_length) }] })); + refused( + json!({ "equal": ["$property", { "const": long(max_length + 1) }] }), + &format!("a string of 1 to {max_length} characters"), + ); + parsed_when(json!({ "in": ["$property", [long(max_length), "body"]] })); + refused( + json!({ "in": ["$property", [long(max_length + 1), "body"]] }), + &format!("strings of 1 to {max_length} characters"), + ); + refused( + json!({ "contains": ["$moderatorAbilities.changeFields", { "const": long(max_length + 1) }] }), + &format!("a string of 1 to {max_length} characters"), + ); +} + +/// Judging a type is billed by the comparisons it may take: each property a slot tries, +/// and each node of the slot's `when` for it. The count reads the declaration and the size +/// of the type, never what the type's properties are. +#[test] +fn should_count_the_comparisons_judging_a_type_takes() { + let parsed = |requirements_value: serde_json::Value| { + let parsed = contract(note_contract(requiring(requirements_value))).expect("parses"); + let requirements = dynamic_reference(&parsed) + .requirements + .expect("requirements"); + let post = parsed.document_type_for_name("post").expect("post"); + ( + requirements.comparisons(post), + post.properties().len() as u64, + ) + }; + + // Any property: each of the type's is tried, whatever it holds + let (comparisons, properties) = + parsed(json!({ "properties": { "body": { "name": "$any", "type": "boolean" } } })); + assert_eq!(properties, 7); + assert_eq!(comparisons, properties); + // A named one: one, and one read from the document: one + let (comparisons, _) = parsed(json!({ "properties": { + "body": { "name": "body", "type": "string" }, + "other": { "name": "$self.bodyProperty", "type": "string" } + } })); + assert_eq!(comparisons, 2); + // A `when` adds its nodes for each property tried, listed strings among them + let (comparisons, properties) = parsed(json!({ "properties": { + "body": { + "name": "$any", + "type": "string", + "when": { "notIn": ["$property", ["title", "summary", "label"]] } + }, + "named": { + "name": "blob", + "type": "array", + "byteArray": true, + "when": { "not": { "equal": ["$property", { "const": "raw" }] } } + } + } })); + assert_eq!(comparisons, properties * (1 + 4) + (1 + 2)); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs index 7ce7c838f8a..1c94b5fae08 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs @@ -430,6 +430,35 @@ impl DocumentTypeRequirements { }) } + /// How many comparisons judging `document_type` against the requirements takes, at + /// most: what a write that checks the reference is billed for, before it is judged. + /// + /// Each slot tries every top-level property of the type when it takes any property + /// (`"$any"`), and one when it names it. Trying a property is one comparison of it with + /// the slot's description, and one more for each node of the slot's `when` + /// ([`SlotCondition::node_count`]), whether or not the description admits it: the + /// count depends on the declaration and the size of the type alone, not on what the + /// type's properties are. The search for an assignment that follows is over at most + /// `max_document_type_requirement_slots` slots and is not counted. + pub fn comparisons(&self, document_type: DocumentTypeRef) -> u64 { + let properties = document_type.properties().len() as u64; + self.slots + .values() + .map(|slot| { + let tried = match slot.name { + SlotPropertyName::Any => properties, + SlotPropertyName::Named(_) | SlotPropertyName::FromProperty(_) => 1, + }; + let per_property = slot + .when + .as_ref() + .map_or(0, |when| when.node_count() as u64) + .saturating_add(1); + tried.saturating_mul(per_property) + }) + .fold(0u64, u64::saturating_add) + } + /// Whether `document_type` meets the requirements. /// /// A slot is filled by one top-level property of the type that its description admits diff --git a/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs b/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs index 1abafa61e27..369ac0c2910 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs @@ -257,15 +257,18 @@ impl SlotCondition { } } - /// How many conditions this one is made of, itself included: what - /// `SystemLimits::max_document_type_requirement_condition_nodes` bounds. + /// How many nodes this condition is made of, itself included, each string an `in` or + /// a `notIn` lists counted as one: what + /// `SystemLimits::max_document_type_requirement_condition_nodes` bounds, and what + /// judging it for one property costs at most. pub fn node_count(&self) -> usize { match self { SlotCondition::Equal(..) | SlotCondition::NotEqual(..) - | SlotCondition::In(..) - | SlotCondition::NotIn(..) | SlotCondition::Contains(..) => 1, + SlotCondition::In(_, values) | SlotCondition::NotIn(_, values) => { + values.len().saturating_add(1) + } SlotCondition::Not(conditions) | SlotCondition::AnyOf(conditions) | SlotCondition::AllOf(conditions) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs index 509291f0867..409a85b40cb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs @@ -1316,6 +1316,17 @@ fn validate_reference_target_v1( .into(), )); } + // Judging the type reads nothing from state, but its work grows with the + // type's properties, the slots and their `when` conditions, and a document + // may hold many such references: billed by the comparisons it may take, + // before it is judged, so a write refused below pays for it too + if let Some(requirements) = &dynamic.requirements { + bill_requirement_comparisons( + requirements.comparisons(referenced_document_type), + execution_context, + platform_version, + )?; + } let meets_requirements = dynamic.requirements.as_ref().is_none_or(|requirements| { requirements.met_by(referenced_document_type, |referring_property| { document_data @@ -2174,6 +2185,37 @@ fn bill_where_list_elements( Ok(()) } +/// Bills `comparisons`, what judging a document type against the +/// `documentTypeRequirements` of a reference naming its target through the +/// referring document may take (`DocumentTypeRequirements::comparisons`): +/// `requirement_credits_per_comparison` each, of the fee schedule's document +/// reference group. The count is bounded by the slots a declaration may hold, +/// the nodes of a `when` and the properties of a type, so the product never +/// nears overflow; an overflow is still an error rather than a wrapped bill. +fn bill_requirement_comparisons( + comparisons: u64, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, +) -> Result<(), Error> { + if comparisons == 0 { + return Ok(()); + } + let credits = comparisons + .checked_mul( + platform_version + .fee_version + .document_reference + .requirement_credits_per_comparison, + ) + .ok_or(Error::Execution(ExecutionError::Overflow( + "document type requirement comparison fee overflow", + )))?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation( + FeeResult::new_from_processing_fee(credits), + )); + Ok(()) +} + /// The value of the bound `condition` compares the referenced side with, read /// from the referring document: a property of `document_data` (`None` when it /// leaves it out), the writer `owner_id`, or a time or height of diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs index ed233a5dda6..d0329c3b66f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/dynamic_document_reference.rs @@ -723,3 +723,134 @@ async fn should_check_a_reference_of_either_kind_as_the_kind_the_named_type_is() let repoint = replacement(&setup.stranger, &setup.contract, EITHER_NOTE, &mut note).await; assert_success(&setup.process(&repoint, &transaction)); } + +/// The processing fee a write was charged, accepted or refused. +fn processing_fee(execution: &StateTransitionExecutionResult) -> u64 { + match execution { + StateTransitionExecutionResult::SuccessfulExecution { fee_result, .. } => { + fee_result.processing_fee + } + StateTransitionExecutionResult::PaidConsensusError { actual_fees, .. } => { + actual_fees.processing_fee + } + other => panic!("expected a paid execution, got {other:?}"), + } +} + +/// Judging the named type against the requirements is billed by the comparisons it may +/// take, before it is judged: a slot's `when` adds its nodes for each property the slot +/// tries, and a write refused because the type does not meet the requirements pays for +/// the judging like one that is accepted. +#[tokio::test] +async fn should_bill_the_comparisons_judging_the_named_type_takes() { + const MEASURED: &str = "measuredNote"; + let any_text = |when: Option| { + let mut slot = platform_value!({ "name": "$any", "type": "string" }); + if let Some(when) = when { + slot.insert("when".to_string(), when) + .expect("expected to set the condition"); + } + platform_value!({ "properties": { "body": slot } }) + }; + // The seven nodes of "a removal takes the text", and the same with two more + let seven = || { + text_removed_with_the_document() + .get("properties") + .expect("expected properties") + .expect("expected properties") + .get("body") + .expect("expected the slot") + .expect("expected the slot") + .get("when") + .expect("expected the condition") + .expect("expected the condition") + .clone() + }; + let nine = || { + platform_value!({ "allOf": [ + seven(), + { "notEqual": ["$property", { "const": "never" }] }, + ] }) + }; + let per_comparison = PlatformVersion::latest() + .fee_version + .document_reference + .requirement_credits_per_comparison; + assert_eq!(per_comparison, 100); + + // Each write is measured on a platform of its own, built the same way every time but + // for the `when` of the one slot of the note it writes: the note type has the same + // name and the same place in the contract, so two writes differ by the judging alone + let mut fee_of = Vec::new(); + for (when, target_type) in [ + (None, POST), + (Some(seven()), POST), + (Some(nine()), POST), + (Some(seven()), KEPT_POST), + (Some(nine()), KEPT_POST), + ] { + let setup = Setup::new_at_with( + Some(moderators_without_lists()), + PlatformVersion::latest(), + |contract| { + add_document_type( + contract, + POST, + post_schema_with(platform_value!({ "canBeDeleted": false })), + ); + add_document_type( + contract, + KEPT_POST, + post_schema_with(platform_value!({ + "canBeDeleted": false, + "moderatorAbilities": { "delete": true, "deleteKeepsFields": ["text"] }, + })), + ); + add_document_type( + contract, + MEASURED, + note_schema_of(platform_value!("moderatedDocument"), any_text(when)), + ); + }, + ) + .await; + // A post has one property, so a slot taking any property tries one + assert_eq!( + setup + .contract + .document_type_for_name(target_type) + .expect("expected the type") + .properties() + .len(), + 1 + ); + let transaction = setup.platform.drive.grove.start_transaction(); + let (target, create_target) = setup + .create_document_of_type(&setup.user, target_type) + .await; + assert_success(&setup.process(&create_target, &transaction)); + let (_, create) = create_with( + &setup, + &setup.stranger, + MEASURED, + note_naming(setup.contract.id(), target_type, target.id()), + ) + .await; + let execution = setup.process(&create, &transaction); + if target_type == POST { + assert_success(&execution); + } else { + assert_paid_with_code(&execution, REFERENCED_DOCUMENT_TYPE_REQUIREMENT_NOT_MET); + } + fee_of.push(processing_fee(&execution)); + } + let [none, seven_accepted, nine_accepted, seven_refused, nine_refused] = fee_of[..] else { + panic!("expected five fees"); + }; + + // One property tried: 1 comparison, 1 + 7 and 1 + 9 + assert_eq!(seven_accepted - none, 7 * per_comparison); + assert_eq!(nine_accepted - seven_accepted, 2 * per_comparison); + // and a refused write pays for the judging that refused it + assert_eq!(nine_refused - seven_refused, 2 * per_comparison); +} diff --git a/packages/rs-platform-version/src/version/fee/document_reference/mod.rs b/packages/rs-platform-version/src/version/fee/document_reference/mod.rs index 049a1ab8a7c..5c8a908330e 100644 --- a/packages/rs-platform-version/src/version/fee/document_reference/mod.rs +++ b/packages/rs-platform-version/src/version/fee/document_reference/mod.rs @@ -17,8 +17,14 @@ pub mod v1; /// A `where` entry comparing typed arrays (an equality between two of them, `contains` or /// `containsAll`) is billed by the list elements it reads, before it compares them. /// +/// A reference naming its target through the referring document judges the named document +/// type against its `documentTypeRequirements` on every write that checks it. Nothing is +/// read from state for it, but the work grows with the type's properties, the slots and +/// their `when` conditions, so it is billed by the comparisons it may take. +/// /// Every earlier schedule carries the same group; it is unread there because neither -/// `chooseFirst` nor a `where` entry parses before protocol version 14. +/// `chooseFirst` nor a `where` entry parses before protocol version 14, and no reference +/// names its target through the referring document before protocol version 15. #[derive(Clone, Debug, Encode, Decode, Default, PartialEq, Eq)] pub struct FeeDocumentReferenceVersion { /// The credits per competitor a `chooseFirst` may read that bound what its scan bills, and @@ -27,4 +33,10 @@ pub struct FeeDocumentReferenceVersion { /// The credits per list element a `where` entry comparing typed arrays reads, on both /// sides, against a document in state or a moderated document's removal record. pub where_list_credits_per_element: u64, + /// The credits per comparison judging the document type a reference names through the + /// referring document against its `documentTypeRequirements` takes + /// (`DocumentTypeRequirements::comparisons`): each property a slot tries, and each node + /// of the slot's `when` for it. Billed before the type is judged, so a write refused + /// for a type that does not meet them pays for it too (protocol version 15). + pub requirement_credits_per_comparison: u64, } diff --git a/packages/rs-platform-version/src/version/fee/document_reference/v1.rs b/packages/rs-platform-version/src/version/fee/document_reference/v1.rs index ed14fed36a0..f404e92bff8 100644 --- a/packages/rs-platform-version/src/version/fee/document_reference/v1.rs +++ b/packages/rs-platform-version/src/version/fee/document_reference/v1.rs @@ -12,8 +12,16 @@ use crate::version::fee::document_reference::FeeDocumentReferenceVersion; /// A list element a `where` entry reads costs a comparison, or a set insertion and a lookup /// for `containsAll`: about a BLAKE3 block's work, which `blake3_per_block` bills at 300, so /// 100 an element covers it. Two lists of 1,024 elements bill 204,800 credits. +/// +/// A comparison judging a document type against `documentTypeRequirements` is a match on a +/// property's type or a lookup of a short string, no more than a list element's +/// comparison, so it bills the same 100. A type of 100 properties under four `"$any"` +/// slots, each with a `when` of 16 nodes, is 6,800 comparisons: 680,000 credits, the most +/// one reference can bill. The field is added in place: nothing reads it before protocol +/// version 15. pub const FEE_DOCUMENT_REFERENCE_VERSION1: FeeDocumentReferenceVersion = FeeDocumentReferenceVersion { choose_first_credits_per_candidate: 100_000, where_list_credits_per_element: 100, + requirement_credits_per_comparison: 100, }; 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 31a4486a270..925289a1397 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -616,6 +616,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_reference_expression_depth: 4, max_document_type_requirement_slots: 4, max_document_type_requirement_condition_nodes: 16, + max_document_type_requirement_condition_text_length: 256, max_property_constraints: 16, max_property_constraint_nodes: 32, max_property_constraint_aggregates: 4, 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 e94fd7080d6..232b84cff7e 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -101,13 +101,21 @@ pub struct SystemLimits { pub max_document_type_requirement_slots: u16, /// Maximum number of conditions the `when` of one `documentTypeRequirements` slot may be /// made of, `anyOf`, `allOf`, `not`, `ifThen` and `ifThenElse` counted with the - /// comparisons under them. Every write that checks the reference judges the condition + /// comparisons under them, and each string an `in` or a `notIn` lists counted too. Every write that checks the reference judges the condition /// against each property of the referenced document type that could fill the slot. It /// also bounds how deep conditions nest, which a decoder holds to /// `MAX_SLOT_CONDITION_DECODE_DEPTH` (a test keeps this limit within it). Refused on /// every parse. Read by `apply_property_reference` 1 (protocol version 15), the only /// version that parses such a reference, and never reached before. pub max_document_type_requirement_condition_nodes: u16, + /// Maximum number of characters of a string the `when` of a `documentTypeRequirements` + /// slot is written with: a `{ "const": ... }` and each string an `in` or a `notIn` + /// lists. They are compared with property names and property paths, which are no + /// longer. With `max_document_type_requirement_condition_nodes`, which counts each + /// listed string, it bounds what one `when` adds to a stored contract. Refused on + /// every parse and by the native schema check. Read by `apply_property_reference` 1 + /// and `check_document_type_schema` 1 (protocol version 15) only. + pub max_document_type_requirement_condition_text_length: u16, /// Maximum number of named rules one document type's `propertyConstraints` may /// declare. Every rule is evaluated on each create and replace of a document of the /// type, so this and `max_property_constraint_nodes` are what bound the arithmetic one 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 7ae8a0de363..21ea8f34f47 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -19,6 +19,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_reference_expression_depth: 4, max_document_type_requirement_slots: 4, max_document_type_requirement_condition_nodes: 16, + max_document_type_requirement_condition_text_length: 256, max_property_constraints: 16, max_property_constraint_nodes: 32, max_property_constraint_aggregates: 4, 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 f93068d4d17..7d338ff17f1 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -25,6 +25,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_reference_expression_depth: 4, max_document_type_requirement_slots: 4, max_document_type_requirement_condition_nodes: 16, + max_document_type_requirement_condition_text_length: 256, max_property_constraints: 16, max_property_constraint_nodes: 32, max_property_constraint_aggregates: 4, 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 3782b0ccafc..f4253a89817 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -27,6 +27,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_reference_expression_depth: 4, max_document_type_requirement_slots: 4, max_document_type_requirement_condition_nodes: 16, + max_document_type_requirement_condition_text_length: 256, max_property_constraints: 16, max_property_constraint_nodes: 32, max_property_constraint_aggregates: 4, 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 cf168439b33..94b0194bfaa 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -148,6 +148,7 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_reference_expression_depth: 4, // refersTo anyOf / allOf (new in v14): contract registration caps how deep they nest max_document_type_requirement_slots: 4, // backfilled: dynamic references parse from protocol version 15 max_document_type_requirement_condition_nodes: 16, // backfilled: a slot `when` parses from protocol version 15 + max_document_type_requirement_condition_text_length: 256, // backfilled: a slot `when` parses from protocol version 15 max_property_constraints: 16, // propertyConstraints (new in v14): contract registration caps the rules one document type declares max_property_constraint_nodes: 32, // propertyConstraints (new in v14): contract registration caps the nodes (comparison, operators, properties, values) of one rule max_property_constraint_aggregates: 4, // propertyConstraints countOf / sumOf (new in v14): contract registration caps the distinct totals one document type's rules read diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index a75bf1e07b5..1bb4ea96b48 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -679,8 +679,12 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// property tried for the slot), `$moderatorAbilities.deleteKeepsFields` and /// `$moderatorAbilities.changeFields` (the type's lists) and `{ "const": }`, /// of at most `max_document_type_requirement_condition_nodes` conditions (16, -/// `SYSTEM_LIMITS_V5`, backfilled); a property it does not hold for does not fill the -/// slot. The native schema check of note 7 admits the keys and meta-schema v3 refuses +/// `SYSTEM_LIMITS_V5`, backfilled), each string an `in` or a `notIn` lists counted, +/// none longer than `max_document_type_requirement_condition_text_length` (256, +/// backfilled); a property it does not hold for does not fill the slot. Judging the +/// named type is billed before it is judged, `requirement_credits_per_comparison` +/// (100, added in place to `FEE_DOCUMENT_REFERENCE_VERSION1`, which nothing read for +/// it before) for each property a slot tries and each node of its `when` for it. The native schema check of note 7 admits the keys and meta-schema v3 refuses /// them. It parses to `DocumentPropertyReferenceTarget::DynamicDocument`, /// appended. Such a declaration carries nothing else (no `contractId`, `documentType`, /// `findBy`, `where`, `inList`, `consume` or `constraints`), is no operand of a reference diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index 7ef508dd856..7513e26a7f9 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -1162,7 +1162,9 @@ describe('DataContract — refersTo declarations (v14)', () => { * ordinary key. */ it('should keep a slot named __proto__ as an own key', () => { - const declared = JSON.stringify(namedSchemas.note.properties.descriptionId.refersTo); + const declared = JSON.stringify( + (namedSchemas.note.properties.descriptionId as { refersTo: object }).refersTo, + ); // A replacer function: the text holds `$`, which a replacement string interprets const protoSchemas = JSON.parse(JSON.stringify(namedSchemas).replace( declared, @@ -1185,6 +1187,97 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(JSON.stringify(slots)).to.equal('{"__proto__":{"name":"$any","type":"string"}}'); }); + /** + * `type` may list both kinds, reported as the list in its declared order + * of kinds, and a slot may carry a `when`, reported as declared: nested + * conditions, constants and listed strings (in order) included. + */ + it('should report a type listing both kinds and a slot condition as declared', () => { + const when = { + ifThen: [ + { equal: ['$kind', { const: 'moderatedDocument' }] }, + { + allOf: [ + { not: { contains: ['$moderatorAbilities.deleteKeepsFields', '$property'] } }, + { not: { contains: ['$moderatorAbilities.changeFields', '$property'] } }, + ], + }, + ], + }; + const eitherKind = structuredClone(namedSchemas); + (eitherKind.note.properties.descriptionId as { refersTo: object }).refersTo = { + // Given the other way round: reported permanent first + type: ['moderatedDocument', 'permanentDocument'], + contractId: '$self.descriptionContractId', + documentType: '$self.descriptionDocumentType', + documentTypeRequirements: { + properties: { + body: { name: '$any', type: 'string', when }, + code: { + name: '$any', + type: 'string', + distinctFrom: 'body', + when: { notIn: ['$property', ['label', 'tags']] }, + }, + }, + }, + }; + const [descriptionId] = buildNamedContract(eitherKind).documentTypeReferences( + 'note', + ) as unknown as NamedReference[]; + + expect(descriptionId.type).to.deep.equal(['permanentDocument', 'moderatedDocument']); + expect(descriptionId.documentTypeRequirements).to.deep.equal({ + properties: { + body: { name: '$any', type: 'string', when }, + code: { + name: '$any', + type: 'string', + distinctFrom: ['body'], + when: { notIn: ['$property', ['label', 'tags']] }, + }, + }, + }); + }); + + it('should keep a slot named __proto__ with its condition as an own key', () => { + const declared = JSON.stringify( + (namedSchemas.note.properties.descriptionId as { refersTo: object }).refersTo, + ); + const slot = '{"name":"$any","type":"string","when":{"notEqual":["$property",{"const":"title"}]}}'; + const protoSchemas = JSON.parse(JSON.stringify(namedSchemas).replace( + declared, + () => `{ + "type": "moderatedDocument", + "contractId": "$self.descriptionContractId", + "documentType": "$self.descriptionDocumentType", + "documentTypeRequirements": { "properties": { "__proto__": ${slot} } } + }`, + )); + const [descriptionId] = buildNamedContract(protoSchemas).documentTypeReferences( + 'note', + ) as unknown as NamedReference[]; + const slots = descriptionId.documentTypeRequirements!.properties; + + expect(Object.keys(slots)).to.deep.equal(['__proto__']); + expect(Object.getPrototypeOf(slots)).to.equal(Object.prototype); + expect(JSON.stringify(slots)).to.equal(`{"__proto__":${slot}}`); + }); + + it('should refuse a list of kinds on a target the schema names', () => { + const listed = structuredClone(namedSchemas); + (listed.note.properties.descriptionId as { refersTo: object }).refersTo = { + type: ['permanentDocument', 'moderatedDocument'], + documentType: 'note', + }; + for (const fullValidation of [true, false]) { + expect( + () => buildNamedContract(listed, 15, fullValidation), + `full validation ${fullValidation}`, + ).to.throw(); + } + }); + it('should not read a target through the document before protocol version 15', () => { for (const fullValidation of [true, false]) { expect( From 065f9a2875fdc0af5cdd9998a33462e2312a85f2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 19:14:45 +0700 Subject: [PATCH 15/20] fix(platform): require a description host whose owner the elected team may moderate Review of the multiple choice proposal's description: - The contract a description lives in must not protect its owner from its elected team (contractRequirements.ownerProtected: false): a description the owner wrote in a contract that does could never be removed. - The protocol document lists the private and indexOnly refusals, in the order the checks run, says how long a contract's electionDelay can hold off its first election, and names the two things about the description's type that nothing in a reference can require yet: that the elected team holds deleteDocuments on it, and that a document stays deletable past a deleteWithin window. The schema's bytes change, so the ten genesis root pins of nullifier_batch_guard move with it. Co-Authored-By: Claude Fable 5.2 --- docs/protocol/decision-proposals.md | 35 +++++++-- .../decision-proposals-contract/README.md | 2 +- ...decision-proposals-contract-documents.json | 5 +- .../decision-proposals-contract/src/lib.rs | 5 +- .../decision-proposals-contract/src/v1/mod.rs | 2 +- .../unit/decisionProposalsContract.spec.js | 2 +- .../token/shielded/nullifier_batch_guard.rs | 20 +++--- .../multiple_choice_proposal_tests.rs | 71 +++++++++++++++++-- 8 files changed, 114 insertions(+), 28 deletions(-) diff --git a/docs/protocol/decision-proposals.md b/docs/protocol/decision-proposals.md index af9fdc85007..d1392d24939 100644 --- a/docs/protocol/decision-proposals.md +++ b/docs/protocol/decision-proposals.md @@ -153,7 +153,7 @@ rejected. | --- | --- | --- | | `title` | string, 1 to 128 characters and at most 512 bytes, required | The question, in one line | | `descriptionId` | identifier, required | The document saying what is being decided: a [reference whose target the proposal names](../../book/src/contract-keywords/refers-to-dynamic.md) | -| `descriptionContractId` | identifier, required | The contract that document lives in: `refersTo` a `contract` with `contractRequirements: { "moderation": "elected" }` | +| `descriptionContractId` | identifier, required | The contract that document lives in: `refersTo` a `contract` with `contractRequirements: { "moderation": "elected", "ownerProtected": false }` | | `descriptionDocumentType` | string, 1 to 64 characters, required | That document's document type | | `url` | string, `format: uri`, 1 to 512 characters and at most 512 bytes | Where the proposal is discussed | | `open` | boolean, required | Whether anyone may enter choices. Only the proposal's owner may when it is `false` | @@ -186,7 +186,10 @@ does, hosted where moderators can remove it: }, "descriptionContractId": { "type": "identifier", - "refersTo": { "type": "contract", "contractRequirements": { "moderation": "elected" } } + "refersTo": { + "type": "contract", + "contractRequirements": { "moderation": "elected", "ownerProtected": false } + } } ``` @@ -200,14 +203,18 @@ where `` is ``` Checked when the proposal is created, and again when an edit points it -elsewhere: +elsewhere. The contract is checked on its own property; the rows after it are +in the order the description's type is checked, the first that applies +refusing the write: | What the proposal names | Refused with | | --- | --- | +| A contract that does not declare elected moderation, or whose declaration protects its owner from the team | `ReferencedContractRequirementNotMetError` (40135) | | A contract that does not exist, or has no such document type | `ReferencedDocumentTypeNotFoundError` (40121) | -| A contract that does not declare elected moderation | `ReferencedContractRequirementNotMetError` (40135) | -| A document type whose documents their owner can delete, or that never leave state | `ReferencedDocumentTypeNotModeratedError` (40143) | +| A private document type, whose documents are encrypted entries the chain never reads | `ReferencedDocumentTypePrivateError` (40150) | +| An `indexOnly` document type, whose documents are index entries with none to find by its id | `ReferencedDocumentTypeIndexOnlyError` (40146) | | A moderated type with no string or byte array a removal takes away: every one is kept in the removal record (`deleteKeepsFields`) or is the moderators' to write (`changeFields`) | `ReferencedDocumentTypeRequirementNotMetError` (40153) | +| A document type whose documents their owner can delete, or that never leave state | `ReferencedDocumentTypeNotModeratedError` (40143) | | A document that is not there | `ReferencedEntityNotFoundError` (40120) | What this does and does not give: @@ -217,7 +224,23 @@ What this does and does not give: gone. - The contract declares a team the masternodes elect. It does not have to have seated one yet: until it does, whoever the contract names for the interim - moderates, which may be its owner or nobody. + moderates, which may be its owner or nobody. How long that lasts is the + contract's to say: its `electionDelay`, counted from its creation, holds off + the first election, and has no upper bound short of some 136 years. A + reference requiring `"moderation": "electionOpen"` would admit only a + contract whose delay has passed; it would still not require a seated team. +- The team may moderate the contract's owner like anyone: a contract whose + declaration protects its owner (`ownerProtected`) is refused, since a + description the owner wrote there could not be removed. +- Two things about the description's type are not checked, because nothing in + a reference can state them yet. The contract may leave the type out of the + abilities it gives its elected team (`deleteDocuments` on that type), so + that once a team is seated nobody can remove its documents. And the type + may let moderators delete only within a window after a document's last + change (`moderatorAbilities.deleteWithin`) without saying who may delete a + settled one, so a description becomes unremovable once the window has + passed. Either makes a description that satisfies every check and cannot be + taken down. - The description's own edit rules are its document type's. A type that lets its author replace a document lets the author change the description after the two hours, and after choices were entered. The proposal's history keeps diff --git a/packages/decision-proposals-contract/README.md b/packages/decision-proposals-contract/README.md index 2e7dc663667..6089f7b3182 100644 --- a/packages/decision-proposals-contract/README.md +++ b/packages/decision-proposals-contract/README.md @@ -72,7 +72,7 @@ contract is one whose moderators the masternodes elect. | --- | --- | --- | | `title` | string, 1 to 128 characters and at most 512 bytes, required | The question, in one line | | `descriptionId` | identifier, required | The document saying what is being decided | -| `descriptionContractId` | identifier, required | The contract that document lives in, one declaring elected moderation | +| `descriptionContractId` | identifier, required | The contract that document lives in, one declaring elected moderation that does not protect its owner from the team | | `descriptionDocumentType` | string, 1 to 64 characters, required | That document's document type | | `url` | string, a URI of 1 to 512 characters and at most 512 bytes | Where the proposal is discussed | | `open` | boolean, required | Whether anyone may enter choices. Only the proposal's owner may when it is false | diff --git a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json index de8d22062a8..9866ce4265f 100644 --- a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json +++ b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json @@ -304,10 +304,11 @@ "refersTo": { "type": "contract", "contractRequirements": { - "moderation": "elected" + "moderation": "elected", + "ownerProtected": false } }, - "description": "The contract the description document lives in, one whose moderators the masternodes elect", + "description": "The contract the description document lives in, one whose moderators the masternodes elect and whose owner they may moderate like anyone", "position": 2 }, "descriptionDocumentType": { diff --git a/packages/decision-proposals-contract/src/lib.rs b/packages/decision-proposals-contract/src/lib.rs index c6c36272100..b753799f34b 100644 --- a/packages/decision-proposals-contract/src/lib.rs +++ b/packages/decision-proposals-contract/src/lib.rs @@ -264,13 +264,14 @@ mod tests { } }) ); - // and that contract is one whose moderators the masternodes elect + // and that contract is one whose moderators the masternodes elect, with no + // protection of its owner from them: a description its owner wrote is removable too assert_eq!( question["properties"][multiple_choice_proposal::properties::DESCRIPTION_CONTRACT_ID] ["refersTo"], serde_json::json!({ "type": "contract", - "contractRequirements": { "moderation": "elected" } + "contractRequirements": { "moderation": "elected", "ownerProtected": false } }) ); let required: Vec<&str> = question["required"] diff --git a/packages/decision-proposals-contract/src/v1/mod.rs b/packages/decision-proposals-contract/src/v1/mod.rs index 69b7ba1216b..7d8986c9996 100644 --- a/packages/decision-proposals-contract/src/v1/mod.rs +++ b/packages/decision-proposals-contract/src/v1/mod.rs @@ -50,7 +50,7 @@ pub mod document_types { /// takes away and that its author writes. pub const DESCRIPTION_ID: &str = "descriptionId"; /// The id of the contract the description document lives in, one declaring - /// elected moderation. + /// elected moderation that does not protect its owner from the team. pub const DESCRIPTION_CONTRACT_ID: &str = "descriptionContractId"; /// The name of the description document's document type. pub const DESCRIPTION_DOCUMENT_TYPE: &str = "descriptionDocumentType"; diff --git a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js index cf3358f7cd8..92df9ca852b 100644 --- a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js +++ b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js @@ -306,7 +306,7 @@ describe('Decision Proposals Contract', () => { }); expect(properties.descriptionContractId.refersTo).to.deep.equal({ type: 'contract', - contractRequirements: { moderation: 'elected' }, + contractRequirements: { moderation: 'elected', ownerProtected: false }, }); }); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index 41a7b9338d3..0a362171075 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -731,7 +731,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "8cc0dab5d2317cd25bc03c082ae8f2cf6acd0cf8e5ac022416d0079a08997b9a" + "f285d08d160e7b8339bd34280e0fb17b0fee2169bfe8d070ff8c1e280ef2c955" ); assert_eq!( pool.platform @@ -2182,39 +2182,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "6a823c2db94612652926e6b9c1ae7feec9c2547dfd4cfc4e474318da1c16d59c", - "0e713261d7fb4871d88a473bc26163a6cd6f2a0d8aade5bc6c8af516af838e74", + "068481fb2599755eaa183ac90a8dde1849abc5b6c7a0c030999da78ec4365409", ), "valid-payment" => ( "9388d5cf63c4a571b9fb009679e2e34d0b681acc88f0f5195e7287c7efbd92f7", - "74af11f7ca0add0278d83611a196c8332d397611747fe728c99306e3dac37bcf", + "86c8a2e5492f8bf87052b455f3b6c4ef5261dac8ce51b274d5df5963b41d87dc", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "86dda6a8e36e42d860493e9ccd78d321391e82fae2bad3127569f98d595dbd2a", + "046b8033adb697c973fa3b64ad6fea2ac851957031096f89c3665a681f31a51e", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "c6e5dbbd33b2a0ee23b93b5e7d8d33e4cb7059de779d8ab79fbccb527ed82ef3", + "69d15f00833f52655c340c4957a426d1c58af7de27173971a5e692597ad61a3f", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "ed9200956b157d504bdfa9d6d60904afb432ae265b413de49388741379a405ee", + "135e69d4932cbc447f564ca7ae25fd7822a6f6418a8fdce5a5bb90efcb1250f2", ), "open-mint-group" => ( "5c68381297c9bf68c5e85ebe6bb9e0f1289e2c07718eecfb5e6773d6009455bb", - "5c6dfedba14495b62936c9bf2b72bf42e7f84db76462f1b2a2e1e7f6b08c9806", + "2759534c7ea51f63a31a8df5f4af7f104c670c60079e7cc5e2e611aa18a10700", ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "50f436d2611be66c6fb7f3279db099e6598524d1e1cf28040d8a113bfa63e8ba", + "07f15e539498c3868ba8f58b57d3ccb05c52d5073efeeabcac1103c9efd54746", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", - "cb6d371ce346df723f329a336b1330a8ef11d02bfb310f6c2a6477510f6285ed", + "66fbd26c23e26ad4e10f9ec921915312a46335be2a0ef230ceda73934a5112ba", ), "closing-burn-group" => ( "93e1732286f168c4a91d2765931e181c5721bfca1bfd5f93b8baacca03e00b2b", - "b366a6251b5e7054cbb205eb1b54a4020e5e36d248d23a9c407c0bd20420d8f4", + "3892048e1f053dbf82fcefb95adfff8fdbccaec9be2bb5b783f3f8a5849800b5", ), _ => unreachable!("enumerated characterization case"), }; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs index a09b13673a1..672c2c316c1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs @@ -136,15 +136,49 @@ fn text_schema(extra: Value) -> Value { schema } +/// Who moderates a contract hosting descriptions. +#[derive(Clone, Copy, PartialEq)] +enum HostModerators { + /// A team the masternodes elect, the contract's owner until one is seated. + Elected, + /// The same, with the contract's owner protected from the team. + ElectedProtectingItsOwner, + /// The contract's owner alone. + Owner, +} + /// A contract hosting the texts proposals describe themselves with. Its moderators are a /// team the masternodes elect, its owner until one is seated, when `elected`; its owner /// alone otherwise. The same contract every time it is built. fn description_host(elected: bool, platform_version: &PlatformVersion) -> DataContract { - let owner_id = Identifier::new([if elected { 0xD5 } else { 0xD6 }; 32]); + description_host_moderated_by( + if elected { + HostModerators::Elected + } else { + HostModerators::Owner + }, + platform_version, + ) +} + +/// A contract hosting descriptions, moderated by `moderators`. The same contract every time +/// it is built. +fn description_host_moderated_by( + moderators: HostModerators, + platform_version: &PlatformVersion, +) -> DataContract { + let owner_id = Identifier::new( + [match moderators { + HostModerators::Elected => 0xD5, + HostModerators::Owner => 0xD6, + HostModerators::ElectedProtectingItsOwner => 0xD7, + }; 32], + ); let mut contract = get_data_contract_fixture(Some(owner_id), 1, platform_version.protocol_version) .data_contract_owned(); - let moderators = if elected { + let owner_protected = moderators == HostModerators::ElectedProtectingItsOwner; + let moderators = if moderators != HostModerators::Owner { ContractModerators::Elected(Box::new(ElectedModerators { join_window: DEFAULT_ELECTION_WINDOW_SECONDS, vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, @@ -161,7 +195,7 @@ fn description_host(elected: bool, platform_version: &PlatformVersion) -> DataCo interim: InterimModerators::ContractOwner, election_delay: None, max_added_moderators: 0, - owner_protected: false, + owner_protected, })) } else { ContractModerators::ContractOwner @@ -210,11 +244,15 @@ fn setup() -> ( .with_latest_protocol_version() .build_with_mock_rpc() .set_genesis_state(); - for elected in [true, false] { + for moderators in [ + HostModerators::Elected, + HostModerators::ElectedProtectingItsOwner, + HostModerators::Owner, + ] { platform .drive .apply_contract( - &description_host(elected, platform_version), + &description_host_moderated_by(moderators, platform_version), BlockInfo::default(), true, StorageFlags::optional_default_as_cow(), @@ -1838,6 +1876,8 @@ async fn should_take_the_description_from_a_moderated_text_of_an_elected_contrac let mut proposer = writer(&mut platform, &mut rng); let elected = description_host(true, platform_version); let owner_moderated = description_host(false, platform_version); + let owner_protecting = + description_host_moderated_by(HostModerators::ElectedProtectingItsOwner, platform_version); for (document_type_name, kind) in [ (TEXT, DocumentReferenceKind::Moderated), (KEPT_TEXT, DocumentReferenceKind::Moderated), @@ -1860,6 +1900,7 @@ async fn should_take_the_description_from_a_moderated_text_of_an_elected_contrac (&elected, KEPT_TEXT), (&elected, PLAIN_TEXT), (&owner_moderated, TEXT), + (&owner_protecting, TEXT), ] { let id = describe( &platform, @@ -1949,6 +1990,26 @@ async fn should_take_the_description_from_a_moderated_text_of_an_elected_contrac "{error:?}" ); + // and of a contract whose elected team may not moderate its owner: a description the + // owner wrote there could never be taken down + let (protected, _) = create_transition( + &contract, + &mut proposer, + multiple_choice_proposal::NAME, + asking(&owner_protecting, TEXT, text_of(&owner_protecting, TEXT)), + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, protected, 20_000, platform_version); + assert!( + matches!( + error, + ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(_)) + ), + "{error:?}" + ); + // A text that is not there, and a type the contract does not have let (missing, _) = create_transition( &contract, From e2bea23c634fc016d6c2ea7d9a4a01a81c4759a1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 12 Nov 2026 14:46:11 +0700 Subject: [PATCH 16/20] feat(platform)!: require a description the elected team may always delete (PV15) Review of the multiple choice proposal's description: a host could give a description that passed every check and could never be removed, by leaving its type out of what the elected team may delete, or by letting moderators delete only within a window with no rule for later. documentTypeRequirements takes a `when` of its own: the condition of a slot's `when`, judged once for the named type as a whole, reading no $property. A `when` may now also look into $electedAbilities, the abilities the type's contract gives its elected moderation team on the type, and test with `present` or `absent` whether the type declares moderatorAbilities.deleteWithin or moderatorAbilities.deleteSettled. The proposal's description asks for both: the team holds deleteDocuments on the type, and the type sets no window or says who deletes past it. The schema's bytes change, so the ten genesis root pins of nullifier_batch_guard move with it. Co-Authored-By: Claude Fable 5.2 --- book/src/contract-keywords.md | 2 +- .../contract-keywords/refers-to-dynamic.md | 28 ++- book/src/data-model/documents.md | 2 +- docs/protocol/decision-proposals.md | 34 +++- .../decision-proposals-contract/README.md | 6 +- ...decision-proposals-contract-documents.json | 24 ++- .../decision-proposals-contract/src/lib.rs | 9 + .../decision-proposals-contract/src/v1/mod.rs | 3 +- .../unit/decisionProposalsContract.spec.js | 12 ++ .../try_from_schema/dynamic_reference.rs | 131 ++++++++++--- .../native_schema/references.rs | 21 +- .../v3/dynamic_reference_tests.rs | 184 +++++++++++++++++- .../property/dynamic_document_reference.rs | 51 ++++- .../document_type/property/mod.rs | 8 +- .../document_type/property/slot_condition.rs | 158 ++++++++++++++- .../referenced_entity_not_found_error.rs | 1 + .../document_reference_validation/v1/mod.rs | 25 ++- .../token/shielded/nullifier_batch_guard.rs | 20 +- .../multiple_choice_proposal_tests.rs | 154 +++++++++++++-- .../rs-platform-version/src/version/v15.rs | 7 +- .../data_contract/document_type_reference.rs | 24 ++- .../unit/DocumentPropertyReference.spec.ts | 15 ++ 22 files changed, 820 insertions(+), 99 deletions(-) diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md index 247beea63a7..907ac7bd19b 100644 --- a/book/src/contract-keywords.md +++ b/book/src/contract-keywords.md @@ -195,7 +195,7 @@ A typed array's element (`items`) takes `type` (the `identifier` and `bytes` sho | `documentType` | document type name | The referenced document type. | 14 | [documentType](contract-keywords/refers-to.md#documenttype) | | `contractId` | identifier | The contract holding `documentType`, when it is not this one. | 14 | [contractId](contract-keywords/refers-to.md#contractid) | | `contractId`, `documentType` as `"$self."` | `$self.` and a top-level property name | On a `permanentDocument` or `moderatedDocument`, or a `type` [listing both](contract-keywords/refers-to-dynamic.md#either-kind): both read from the referring document, an identifier property holding the id of the contract and a string property holding the name of the document type. Both or neither. | 15 | [A Target the Document Names](contract-keywords/refers-to-dynamic.md) | -| `documentTypeRequirements` | `{ "properties", "require"? }` | Beside a target read from the document: 1 to 4 named slots, each a property the named type must have (`name`, `type`, `maxBytes`, `distinctFrom`, and `when`, a [condition](contract-keywords/refers-to-dynamic.md#a-condition-on-the-property-when) on what the type says of the property), and which of them must be filled. | 15 | [documentTypeRequirements](contract-keywords/refers-to-dynamic.md#documenttyperequirements) | +| `documentTypeRequirements` | `{ "properties", "require"? }` | Beside a target read from the document: 1 to 4 named slots, each a property the named type must have (`name`, `type`, `maxBytes`, `distinctFrom`, and `when`, a [condition](contract-keywords/refers-to-dynamic.md#a-condition-on-the-property-when) on what the type says of the property), which of them must be filled, and a [`when` on the type as a whole](contract-keywords/refers-to-dynamic.md#a-condition-on-the-type-when). | 15 | [documentTypeRequirements](contract-keywords/refers-to-dynamic.md#documenttyperequirements) | | `findBy` | 1 to 10 entries: referenced property → `"."`, `"$ownerId"`, a path or a function | Finds the document through the unique index of `documentType` over exactly these properties, in any order; `"."` is the value itself. Without it the value is the document's `$id`. | 14 | [findBy](contract-keywords/refers-to-lookup.md) · [internals](data-model/documents.md#found-by-a-unique-index-findby) | | `findBy.` | `{ "function": "sys.hash.sha256d", "params" }` | A function entry: the referenced property holds the hash of `params` (paths, `{ "const": text }`, `"."` for a value without a path), which fills that part of the key, finding a commitment made earlier. At most one. | 14 | [Commit and reveal](contract-keywords/refers-to-lookup.md#commit-and-reveal) · [internals](data-model/documents.md#commit-and-reveal-a-findby-function) | | `where` | 1 to 10 entries `{ "": "" }` | Checked on the document found: each referenced property (or `$ownerId`, `$creatorId`, `$id`) must equal the referring property. A value of `"$ownerId"`, the writer, makes a write gate. Never finds the document. | 14 | [where](contract-keywords/refers-to.md#where) | diff --git a/book/src/contract-keywords/refers-to-dynamic.md b/book/src/contract-keywords/refers-to-dynamic.md index e95ddbcf897..84c3882c0ce 100644 --- a/book/src/contract-keywords/refers-to-dynamic.md +++ b/book/src/contract-keywords/refers-to-dynamic.md @@ -48,7 +48,7 @@ A note is described by a document its author points at: `descriptionContractId` |---|---|---| | `contractId` | `"$self."` followed by the name of a top-level identifier property of the document type, 1 to 64 letters, digits or underscores | The property holding the id of the contract the referenced document type lives in, where a reference naming its target in the schema writes the id itself. | | `documentType` | `"$self."` followed by the name of a top-level string property | The property holding the name of the referenced document type, where a reference naming its target in the schema writes the name itself. | -| `documentTypeRequirements` | object: `properties` (required), `require` | What the named document type must look like. Absent, the type is held to its kind alone. | +| `documentTypeRequirements` | object: `properties` (required), `require`, `when` | What the named document type must look like. Absent, the type is held to its kind alone. | The two come together: a declaration names its target in the schema or through the document, never half of each, so a `$self.` `contractId` beside a literal `documentType` is refused, as is one beside none. The declaring contract is named like any other: a document pointing into its own contract writes that contract's id. Nothing a schema could write literally starts with `$self.`: a contract id is base58 and a document type name holds no `$`. @@ -140,18 +140,42 @@ When the type is a moderated one, the string filling `body` is one a moderator's | `"$property"` | The name of the property tried for the slot. | | `"$moderatorAbilities.deleteKeepsFields"` | The referenced type's [`deleteKeepsFields`](moderator-abilities.md): the property paths a removal record keeps. Empty when the type lists none. | | `"$moderatorAbilities.changeFields"` | The referenced type's [`changeFields`](moderator-abilities.md): the properties only moderators write. Empty when the type lists none. | +| `"$electedAbilities"` | The abilities the referenced type's contract gives its [elected moderation team](../data-model/contract-moderation.md) on the type, by name: `deleteDocuments`, `ban`, `suspend`, `warn`, `changeDocumentFields`. Empty when the contract elects no team, or leaves the type out of what the team moderates. | +| `"$moderatorAbilities.deleteWithin"`, `"$moderatorAbilities.deleteSettled"` | Settings of the referenced type a `present` or an `absent` tests: whether moderators delete a document only within a window after its last change, and whether the type says who of a seated team may delete one past it. | | `{ "const": "" }` | The string, 1 to 256 characters. | | Condition | Holds when | |---|---| | `{ "equal": [a, b] }`, `{ "notEqual": [a, b] }` | `$kind` or `$property` is, or is not, the constant on the other side. Compared with `$kind`, the constant is `permanentDocument` or `moderatedDocument`. | | `{ "in": [a, [...]] }`, `{ "notIn": [a, [...]] }` | `$kind` or `$property` is, or is not, one of two or more distinct strings. | -| `{ "contains": [list, a] }` | One of the two lists holds `$property` or a constant, as a whole entry. | +| `{ "contains": [list, a] }` | One of the two property lists holds `$property` or a constant, as a whole entry; or `$electedAbilities` holds the ability a constant names. | +| `{ "present": setting }`, `{ "absent": setting }` | The type declares, or does not declare, `$moderatorAbilities.deleteWithin` or `$moderatorAbilities.deleteSettled`. | | `{ "not": c }`, `{ "anyOf": [...] }`, `{ "allOf": [...] }` | As in `propertyConstraints`: `anyOf` and `allOf` take two or more conditions, none listed twice and none the same combinator; `not` holds no `not` and no `notIn`. | | `{ "ifThen": [c, then] }`, `{ "ifThenElse": [c, then, else] }` | `then` holds whenever `c` does; with an `else`, it holds when `c` does not. | One `when` is made of at most 16 conditions (`max_document_type_requirement_condition_nodes`), combinators counted and each string an `in` or a `notIn` lists counted too, and no string in it is longer than 256 characters (`max_document_type_requirement_condition_text_length`), so what it adds to a stored contract is bounded. It reads the type, never a document: no property path, no arithmetic, and no value of the referenced document. `distinctFrom`, `require` and the search for an assignment work as before, over the properties each slot's `when` leaves it. +## A condition on the type (`when`) + +`documentTypeRequirements` takes a `when` of its own, beside `properties`: the same condition, judged once for the named type as a whole, whatever fills the slots. No property is in hand, so it reads no `$property`. A type it does not hold for does not meet the requirements (40153). + +```json +"documentTypeRequirements": { + "when": { "allOf": [ + { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, + { "anyOf": [ + { "absent": "$moderatorAbilities.deleteWithin" }, + { "present": "$moderatorAbilities.deleteSettled" } + ] } + ] }, + "properties": { "body": { "name": "$any", "type": "string" } } +} +``` + +The contract of the named type gives its elected team the deletion of the type's documents, and a document stays deletable: the type sets no window, or says who of the team deletes one past it. Without the first, a type can be a moderated one that nobody may moderate once a team is seated; without the second, a document outlasts its window for good. A reference that relies on a document being removable asks for both, and usually for [an elected contract](#who-vouches-for-the-target) too. + +A slot's `when` reads the same facts beside `$property`. Each `when` is held to the limits above on its own. + ## How it works Nothing about the target is known when the contract is registered, so registration checks the declaration alone. Everything about the target is checked when a document is written, in this order, the first failure refusing the write: diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 4d503d61ee0..a488591e8f9 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -638,7 +638,7 @@ Version 1 of `apply_property_reference` reads the declaration (`try_from_schema/ The write-time check, `document_reference_validation` 1, builds the declaration a by-id reference would carry from the document's own values (the contract id and the type name, no `where`) and runs the flow every document reference runs: contract, document type, kind, document, removal record. Between the document type and the kind it does what registration does for a reference naming its type in the schema, refusing a private type, and judges the requirements with `DocumentTypeRequirements::met_by`. That search is over sets of slots, fullest first, and within a set fills the slot with the fewest candidates first; with at most `max_document_type_requirement_slots` slots it stays small whatever the referenced type declares. A replace checks the reference again when one of `DynamicDocumentReference::target_properties` changed. -A `type` listing both kinds parses to `DynamicDocumentReferenceKind::PermanentOrModerated`, and the write-time check takes the kind the reference is checked as from the named type (`checked_as`): moderated into a moderated type, permanent into any other. A slot's `when` is a `SlotCondition` (`property/slot_condition.rs`), judged by `met_by` for each property that could fill the slot against a `SlotConditionSubject`: the type's kind, the property's name, and the type's `moderator_deletion_kept_fields` and `moderator_changeable_fields`. The condition is stored in the compiled contract and travels in the consensus errors that carry a reference target, so the conditions it holds inside it sit behind `SlotConditions`, whose decoders refuse a nesting past `MAX_SLOT_CONDITION_DECODE_DEPTH` (16), as `ReferenceOperands` does for reference expressions. The parser holds one `when` to `max_document_type_requirement_condition_nodes` conditions (16), each string an `in` or a `notIn` lists counted, which keeps every registered condition within that depth and bounds its size with `max_document_type_requirement_condition_text_length`. `DocumentTypeRequirements::comparisons` counts what judging a type may take (each property a slot tries, times one plus the nodes of its `when`), and `document_reference_validation` 1 bills it through `bill_requirement_comparisons` before calling `met_by`, as `bill_where_list_elements` bills a `where` over lists. +A `type` listing both kinds parses to `DynamicDocumentReferenceKind::PermanentOrModerated`, and the write-time check takes the kind the reference is checked as from the named type (`checked_as`): moderated into a moderated type, permanent into any other. A slot's `when` is a `SlotCondition` (`property/slot_condition.rs`), judged by `met_by` for each property that could fill the slot against a `SlotConditionSubject`: the type's kind, the property's name, and the type's `moderator_deletion_kept_fields` and `moderator_changeable_fields`. The condition is stored in the compiled contract and travels in the consensus errors that carry a reference target, so the conditions it holds inside it sit behind `SlotConditions`, whose decoders refuse a nesting past `MAX_SLOT_CONDITION_DECODE_DEPTH` (16), as `ReferenceOperands` does for reference expressions. The parser holds one `when` to `max_document_type_requirement_condition_nodes` conditions (16), each string an `in` or a `notIn` lists counted, which keeps every registered condition within that depth and bounds its size with `max_document_type_requirement_condition_text_length`. `DocumentTypeRequirements::comparisons` counts what judging a type may take (each property a slot tries, times one plus the nodes of its `when`), and `document_reference_validation` 1 bills it through `bill_requirement_comparisons` before calling `met_by`, as `bill_where_list_elements` bills a `where` over lists. `DocumentTypeRequirements::when` is a condition of the same type judged once for the named type, which the parser holds to reading no `$property` (`SlotCondition::reads_the_property`). What it and a slot's `when` read of the type is a `SlotConditionSubject`: beside the kind and the two field lists, the abilities the type's contract gives its elected team on it (`ElectedModerators::moderated_document_types`, which `document_reference_validation` 1 hands to `met_by`) and whether the type declares `deleteWithin` and `deleteSettled`. ## Immutable Properties on Mutable Document Types diff --git a/docs/protocol/decision-proposals.md b/docs/protocol/decision-proposals.md index d1392d24939..3ac013392c1 100644 --- a/docs/protocol/decision-proposals.md +++ b/docs/protocol/decision-proposals.md @@ -176,6 +176,7 @@ does, hosted where moderators can remove it: "contractId": "$self.descriptionContractId", "documentType": "$self.descriptionDocumentType", "documentTypeRequirements": { + "when": , "properties": { "body": { "name": "$any", "type": "string", "when": }, "bodyBytes": { "name": "$any", "type": "array", "byteArray": true, "when": } @@ -193,7 +194,19 @@ does, hosted where moderators can remove it: } ``` -where `` is +where `` is + +```json +{ "allOf": [ + { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, + { "anyOf": [ + { "absent": "$moderatorAbilities.deleteWithin" }, + { "present": "$moderatorAbilities.deleteSettled" } + ] } +] } +``` + +and `` is ```json { "allOf": [ @@ -213,6 +226,7 @@ refusing the write: | A contract that does not exist, or has no such document type | `ReferencedDocumentTypeNotFoundError` (40121) | | A private document type, whose documents are encrypted entries the chain never reads | `ReferencedDocumentTypePrivateError` (40150) | | An `indexOnly` document type, whose documents are index entries with none to find by its id | `ReferencedDocumentTypeIndexOnlyError` (40146) | +| A type its contract's elected team may not delete from (the contract elects no team, or leaves the type out of the team's `deleteDocuments`), or one moderators delete only within a window (`deleteWithin`) with no rule for later (`deleteSettled`) | `ReferencedDocumentTypeRequirementNotMetError` (40153) | | A moderated type with no string or byte array a removal takes away: every one is kept in the removal record (`deleteKeepsFields`) or is the moderators' to write (`changeFields`) | `ReferencedDocumentTypeRequirementNotMetError` (40153) | | A document type whose documents their owner can delete, or that never leave state | `ReferencedDocumentTypeNotModeratedError` (40143) | | A document that is not there | `ReferencedEntityNotFoundError` (40120) | @@ -232,15 +246,15 @@ What this does and does not give: - The team may moderate the contract's owner like anyone: a contract whose declaration protects its owner (`ownerProtected`) is refused, since a description the owner wrote there could not be removed. -- Two things about the description's type are not checked, because nothing in - a reference can state them yet. The contract may leave the type out of the - abilities it gives its elected team (`deleteDocuments` on that type), so - that once a team is seated nobody can remove its documents. And the type - may let moderators delete only within a window after a document's last - change (`moderatorAbilities.deleteWithin`) without saying who may delete a - settled one, so a description becomes unremovable once the window has - passed. Either makes a description that satisfies every check and cannot be - taken down. +- The team is given the deletion of the description's type, and a description + stays deletable. A contract that leaves the type out of the abilities it + gives its elected team (`deleteDocuments`) is refused, since once a team is + seated nobody could remove its documents. So is a type that lets moderators + delete only within a window after a document's last change + (`moderatorAbilities.deleteWithin`) without saying who may delete a settled + one (`deleteSettled`), since a description would outlast the window for + good. A settled document is deleted by a seated team alone, so within the + interim a description past its window waits for the first election. - The description's own edit rules are its document type's. A type that lets its author replace a document lets the author change the description after the two hours, and after choices were entered. The proposal's history keeps diff --git a/packages/decision-proposals-contract/README.md b/packages/decision-proposals-contract/README.md index 6089f7b3182..14c04343ecc 100644 --- a/packages/decision-proposals-contract/README.md +++ b/packages/decision-proposals-contract/README.md @@ -65,8 +65,10 @@ The proposal holds no description of its own. It points at a document saying what is being decided, in another contract, so that the description can be taken down: the document type is a moderated one (its documents leave state only through a moderator's recorded removal) holding a string or a byte array -that the removal takes away and that the document's author writes, and the -contract is one whose moderators the masternodes elect. +that the removal takes away and that the document's author writes, the +contract is one whose moderators the masternodes elect and whose owner they +may moderate, and it gives that team the deletion of the type's documents, +at any time after they are written. | Property | Type | Meaning | | --- | --- | --- | diff --git a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json index 9866ce4265f..cb84bb13fd7 100644 --- a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json +++ b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json @@ -233,6 +233,28 @@ "contractId": "$self.descriptionContractId", "documentType": "$self.descriptionDocumentType", "documentTypeRequirements": { + "when": { + "allOf": [ + { + "contains": [ + "$electedAbilities", + { + "const": "deleteDocuments" + } + ] + }, + { + "anyOf": [ + { + "absent": "$moderatorAbilities.deleteWithin" + }, + { + "present": "$moderatorAbilities.deleteSettled" + } + ] + } + ] + }, "properties": { "body": { "name": "$any", @@ -292,7 +314,7 @@ } } }, - "description": "The document saying what is being decided: a document of a moderated type, holding text its author wrote that a moderator's removal takes away with it", + "description": "The document saying what is being decided: a document of a moderated type its contract's elected team may delete, at any time after it is written, holding text its author wrote that the removal takes away with it", "position": 1 }, "descriptionContractId": { diff --git a/packages/decision-proposals-contract/src/lib.rs b/packages/decision-proposals-contract/src/lib.rs index b753799f34b..9d2180cc418 100644 --- a/packages/decision-proposals-contract/src/lib.rs +++ b/packages/decision-proposals-contract/src/lib.rs @@ -247,6 +247,15 @@ mod tests { multiple_choice_proposal::properties::DESCRIPTION_DOCUMENT_TYPE ), "documentTypeRequirements": { + // The contract's elected team may delete documents of the type, and + // at any time: no window, or a rule for deleting past it + "when": { "allOf": [ + { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, + { "anyOf": [ + { "absent": "$moderatorAbilities.deleteWithin" }, + { "present": "$moderatorAbilities.deleteSettled" } + ] } + ] }, "properties": { "body": { "name": "$any", diff --git a/packages/decision-proposals-contract/src/v1/mod.rs b/packages/decision-proposals-contract/src/v1/mod.rs index 7d8986c9996..ed0f96862b7 100644 --- a/packages/decision-proposals-contract/src/v1/mod.rs +++ b/packages/decision-proposals-contract/src/v1/mod.rs @@ -47,7 +47,8 @@ pub mod document_types { pub const TITLE: &str = "title"; /// The id of the document saying what is being decided. Its document type is a /// moderated one holding a string or a byte array that a moderator's removal - /// takes away and that its author writes. + /// takes away and that its author writes, which its contract's elected team may + /// delete from at any time. pub const DESCRIPTION_ID: &str = "descriptionId"; /// The id of the contract the description document lives in, one declaring /// elected moderation that does not protect its owner from the team. diff --git a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js index 92df9ca852b..678a2daaa30 100644 --- a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js +++ b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js @@ -295,6 +295,18 @@ describe('Decision Proposals Contract', () => { contractId: '$self.descriptionContractId', documentType: '$self.descriptionDocumentType', documentTypeRequirements: { + // The contract's elected team may delete documents of the type, at any time + when: { + allOf: [ + { contains: ['$electedAbilities', { const: 'deleteDocuments' }] }, + { + anyOf: [ + { absent: '$moderatorAbilities.deleteWithin' }, + { present: '$moderatorAbilities.deleteSettled' }, + ], + }, + ], + }, properties: { body: { name: '$any', type: 'string', when: takenWithTheDocument }, bodyBytes: { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs index 406387e5464..d03cddc0480 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs @@ -3,14 +3,16 @@ //! version 1 of `apply_property_reference` reads (protocol version 15). See //! [`DynamicDocumentReference`]. +use crate::data_contract::config::moderation::ModerationAbility; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::property::{ DocumentPropertyType, DocumentTypeRequirements, DynamicDocumentReference, DynamicDocumentReferenceKind, PropertyReference, ReferenceHolder, RequiredPropertySlot, - SlotCondition, SlotConditionList, SlotConditionValue, SlotConditions, SlotPropertyName, - SlotRequirement, SlotRequirementTerm, SlotValue, ANY_PROPERTY_NAME, REFERRING_DOCUMENT_PREFIX, - SLOT_CONDITION_CHANGE_FIELDS, SLOT_CONDITION_DELETE_KEEPS_FIELDS, SLOT_CONDITION_KIND, - SLOT_CONDITION_PROPERTY, + SlotCondition, SlotConditionList, SlotConditionSetting, SlotConditionValue, SlotConditions, + SlotPropertyName, SlotRequirement, SlotRequirementTerm, SlotValue, ANY_PROPERTY_NAME, + REFERRING_DOCUMENT_PREFIX, SLOT_CONDITION_CHANGE_FIELDS, SLOT_CONDITION_DELETE_KEEPS_FIELDS, + SLOT_CONDITION_DELETE_SETTLED, SLOT_CONDITION_DELETE_WITHIN, SLOT_CONDITION_ELECTED_ABILITIES, + SLOT_CONDITION_KIND, SLOT_CONDITION_PROPERTY, }; use crate::data_contract::document_type::{property_names, DocumentTypeRef}; use crate::data_contract::errors::DataContractError; @@ -59,6 +61,26 @@ struct SlotConditionLimits { text_length: usize, } +impl SlotConditionLimits { + fn of(platform_version: &PlatformVersion) -> Self { + let limits = &platform_version.system_limits; + Self { + nodes: usize::from(limits.max_document_type_requirement_condition_nodes), + text_length: usize::from(limits.max_document_type_requirement_condition_text_length), + } + } +} + +/// The names of the abilities a contract may give its elected moderation team on a document +/// type, what `$electedAbilities` lists. +const ELECTED_ABILITY_NAMES: [ModerationAbility; 5] = [ + ModerationAbility::DeleteDocuments, + ModerationAbility::Ban, + ModerationAbility::Suspend, + ModerationAbility::Warn, + ModerationAbility::ChangeDocumentFields, +]; + fn refused(message: String) -> DataContractError { DataContractError::InvalidContractStructure(message) } @@ -240,10 +262,11 @@ fn parse_document_type_requirements( .map_err(|_| refused("documentTypeRequirements must be an object".to_string()))?; if let Some(key) = requirements .keys() - .find(|key| !matches!(key.as_str(), "properties" | "require")) + .find(|key| !matches!(key.as_str(), "properties" | "require" | "when")) { return Err(refused(format!( - "documentTypeRequirements does not take {key}: it holds properties and require" + "documentTypeRequirements does not take {key}: it holds properties, require and \ + when" ))); } let slot_values = requirements @@ -298,7 +321,34 @@ fn parse_document_type_requirements( .map(|value| parse_require(value, &slots)) .transpose()?; - Ok(DocumentTypeRequirements { slots, require }) + // What the type must say of itself, whatever fills the slots: the grammar of a slot's + // `when`, with no property in hand to read + let when = requirements + .get("when") + .map(|when| { + let mut nodes = 0; + let condition = parse_slot_condition( + when, + "documentTypeRequirements", + &mut nodes, + SlotConditionLimits::of(platform_version), + )?; + if condition.reads_the_property() { + return Err(refused(format!( + "documentTypeRequirements when is judged for the document type as a \ + whole and reads no \"{SLOT_CONDITION_PROPERTY}\": a condition on a property \ + belongs on the slot the property fills" + ))); + } + Ok(condition) + }) + .transpose()?; + + Ok(DocumentTypeRequirements { + slots, + require, + when, + }) } fn parse_slot( @@ -417,20 +467,13 @@ fn parse_slot( let when = slot .get("when") .map(|when| { - let limits = SlotConditionLimits { - nodes: usize::from( - platform_version - .system_limits - .max_document_type_requirement_condition_nodes, - ), - text_length: usize::from( - platform_version - .system_limits - .max_document_type_requirement_condition_text_length, - ), - }; let mut nodes = 0; - parse_slot_condition(when, &at(), &mut nodes, limits) + parse_slot_condition( + when, + &at(), + &mut nodes, + SlotConditionLimits::of(platform_version), + ) }) .transpose()?; @@ -530,7 +573,8 @@ fn parse_slot_condition( .ok_or_else(|| { refused(format!( "{at} when, and every condition inside it, is an object of one key: equal, \ - notEqual, in, notIn, contains, not, anyOf, allOf, ifThen or ifThenElse" + notEqual, in, notIn, contains, present, absent, not, anyOf, allOf, ifThen or \ + ifThenElse" )) })?; let Some((key, operand)) = condition.iter().next() else { @@ -627,15 +671,37 @@ fn parse_slot_condition( let list = match pair[0].as_text() { Some(SLOT_CONDITION_DELETE_KEEPS_FIELDS) => SlotConditionList::DeletionKeptFields, Some(SLOT_CONDITION_CHANGE_FIELDS) => SlotConditionList::ModeratorChangeableFields, + Some(SLOT_CONDITION_ELECTED_ABILITIES) => SlotConditionList::ElectedAbilities, _ => { return Err(refused(format!( - "{at} when contains looks into \"{SLOT_CONDITION_DELETE_KEEPS_FIELDS}\" \ - or \"{SLOT_CONDITION_CHANGE_FIELDS}\", got {}", + "{at} when contains looks into \"{SLOT_CONDITION_DELETE_KEEPS_FIELDS}\", \ + \"{SLOT_CONDITION_CHANGE_FIELDS}\" or \ + \"{SLOT_CONDITION_ELECTED_ABILITIES}\", got {}", pair[0] ))) } }; let needle = slot_condition_value(&pair[1], at, limits.text_length)?; + // The team's abilities are named, not properties: a constant that names one + if list == SlotConditionList::ElectedAbilities { + let names_an_ability = matches!( + &needle, + SlotConditionValue::Constant(name) + if ELECTED_ABILITY_NAMES.iter().any(|ability| ability.as_str() == name) + ); + if !names_an_ability { + return Err(refused(format!( + "{at} when contains looks in \"{SLOT_CONDITION_ELECTED_ABILITIES}\" for \ + a {{ \"const\": ... }} naming an ability: {}", + ELECTED_ABILITY_NAMES + .iter() + .map(ModerationAbility::as_str) + .collect::>() + .join(", ") + ))); + } + return Ok(SlotCondition::Contains(list, needle)); + } if needle == SlotConditionValue::Kind { return Err(refused(format!( "{at} when contains looks for \"{SLOT_CONDITION_PROPERTY}\" or a {{ \"const\": \ @@ -692,9 +758,26 @@ fn parse_slot_condition( .map(SlotCondition::IfThen), "ifThenElse" => parse_slot_conditions(items(3..=3, "three conditions")?, at, nodes, limits) .map(SlotCondition::IfThenElse), + "present" | "absent" => { + let setting = match operand.as_text() { + Some(SLOT_CONDITION_DELETE_WITHIN) => SlotConditionSetting::DeleteWithin, + Some(SLOT_CONDITION_DELETE_SETTLED) => SlotConditionSetting::DeleteSettled, + _ => { + return Err(refused(format!( + "{at} when {key} tests \"{SLOT_CONDITION_DELETE_WITHIN}\" or \ + \"{SLOT_CONDITION_DELETE_SETTLED}\", got {operand}" + ))) + } + }; + Ok(if key == "present" { + SlotCondition::Present(setting) + } else { + SlotCondition::Absent(setting) + }) + } other => Err(refused(format!( "{at} when does not take {other}: a condition is equal, notEqual, in, notIn, \ - contains, not, anyOf, allOf, ifThen or ifThenElse" + contains, present, absent, not, anyOf, allOf, ifThen or ifThenElse" ))), } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs index baede1fb6bb..de8abb73f0c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs @@ -31,18 +31,27 @@ const DYNAMIC_TARGET_KINDS: [&str; 2] = ["permanentDocument", "moderatedDocument /// The operands a slot's `when` reads as a string, and the two lists it looks into. const SLOT_CONDITION_STRINGS: [&str; 2] = ["$kind", "$property"]; -const SLOT_CONDITION_LISTS: [&str; 2] = [ +const SLOT_CONDITION_LISTS: [&str; 3] = [ "$moderatorAbilities.deleteKeepsFields", "$moderatorAbilities.changeFields", + "$electedAbilities", +]; + +/// The settings of the referenced type a `present` or an `absent` tests. +const SLOT_CONDITION_SETTINGS: [&str; 2] = [ + "$moderatorAbilities.deleteWithin", + "$moderatorAbilities.deleteSettled", ]; /// The keys of a condition of a slot's `when`, each the one key of its object. -const SLOT_CONDITION_KEYS: [&str; 10] = [ +const SLOT_CONDITION_KEYS: [&str; 12] = [ "equal", "notEqual", "in", "notIn", "contains", + "present", + "absent", "not", "anyOf", "allOf", @@ -376,7 +385,7 @@ impl Checker<'_> { /// it uses are slots is the parser's to check. fn document_type_requirements(&self, value: &JsonValue) -> Checked { let map = self.object(value)?; - self.closed(map, &["properties", "require"])?; + self.closed(map, &["properties", "require", "when"])?; self.required(map, &["properties"])?; self.member(map, "properties", |c, properties| { let slots = c.object(properties)?; @@ -394,7 +403,10 @@ impl Checker<'_> { return c.word(require); } c.slot_combinator(require, true) - }) + })?; + // A condition on the type as a whole: that it reads no property is the parser's + // to check + self.member(map, "when", |c, when| c.slot_condition(when)) } /// One slot of `documentTypeRequirements.properties`. @@ -509,6 +521,7 @@ impl Checker<'_> { None => Ok(()), } } + "present" | "absent" => c.one_of_strings(operand, &SLOT_CONDITION_SETTINGS), "ifThen" => c.tuple(operand, 2, 2, |c, _, item| c.slot_condition(item)), "ifThenElse" => c.tuple(operand, 3, 3, |c, _, item| c.slot_condition(item)), _ => Ok(()), diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs index f238739d284..d13001b4cb9 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs @@ -8,7 +8,9 @@ use super::reference_test_helpers::{ assert_refused, assert_refused_by_the_parser, contract, contract_on, identifier, CONTRACT_ID, }; use crate::data_contract::accessors::v0::DataContractV0Getters; -use crate::data_contract::config::moderation::{ContractModerationConfig, ContractModerators}; +use crate::data_contract::config::moderation::{ + ContractModerationConfig, ContractModerators, ModerationAbility, +}; use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, @@ -16,8 +18,9 @@ use crate::data_contract::document_type::accessors::{ use crate::data_contract::document_type::{ DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceKind, DocumentType, DocumentTypeRequirements, DynamicDocumentReference, DynamicDocumentReferenceKind, - RequiredPropertySlot, SlotCondition, SlotConditionList, SlotConditionValue, SlotConditions, - SlotPropertyName, SlotRequirement, SlotRequirementTerm, SlotValue, + RequiredPropertySlot, SlotCondition, SlotConditionList, SlotConditionSetting, + SlotConditionValue, SlotConditions, SlotPropertyName, SlotRequirement, SlotRequirementTerm, + SlotValue, }; use crate::data_contract::DataContract; use crate::serialization::{ @@ -144,7 +147,7 @@ fn post_meets(requirements_value: serde_json::Value, body_property: Option<&str> dynamic_reference(&parsed) .requirements .expect("requirements") - .met_by(post, |referring_property| { + .met_by(post, None, |referring_property| { assert_eq!(referring_property, "bodyProperty"); body_property.map(str::to_string) }) @@ -263,6 +266,7 @@ fn should_parse_a_reference_naming_its_target_through_the_referring_document() { SlotRequirementTerm::Slot("body".to_string()), SlotRequirementTerm::Slot("bodyBytes".to_string()), ])), + when: None, }), } ); @@ -765,7 +769,7 @@ fn should_hold_a_slot_to_its_byte_bound() { dynamic_reference(&parsed) .requirements .expect("requirements") - .met_by(post, |_| None) + .met_by(post, None, |_| None) }; assert!(bounded_both_ways("code", 8)); assert!(!bounded_both_ways("code", 7)); @@ -1128,7 +1132,11 @@ fn should_refuse_a_malformed_slot_condition() { json!({ "equal": ["$property", { "const": "a" }], "not": is_body() }), "is an object of one key", ); - refused(json!({ "present": "$property" }), "does not take present"); + refused(json!({ "present": "$property" }), "when present tests"); + refused( + json!({ "startsWith": ["$property", { "const": "a" }] }), + "does not take startsWith", + ); refused( json!({ "greaterThan": ["$property", { "const": "a" }] }), "does not take greaterThan", @@ -1209,11 +1217,11 @@ fn should_refuse_a_malformed_slot_condition() { // `contains` refused( json!({ "contains": ["$property", "$property"] }), - "contains looks into \"$moderatorAbilities.deleteKeepsFields\" or", + "contains looks into \"$moderatorAbilities.deleteKeepsFields\",", ); refused( json!({ "contains": ["$moderatorAbilities.delete", "$property"] }), - "contains looks into \"$moderatorAbilities.deleteKeepsFields\" or", + "contains looks into \"$moderatorAbilities.deleteKeepsFields\",", ); refused( json!({ "contains": ["$moderatorAbilities.changeFields", "$kind"] }), @@ -1355,7 +1363,7 @@ fn should_ask_of_a_moderated_type_what_it_does_not_ask_of_a_permanent_one() { .expect("requirements") }; let met = |name: serde_json::Value, post: &DocumentType| { - slot_requirements(name).met_by(post.as_ref(), |_| None) + slot_requirements(name).met_by(post.as_ref(), None, |_| None) }; // Moderators delete posts, the removal record keeping `tags`, and they alone write @@ -1799,3 +1807,161 @@ fn should_count_the_comparisons_judging_a_type_takes() { } })); assert_eq!(comparisons, properties * (1 + 4) + (1 + 2)); } + +/// The requirements may carry a `when` of their own, judged once for the named type as a +/// whole: the grammar of a slot's `when` with no property to read, over what the type's +/// contract lets its elected team do on it (`$electedAbilities`) and the settings the type +/// declares (`present`, `absent`). +#[test] +fn should_parse_a_condition_on_the_type_as_a_whole() { + let removable = || { + json!({ "allOf": [ + { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, + { "anyOf": [ + { "absent": "$moderatorAbilities.deleteWithin" }, + { "present": "$moderatorAbilities.deleteSettled" } + ] } + ] }) + }; + let with_when = |when: serde_json::Value| { + json!({ + "when": when, + "properties": { "body": { "name": "$any", "type": "string" } } + }) + }; + let parsed = requirements(with_when(removable())); + assert_eq!( + parsed.when, + Some(SlotCondition::AllOf(SlotConditions::new(vec![ + SlotCondition::Contains( + SlotConditionList::ElectedAbilities, + SlotConditionValue::Constant("deleteDocuments".to_string()) + ), + SlotCondition::AnyOf(SlotConditions::new(vec![ + SlotCondition::Absent(SlotConditionSetting::DeleteWithin), + SlotCondition::Present(SlotConditionSetting::DeleteSettled), + ])), + ]))) + ); + // Written back as declared, which parses to the same requirements + let written = parsed + .to_schema_value() + .try_into_validating_json() + .expect("the schema value converts to JSON"); + assert_eq!(written, with_when(removable())); + assert_eq!(requirements(written), parsed); + // and it survives the platform serialization of its contract + let platform_version = PlatformVersion::latest(); + let original = contract(note_contract(requiring(with_when(removable())))).expect("parses"); + let bytes = original + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract should serialize"); + let recovered = DataContract::versioned_deserialize_untrusted(&bytes, false, platform_version) + .expect("the contract should deserialize"); + assert_eq!(dynamic_reference(&original), dynamic_reference(&recovered)); + + // The same facts are a slot's to read too, beside the property + requirements(json!({ "properties": { "body": { + "name": "$any", + "type": "string", + "when": { "ifThen": [ + { "present": "$moderatorAbilities.deleteWithin" }, + { "notEqual": ["$property", { "const": "title" }] } + ] } + } } })); + + let refused = |when: serde_json::Value, fragment: &str| { + assert_refused_by_the_parser(note_contract(requiring(with_when(when))), fragment); + }; + // No property is in hand for the type as a whole + refused( + json!({ "notEqual": ["$property", { "const": "title" }] }), + "is judged for the document type as a whole and reads no \"$property\"", + ); + refused( + json!({ "anyOf": [ + removable(), + { "not": { "contains": ["$moderatorAbilities.changeFields", "$property"] } } + ] }), + "is judged for the document type as a whole and reads no \"$property\"", + ); + // An ability is one a contract can give, named by a constant + refused( + json!({ "contains": ["$electedAbilities", { "const": "delete" }] }), + "for a { \"const\": ... } naming an ability: deleteDocuments, ban, suspend, warn, \ + changeDocumentFields", + ); + refused( + json!({ "contains": ["$electedAbilities", "$kind"] }), + "naming an ability", + ); + // and a setting is one of the two that say how long a document stays deletable + refused( + json!({ "absent": "$moderatorAbilities.delete" }), + "when absent tests \"$moderatorAbilities.deleteWithin\" or", + ); + refused( + json!({ "present": ["$moderatorAbilities.deleteWithin"] }), + "when present tests", + ); + // It is held to the limits of a slot's + let max_nodes = usize::from( + platform_version + .system_limits + .max_document_type_requirement_condition_nodes, + ); + refused( + json!({ "anyOf": (0..max_nodes) + .map(|index| json!({ "notEqual": ["$kind", { "const": if index % 2 == 0 { "permanentDocument" } else { "moderatedDocument" } }] })) + .collect::>() }), + &format!("documentTypeRequirements when is made of more than {max_nodes} conditions"), + ); + refused( + json!({ "in": ["$kind", ["permanentDocument", "moderatedDocument"]], "extra": 1 }), + "is an object of one key", + ); +} + +/// A type meets a condition on it by what its contract and its own moderation say: the +/// elected team's abilities come from the contract, the window and the settled deletion +/// from the type. The judging counts the condition's nodes once. +#[test] +fn should_judge_the_named_type_by_its_moderation() { + let parsed = requirements(json!({ + "when": { "allOf": [ + { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, + { "absent": "$moderatorAbilities.deleteWithin" } + ] }, + "properties": { "body": { "name": "$any", "type": "string" } } + })); + let deleting = BTreeSet::from([ModerationAbility::DeleteDocuments]); + let banning = BTreeSet::from([ModerationAbility::Ban, ModerationAbility::Suspend]); + let met = |post: &DocumentType, abilities: Option<&BTreeSet>| { + parsed.met_by(post.as_ref(), abilities, |_| None) + }; + + // Moderators delete posts, at any time + let durable = moderated_post(json!({ + "canBeDeleted": false, + "moderatorAbilities": { "delete": true } + })); + assert!(met(&durable, Some(&deleting))); + // The contract elects no team, leaves the type out, or gives the team other abilities + assert!(!met(&durable, None)); + assert!(!met(&durable, Some(&BTreeSet::new()))); + assert!(!met(&durable, Some(&banning))); + + // Moderators delete posts for an hour after their last change, and nobody after + let windowed = moderated_post(json!({ + "canBeDeleted": false, + "required": ["$createdAt", "$updatedAt"], + "moderatorAbilities": { "delete": true, "deleteWithin": 3600 } + })); + assert!(windowed + .documents_can_be_deleted_by_moderators_for() + .is_some()); + assert!(!met(&windowed, Some(&deleting))); + + // Three string properties tried for the slot, and the three nodes of the condition + assert_eq!(parsed.comparisons(durable.as_ref()), 3 + 3); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs index 1c94b5fae08..b2c21878ccd 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs @@ -37,6 +37,7 @@ //! carry a `when` ([`SlotCondition`]) telling the two apart, so that it asks of a moderated //! type what it does not ask of a permanent one. +use crate::data_contract::config::moderation::ModerationAbility; use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; @@ -170,6 +171,10 @@ pub struct DocumentTypeRequirements { pub slots: BTreeMap, /// Which slots must be filled (`require`); `None` means every one of them. pub require: Option, + /// What the referenced type must say of itself, whatever fills the slots (`when`): a + /// [`SlotCondition`] that reads no `$property`. A type it does not hold for does not + /// meet the requirements. `None` asks nothing. + pub when: Option, } /// One slot of a [`DocumentTypeRequirements`]. @@ -419,6 +424,9 @@ impl DocumentTypeRequirements { if let Some(require) = &self.require { members.push(("require", require.to_schema_value())); } + if let Some(when) = &self.when { + members.push(("when", when.to_schema_value())); + } schema_object(members) } @@ -438,10 +446,15 @@ impl DocumentTypeRequirements { /// the slot's description, and one more for each node of the slot's `when` /// ([`SlotCondition::node_count`]), whether or not the description admits it: the /// count depends on the declaration and the size of the type alone, not on what the - /// type's properties are. The search for an assignment that follows is over at most + /// type's properties are. The requirements' own `when`, judged once for the type, adds + /// its nodes. The search for an assignment that follows is over at most /// `max_document_type_requirement_slots` slots and is not counted. pub fn comparisons(&self, document_type: DocumentTypeRef) -> u64 { let properties = document_type.properties().len() as u64; + let of_the_type = self + .when + .as_ref() + .map_or(0, |when| when.node_count() as u64); self.slots .values() .map(|slot| { @@ -456,7 +469,7 @@ impl DocumentTypeRequirements { .saturating_add(1); tried.saturating_mul(per_property) }) - .fold(0u64, u64::saturating_add) + .fold(of_the_type, u64::saturating_add) } /// Whether `document_type` meets the requirements. @@ -475,15 +488,45 @@ impl DocumentTypeRequirements { /// candidates between them than slots, so with the number of slots held to /// `max_document_type_requirement_slots` the search stays small whatever the type /// declares. + /// + /// `elected_abilities` are the abilities the type's contract gives its elected + /// moderation team on the type (`ElectedModerators::moderated_document_types`), `None` + /// when the contract elects no team or leaves the type out: what `$electedAbilities` + /// reads. The requirements' own `when` is judged first, for the type as a whole. pub fn met_by( &self, document_type: DocumentTypeRef, + elected_abilities: Option<&BTreeSet>, name_from_property: impl Fn(&str) -> Option, ) -> bool { let properties = document_type.properties(); let kind = document_type.document_reference_kind(); let deletion_kept_fields = document_type.moderator_deletion_kept_fields(); let moderator_changeable_fields = document_type.moderator_changeable_fields(); + let elected_abilities: BTreeSet = elected_abilities + .into_iter() + .flatten() + .map(|ability| ability.as_str().to_string()) + .collect(); + // The type as a whole: no property is in hand, and its condition reads none + let of_the_type = SlotConditionSubject { + kind, + property_name: "", + deletion_kept_fields, + moderator_changeable_fields, + elected_abilities: &elected_abilities, + deletion_window: document_type + .documents_can_be_deleted_by_moderators_for() + .is_some(), + settled_deletion: document_type.moderator_settled_deletion().is_some(), + }; + if self + .when + .as_ref() + .is_some_and(|when| !when.holds(&of_the_type)) + { + return false; + } let slots: Vec<(&str, &RequiredPropertySlot, Vec<&str>)> = self .slots .iter() @@ -492,10 +535,8 @@ impl DocumentTypeRequirements { slot.admits(property) && slot.when.as_ref().is_none_or(|when| { when.holds(&SlotConditionSubject { - kind, property_name: name, - deletion_kept_fields, - moderator_changeable_fields, + ..of_the_type }) }) }; 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 fbcfb6a7b19..c95da577365 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 @@ -78,9 +78,11 @@ pub use reference_where::{ WhereOrdering, WhereOrderingBound, }; pub use slot_condition::{ - SlotCondition, SlotConditionList, SlotConditionSubject, SlotConditionValue, SlotConditions, - MAX_SLOT_CONDITION_DECODE_DEPTH, SLOT_CONDITION_CHANGE_FIELDS, - SLOT_CONDITION_DELETE_KEEPS_FIELDS, SLOT_CONDITION_KIND, SLOT_CONDITION_PROPERTY, + SlotCondition, SlotConditionList, SlotConditionSetting, SlotConditionSubject, + SlotConditionValue, SlotConditions, MAX_SLOT_CONDITION_DECODE_DEPTH, + SLOT_CONDITION_CHANGE_FIELDS, SLOT_CONDITION_DELETE_KEEPS_FIELDS, + SLOT_CONDITION_DELETE_SETTLED, SLOT_CONDITION_DELETE_WITHIN, SLOT_CONDITION_ELECTED_ABILITIES, + SLOT_CONDITION_KIND, SLOT_CONDITION_PROPERTY, }; #[cfg(test)] diff --git a/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs b/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs index 369ac0c2910..cb4e9c29545 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs @@ -47,6 +47,15 @@ pub const SLOT_CONDITION_PROPERTY: &str = "$property"; pub const SLOT_CONDITION_DELETE_KEEPS_FIELDS: &str = "$moderatorAbilities.deleteKeepsFields"; /// The operand reading the referenced type's `moderatorAbilities.changeFields`. pub const SLOT_CONDITION_CHANGE_FIELDS: &str = "$moderatorAbilities.changeFields"; +/// The operand reading the abilities the referenced type's contract gives its elected +/// moderation team on the type. +pub const SLOT_CONDITION_ELECTED_ABILITIES: &str = "$electedAbilities"; +/// The operand a `present` or an `absent` tests for the referenced type's +/// `moderatorAbilities.deleteWithin`. +pub const SLOT_CONDITION_DELETE_WITHIN: &str = "$moderatorAbilities.deleteWithin"; +/// The operand a `present` or an `absent` tests for the referenced type's +/// `moderatorAbilities.deleteSettled`. +pub const SLOT_CONDITION_DELETE_SETTLED: &str = "$moderatorAbilities.deleteSettled"; /// The deepest a decoder accepts conditions inside one another. It only keeps crafted bytes /// from driving the decoder, and the evaluation of what it decodes, into unbounded @@ -77,6 +86,32 @@ pub enum SlotConditionList { DeletionKeptFields, /// `$moderatorAbilities.changeFields`: what only moderators write. ModeratorChangeableFields, + /// `$electedAbilities`: the abilities the type's contract gives its elected moderation + /// team on the type, by their names (`deleteDocuments`, `changeDocumentFields`, ...). + /// Empty when the contract elects no team, or leaves the type out of what the team + /// moderates. + ElectedAbilities, +} + +/// A setting of the referenced type a `present` or an `absent` tests. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Encode, Decode, DecodeUntrusted)] +pub enum SlotConditionSetting { + /// `$moderatorAbilities.deleteWithin`: the type gives moderators a window after a + /// document's last change to delete it in. + DeleteWithin, + /// `$moderatorAbilities.deleteSettled`: the type says who of a seated team may delete a + /// document past that window. + DeleteSettled, +} + +impl SlotConditionSetting { + /// The operand the setting is written as. + pub const fn wire_name(self) -> &'static str { + match self { + SlotConditionSetting::DeleteWithin => SLOT_CONDITION_DELETE_WITHIN, + SlotConditionSetting::DeleteSettled => SLOT_CONDITION_DELETE_SETTLED, + } + } } impl SlotConditionList { @@ -85,6 +120,7 @@ impl SlotConditionList { match self { SlotConditionList::DeletionKeptFields => SLOT_CONDITION_DELETE_KEEPS_FIELDS, SlotConditionList::ModeratorChangeableFields => SLOT_CONDITION_CHANGE_FIELDS, + SlotConditionList::ElectedAbilities => SLOT_CONDITION_ELECTED_ABILITIES, } } } @@ -113,6 +149,10 @@ pub enum SlotCondition { /// `ifThenElse`: three conditions; the second holds when the first does, the third /// when it does not. IfThenElse(SlotConditions), + /// `present`: the type declares the setting. + Present(SlotConditionSetting), + /// `absent`: the type does not declare the setting. + Absent(SlotConditionSetting), } /// What a slot condition is judged against: the referenced document type, and the one of @@ -127,6 +167,22 @@ pub struct SlotConditionSubject<'a> { pub deletion_kept_fields: &'a BTreeSet, /// The referenced type's `moderatorAbilities.changeFields`. pub moderator_changeable_fields: &'a BTreeSet, + /// The abilities the referenced type's contract gives its elected team on the type, by + /// name; empty when it elects none or leaves the type out. + pub elected_abilities: &'a BTreeSet, + /// Whether the referenced type declares `moderatorAbilities.deleteWithin`. + pub deletion_window: bool, + /// Whether the referenced type declares `moderatorAbilities.deleteSettled`. + pub settled_deletion: bool, +} + +impl SlotConditionSubject<'_> { + fn declares(&self, setting: SlotConditionSetting) -> bool { + match setting { + SlotConditionSetting::DeleteWithin => self.deletion_window, + SlotConditionSetting::DeleteSettled => self.settled_deletion, + } + } } impl SlotConditionValue { @@ -205,6 +261,12 @@ impl SlotCondition { SlotCondition::AllOf(conditions) => ("allOf", listed(conditions)), SlotCondition::IfThen(conditions) => ("ifThen", listed(conditions)), SlotCondition::IfThenElse(conditions) => ("ifThenElse", listed(conditions)), + SlotCondition::Present(setting) => { + ("present", Value::Text(setting.wire_name().to_string())) + } + SlotCondition::Absent(setting) => { + ("absent", Value::Text(setting.wire_name().to_string())) + } }; Value::Map(vec![(Value::Text(key.to_string()), operand)]) } @@ -225,6 +287,7 @@ impl SlotCondition { SlotConditionList::ModeratorChangeableFields => { subject.moderator_changeable_fields } + SlotConditionList::ElectedAbilities => subject.elected_abilities, }; list.contains(value.read(subject)) } @@ -254,6 +317,31 @@ impl SlotCondition { } _ => false, }, + SlotCondition::Present(setting) => subject.declares(*setting), + SlotCondition::Absent(setting) => !subject.declares(*setting), + } + } + + /// Whether the condition, or one inside it, reads `$property`: a condition on the type + /// as a whole, which has no property in hand, reads none. + pub fn reads_the_property(&self) -> bool { + let reads = |value: &SlotConditionValue| *value == SlotConditionValue::PropertyName; + match self { + SlotCondition::Equal(left, right) | SlotCondition::NotEqual(left, right) => { + reads(left) || reads(right) + } + SlotCondition::In(value, _) + | SlotCondition::NotIn(value, _) + | SlotCondition::Contains(_, value) => reads(value), + SlotCondition::Not(conditions) + | SlotCondition::AnyOf(conditions) + | SlotCondition::AllOf(conditions) + | SlotCondition::IfThen(conditions) + | SlotCondition::IfThenElse(conditions) => conditions + .conditions() + .iter() + .any(SlotCondition::reads_the_property), + SlotCondition::Present(_) | SlotCondition::Absent(_) => false, } } @@ -265,7 +353,9 @@ impl SlotCondition { match self { SlotCondition::Equal(..) | SlotCondition::NotEqual(..) - | SlotCondition::Contains(..) => 1, + | SlotCondition::Contains(..) + | SlotCondition::Present(_) + | SlotCondition::Absent(_) => 1, SlotCondition::In(_, values) | SlotCondition::NotIn(_, values) => { values.len().saturating_add(1) } @@ -431,6 +521,9 @@ mod tests { property_name, deletion_kept_fields: kept, moderator_changeable_fields: changeable, + elected_abilities: &none, + deletion_window: false, + settled_deletion: false, }) }; @@ -464,6 +557,9 @@ mod tests { property_name: "body", deletion_kept_fields: &empty, moderator_changeable_fields: &empty, + elected_abilities: &empty, + deletion_window: false, + settled_deletion: false, }; let name_is = |name: &str| { SlotCondition::Equal( @@ -525,6 +621,66 @@ mod tests { ); } + /// A condition on the type as a whole: what its contract lets the elected team do on + /// it, and which settings it declares. "The team may delete its documents, and a + /// document stays deletable": no window, or a rule for deleting past it. + #[test] + fn should_judge_a_type_by_what_its_moderation_says_of_it() { + let condition = SlotCondition::AllOf(SlotConditions::new(vec![ + SlotCondition::Contains( + SlotConditionList::ElectedAbilities, + SlotConditionValue::Constant("deleteDocuments".to_string()), + ), + SlotCondition::AnyOf(SlotConditions::new(vec![ + SlotCondition::Absent(SlotConditionSetting::DeleteWithin), + SlotCondition::Present(SlotConditionSetting::DeleteSettled), + ])), + ])); + assert_eq!(condition.node_count(), 5); + assert!(!condition.reads_the_property()); + assert!(removed_with_the_document().reads_the_property()); + + let empty = BTreeSet::new(); + let holds = |abilities: &[&str], deletion_window, settled_deletion| { + condition.holds(&SlotConditionSubject { + kind: DocumentReferenceKind::Moderated, + property_name: "", + deletion_kept_fields: &empty, + moderator_changeable_fields: &empty, + elected_abilities: &kept(abilities), + deletion_window, + settled_deletion, + }) + }; + assert!(holds(&["deleteDocuments"], false, false)); + assert!(holds(&["ban", "deleteDocuments"], true, true)); + // The team does not delete documents of the type + assert!(!holds(&[], false, false)); + assert!(!holds(&["changeDocumentFields"], false, false)); + // A window with nobody to delete past it + assert!(!holds(&["deleteDocuments"], true, false)); + + // Written back as declared, and decoded as encoded + assert_eq!( + condition + .to_schema_value() + .try_into_validating_json() + .expect("converts"), + serde_json::json!({ "allOf": [ + { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, + { "anyOf": [ + { "absent": "$moderatorAbilities.deleteWithin" }, + { "present": "$moderatorAbilities.deleteSettled" } + ] } + ] }) + ); + let bytes = + bincode::encode_to_vec(&condition, bincode::config::standard()).expect("encodes"); + for decoded in decode_every_way(&bytes) { + assert_eq!(decoded.expect("decodes"), condition); + } + } + #[test] fn should_round_trip_through_every_decoder() { let condition = removed_with_the_document(); diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_entity_not_found_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_entity_not_found_error.rs index 3e7a4860c97..d121cef7aa1 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/referenced_entity_not_found_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_entity_not_found_error.rs @@ -125,6 +125,7 @@ mod tests { }) .collect(), )), + when: None, }), } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs index 409a85b40cb..a334c8304ce 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v1/mod.rs @@ -21,6 +21,7 @@ use dpp::data_contract::document_type::{ ReferenceCombinator, ReferenceHolder, ReferringWrite, }; use dpp::data_contract::config::moderation::{kept_value_at, ContractDocumentRemoval}; +use dpp::data_contract::config::v2::DataContractConfigGettersV2; use dpp::data_contract::document_type::property_constraints::{ referenced_document_value, referenced_document_values, referenced_fields, DocumentSystemValues, }; @@ -1327,13 +1328,25 @@ fn validate_reference_target_v1( platform_version, )?; } + // What the named contract lets its elected team do on the type, which a + // requirement may ask for (`$electedAbilities`): nothing where the contract + // elects no team or leaves the type out of what the team moderates + let elected_abilities = referenced_contract + .config() + .moderation() + .and_then(|moderation| moderation.moderators.elected()) + .and_then(|elected| elected.moderated_document_types.get(document_type_name)); let meets_requirements = dynamic.requirements.as_ref().is_none_or(|requirements| { - requirements.met_by(referenced_document_type, |referring_property| { - document_data - .get(referring_property) - .and_then(|name| name.as_text()) - .map(str::to_string) - }) + requirements.met_by( + referenced_document_type, + elected_abilities, + |referring_property| { + document_data + .get(referring_property) + .and_then(|name| name.as_text()) + .map(str::to_string) + }, + ) }); if !meets_requirements { return Ok(SimpleConsensusValidationResult::new_with_error( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index 0a362171075..9343865b832 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -731,7 +731,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "f285d08d160e7b8339bd34280e0fb17b0fee2169bfe8d070ff8c1e280ef2c955" + "3632a02001559cc4efb7d7b5067a866c890e8b1eb7574869e7af3660ca780d88" ); assert_eq!( pool.platform @@ -2182,39 +2182,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "6a823c2db94612652926e6b9c1ae7feec9c2547dfd4cfc4e474318da1c16d59c", - "068481fb2599755eaa183ac90a8dde1849abc5b6c7a0c030999da78ec4365409", + "976b430ffb26f6eb9d1fbb6b37a84dd91122b588365abc140b684fc78daab4ef", ), "valid-payment" => ( "9388d5cf63c4a571b9fb009679e2e34d0b681acc88f0f5195e7287c7efbd92f7", - "86c8a2e5492f8bf87052b455f3b6c4ef5261dac8ce51b274d5df5963b41d87dc", + "58ae2ac62ab849f0c32cde35ae040124f62121721e60c97f09b71ab9d88e0294", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "046b8033adb697c973fa3b64ad6fea2ac851957031096f89c3665a681f31a51e", + "a7ac7fbc3092fbd0bcabdd5dea09d449cf5e36459b6253f7e3721e9d9ce9d9e6", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "69d15f00833f52655c340c4957a426d1c58af7de27173971a5e692597ad61a3f", + "622192ead265ff67167978a3156aba3a690d420abe7ec24ff10e6377dcf11ed2", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "135e69d4932cbc447f564ca7ae25fd7822a6f6418a8fdce5a5bb90efcb1250f2", + "5304e75f314731cfda271a6956cdbafaef116b12dcd6a07b276413d352ca0bf4", ), "open-mint-group" => ( "5c68381297c9bf68c5e85ebe6bb9e0f1289e2c07718eecfb5e6773d6009455bb", - "2759534c7ea51f63a31a8df5f4af7f104c670c60079e7cc5e2e611aa18a10700", + "56dc60cc546018420194c6d486d09bc26734298bd2aa100ab7bbdcfe3ef3694d", ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "07f15e539498c3868ba8f58b57d3ccb05c52d5073efeeabcac1103c9efd54746", + "730ace0f1854cf6024017f8e36c0b88f8a1721951aa761c802630b82133e3031", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", - "66fbd26c23e26ad4e10f9ec921915312a46335be2a0ef230ceda73934a5112ba", + "5a9fc7f47ad850835f9d201c42f4f32bee76231650bcd439530eb02a1055c55c", ), "closing-burn-group" => ( "93e1732286f168c4a91d2765931e181c5721bfca1bfd5f93b8baacca03e00b2b", - "3892048e1f053dbf82fcefb95adfff8fdbccaec9be2bb5b783f3f8a5849800b5", + "ad2109b552d4c96144628a297c343f7b742dc269399a5a0eac9d4998919efff6", ), _ => unreachable!("enumerated characterization case"), }; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs index 672c2c316c1..c64a4b175d7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs @@ -119,6 +119,14 @@ const TEXT: &str = "text"; const KEPT_TEXT: &str = "keptText"; /// One whose documents their owner deletes. const PLAIN_TEXT: &str = "plainText"; +/// One whose documents their owner deletes, and the elected team too. +const DELETABLE_TEXT: &str = "deletableText"; +/// One moderators delete, which the contract leaves out of what its elected team may do. +const UNGRANTED_TEXT: &str = "ungrantedText"; +/// One moderators delete for an hour after a document's last change, and nobody after. +const WINDOWED_TEXT: &str = "windowedText"; +/// The same, with a rule for deleting a document past the hour. +const SETTLED_TEXT: &str = "settledText"; /// A document type holding a `text`, with `extra` merged into its schema. fn text_schema(extra: Value) -> Value { @@ -131,7 +139,11 @@ fn text_schema(extra: Value) -> Value { "additionalProperties": false, }); if let (Value::Map(schema_map), Value::Map(extra_map)) = (&mut schema, extra) { - schema_map.extend(extra_map); + for (key, value) in extra_map { + // A keyword given again replaces the one the base schema has + schema_map.retain(|(existing, _)| existing != &key); + schema_map.push((key, value)); + } } schema } @@ -178,20 +190,27 @@ fn description_host_moderated_by( get_data_contract_fixture(Some(owner_id), 1, platform_version.protocol_version) .data_contract_owned(); let owner_protected = moderators == HostModerators::ElectedProtectingItsOwner; - let moderators = if moderators != HostModerators::Owner { + let elects_a_team = moderators != HostModerators::Owner; + let moderators = if elects_a_team { ContractModerators::Elected(Box::new(ElectedModerators { join_window: DEFAULT_ELECTION_WINDOW_SECONDS, vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, challenge_cool_down: Some(1_209_600), - moderated_document_types: [TEXT, KEPT_TEXT] - .into_iter() - .map(|name| { - ( - name.to_string(), - BTreeSet::from([ModerationAbility::DeleteDocuments]), - ) - }) - .collect(), + moderated_document_types: [ + TEXT, + KEPT_TEXT, + DELETABLE_TEXT, + WINDOWED_TEXT, + SETTLED_TEXT, + ] + .into_iter() + .map(|name| { + ( + name.to_string(), + BTreeSet::from([ModerationAbility::DeleteDocuments]), + ) + }) + .collect(), interim: InterimModerators::ContractOwner, election_delay: None, max_added_moderators: 0, @@ -209,7 +228,7 @@ fn description_host_moderated_by( }, ))); // After the config: a type moderators delete from needs the contract to be moderated - for (name, schema) in [ + let mut document_types = vec![ ( TEXT, text_schema(platform_value!({ @@ -225,7 +244,43 @@ fn description_host_moderated_by( })), ), (PLAIN_TEXT, text_schema(platform_value!({}))), - ] { + ( + DELETABLE_TEXT, + text_schema(platform_value!({ "moderatorAbilities": { "delete": true } })), + ), + ( + UNGRANTED_TEXT, + text_schema(platform_value!({ + "canBeDeleted": false, + "moderatorAbilities": { "delete": true }, + })), + ), + ( + WINDOWED_TEXT, + text_schema(platform_value!({ + "canBeDeleted": false, + "required": ["text", "$createdAt", "$updatedAt"], + "moderatorAbilities": { "delete": true, "deleteWithin": 3600 }, + })), + ), + ]; + // Who of a team approves the deletion of a settled document is an elected contract's + // to say + if elects_a_team { + document_types.push(( + SETTLED_TEXT, + text_schema(platform_value!({ + "canBeDeleted": false, + "required": ["text", "$createdAt", "$updatedAt"], + "moderatorAbilities": { + "delete": true, + "deleteWithin": 3600, + "deleteSettled": { "approvals": 1 }, + }, + })), + )); + } + for (name, schema) in document_types { contract .set_document_schema(name, schema, true, &mut vec![], platform_version) .expect("expected to add the document type"); @@ -1881,7 +1936,10 @@ async fn should_take_the_description_from_a_moderated_text_of_an_elected_contrac for (document_type_name, kind) in [ (TEXT, DocumentReferenceKind::Moderated), (KEPT_TEXT, DocumentReferenceKind::Moderated), + (UNGRANTED_TEXT, DocumentReferenceKind::Moderated), + (WINDOWED_TEXT, DocumentReferenceKind::Moderated), (PLAIN_TEXT, DocumentReferenceKind::Deletable), + (DELETABLE_TEXT, DocumentReferenceKind::Deletable), ] { assert_eq!( elected @@ -1899,6 +1957,10 @@ async fn should_take_the_description_from_a_moderated_text_of_an_elected_contrac (&elected, TEXT), (&elected, KEPT_TEXT), (&elected, PLAIN_TEXT), + (&elected, DELETABLE_TEXT), + (&elected, UNGRANTED_TEXT), + (&elected, WINDOWED_TEXT), + (&elected, SETTLED_TEXT), (&owner_moderated, TEXT), (&owner_protecting, TEXT), ] { @@ -1952,7 +2014,26 @@ async fn should_take_the_description_from_a_moderated_text_of_an_elected_contrac "{error:?}" ); - // A type whose documents their owner deletes is not a moderated one + // A type whose documents their owner deletes is not a moderated one, though the team + // may delete them too + let (deletable, _) = create_transition( + &contract, + &mut proposer, + multiple_choice_proposal::NAME, + asking(&elected, DELETABLE_TEXT, text_of(&elected, DELETABLE_TEXT)), + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, deletable, 20_000, platform_version); + assert!( + matches!( + error, + ConsensusError::StateError(StateError::ReferencedDocumentTypeNotModeratedError(_)) + ), + "{error:?}" + ); + // and one the team does not moderate at all fails what is asked of the type first let (plain, _) = create_transition( &contract, &mut proposer, @@ -1966,12 +2047,15 @@ async fn should_take_the_description_from_a_moderated_text_of_an_elected_contrac assert!( matches!( error, - ConsensusError::StateError(StateError::ReferencedDocumentTypeNotModeratedError(_)) + ConsensusError::StateError(StateError::ReferencedDocumentTypeRequirementNotMetError(_)) ), "{error:?}" ); - // A moderated text of a contract its owner moderates: the masternodes elect nobody there + // A moderated text of a contract its owner moderates: the masternodes elect nobody + // there, so no elected team is given deletion on the type. The description, checked + // before the contract it names, refuses it for that; the contract's own requirement + // would refuse it too let (unelected, _) = create_transition( &contract, &mut proposer, @@ -1985,7 +2069,7 @@ async fn should_take_the_description_from_a_moderated_text_of_an_elected_contrac assert!( matches!( error, - ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(_)) + ConsensusError::StateError(StateError::ReferencedDocumentTypeRequirementNotMetError(_)) ), "{error:?}" ); @@ -2010,6 +2094,42 @@ async fn should_take_the_description_from_a_moderated_text_of_an_elected_contrac "{error:?}" ); + // A moderated type the contract's elected team is given no deletion on: once a team is + // seated, nobody could remove the text. And one moderators delete only for an hour after + // its last change, with no rule for later: the text would outlast the hour for good + for unremovable in [UNGRANTED_TEXT, WINDOWED_TEXT] { + let (transition, _) = create_transition( + &contract, + &mut proposer, + multiple_choice_proposal::NAME, + asking(&elected, unremovable, text_of(&elected, unremovable)), + &mut rng, + platform_version, + ) + .await; + let error = process_refused(&platform, transition, 20_000, platform_version); + assert!( + matches!( + error, + ConsensusError::StateError( + StateError::ReferencedDocumentTypeRequirementNotMetError(_) + ) + ), + "{unremovable}: {error:?}" + ); + } + // while a window with a rule for deleting past it qualifies + let (settled, _) = create_transition( + &contract, + &mut proposer, + multiple_choice_proposal::NAME, + asking(&elected, SETTLED_TEXT, text_of(&elected, SETTLED_TEXT)), + &mut rng, + platform_version, + ) + .await; + process_valid(&platform, settled, 20_000, platform_version); + // A text that is not there, and a type the contract does not have let (missing, _) = create_transition( &contract, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 22b74f594d8..050af17d683 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -684,7 +684,12 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// backfilled); a property it does not hold for does not fill the slot. Judging the /// named type is billed before it is judged, `requirement_credits_per_comparison` /// (100, added in place to `FEE_DOCUMENT_REFERENCE_VERSION1`, which nothing read for -/// it before) for each property a slot tries and each node of its `when` for it. The native schema check of note 7 admits the keys and meta-schema v3 refuses +/// it before) for each property a slot tries and each node of its `when` for it. +/// `documentTypeRequirements` takes a `when` of its own, the same condition judged +/// once for the named type as a whole, reading no `$property`; a `when` may also look +/// into `$electedAbilities` (the abilities the type's contract gives its elected +/// moderation team on the type) and test with `present` or `absent` whether the type +/// declares `moderatorAbilities.deleteWithin` or `moderatorAbilities.deleteSettled`. The native schema check of note 7 admits the keys and meta-schema v3 refuses /// them. It parses to `DocumentPropertyReferenceTarget::DynamicDocument`, /// appended. Such a declaration carries nothing else (no `contractId`, `documentType`, /// `findBy`, `where`, `inList`, `consume` or `constraints`), is no operand of a reference diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index 5ee682ca947..300bbdb0a0f 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -321,6 +321,12 @@ export type DocumentTypeRequirements = { * when every slot must be filled. */ require?: string | DocumentTypeSlotRequirement; + /** + * What the named type must say of itself, whatever fills the slots: a + * condition that reads no `'$property'`. A type it does not hold for does + * not meet the requirements. + */ + when?: DocumentTypeSlotCondition; }; export type DocumentTypeRequirementSlot = { @@ -359,7 +365,12 @@ export type DocumentTypeSlotConditionString = * condition, read against the referenced document type instead of a * document. `contains` looks into the type's * `moderatorAbilities.deleteKeepsFields` or `moderatorAbilities.changeFields`, - * empty where the type declares none. + * empty where the type declares none, or into `'$electedAbilities'`, the + * abilities the type's contract gives its elected moderation team on the + * type (`'deleteDocuments'`, `'changeDocumentFields'`, ...), empty where it + * elects none or leaves the type out. `present` and `absent` test whether + * the type declares `moderatorAbilities.deleteWithin` or + * `moderatorAbilities.deleteSettled`. */ export type DocumentTypeSlotCondition = | { equal: [DocumentTypeSlotConditionString, DocumentTypeSlotConditionString] } @@ -371,10 +382,13 @@ export type DocumentTypeSlotCondition = ( | '$moderatorAbilities.deleteKeepsFields' | '$moderatorAbilities.changeFields' + | '$electedAbilities' ), '$property' | { const: string }, ]; } + | { present: '$moderatorAbilities.deleteWithin' | '$moderatorAbilities.deleteSettled' } + | { absent: '$moderatorAbilities.deleteWithin' | '$moderatorAbilities.deleteSettled' } | { not: DocumentTypeSlotCondition } | { anyOf: Array } | { allOf: Array } @@ -1190,6 +1204,14 @@ fn document_type_requirements_to_js( path, )?; } + if let Some(when) = &requirements.when { + set_field( + &object, + "when", + &platform_value_to_object(&when.to_schema_value())?, + path, + )?; + } Ok(object) } diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index 7513e26a7f9..1230a57479f 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -1064,6 +1064,7 @@ describe('DataContract — refersTo declarations (v14)', () => { documentTypeRequirements?: { properties: Record>; require?: unknown; + when?: unknown; }; }; @@ -1204,6 +1205,18 @@ describe('DataContract — refersTo declarations (v14)', () => { }, ], }; + // A condition on the type as a whole, beside the slots + const ofTheType = { + allOf: [ + { contains: ['$electedAbilities', { const: 'deleteDocuments' }] }, + { + anyOf: [ + { absent: '$moderatorAbilities.deleteWithin' }, + { present: '$moderatorAbilities.deleteSettled' }, + ], + }, + ], + }; const eitherKind = structuredClone(namedSchemas); (eitherKind.note.properties.descriptionId as { refersTo: object }).refersTo = { // Given the other way round: reported permanent first @@ -1211,6 +1224,7 @@ describe('DataContract — refersTo declarations (v14)', () => { contractId: '$self.descriptionContractId', documentType: '$self.descriptionDocumentType', documentTypeRequirements: { + when: ofTheType, properties: { body: { name: '$any', type: 'string', when }, code: { @@ -1228,6 +1242,7 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(descriptionId.type).to.deep.equal(['permanentDocument', 'moderatedDocument']); expect(descriptionId.documentTypeRequirements).to.deep.equal({ + when: ofTheType, properties: { body: { name: '$any', type: 'string', when }, code: { From 6d364669a1a40d82a26376ee00ffed7ccfbd0011 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 12 Nov 2026 15:35:01 +0700 Subject: [PATCH 17/20] test(drive-abci): pin the contested consume fee with the contests of the decision proposals contract `should_pin_the_fees_of_a_consume_through_a_plain_and_a_contested_create` came in with v5.1-dev (#5874) and pins the fee of a contested create at protocol version 15. On this branch the decision proposals contract, registered at genesis from 15, has a contested index of its own (`choice`), so the tree of contracts holding contests has one entry more and a write below it pays for the larger tree: 3_528_980 rather than 3_478_880. The plain create, the storage fee, the refund and every protocol version 14 figure are unchanged. Co-Authored-By: Claude Fable 5.2 --- .../batch/tests/document/commit_reveal_lookup.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs index 5b32acbd224..f3468e73554 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs @@ -839,6 +839,11 @@ mod commit_reveal_lookup_tests { /// create: the processing fee, the storage fee and the refund of the consumed commitment's /// storage to its owner. At 15 the create deletes the commitment from what its lookup read, /// so it pays one read of it fewer than at 14; the storage fee and the refund are the same. + /// + /// The contested create still costs more at 15 than at 14. The decision proposals contract, + /// registered at genesis from 15, holds contests of its own (the choices of a multiple + /// choice proposal), so the tree of contracts holding contests has one entry more, and a + /// write below it pays for the larger tree: 50_100 credits here. #[tokio::test] async fn should_pin_the_fees_of_a_consume_through_a_plain_and_a_contested_create() { // A fee of a reveal: processing, storage, and the refund to the commitment's owner @@ -900,7 +905,7 @@ mod commit_reveal_lookup_tests { ( 15, (1_766_860, 9_720_000, 15_181_239), - (3_478_880, 75_411_000, 15_181_239) + (3_528_980, 75_411_000, 15_181_239) ), ] ); From 89a7e5f1d0cb3a92f013d8966d1a623ab14bf14f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 12 Nov 2026 16:02:20 +0700 Subject: [PATCH 18/20] docs(contract): say when a choice leaves state and how its fund grows - A choice may be neither changed nor deleted by anyone, yet the end of its vote removes the choices that were not kept: the README said "never deleted". - The fund a choice pays doubles once the vote holds 250 choices and again for every 50 more, as the protocol guide already says; the README gave 0.1 Dash alone. - The protocol guide says where the ids of the choices not kept are read: the stored result, which the vote state query returns, lists every choice by its document id with its tally. Co-Authored-By: Claude Fable 5.2 --- docs/protocol/decision-proposals.md | 5 +++-- packages/decision-proposals-contract/README.md | 11 +++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/protocol/decision-proposals.md b/docs/protocol/decision-proposals.md index 02f0ada2110..82891cd4bb2 100644 --- a/docs/protocol/decision-proposals.md +++ b/docs/protocol/decision-proposals.md @@ -213,8 +213,9 @@ must be the proposal's owner; a choice anyone else enters is refused it. - **Reading it.** `getContestedResourceVoteState` returns the choices and their tallies while the vote runs and its outcome afterwards. Once the vote has - ended, the text of the choices that were not kept is no longer in state; the - stored result names them by their document ids. + ended, the text of the choices that were not kept is no longer in state. The + stored result, which the query returns, still lists every choice by its + document id with its tally. Unlike a yes/no proposal, a multiple choice proposal has no pass threshold, no minimum turnout and no end date of its own: the contest's windows and its diff --git a/packages/decision-proposals-contract/README.md b/packages/decision-proposals-contract/README.md index aba7509e048..9d1ca3405fc 100644 --- a/packages/decision-proposals-contract/README.md +++ b/packages/decision-proposals-contract/README.md @@ -72,8 +72,9 @@ Indexes: `byOwner` (`$ownerId`, `$createdAt`) lists an identity's proposals; ## `choice` -An answer to a multiple choice proposal. It never changes and is never -deleted. +An answer to a multiple choice proposal. Nobody may change or delete it. A +choice leaves state only when its vote ends, which removes the choices that +were not kept. | Property | Type | Meaning | | --- | --- | --- | @@ -92,8 +93,10 @@ its own, named by its document id. - an identity enters as many choices as it wants, each paying the fund; - the first choice opens the vote, and later ones join it while its join window is open (one week on mainnet); -- each choice prefunds the masternode votes with the contested document fund - (0.1 Dash); +- each choice prefunds the masternode votes with the contested document fund: + 0.1 Dash, doubling once the vote holds 250 choices and again for every 50 + more. A choice paying less than the fund at that moment is refused + (`DocumentContestNotPaidForError`, 40114); - masternodes vote for a choice by its document id, abstain, or lock, which here means none of the choices; - when the vote ends (two weeks after the first choice on mainnet), the choice From 8e77ae7ae4ed6a4de4ed7de6ddd608fdd7d0956d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 12 Nov 2026 18:38:01 +0700 Subject: [PATCH 19/20] docs(contract): state the whole tie rule of a multiple choice vote A tie goes to the earliest choice by creation time, then block height, then core block height, and choices entered in the same block share all three: among them the lower document id wins, not the one entered first. The protocol guide and the README said only "the earliest choice", and the guide that a vote nobody votes in keeps the first choice entered. Co-Authored-By: Claude Fable 5.2 --- docs/protocol/decision-proposals.md | 5 ++++- packages/decision-proposals-contract/README.md | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/protocol/decision-proposals.md b/docs/protocol/decision-proposals.md index 82891cd4bb2..4cca25cf168 100644 --- a/docs/protocol/decision-proposals.md +++ b/docs/protocol/decision-proposals.md @@ -204,7 +204,10 @@ must be the proposal's owner; a choice anyone else enters is refused 40307). - **The result.** The choice with the most votes is stored as an ordinary document and the other choices are removed. A tie goes to the earliest - choice, so a vote in which nobody votes keeps the first choice entered. When + choice: by creation time, then block height, then core block height. + Choices entered in the same block share all three, and among them the lower + document id wins, whichever was entered first. A vote in which nobody votes + is a tie among every choice, so it keeps the earliest by that order. When the lock votes outnumber the leading choice, no choice is stored, and no choice can be entered for that proposal again (`DocumentContestCurrentlyLockedError`, 40110). The vote runs its full diff --git a/packages/decision-proposals-contract/README.md b/packages/decision-proposals-contract/README.md index 9d1ca3405fc..1436b3adee6 100644 --- a/packages/decision-proposals-contract/README.md +++ b/packages/decision-proposals-contract/README.md @@ -102,7 +102,8 @@ its own, named by its document id. - when the vote ends (two weeks after the first choice on mainnet), the choice with the most votes is stored and the others are removed, unless the lock votes outnumber it, in which case none is stored. A tie goes to the earliest - choice. + choice, and among choices entered in the same block to the lower document + id. See [the protocol guide](../../docs/protocol/decision-proposals.md) for details. From bd0e91c28bf93df6e834f81d22fdc7da66a16c63 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 12 Nov 2026 19:20:11 +0700 Subject: [PATCH 20/20] feat(platform)!: require a description whose settled deletion counts every member (PV15) A description's type had to let the elected team delete its documents and keep a document deletable: no `deleteWithin` window, or a `deleteSettled` rule for deleting past it. A rule could still leave a description for good. With `approversPredateDocument` (the default once a rule asks for more than one approval) a member the team's leader added counts only for documents created after its addition, so under a rule taking more approvals than the leader and the elected members give, a description older than the added members is never deleted once settled. - A `when` tests one more setting with `present` or `absent`: `$moderatorAbilities.deleteSettled.approversPredateDocument`, the rule's setting with its default applied, absent on a type with no rule. `SlotConditionSetting` gains the variant at its tail; the parser, the native schema check, the schema it is written back as and the JS types follow. - The decision proposals contract's `multipleChoiceProposal` asks for it: a windowed type qualifies with a settled rule that does not set it (one approval, or `approversPredateDocument: false`). A host type whose rule takes two approvals is refused with 40153 in the proposal tests. - The abilities a `$electedAbilities` needle may name come from `ModerationAbility::ALL`, held to the enum by a test, instead of a list kept beside the parser. The contract schema changes, so the ten nullifier_batch_guard roots, the activation root and the stored contract fixture line of the decision proposals contract are recomputed. Protocol version 15 is unreleased; protocol version 14 and earlier are unchanged. Co-Authored-By: Claude Fable 5.2 --- .../contract-keywords/refers-to-dynamic.md | 10 ++-- docs/protocol/decision-proposals.md | 19 +++++-- ...decision-proposals-contract-documents.json | 9 +++- .../decision-proposals-contract/src/lib.rs | 9 +++- .../unit/decisionProposalsContract.spec.js | 7 ++- .../config/moderation/elected.rs | 27 ++++++++++ .../try_from_schema/dynamic_reference.rs | 23 ++++---- .../native_schema/references.rs | 3 +- .../v3/dynamic_reference_tests.rs | 20 +++++-- .../property/dynamic_document_reference.rs | 5 +- .../document_type/property/mod.rs | 1 + .../document_type/property/slot_condition.rs | 53 +++++++++++++++---- .../fixtures/stored_contracts/stored.jsonl | 2 +- .../v3/mod.rs | 2 +- .../token/shielded/nullifier_batch_guard.rs | 20 +++---- .../multiple_choice_proposal_tests.rs | 33 ++++++++++-- .../rs-platform-version/src/version/v15.rs | 5 +- .../data_contract/document_type_reference.rs | 21 ++++++-- .../unit/DocumentPropertyReference.spec.ts | 7 ++- 19 files changed, 211 insertions(+), 65 deletions(-) diff --git a/book/src/contract-keywords/refers-to-dynamic.md b/book/src/contract-keywords/refers-to-dynamic.md index 84c3882c0ce..bbe1bb697d5 100644 --- a/book/src/contract-keywords/refers-to-dynamic.md +++ b/book/src/contract-keywords/refers-to-dynamic.md @@ -142,6 +142,7 @@ When the type is a moderated one, the string filling `body` is one a moderator's | `"$moderatorAbilities.changeFields"` | The referenced type's [`changeFields`](moderator-abilities.md): the properties only moderators write. Empty when the type lists none. | | `"$electedAbilities"` | The abilities the referenced type's contract gives its [elected moderation team](../data-model/contract-moderation.md) on the type, by name: `deleteDocuments`, `ban`, `suspend`, `warn`, `changeDocumentFields`. Empty when the contract elects no team, or leaves the type out of what the team moderates. | | `"$moderatorAbilities.deleteWithin"`, `"$moderatorAbilities.deleteSettled"` | Settings of the referenced type a `present` or an `absent` tests: whether moderators delete a document only within a window after its last change, and whether the type says who of a seated team may delete one past it. | +| `"$moderatorAbilities.deleteSettled.approversPredateDocument"` | A setting a `present` or an `absent` tests: whether that rule counts a member the team's leader added only for documents created after its addition ([`approversPredateDocument`](moderator-abilities.md), as the rule reads once its default is applied: set unless one approval meets it). Absent on a type with no such rule. | | `{ "const": "" }` | The string, 1 to 256 characters. | | Condition | Holds when | @@ -149,7 +150,7 @@ When the type is a moderated one, the string filling `body` is one a moderator's | `{ "equal": [a, b] }`, `{ "notEqual": [a, b] }` | `$kind` or `$property` is, or is not, the constant on the other side. Compared with `$kind`, the constant is `permanentDocument` or `moderatedDocument`. | | `{ "in": [a, [...]] }`, `{ "notIn": [a, [...]] }` | `$kind` or `$property` is, or is not, one of two or more distinct strings. | | `{ "contains": [list, a] }` | One of the two property lists holds `$property` or a constant, as a whole entry; or `$electedAbilities` holds the ability a constant names. | -| `{ "present": setting }`, `{ "absent": setting }` | The type declares, or does not declare, `$moderatorAbilities.deleteWithin` or `$moderatorAbilities.deleteSettled`. | +| `{ "present": setting }`, `{ "absent": setting }` | The type declares, or does not declare, `$moderatorAbilities.deleteWithin` or `$moderatorAbilities.deleteSettled`; or its settled rule sets, or does not set, `approversPredateDocument`. | | `{ "not": c }`, `{ "anyOf": [...] }`, `{ "allOf": [...] }` | As in `propertyConstraints`: `anyOf` and `allOf` take two or more conditions, none listed twice and none the same combinator; `not` holds no `not` and no `notIn`. | | `{ "ifThen": [c, then] }`, `{ "ifThenElse": [c, then, else] }` | `then` holds whenever `c` does; with an `else`, it holds when `c` does not. | @@ -165,14 +166,17 @@ One `when` is made of at most 16 conditions (`max_document_type_requirement_cond { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, { "anyOf": [ { "absent": "$moderatorAbilities.deleteWithin" }, - { "present": "$moderatorAbilities.deleteSettled" } + { "allOf": [ + { "present": "$moderatorAbilities.deleteSettled" }, + { "absent": "$moderatorAbilities.deleteSettled.approversPredateDocument" } + ] } ] } ] }, "properties": { "body": { "name": "$any", "type": "string" } } } ``` -The contract of the named type gives its elected team the deletion of the type's documents, and a document stays deletable: the type sets no window, or says who of the team deletes one past it. Without the first, a type can be a moderated one that nobody may moderate once a team is seated; without the second, a document outlasts its window for good. A reference that relies on a document being removable asks for both, and usually for [an elected contract](#who-vouches-for-the-target) too. +The contract of the named type gives its elected team the deletion of the type's documents, and a document stays deletable: the type sets no window, or says who of the team deletes one past it and counts every member for every document. Without the first, a type can be a moderated one that nobody may moderate once a team is seated; without the second, a document outlasts its window for good; without the third, a rule taking more approvals than the leader and the elected members give never deletes a document older than the members the leader added. A reference that relies on a document being removable asks for both, and usually for [an elected contract](#who-vouches-for-the-target) too. A slot's `when` reads the same facts beside `$property`. Each `when` is held to the limits above on its own. diff --git a/docs/protocol/decision-proposals.md b/docs/protocol/decision-proposals.md index f95d2da163b..d3c3ca3b7bf 100644 --- a/docs/protocol/decision-proposals.md +++ b/docs/protocol/decision-proposals.md @@ -201,7 +201,10 @@ where `` is { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, { "anyOf": [ { "absent": "$moderatorAbilities.deleteWithin" }, - { "present": "$moderatorAbilities.deleteSettled" } + { "allOf": [ + { "present": "$moderatorAbilities.deleteSettled" }, + { "absent": "$moderatorAbilities.deleteSettled.approversPredateDocument" } + ] } ] } ] } ``` @@ -226,7 +229,7 @@ refusing the write: | A contract that does not exist, or has no such document type | `ReferencedDocumentTypeNotFoundError` (40121) | | A private document type, whose documents are encrypted entries the chain never reads | `ReferencedDocumentTypePrivateError` (40150) | | An `indexOnly` document type, whose documents are index entries with none to find by its id | `ReferencedDocumentTypeIndexOnlyError` (40146) | -| A type its contract's elected team may not delete from (the contract elects no team, or leaves the type out of the team's `deleteDocuments`), or one moderators delete only within a window (`deleteWithin`) with no rule for later (`deleteSettled`) | `ReferencedDocumentTypeRequirementNotMetError` (40153) | +| A type its contract's elected team may not delete from (the contract elects no team, or leaves the type out of the team's `deleteDocuments`), or one moderators delete only within a window (`deleteWithin`) with no rule for later (`deleteSettled`), or with a rule that counts a member the leader added only for documents created after its addition (`approversPredateDocument`) | `ReferencedDocumentTypeRequirementNotMetError` (40153) | | A moderated type with no string or byte array a removal takes away: every one is kept in the removal record (`deleteKeepsFields`) or is the moderators' to write (`changeFields`) | `ReferencedDocumentTypeRequirementNotMetError` (40153) | | A document type whose documents their owner can delete, or that never leave state | `ReferencedDocumentTypeNotModeratedError` (40143) | | A document that is not there | `ReferencedEntityNotFoundError` (40120) | @@ -253,8 +256,16 @@ What this does and does not give: delete only within a window after a document's last change (`moderatorAbilities.deleteWithin`) without saying who may delete a settled one (`deleteSettled`), since a description would outlast the window for - good. A settled document is deleted by a seated team alone, so within the - interim a description past its window waits for the first election. + good. And so is a type whose rule for a settled document counts a member the + team's leader added only for documents created after its addition + (`approversPredateDocument`, the default once a rule asks for more than one + approval): under a rule taking more approvals than the leader and the + elected members give, a description older than the added members could + never be deleted. A type with a window qualifies with a rule one approval + meets, or one that sets `approversPredateDocument` to false. A settled + document is deleted by a seated team alone, so within the interim a + description past its window waits for the first election, and a rule taking + several approvals takes that many members of the team. - The description's own edit rules are its document type's. A type that lets its author replace a document lets the author change the description after the two hours, and after choices were entered. The proposal's history keeps diff --git a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json index cb84bb13fd7..0a95ad1f3b9 100644 --- a/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json +++ b/packages/decision-proposals-contract/schema/v1/decision-proposals-contract-documents.json @@ -249,7 +249,14 @@ "absent": "$moderatorAbilities.deleteWithin" }, { - "present": "$moderatorAbilities.deleteSettled" + "allOf": [ + { + "present": "$moderatorAbilities.deleteSettled" + }, + { + "absent": "$moderatorAbilities.deleteSettled.approversPredateDocument" + } + ] } ] } diff --git a/packages/decision-proposals-contract/src/lib.rs b/packages/decision-proposals-contract/src/lib.rs index 9d2180cc418..f5032f6e9a6 100644 --- a/packages/decision-proposals-contract/src/lib.rs +++ b/packages/decision-proposals-contract/src/lib.rs @@ -248,12 +248,17 @@ mod tests { ), "documentTypeRequirements": { // The contract's elected team may delete documents of the type, and - // at any time: no window, or a rule for deleting past it + // at any time: no window, or a rule for deleting past it that counts + // every member of the team, whenever the document was created "when": { "allOf": [ { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, { "anyOf": [ { "absent": "$moderatorAbilities.deleteWithin" }, - { "present": "$moderatorAbilities.deleteSettled" } + { "allOf": [ + { "present": "$moderatorAbilities.deleteSettled" }, + { "absent": + "$moderatorAbilities.deleteSettled.approversPredateDocument" } + ] } ] } ] }, "properties": { diff --git a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js index 678a2daaa30..639c6172f2c 100644 --- a/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js +++ b/packages/decision-proposals-contract/test/unit/decisionProposalsContract.spec.js @@ -302,7 +302,12 @@ describe('Decision Proposals Contract', () => { { anyOf: [ { absent: '$moderatorAbilities.deleteWithin' }, - { present: '$moderatorAbilities.deleteSettled' }, + { + allOf: [ + { present: '$moderatorAbilities.deleteSettled' }, + { absent: '$moderatorAbilities.deleteSettled.approversPredateDocument' }, + ], + }, ], }, ], diff --git a/packages/rs-dpp/src/data_contract/config/moderation/elected.rs b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs index fc3f08ddd23..7876eae4715 100644 --- a/packages/rs-dpp/src/data_contract/config/moderation/elected.rs +++ b/packages/rs-dpp/src/data_contract/config/moderation/elected.rs @@ -99,6 +99,15 @@ pub enum ModerationAbility { } impl ModerationAbility { + /// Every ability, for what lists or names them all. A test holds it to the variants. + pub const ALL: [ModerationAbility; 5] = [ + ModerationAbility::DeleteDocuments, + ModerationAbility::Ban, + ModerationAbility::Suspend, + ModerationAbility::Warn, + ModerationAbility::ChangeDocumentFields, + ]; + /// The wire name of the ability pub fn as_str(&self) -> &'static str { match self { @@ -619,6 +628,24 @@ mod tests { ids.iter().map(|b| Identifier::from([*b; 32])).collect() } + /// `ModerationAbility::ALL` names every variant, once. The match has no wildcard arm, so + /// a new variant does not compile here until it is given its place in `ALL`. + #[test] + fn should_list_every_ability() { + let place = |ability: &ModerationAbility| match ability { + ModerationAbility::DeleteDocuments => 0, + ModerationAbility::Ban => 1, + ModerationAbility::Suspend => 2, + ModerationAbility::Warn => 3, + ModerationAbility::ChangeDocumentFields => 4, + }; + let places: Vec = ModerationAbility::ALL.iter().map(place).collect(); + assert_eq!( + places, + (0..ModerationAbility::ALL.len()).collect::>() + ); + } + /// `post`, which moderators may delete, and `like`, which they may not fn schemas() -> BTreeMap { BTreeMap::from([ diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs index 00cae767a69..323b3b0c455 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/dynamic_reference.rs @@ -13,6 +13,7 @@ use crate::data_contract::document_type::property::{ REFERRING_DOCUMENT_PREFIX, SLOT_CONDITION_CHANGE_FIELDS, SLOT_CONDITION_DELETE_KEEPS_FIELDS, SLOT_CONDITION_DELETE_SETTLED, SLOT_CONDITION_DELETE_WITHIN, SLOT_CONDITION_ELECTED_ABILITIES, SLOT_CONDITION_KIND, SLOT_CONDITION_PROPERTY, + SLOT_CONDITION_SETTLED_APPROVERS_PREDATE_DOCUMENT, }; use crate::data_contract::document_type::{property_names, DocumentTypeRef}; use crate::data_contract::errors::DataContractError; @@ -79,16 +80,6 @@ impl SlotConditionLimits { } } -/// The names of the abilities a contract may give its elected moderation team on a document -/// type, what `$electedAbilities` lists. -const ELECTED_ABILITY_NAMES: [ModerationAbility; 5] = [ - ModerationAbility::DeleteDocuments, - ModerationAbility::Ban, - ModerationAbility::Suspend, - ModerationAbility::Warn, - ModerationAbility::ChangeDocumentFields, -]; - fn refused(message: String) -> DataContractError { DataContractError::InvalidContractStructure(message) } @@ -710,13 +701,13 @@ fn parse_slot_condition( let names_an_ability = matches!( &needle, SlotConditionValue::Constant(name) - if ELECTED_ABILITY_NAMES.iter().any(|ability| ability.as_str() == name) + if ModerationAbility::ALL.iter().any(|ability| ability.as_str() == name) ); if !names_an_ability { return Err(refused(format!( "{at} when contains looks in \"{SLOT_CONDITION_ELECTED_ABILITIES}\" for \ a {{ \"const\": ... }} naming an ability: {}", - ELECTED_ABILITY_NAMES + ModerationAbility::ALL .iter() .map(ModerationAbility::as_str) .collect::>() @@ -785,10 +776,14 @@ fn parse_slot_condition( let setting = match operand.as_text() { Some(SLOT_CONDITION_DELETE_WITHIN) => SlotConditionSetting::DeleteWithin, Some(SLOT_CONDITION_DELETE_SETTLED) => SlotConditionSetting::DeleteSettled, + Some(SLOT_CONDITION_SETTLED_APPROVERS_PREDATE_DOCUMENT) => { + SlotConditionSetting::SettledApproversPredateDocument + } _ => { return Err(refused(format!( - "{at} when {key} tests \"{SLOT_CONDITION_DELETE_WITHIN}\" or \ - \"{SLOT_CONDITION_DELETE_SETTLED}\", got {operand}" + "{at} when {key} tests \"{SLOT_CONDITION_DELETE_WITHIN}\", \ + \"{SLOT_CONDITION_DELETE_SETTLED}\" or \ + \"{SLOT_CONDITION_SETTLED_APPROVERS_PREDATE_DOCUMENT}\", got {operand}" ))) } }; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs index de8abb73f0c..f2dcbeeba9a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/references.rs @@ -38,9 +38,10 @@ const SLOT_CONDITION_LISTS: [&str; 3] = [ ]; /// The settings of the referenced type a `present` or an `absent` tests. -const SLOT_CONDITION_SETTINGS: [&str; 2] = [ +const SLOT_CONDITION_SETTINGS: [&str; 3] = [ "$moderatorAbilities.deleteWithin", "$moderatorAbilities.deleteSettled", + "$moderatorAbilities.deleteSettled.approversPredateDocument", ]; /// The keys of a condition of a slot's `when`, each the one key of its object. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs index 22e68893aab..43cadfff01b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/dynamic_reference_tests.rs @@ -2005,7 +2005,10 @@ fn should_parse_a_condition_on_the_type_as_a_whole() { { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, { "anyOf": [ { "absent": "$moderatorAbilities.deleteWithin" }, - { "present": "$moderatorAbilities.deleteSettled" } + { "allOf": [ + { "present": "$moderatorAbilities.deleteSettled" }, + { "absent": "$moderatorAbilities.deleteSettled.approversPredateDocument" } + ] } ] } ] }) }; @@ -2025,7 +2028,10 @@ fn should_parse_a_condition_on_the_type_as_a_whole() { ), SlotCondition::AnyOf(SlotConditions::new(vec![ SlotCondition::Absent(SlotConditionSetting::DeleteWithin), - SlotCondition::Present(SlotConditionSetting::DeleteSettled), + SlotCondition::AllOf(SlotConditions::new(vec![ + SlotCondition::Present(SlotConditionSetting::DeleteSettled), + SlotCondition::Absent(SlotConditionSetting::SettledApproversPredateDocument), + ])), ])), ]))) ); @@ -2081,10 +2087,16 @@ fn should_parse_a_condition_on_the_type_as_a_whole() { json!({ "contains": ["$electedAbilities", "$kind"] }), "naming an ability", ); - // and a setting is one of the two that say how long a document stays deletable + // and a setting is one of those that say how long a document stays deletable refused( json!({ "absent": "$moderatorAbilities.delete" }), - "when absent tests \"$moderatorAbilities.deleteWithin\" or", + "when absent tests \"$moderatorAbilities.deleteWithin\", \ + \"$moderatorAbilities.deleteSettled\" or \ + \"$moderatorAbilities.deleteSettled.approversPredateDocument\"", + ); + refused( + json!({ "absent": "$moderatorAbilities.deleteSettled.approvals" }), + "when absent tests", ); refused( json!({ "present": ["$moderatorAbilities.deleteWithin"] }), diff --git a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs index b2c21878ccd..ce20eab3063 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/dynamic_document_reference.rs @@ -508,6 +508,7 @@ impl DocumentTypeRequirements { .flatten() .map(|ability| ability.as_str().to_string()) .collect(); + let settled_deletion = document_type.moderator_settled_deletion(); // The type as a whole: no property is in hand, and its condition reads none let of_the_type = SlotConditionSubject { kind, @@ -518,7 +519,9 @@ impl DocumentTypeRequirements { deletion_window: document_type .documents_can_be_deleted_by_moderators_for() .is_some(), - settled_deletion: document_type.moderator_settled_deletion().is_some(), + settled_deletion: settled_deletion.is_some(), + settled_approvers_predate_document: settled_deletion + .is_some_and(|rule| rule.approvers_predate_document), }; if self .when 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 c95da577365..b876600827a 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 @@ -83,6 +83,7 @@ pub use slot_condition::{ SLOT_CONDITION_CHANGE_FIELDS, SLOT_CONDITION_DELETE_KEEPS_FIELDS, SLOT_CONDITION_DELETE_SETTLED, SLOT_CONDITION_DELETE_WITHIN, SLOT_CONDITION_ELECTED_ABILITIES, SLOT_CONDITION_KIND, SLOT_CONDITION_PROPERTY, + SLOT_CONDITION_SETTLED_APPROVERS_PREDATE_DOCUMENT, }; #[cfg(test)] diff --git a/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs b/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs index cb4e9c29545..b93e5c104b4 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/slot_condition.rs @@ -56,6 +56,10 @@ pub const SLOT_CONDITION_DELETE_WITHIN: &str = "$moderatorAbilities.deleteWithin /// The operand a `present` or an `absent` tests for the referenced type's /// `moderatorAbilities.deleteSettled`. pub const SLOT_CONDITION_DELETE_SETTLED: &str = "$moderatorAbilities.deleteSettled"; +/// The operand a `present` or an `absent` tests for the `approversPredateDocument` of the +/// referenced type's `moderatorAbilities.deleteSettled`. +pub const SLOT_CONDITION_SETTLED_APPROVERS_PREDATE_DOCUMENT: &str = + "$moderatorAbilities.deleteSettled.approversPredateDocument"; /// The deepest a decoder accepts conditions inside one another. It only keeps crafted bytes /// from driving the decoder, and the evaluation of what it decodes, into unbounded @@ -102,6 +106,12 @@ pub enum SlotConditionSetting { /// `$moderatorAbilities.deleteSettled`: the type says who of a seated team may delete a /// document past that window. DeleteSettled, + /// `$moderatorAbilities.deleteSettled.approversPredateDocument`: that rule counts a member + /// the team's leader added only for documents created after its addition, as the rule + /// reads once its default is applied (set unless one approval meets it). A document + /// older than the members it would take is then never deleted once settled. Absent on a + /// type with no such rule. + SettledApproversPredateDocument, } impl SlotConditionSetting { @@ -110,6 +120,9 @@ impl SlotConditionSetting { match self { SlotConditionSetting::DeleteWithin => SLOT_CONDITION_DELETE_WITHIN, SlotConditionSetting::DeleteSettled => SLOT_CONDITION_DELETE_SETTLED, + SlotConditionSetting::SettledApproversPredateDocument => { + SLOT_CONDITION_SETTLED_APPROVERS_PREDATE_DOCUMENT + } } } } @@ -174,6 +187,9 @@ pub struct SlotConditionSubject<'a> { pub deletion_window: bool, /// Whether the referenced type declares `moderatorAbilities.deleteSettled`. pub settled_deletion: bool, + /// Whether that rule counts a member the leader added only for documents created after + /// its addition (`approversPredateDocument`, its default applied). + pub settled_approvers_predate_document: bool, } impl SlotConditionSubject<'_> { @@ -181,6 +197,9 @@ impl SlotConditionSubject<'_> { match setting { SlotConditionSetting::DeleteWithin => self.deletion_window, SlotConditionSetting::DeleteSettled => self.settled_deletion, + SlotConditionSetting::SettledApproversPredateDocument => { + self.settled_approvers_predate_document + } } } } @@ -524,6 +543,7 @@ mod tests { elected_abilities: &none, deletion_window: false, settled_deletion: false, + settled_approvers_predate_document: false, }) }; @@ -560,6 +580,7 @@ mod tests { elected_abilities: &empty, deletion_window: false, settled_deletion: false, + settled_approvers_predate_document: false, }; let name_is = |name: &str| { SlotCondition::Equal( @@ -623,7 +644,8 @@ mod tests { /// A condition on the type as a whole: what its contract lets the elected team do on /// it, and which settings it declares. "The team may delete its documents, and a - /// document stays deletable": no window, or a rule for deleting past it. + /// document stays deletable": no window, or a rule for deleting past it that counts + /// every member of the team for every document. #[test] fn should_judge_a_type_by_what_its_moderation_says_of_it() { let condition = SlotCondition::AllOf(SlotConditions::new(vec![ @@ -633,15 +655,18 @@ mod tests { ), SlotCondition::AnyOf(SlotConditions::new(vec![ SlotCondition::Absent(SlotConditionSetting::DeleteWithin), - SlotCondition::Present(SlotConditionSetting::DeleteSettled), + SlotCondition::AllOf(SlotConditions::new(vec![ + SlotCondition::Present(SlotConditionSetting::DeleteSettled), + SlotCondition::Absent(SlotConditionSetting::SettledApproversPredateDocument), + ])), ])), ])); - assert_eq!(condition.node_count(), 5); + assert_eq!(condition.node_count(), 7); assert!(!condition.reads_the_property()); assert!(removed_with_the_document().reads_the_property()); let empty = BTreeSet::new(); - let holds = |abilities: &[&str], deletion_window, settled_deletion| { + let holds = |abilities: &[&str], deletion_window, settled_deletion, approvers_predate| { condition.holds(&SlotConditionSubject { kind: DocumentReferenceKind::Moderated, property_name: "", @@ -650,15 +675,19 @@ mod tests { elected_abilities: &kept(abilities), deletion_window, settled_deletion, + settled_approvers_predate_document: approvers_predate, }) }; - assert!(holds(&["deleteDocuments"], false, false)); - assert!(holds(&["ban", "deleteDocuments"], true, true)); + assert!(holds(&["deleteDocuments"], false, false, false)); + assert!(holds(&["ban", "deleteDocuments"], true, true, false)); // The team does not delete documents of the type - assert!(!holds(&[], false, false)); - assert!(!holds(&["changeDocumentFields"], false, false)); + assert!(!holds(&[], false, false, false)); + assert!(!holds(&["changeDocumentFields"], false, false, false)); // A window with nobody to delete past it - assert!(!holds(&["deleteDocuments"], true, false)); + assert!(!holds(&["deleteDocuments"], true, false, false)); + // A window, and a rule past it under which members added after a document do not + // count for it: a document older than the members it would take stays for good + assert!(!holds(&["deleteDocuments"], true, true, true)); // Written back as declared, and decoded as encoded assert_eq!( @@ -670,7 +699,11 @@ mod tests { { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, { "anyOf": [ { "absent": "$moderatorAbilities.deleteWithin" }, - { "present": "$moderatorAbilities.deleteSettled" } + { "allOf": [ + { "present": "$moderatorAbilities.deleteSettled" }, + { "absent": + "$moderatorAbilities.deleteSettled.approversPredateDocument" } + ] } ] } ] }) ); diff --git a/packages/rs-dpp/tests/fixtures/stored_contracts/stored.jsonl b/packages/rs-dpp/tests/fixtures/stored_contracts/stored.jsonl index 91a6fc62add..bca76fdb3f4 100644 --- a/packages/rs-dpp/tests/fixtures/stored_contracts/stored.jsonl +++ b/packages/rs-dpp/tests/fixtures/stored_contracts/stored.jsonl @@ -8,7 +8,7 @@ {"contract":"system DocumentHistory","sha256":"60a6904648adf2f98aa4a806e6279112fc5460f47bf529c916b0ef2dbf083cfd","indexStructures":"9f16f37fbeb19d500ad6b3ec489e45d2d3fcea730b99baf57b3e397b96fd2dd3","bytes":"AlgSjNCz5/I54csE0vVfiFygp3B2re5TPurm3hDnHmNiAgAAAAABAQAAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwtwcmljZVVwZGF0ZRYKEgR0eXBlEgZvYmplY3QSCCRjb21tZW50EshUaW1lLXJhbmdlIGFnZ3JlZ2F0ZXMgb3ZlciB0aGVzZSByZWNvcmRzIGFyZSB3ZWlnaHRlZCBieSBsaXN0aW5nIGV2ZW50czogYSBkb2N1bWVudCByZWxpc3RlZCBOIHRpbWVzIGluIHRoZSB3aW5kb3cgY29udHJpYnV0ZXMgTiByZWNvcmRzLiBUaGV5IGFyZSBhY3Rpdml0eS13ZWlnaHRlZCBhdmVyYWdlcywgbm90IHRpbWUtd2VpZ2h0ZWQgcHJpY2VzLhIQZG9jdW1lbnRzTXV0YWJsZRMAEgxjYW5CZURlbGV0ZWQTABIXY3JlYXRpb25SZXN0cmljdGlvbk1vZGUCAhISZG9jdW1lbnRzQ291bnRhYmxlEwESB2luZGljZXMVAhYEEgRuYW1lEgpieURvY3VtZW50Egpwcm9wZXJ0aWVzFQMWARIOZGF0YUNvbnRyYWN0SWQSA2FzYxYBEgpkb2N1bWVudElkEgNhc2MWARIKJGNyZWF0ZWRBdBIDYXNjEgthdmVyYWdlYWJsZRIFcHJpY2USEHJhbmdlQXZlcmFnZWFibGUTARYEEgRuYW1lEgpieUNvbnRyYWN0Egpwcm9wZXJ0aWVzFQIWARIOZGF0YUNvbnRyYWN0SWQSA2FzYxYBEgokY3JlYXRlZEF0EgNhc2MSC2F2ZXJhZ2VhYmxlEgVwcmljZRIQcmFuZ2VBdmVyYWdlYWJsZRMBEgpwcm9wZXJ0aWVzFgQSDmRhdGFDb250cmFjdElkFgcSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIIbWluSXRlbXMCIBIIbWF4SXRlbXMCIBILZGVzY3JpcHRpb24SKFRoZSBkYXRhIGNvbnRyYWN0IG9mIHRoZSBsaXN0ZWQgZG9jdW1lbnQSCHBvc2l0aW9uAgASEGNvbnRlbnRNZWRpYVR5cGUSIWFwcGxpY2F0aW9uL3guZGFzaC5kcHAuaWRlbnRpZmllchIQZG9jdW1lbnRUeXBlTmFtZRYEEgR0eXBlEgZzdHJpbmcSCW1heExlbmd0aAJAEgtkZXNjcmlwdGlvbhIoVGhlIGRvY3VtZW50IHR5cGUgb2YgdGhlIGxpc3RlZCBkb2N1bWVudBIIcG9zaXRpb24CARIKZG9jdW1lbnRJZBYHEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAiASCG1heEl0ZW1zAiASC2Rlc2NyaXB0aW9uEhNUaGUgbGlzdGVkIGRvY3VtZW50Eghwb3NpdGlvbgICEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISBXByaWNlFgMSBHR5cGUSB2ludGVnZXISC2Rlc2NyaXB0aW9uEs1UaGUgbmV3IGFza2luZyBwcmljZSBmb3IgdGhlIGRvY3VtZW50IGluIGNyZWRpdHMuIFVuYm91bmRlZCBpbnRlZ2VyIHNvIHRoZSBwcm9wZXJ0eSBpcyBzdW0tYWdncmVnYXRhYmxlIChpNjQpOyBhY3R1YWwgdmFsdWVzIGFyZSBjb25zZW5zdXMtdmFsaWRhdGVkIG5vbi1uZWdhdGl2ZSBjcmVkaXQgYW1vdW50cyB3cml0dGVuIG9ubHkgYnkgdGhlIHByb3RvY29sEghwb3NpdGlvbgIDEghyZXF1aXJlZBUGEg5kYXRhQ29udHJhY3RJZBIQZG9jdW1lbnRUeXBlTmFtZRIKZG9jdW1lbnRJZBIFcHJpY2USCiRjcmVhdGVkQXQSFSRjcmVhdGVkQXRCbG9ja0hlaWdodBIUYWRkaXRpb25hbFByb3BlcnRpZXMTAAhwdXJjaGFzZRYJEgR0eXBlEgZvYmplY3QSEGRvY3VtZW50c011dGFibGUTABIMY2FuQmVEZWxldGVkEwASF2NyZWF0aW9uUmVzdHJpY3Rpb25Nb2RlAgISFGRvY3VtZW50c0F2ZXJhZ2VhYmxlEgVwcmljZRIHaW5kaWNlcxUFFgQSBG5hbWUSCmJ5RG9jdW1lbnQSCnByb3BlcnRpZXMVAxYBEg5kYXRhQ29udHJhY3RJZBIDYXNjFgESCmRvY3VtZW50SWQSA2FzYxYBEgokY3JlYXRlZEF0EgNhc2MSC2F2ZXJhZ2VhYmxlEgVwcmljZRIQcmFuZ2VBdmVyYWdlYWJsZRMBFgQSBG5hbWUSCmJ5Q29udHJhY3QSCnByb3BlcnRpZXMVAhYBEg5kYXRhQ29udHJhY3RJZBIDYXNjFgESCiRjcmVhdGVkQXQSA2FzYxILYXZlcmFnZWFibGUSBXByaWNlEhByYW5nZUF2ZXJhZ2VhYmxlEwEWAhIEbmFtZRIFYnV5ZXISCnByb3BlcnRpZXMVAhYBEggkb3duZXJJZBIDYXNjFgESCiRjcmVhdGVkQXQSA2FzYxYCEgRuYW1lEgZzZWxsZXISCnByb3BlcnRpZXMVAhYBEghzZWxsZXJJZBIDYXNjFgESCiRjcmVhdGVkQXQSA2FzYxYCEgRuYW1lEgdieVByaWNlEgpwcm9wZXJ0aWVzFQIWARIOZGF0YUNvbnRyYWN0SWQSA2FzYxYBEgVwcmljZRIDYXNjEgpwcm9wZXJ0aWVzFgUSDmRhdGFDb250cmFjdElkFgcSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIIbWluSXRlbXMCIBIIbWF4SXRlbXMCIBILZGVzY3JpcHRpb24SK1RoZSBkYXRhIGNvbnRyYWN0IG9mIHRoZSBwdXJjaGFzZWQgZG9jdW1lbnQSCHBvc2l0aW9uAgASEGNvbnRlbnRNZWRpYVR5cGUSIWFwcGxpY2F0aW9uL3guZGFzaC5kcHAuaWRlbnRpZmllchIQZG9jdW1lbnRUeXBlTmFtZRYEEgR0eXBlEgZzdHJpbmcSCW1heExlbmd0aAJAEgtkZXNjcmlwdGlvbhIrVGhlIGRvY3VtZW50IHR5cGUgb2YgdGhlIHB1cmNoYXNlZCBkb2N1bWVudBIIcG9zaXRpb24CARIKZG9jdW1lbnRJZBYHEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAiASCG1heEl0ZW1zAiASC2Rlc2NyaXB0aW9uEhZUaGUgcHVyY2hhc2VkIGRvY3VtZW50Eghwb3NpdGlvbgICEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISCHNlbGxlcklkFgcSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIIbWluSXRlbXMCIBIIbWF4SXRlbXMCIBILZGVzY3JpcHRpb24SI1RoZSBpZGVudGl0eSB0aGF0IHNvbGQgdGhlIGRvY3VtZW50Eghwb3NpdGlvbgIDEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISBXByaWNlFgMSBHR5cGUSB2ludGVnZXISC2Rlc2NyaXB0aW9uEsdUaGUgcHJpY2UgcGFpZCBmb3IgdGhlIGRvY3VtZW50IGluIGNyZWRpdHMuIFVuYm91bmRlZCBpbnRlZ2VyIHNvIHRoZSBwcm9wZXJ0eSBpcyBzdW0tYWdncmVnYXRhYmxlIChpNjQpOyBhY3R1YWwgdmFsdWVzIGFyZSBjb25zZW5zdXMtdmFsaWRhdGVkIG5vbi1uZWdhdGl2ZSBjcmVkaXQgYW1vdW50cyB3cml0dGVuIG9ubHkgYnkgdGhlIHByb3RvY29sEghwb3NpdGlvbgIEEghyZXF1aXJlZBUHEg5kYXRhQ29udHJhY3RJZBIQZG9jdW1lbnRUeXBlTmFtZRIKZG9jdW1lbnRJZBIIc2VsbGVySWQSBXByaWNlEgokY3JlYXRlZEF0EhUkY3JlYXRlZEF0QmxvY2tIZWlnaHQSFGFkZGl0aW9uYWxQcm9wZXJ0aWVzEwAIdHJhbnNmZXIWCRIEdHlwZRIGb2JqZWN0EhBkb2N1bWVudHNNdXRhYmxlEwASDGNhbkJlRGVsZXRlZBMAEhdjcmVhdGlvblJlc3RyaWN0aW9uTW9kZQICEhJkb2N1bWVudHNDb3VudGFibGUTARIHaW5kaWNlcxUEFgISBG5hbWUSCmJ5RG9jdW1lbnQSCnByb3BlcnRpZXMVAxYBEg5kYXRhQ29udHJhY3RJZBIDYXNjFgESCmRvY3VtZW50SWQSA2FzYxYBEgokY3JlYXRlZEF0EgNhc2MWBBIEbmFtZRIKYnlDb250cmFjdBIKcHJvcGVydGllcxUCFgESDmRhdGFDb250cmFjdElkEgNhc2MWARIKJGNyZWF0ZWRBdBIDYXNjEgljb3VudGFibGUSCWNvdW50YWJsZRIOcmFuZ2VDb3VudGFibGUTARYCEgRuYW1lEgRmcm9tEgpwcm9wZXJ0aWVzFQIWARIIJG93bmVySWQSA2FzYxYBEgokY3JlYXRlZEF0EgNhc2MWAhIEbmFtZRICdG8SCnByb3BlcnRpZXMVAhYBEgx0b0lkZW50aXR5SWQSA2FzYxYBEgokY3JlYXRlZEF0EgNhc2MSCnByb3BlcnRpZXMWBBIOZGF0YUNvbnRyYWN0SWQWBxIEdHlwZRIFYXJyYXkSCWJ5dGVBcnJheRMBEghtaW5JdGVtcwIgEghtYXhJdGVtcwIgEgtkZXNjcmlwdGlvbhItVGhlIGRhdGEgY29udHJhY3Qgb2YgdGhlIHRyYW5zZmVycmVkIGRvY3VtZW50Eghwb3NpdGlvbgIAEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISEGRvY3VtZW50VHlwZU5hbWUWBBIEdHlwZRIGc3RyaW5nEgltYXhMZW5ndGgCQBILZGVzY3JpcHRpb24SLVRoZSBkb2N1bWVudCB0eXBlIG9mIHRoZSB0cmFuc2ZlcnJlZCBkb2N1bWVudBIIcG9zaXRpb24CARIKZG9jdW1lbnRJZBYHEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAiASCG1heEl0ZW1zAiASC2Rlc2NyaXB0aW9uEhhUaGUgdHJhbnNmZXJyZWQgZG9jdW1lbnQSCHBvc2l0aW9uAgISEGNvbnRlbnRNZWRpYVR5cGUSIWFwcGxpY2F0aW9uL3guZGFzaC5kcHAuaWRlbnRpZmllchIMdG9JZGVudGl0eUlkFgcSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIIbWluSXRlbXMCIBIIbWF4SXRlbXMCIBILZGVzY3JpcHRpb24SLFRoZSBpZGVudGl0eSB0aGUgZG9jdW1lbnQgd2FzIHRyYW5zZmVycmVkIHRvEghwb3NpdGlvbgIDEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISCHJlcXVpcmVkFQYSDmRhdGFDb250cmFjdElkEhBkb2N1bWVudFR5cGVOYW1lEgpkb2N1bWVudElkEgx0b0lkZW50aXR5SWQSCiRjcmVhdGVkQXQSFSRjcmVhdGVkQXRCbG9ja0hlaWdodBIUYWRkaXRpb25hbFByb3BlcnRpZXMTAAAAAAAAAAAAAAADC3ByaWNlVXBkYXRlAAQOZGF0YUNvbnRyYWN0SWQNAQAAAAAAABBkb2N1bWVudFR5cGVOYW1lCwABQAABAAAAAAAACmRvY3VtZW50SWQNAQAAAAAAAAVwcmljZQMBAAAAAAAABA5kYXRhQ29udHJhY3RJZA0BAAAAAAAAEGRvY3VtZW50VHlwZU5hbWULAAFAAAEAAAAAAAAKZG9jdW1lbnRJZA0BAAAAAAAABXByaWNlAwEAAAAAAAACCmJ5Q29udHJhY3QKYnlDb250cmFjdAIOZGF0YUNvbnRyYWN0SWQBCiRjcmVhdGVkQXQBAAEAAQEBBXByaWNlAQAAAAAAAAAAAAAAAAAAAApieURvY3VtZW50CmJ5RG9jdW1lbnQDDmRhdGFDb250cmFjdElkAQpkb2N1bWVudElkAQokY3JlYXRlZEF0AQABAAEBAQVwcmljZQEAAAAAAAAAAAAAAAAAAAAAAg5kYXRhQ29udHJhY3RJZApkb2N1bWVudElkAAYKJGNyZWF0ZWRBdBUkY3JlYXRlZEF0QmxvY2tIZWlnaHQOZGF0YUNvbnRyYWN0SWQKZG9jdW1lbnRJZBBkb2N1bWVudFR5cGVOYW1lBXByaWNlAAAAAAAAAAAAAAAAAAACAAACAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAIAuQABAAIAAAEBBA5kYXRhQ29udHJhY3RJZAABAAMAAAABAAABIAEgAAEAAAAQZG9jdW1lbnRUeXBlTmFtZQABAAUAAAAAAQABQAAAAAAKZG9jdW1lbnRJZAABAAMAAAABAAABIAEgAAEAAAAFcHJpY2UAAQAGAAAAAAAAAAEAAQQOZGF0YUNvbnRyYWN0SWQQZG9jdW1lbnRUeXBlTmFtZQpkb2N1bWVudElkBXByaWNlAAAAAAAAAAAACHB1cmNoYXNlAAUOZGF0YUNvbnRyYWN0SWQNAQAAAAAAABBkb2N1bWVudFR5cGVOYW1lCwABQAABAAAAAAAACmRvY3VtZW50SWQNAQAAAAAAAAhzZWxsZXJJZA0BAAAAAAAABXByaWNlAwEAAAAAAAAFDmRhdGFDb250cmFjdElkDQEAAAAAAAAQZG9jdW1lbnRUeXBlTmFtZQsAAUAAAQAAAAAAAApkb2N1bWVudElkDQEAAAAAAAAIc2VsbGVySWQNAQAAAAAAAAVwcmljZQMBAAAAAAAABQVidXllcgVidXllcgIIJG93bmVySWQBCiRjcmVhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAApieUNvbnRyYWN0CmJ5Q29udHJhY3QCDmRhdGFDb250cmFjdElkAQokY3JlYXRlZEF0AQABAAEBAQVwcmljZQEAAAAAAAAAAAAAAAAAAAAKYnlEb2N1bWVudApieURvY3VtZW50Aw5kYXRhQ29udHJhY3RJZAEKZG9jdW1lbnRJZAEKJGNyZWF0ZWRBdAEAAQABAQEFcHJpY2UBAAAAAAAAAAAAAAAAAAAAB2J5UHJpY2UHYnlQcmljZQIOZGF0YUNvbnRyYWN0SWQBBXByaWNlAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAGc2VsbGVyBnNlbGxlcgIIc2VsbGVySWQBCiRjcmVhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAADDmRhdGFDb250cmFjdElkCmRvY3VtZW50SWQIc2VsbGVySWQABwokY3JlYXRlZEF0FSRjcmVhdGVkQXRCbG9ja0hlaWdodA5kYXRhQ29udHJhY3RJZApkb2N1bWVudElkEGRvY3VtZW50VHlwZU5hbWUFcHJpY2UIc2VsbGVySWQAAAAAAAAAAAAAAAAAAAIAAAIAAAAAAAABAAEFcHJpY2UAAAAAAAAAAAAAAAAAAAAAAgDeAAEAAgAAAQEFDmRhdGFDb250cmFjdElkAAEAAwAAAAEAAAEgASAAAQAAABBkb2N1bWVudFR5cGVOYW1lAAEABQAAAAABAAFAAAAAAApkb2N1bWVudElkAAEAAwAAAAEAAAEgASAAAQAAAAhzZWxsZXJJZAABAAMAAAABAAABIAEgAAEAAAAFcHJpY2UAAQAGAAAAAAAAAAEAAQUOZGF0YUNvbnRyYWN0SWQQZG9jdW1lbnRUeXBlTmFtZQpkb2N1bWVudElkCHNlbGxlcklkBXByaWNlAAAAAAAAAAAACHRyYW5zZmVyAAQOZGF0YUNvbnRyYWN0SWQNAQAAAAAAABBkb2N1bWVudFR5cGVOYW1lCwABQAABAAAAAAAACmRvY3VtZW50SWQNAQAAAAAAAAx0b0lkZW50aXR5SWQNAQAAAAAAAAQOZGF0YUNvbnRyYWN0SWQNAQAAAAAAABBkb2N1bWVudFR5cGVOYW1lCwABQAABAAAAAAAACmRvY3VtZW50SWQNAQAAAAAAAAx0b0lkZW50aXR5SWQNAQAAAAAAAAQKYnlDb250cmFjdApieUNvbnRyYWN0Ag5kYXRhQ29udHJhY3RJZAEKJGNyZWF0ZWRBdAEAAQABAQAAAAAAAAAAAAAAAAAAAAAACmJ5RG9jdW1lbnQKYnlEb2N1bWVudAMOZGF0YUNvbnRyYWN0SWQBCmRvY3VtZW50SWQBCiRjcmVhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAARmcm9tBGZyb20CCCRvd25lcklkAQokY3JlYXRlZEF0AQABAAAAAAAAAAAAAAAAAAAAAAAAAAACdG8CdG8CDHRvSWRlbnRpdHlJZAEKJGNyZWF0ZWRBdAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAMOZGF0YUNvbnRyYWN0SWQKZG9jdW1lbnRJZAx0b0lkZW50aXR5SWQABgokY3JlYXRlZEF0FSRjcmVhdGVkQXRCbG9ja0hlaWdodA5kYXRhQ29udHJhY3RJZApkb2N1bWVudElkEGRvY3VtZW50VHlwZU5hbWUMdG9JZGVudGl0eUlkAAAAAAAAAAAAAAAAAAACAAACAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAIAzwABAAIAAAEBBA5kYXRhQ29udHJhY3RJZAABAAMAAAABAAABIAEgAAEAAAAQZG9jdW1lbnRUeXBlTmFtZQABAAUAAAAAAQABQAAAAAAKZG9jdW1lbnRJZAABAAMAAAABAAABIAEgAAEAAAAMdG9JZGVudGl0eUlkAAEAAwAAAAEAAAEgASAAAQAAAAEAAQQOZGF0YUNvbnRyYWN0SWQQZG9jdW1lbnRUeXBlTmFtZQpkb2N1bWVudElkDHRvSWRlbnRpdHlJZAAAAAAAAAAAAA=="} {"contract":"system AppConnect","sha256":"4d6dd642c6f74fe2fcfd04696bd8d58b8ca418586619301b235fe7729f73c09a","indexStructures":"05f449543911990d081569c8124f9fbb312a5d3dcbbe045d6f7dfb0a14dd8da4","bytes":"Au+WDqVpcuutvviifvfaXIH/S7OKApaXRX4k2kK3m1S3AgAAAAABAQAAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARBsb2dpbktleVJlc3BvbnNlFgsSBHR5cGUSBm9iamVjdBIQZG9jdW1lbnRzTXV0YWJsZRMAEgxjYW5CZURlbGV0ZWQTARIXY3JlYXRpb25SZXN0cmljdGlvbk1vZGUCABIHaW5kaWNlcxUBFgISBG5hbWUSCWJ5UmVxdWVzdBIIdGVybWluYWwVAhIWYXBwRXBoZW1lcmFsUHViS2V5SGFzaBIIJG93bmVySWQSCnByb3BlcnRpZXMWAxIWYXBwRXBoZW1lcmFsUHViS2V5SGFzaBYGEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAhQSCG1heEl0ZW1zAhQSC2Rlc2NyaXB0aW9uEl5oYXNoMTYwIG9mIHRoZSBlcGhlbWVyYWwgcHVibGljIGtleSB0aGUgYXBwIHB1dCBpbiBpdHMgY29ubmVjdCByZXF1ZXN0OyBpZGVudGlmaWVzIHRoZSByZXF1ZXN0Eghwb3NpdGlvbgIAEhV3YWxsZXRFcGhlbWVyYWxQdWJLZXkWBhIEdHlwZRIFYXJyYXkSCWJ5dGVBcnJheRMBEghtaW5JdGVtcwIhEghtYXhJdGVtcwIhEgtkZXNjcmlwdGlvbhJ5VGhlIHdhbGxldCdzIGNvbXByZXNzZWQgZXBoZW1lcmFsIHB1YmxpYyBrZXk7IHRoZSBhcHAgZGVyaXZlcyB0aGUgc2hhcmVkIHNlY3JldCBmcm9tIGl0IGFuZCBpdHMgb3duIGVwaGVtZXJhbCBwcml2YXRlIGtleRIIcG9zaXRpb24CARIQZW5jcnlwdGVkUGF5bG9hZBYGEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAjwSCG1heEl0ZW1zAvsCPBILZGVzY3JpcHRpb24SrVRoZSBncmFudGVkIHByaXZhdGUga2V5cyBlbmNyeXB0ZWQgdG8gdGhlIGFwcDogYSAyOC1ieXRlIGVudmVsb3BlIHBsdXMgMzIgYnl0ZXMgcGVyIGtleSwgb25lIHRvIHNldmVudGVlbiBrZXlzICh0aGUgc2Vzc2lvbiBrZXkgcGx1cyB1cCB0byBlaWdodCBiaW5kaW5ncyB3aXRoIGJvdGggcHVycG9zZXMpEghwb3NpdGlvbgICEghyZXF1aXJlZBUDEhZhcHBFcGhlbWVyYWxQdWJLZXlIYXNoEhV3YWxsZXRFcGhlbWVyYWxQdWJLZXkSEGVuY3J5cHRlZFBheWxvYWQSC2Rlc2NyaXB0aW9uEvsBDEEgd2FsbGV0J3MgcmVzcG9uc2UgYWZ0ZXIgYXBwcm92aW5nIGEgbG9naW4gcmVxdWVzdC4gVGhlIGZsYXQgZW50cnkgaXMga2V5ZWQgYnkgdGhlIHJlcXVlc3QgaGFzaCBhbmQgd2FsbGV0IGlkZW50aXR5LCBzbyBvdGhlciBpZGVudGl0aWVzIGNhbm5vdCBibG9jayBpdHMgd3JpdGUuIE9uIHJlLWxvZ2luIHRoZSB3YWxsZXQgZGVsZXRlcyBpdHMgcHJldmlvdXMgcmVzcG9uc2UgYnkgdmFsdWVzIGFuZCBjcmVhdGVzIGEgcmVzcG9uc2UgZm9yIHRoZSBuZXcgcmVxdWVzdC4SFGFkZGl0aW9uYWxQcm9wZXJ0aWVzEwASCWluZGV4T25seRMBEgxlbnRyeVBheWxvYWQVAhIVd2FsbGV0RXBoZW1lcmFsUHViS2V5EhBlbmNyeXB0ZWRQYXlsb2FkAAAAAAAAAAAAAAEQbG9naW5LZXlSZXNwb25zZQADFmFwcEVwaGVtZXJhbFB1YktleUhhc2gMARQBFAEAAAAAAAAVd2FsbGV0RXBoZW1lcmFsUHViS2V5DAEhASEBAAAAAAAAEGVuY3J5cHRlZFBheWxvYWQMATwB+wI8AQAAAAAAAAMWYXBwRXBoZW1lcmFsUHViS2V5SGFzaAwBFAEUAQAAAAAAABV3YWxsZXRFcGhlbWVyYWxQdWJLZXkMASEBIQEAAAAAAAAQZW5jcnlwdGVkUGF5bG9hZAwBPAH7AjwBAAAAAAAAAQlieVJlcXVlc3QJYnlSZXF1ZXN0AAABAAAAAAAAAAAAAAAAAAECFmFwcEVwaGVtZXJhbFB1YktleUhhc2gIJG93bmVySWQAAAAAAAAAAAMWYXBwRXBoZW1lcmFsUHViS2V5SGFzaBBlbmNyeXB0ZWRQYXlsb2FkFXdhbGxldEVwaGVtZXJhbFB1YktleQMWYXBwRXBoZW1lcmFsUHViS2V5SGFzaBBlbmNyeXB0ZWRQYXlsb2FkFXdhbGxldEVwaGVtZXJhbFB1YktleQAAAAACEGVuY3J5cHRlZFBheWxvYWQVd2FsbGV0RXBoZW1lcmFsUHViS2V5AAAAAAABAAAAAAAAAgAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAACAM0AAQACAAABAQMWYXBwRXBoZW1lcmFsUHViS2V5SGFzaAABAAMAAAABAAABFAEUAAEAAAAVd2FsbGV0RXBoZW1lcmFsUHViS2V5AAEAAwAAAAEAAAEhASEAAQAAABBlbmNyeXB0ZWRQYXlsb2FkAAEAAwAAAAEAAAE8AfsCPAABAAAAAQABAxZhcHBFcGhlbWVyYWxQdWJLZXlIYXNoFXdhbGxldEVwaGVtZXJhbFB1YktleRBlbmNyeXB0ZWRQYXlsb2FkAAAAAAAAAAAA"} {"contract":"system ModerationCharters","sha256":"08e879d58b351ca5dbb3beb72f0c2905679a24233c4aef7682a5cf4d7bae24cb","indexStructures":"959daf512c8055ab2f16c63e84b5ca8cbf1fba87c461bbc30279be340c74ad35","bytes":"AsUG5khqxlKB/YcrVrm2EXCkf2AFa7mcLg4Kbe1N5PiBAgAAAAABAQAAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABw5hZGRlZE1vZGVyYXRvchYIEgR0eXBlEgZvYmplY3QSEGRvY3VtZW50c011dGFibGUTABIMY2FuQmVEZWxldGVkEwESB2luZGljZXMVARYDEgRuYW1lEhZieUVsZWN0ZWRDaGFydGVyTWVtYmVyEgpwcm9wZXJ0aWVzFQIWARIQZWxlY3RlZENoYXJ0ZXJJZBIDYXNjFgESCG1lbWJlcklkEgNhc2MSBnVuaXF1ZRMBEgpwcm9wZXJ0aWVzFgMSEGVsZWN0ZWRDaGFydGVySWQWCBIEdHlwZRIFYXJyYXkSCWJ5dGVBcnJheRMBEghtaW5JdGVtcwIgEghtYXhJdGVtcwIgEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISCHJlZmVyc1RvFgMSBHR5cGUSEXBlcm1hbmVudERvY3VtZW50Egxkb2N1bWVudFR5cGUSDmVsZWN0ZWRDaGFydGVyEgV3aGVyZRYCEggkb3duZXJJZBIIJG93bmVySWQSEnN1Ym1pdHRlZENoYXJ0ZXJJZBISc3VibWl0dGVkQ2hhcnRlcklkEgtkZXNjcmlwdGlvbhIrVGhlIHNlYXRlZCBjaGFydGVyOyBvbmx5IGl0cyBsZWFkZXIgbWF5IGFkZBIIcG9zaXRpb24CABISc3VibWl0dGVkQ2hhcnRlcklkFgYSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIIbWluSXRlbXMCIBIIbWF4SXRlbXMCIBIQY29udGVudE1lZGlhVHlwZRIhYXBwbGljYXRpb24veC5kYXNoLmRwcC5pZGVudGlmaWVyEghwb3NpdGlvbgIBEghtZW1iZXJJZBYJEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAiASCG1heEl0ZW1zAiASEGNvbnRlbnRNZWRpYVR5cGUSIWFwcGxpY2F0aW9uL3guZGFzaC5kcHAuaWRlbnRpZmllchIMZGlzdGluY3RGcm9tEggkb3duZXJJZBIIcmVmZXJzVG8WAxIEdHlwZRIRcGVybWFuZW50RG9jdW1lbnQSDGRvY3VtZW50VHlwZRILam9pblJlcXVlc3QSBmZpbmRCeRYCEhJzdWJtaXR0ZWRDaGFydGVySWQSEnN1Ym1pdHRlZENoYXJ0ZXJJZBIIJG93bmVySWQSAS4SC2Rlc2NyaXB0aW9uEitBbiBpZGVudGl0eSB0aGF0IGFza2VkIHRvIGpvaW4gdGhlIHByb3Bvc2FsEghwb3NpdGlvbgICEghyZXF1aXJlZBUEEgokY3JlYXRlZEF0EhBlbGVjdGVkQ2hhcnRlcklkEhJzdWJtaXR0ZWRDaGFydGVySWQSCG1lbWJlcklkEhRhZGRpdGlvbmFsUHJvcGVydGllcxMAEgtkZXNjcmlwdGlvbhKJQSBtZW1iZXIgdGhlIGxlYWRlciBhZGRzIGFmdGVyIHRoZSBlbGVjdGlvbiwgYXQgbW9zdCB0aGUgdGFyZ2V0J3MgbWF4QWRkZWRNb2RlcmF0b3JzIGF0IGEgdGltZTsgZGVsZXRpbmcgaXQgdGFrZXMgdGhlIG1lbWJlciBvZmYgdGhlIHRlYW0OZWxlY3RlZENoYXJ0ZXIWCBIEdHlwZRIGb2JqZWN0EhBkb2N1bWVudHNNdXRhYmxlEwASDGNhbkJlRGVsZXRlZBMAEgdpbmRpY2VzFQIWBBIEbmFtZRIQYnlUYXJnZXRDb250cmFjdBIKcHJvcGVydGllcxUBFgESEHRhcmdldENvbnRyYWN0SWQSA2FzYxIGdW5pcXVlEwESCWNvbnRlc3RlZBYCEgpyZXNvbHV0aW9uAgESC2Rlc2NyaXB0aW9uEh9Db250ZXN0IGZvciB0aGUgbW9kZXJhdGlvbiBzZWF0FgISBG5hbWUSEmJ5U3VibWl0dGVkQ2hhcnRlchIKcHJvcGVydGllcxUBFgESEnN1Ym1pdHRlZENoYXJ0ZXJJZBIDYXNjEgpwcm9wZXJ0aWVzFgMSEHRhcmdldENvbnRyYWN0SWQWBxIEdHlwZRIFYXJyYXkSCWJ5dGVBcnJheRMBEghtaW5JdGVtcwIgEghtYXhJdGVtcwIgEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISCHJlZmVyc1RvFgISBHR5cGUSCGNvbnRyYWN0EhRjb250cmFjdFJlcXVpcmVtZW50cxYBEgptb2RlcmF0aW9uEgxlbGVjdGlvbk9wZW4SCHBvc2l0aW9uAgASEnN1Ym1pdHRlZENoYXJ0ZXJJZBYHEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAiASCG1heEl0ZW1zAiASEGNvbnRlbnRNZWRpYVR5cGUSIWFwcGxpY2F0aW9uL3guZGFzaC5kcHAuaWRlbnRpZmllchIIcmVmZXJzVG8WAxIEdHlwZRIRcGVybWFuZW50RG9jdW1lbnQSDGRvY3VtZW50VHlwZRIQc3VibWl0dGVkQ2hhcnRlchIFd2hlcmUWAhIIJG93bmVySWQSCCRvd25lcklkEhB0YXJnZXRDb250cmFjdElkEhB0YXJnZXRDb250cmFjdElkEghwb3NpdGlvbgIBEgdtZW1iZXJzFgcSBHR5cGUSBWFycmF5EghtaW5JdGVtcwIAEghtYXhJdGVtcwIPEgt1bmlxdWVJdGVtcxMBEgVpdGVtcxYHEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAiASCG1heEl0ZW1zAiASEGNvbnRlbnRNZWRpYVR5cGUSIWFwcGxpY2F0aW9uL3guZGFzaC5kcHAuaWRlbnRpZmllchIMZGlzdGluY3RGcm9tEggkb3duZXJJZBIIcmVmZXJzVG8WAxIEdHlwZRIRcGVybWFuZW50RG9jdW1lbnQSDGRvY3VtZW50VHlwZRILam9pblJlcXVlc3QSBmZpbmRCeRYCEhJzdWJtaXR0ZWRDaGFydGVySWQSEnN1Ym1pdHRlZENoYXJ0ZXJJZBIIJG93bmVySWQSAS4SC2Rlc2NyaXB0aW9uEjFUZWFtIGJlc2lkZXMgdGhlIGxlYWRlciwgZWFjaCB3aXRoIGEgam9pbiByZXF1ZXN0Eghwb3NpdGlvbgICEghyZXF1aXJlZBUEEgokY3JlYXRlZEF0EhB0YXJnZXRDb250cmFjdElkEhJzdWJtaXR0ZWRDaGFydGVySWQSB21lbWJlcnMSFGFkZGl0aW9uYWxQcm9wZXJ0aWVzEwASC2Rlc2NyaXB0aW9uEihBIHByb3Bvc2FsIHB1dCB0byB0aGUgdm90ZSB3aXRoIGl0cyB0ZWFtC2pvaW5SZXF1ZXN0FgkSBHR5cGUSBm9iamVjdBIQZG9jdW1lbnRzTXV0YWJsZRMAEgxjYW5CZURlbGV0ZWQTABIkcmVxdWlyZXNJZGVudGl0eUVuY3J5cHRpb25Cb3VuZGVkS2V5AgESB2luZGljZXMVAhYDEgRuYW1lEhJieVN1Ym1pdHRlZENoYXJ0ZXISCnByb3BlcnRpZXMVAhYBEhJzdWJtaXR0ZWRDaGFydGVySWQSA2FzYxYBEggkb3duZXJJZBIDYXNjEgZ1bmlxdWUTARYCEgRuYW1lEgdieU93bmVyEgpwcm9wZXJ0aWVzFQEWARIIJG93bmVySWQSA2FzYxIKcHJvcGVydGllcxYFEhJzdWJtaXR0ZWRDaGFydGVySWQWBxIEdHlwZRIFYXJyYXkSCWJ5dGVBcnJheRMBEghtaW5JdGVtcwIgEghtYXhJdGVtcwIgEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISCHJlZmVyc1RvFgMSBHR5cGUSEXBlcm1hbmVudERvY3VtZW50Egxkb2N1bWVudFR5cGUSEHN1Ym1pdHRlZENoYXJ0ZXISBXdoZXJlFgESCCRvd25lcklkEgtyZWNpcGllbnRJZBIIcG9zaXRpb24CABILcmVjaXBpZW50SWQWCBIEdHlwZRIFYXJyYXkSCWJ5dGVBcnJheRMBEghtaW5JdGVtcwIgEghtYXhJdGVtcwIgEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISCHJlZmVyc1RvFgMSBHR5cGUSEWlkZW50aXR5UHVibGljS2V5Eg1rZXlJZFByb3BlcnR5Eg5yZWNpcGllbnRLZXlJZBIPa2V5UmVxdWlyZW1lbnRzFgISB3B1cnBvc2USCmRlY3J5cHRpb24SB2JvdW5kVG8SEHN1Ym1pdHRlZENoYXJ0ZXISC2Rlc2NyaXB0aW9uEhVUaGUgcHJvcG9zYWwncyBsZWFkZXISCHBvc2l0aW9uAgESDnJlY2lwaWVudEtleUlkFgQSBHR5cGUSB2ludGVnZXISB21pbmltdW0CABIHbWF4aW11bQL8/////xIIcG9zaXRpb24CAhILc2VuZGVyS2V5SWQWBRIEdHlwZRIHaW50ZWdlchIHbWluaW11bQIAEgdtYXhpbXVtAvz/////EghyZWZlcnNUbxYDEgR0eXBlEhFpZGVudGl0eVB1YmxpY0tleRIQaWRlbnRpdHlQcm9wZXJ0eRIIJG93bmVySWQSD2tleVJlcXVpcmVtZW50cxYCEgdwdXJwb3NlEgplbmNyeXB0aW9uEgdib3VuZFRvEgtqb2luUmVxdWVzdBIIcG9zaXRpb24CAxIQZW5jcnlwdGVkTWVzc2FnZRYHEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAiASCG1heEl0ZW1zAvsEEBIMZW5jcnlwdGVkRm9yFgQSCXJlY2lwaWVudBILcmVjaXBpZW50SWQSDHJlY2lwaWVudEtleRIOcmVjaXBpZW50S2V5SWQSCXNlbmRlcktleRILc2VuZGVyS2V5SWQSBnNjaGVtZRIZZWNkaC1zZWNwMjU2azEtYWVzMjU2LWNiYxILZGVzY3JpcHRpb24SG1JlYWRhYmxlIGJ5IHRoZSBsZWFkZXIgb25seRIIcG9zaXRpb24CBBIIcmVxdWlyZWQVBhIKJGNyZWF0ZWRBdBISc3VibWl0dGVkQ2hhcnRlcklkEgtyZWNpcGllbnRJZBIOcmVjaXBpZW50S2V5SWQSC3NlbmRlcktleUlkEhBlbmNyeXB0ZWRNZXNzYWdlEhRhZGRpdGlvbmFsUHJvcGVydGllcxMAEgtkZXNjcmlwdGlvbhIiQW4gb2ZmZXIgdG8gam9pbiBhIHByb3Bvc2FsJ3MgdGVhbQZyZWFzb24WCBIEdHlwZRIGb2JqZWN0EhBkb2N1bWVudHNNdXRhYmxlEwASDGNhbkJlRGVsZXRlZBMAEgdpbmRpY2VzFQEWAxIEbmFtZRILYnlPd25lckNvZGUSCnByb3BlcnRpZXMVAhYBEggkb3duZXJJZBIDYXNjFgESBGNvZGUSA2FzYxIGdW5pcXVlEwESCnByb3BlcnRpZXMWAxIEY29kZRYGEgR0eXBlEgZzdHJpbmcSCW1pbkxlbmd0aAIDEgltYXhMZW5ndGgCAxIHcGF0dGVybhIKXltBLVpdezN9JBILZGVzY3JpcHRpb24SKVRocmVlIHVwcGVyY2FzZSBsZXR0ZXJzLCB1bmlxdWUgcGVyIG93bmVyEghwb3NpdGlvbgIAEgVsYWJlbBYEEgR0eXBlEgZzdHJpbmcSCW1pbkxlbmd0aAIBEgltYXhMZW5ndGgCQBIIcG9zaXRpb24CARILZGVzY3JpcHRpb24WBBIEdHlwZRIGc3RyaW5nEgltaW5MZW5ndGgCARIJbWF4TGVuZ3RoAvsEABIIcG9zaXRpb24CAhIIcmVxdWlyZWQVAxIKJGNyZWF0ZWRBdBIEY29kZRIFbGFiZWwSFGFkZGl0aW9uYWxQcm9wZXJ0aWVzEwASC2Rlc2NyaXB0aW9uEiBBIGdyb3VuZCBmb3IgYSBtb2RlcmF0aW9uIGFjdGlvbhByZW1vdmVkTW9kZXJhdG9yFggSBHR5cGUSBm9iamVjdBIQZG9jdW1lbnRzTXV0YWJsZRMAEgxjYW5CZURlbGV0ZWQTARIHaW5kaWNlcxUBFgMSBG5hbWUSFmJ5RWxlY3RlZENoYXJ0ZXJNZW1iZXISCnByb3BlcnRpZXMVAhYBEhBlbGVjdGVkQ2hhcnRlcklkEgNhc2MWARIIbWVtYmVySWQSA2FzYxIGdW5pcXVlEwESCnByb3BlcnRpZXMWAhIQZWxlY3RlZENoYXJ0ZXJJZBYIEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAiASCG1heEl0ZW1zAiASEGNvbnRlbnRNZWRpYVR5cGUSIWFwcGxpY2F0aW9uL3guZGFzaC5kcHAuaWRlbnRpZmllchIIcmVmZXJzVG8WAxIEdHlwZRIRcGVybWFuZW50RG9jdW1lbnQSDGRvY3VtZW50VHlwZRIOZWxlY3RlZENoYXJ0ZXISBXdoZXJlFgESCCRvd25lcklkEggkb3duZXJJZBILZGVzY3JpcHRpb24SLlRoZSBzZWF0ZWQgY2hhcnRlcjsgb25seSBpdHMgbGVhZGVyIG1heSByZW1vdmUSCHBvc2l0aW9uAgASCG1lbWJlcklkFgkSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIIbWluSXRlbXMCIBIIbWF4SXRlbXMCIBIQY29udGVudE1lZGlhVHlwZRIhYXBwbGljYXRpb24veC5kYXNoLmRwcC5pZGVudGlmaWVyEgxkaXN0aW5jdEZyb20SCCRvd25lcklkEghyZWZlcnNUbxYEEgR0eXBlEhFwZXJtYW5lbnREb2N1bWVudBIMZG9jdW1lbnRUeXBlEg5lbGVjdGVkQ2hhcnRlchIGZmluZEJ5FgESAyRpZBIQZWxlY3RlZENoYXJ0ZXJJZBIGaW5MaXN0EgdtZW1iZXJzEgtkZXNjcmlwdGlvbhJUQW4gZWxlY3RlZCBtZW1iZXIgb2YgdGhlIGNoYXJ0ZXI7IGFuIGFkZGVkIG9uZSBpcyB0YWtlbiBvZmYgYnkgZGVsZXRpbmcgaXRzIGFkZGl0aW9uEghwb3NpdGlvbgIBEghyZXF1aXJlZBUDEgokY3JlYXRlZEF0EhBlbGVjdGVkQ2hhcnRlcklkEghtZW1iZXJJZBIUYWRkaXRpb25hbFByb3BlcnRpZXMTABILZGVzY3JpcHRpb24SS0FuIGVsZWN0ZWQgbWVtYmVyIHRoZSBsZWFkZXIgdGFrZXMgb2ZmIHRoZSB0ZWFtOyBkZWxldGluZyBpdCBwdXRzIHRoZW0gYmFjaxJyZXNpZ25hdGlvblJlcXVlc3QWCRIEdHlwZRIGb2JqZWN0EhBkb2N1bWVudHNNdXRhYmxlEwASDGNhbkJlRGVsZXRlZBMBEg1vd25lclJlZmVyc1RvFgESBWFueU9mFQIWBBIEdHlwZRIRcGVybWFuZW50RG9jdW1lbnQSDGRvY3VtZW50VHlwZRIOZWxlY3RlZENoYXJ0ZXISBmZpbmRCeRYBEgMkaWQSEGVsZWN0ZWRDaGFydGVySWQSBmluTGlzdBIHbWVtYmVycxYDEgR0eXBlEhFkZWxldGFibGVEb2N1bWVudBIMZG9jdW1lbnRUeXBlEg5hZGRlZE1vZGVyYXRvchIGZmluZEJ5FgISEGVsZWN0ZWRDaGFydGVySWQSEGVsZWN0ZWRDaGFydGVySWQSCG1lbWJlcklkEgEuEgdpbmRpY2VzFQEWAxIEbmFtZRIVYnlFbGVjdGVkQ2hhcnRlck93bmVyEgpwcm9wZXJ0aWVzFQIWARIQZWxlY3RlZENoYXJ0ZXJJZBIDYXNjFgESCCRvd25lcklkEgNhc2MSBnVuaXF1ZRMBEgpwcm9wZXJ0aWVzFgUSEGVsZWN0ZWRDaGFydGVySWQWBxIEdHlwZRIFYXJyYXkSCWJ5dGVBcnJheRMBEghtaW5JdGVtcwIgEghtYXhJdGVtcwIgEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISCHJlZmVyc1RvFgMSBHR5cGUSEXBlcm1hbmVudERvY3VtZW50Egxkb2N1bWVudFR5cGUSDmVsZWN0ZWRDaGFydGVyEgV3aGVyZRYBEggkb3duZXJJZBILcmVjaXBpZW50SWQSCHBvc2l0aW9uAgASC3JlY2lwaWVudElkFggSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIIbWluSXRlbXMCIBIIbWF4SXRlbXMCIBIQY29udGVudE1lZGlhVHlwZRIhYXBwbGljYXRpb24veC5kYXNoLmRwcC5pZGVudGlmaWVyEghyZWZlcnNUbxYDEgR0eXBlEhFpZGVudGl0eVB1YmxpY0tleRINa2V5SWRQcm9wZXJ0eRIOcmVjaXBpZW50S2V5SWQSD2tleVJlcXVpcmVtZW50cxYCEgdwdXJwb3NlEgpkZWNyeXB0aW9uEgdib3VuZFRvEhBzdWJtaXR0ZWRDaGFydGVyEgtkZXNjcmlwdGlvbhIKVGhlIGxlYWRlchIIcG9zaXRpb24CARIOcmVjaXBpZW50S2V5SWQWBBIEdHlwZRIHaW50ZWdlchIHbWluaW11bQIAEgdtYXhpbXVtAvz/////Eghwb3NpdGlvbgICEgtzZW5kZXJLZXlJZBYFEgR0eXBlEgdpbnRlZ2VyEgdtaW5pbXVtAgASB21heGltdW0C/P////8SCHJlZmVyc1RvFgMSBHR5cGUSEWlkZW50aXR5UHVibGljS2V5EhBpZGVudGl0eVByb3BlcnR5Eggkb3duZXJJZBIPa2V5UmVxdWlyZW1lbnRzFgISB3B1cnBvc2USCmVuY3J5cHRpb24SB2JvdW5kVG8SC2pvaW5SZXF1ZXN0Eghwb3NpdGlvbgIDEhBlbmNyeXB0ZWRNZXNzYWdlFgcSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIIbWluSXRlbXMCIBIIbWF4SXRlbXMC+wQQEgxlbmNyeXB0ZWRGb3IWBBIJcmVjaXBpZW50EgtyZWNpcGllbnRJZBIMcmVjaXBpZW50S2V5Eg5yZWNpcGllbnRLZXlJZBIJc2VuZGVyS2V5EgtzZW5kZXJLZXlJZBIGc2NoZW1lEhllY2RoLXNlY3AyNTZrMS1hZXMyNTYtY2JjEgtkZXNjcmlwdGlvbhIyV2h5IHRoZSBtZW1iZXIgbGVhdmVzLCByZWFkYWJsZSBieSB0aGUgbGVhZGVyIG9ubHkSCHBvc2l0aW9uAgQSCHJlcXVpcmVkFQYSCiRjcmVhdGVkQXQSEGVsZWN0ZWRDaGFydGVySWQSC3JlY2lwaWVudElkEg5yZWNpcGllbnRLZXlJZBILc2VuZGVyS2V5SWQSEGVuY3J5cHRlZE1lc3NhZ2USFGFkZGl0aW9uYWxQcm9wZXJ0aWVzEwASC2Rlc2NyaXB0aW9uElhBIG1lbWJlciBhc2tpbmcgdG8gbGVhdmU7IHRoZSBsZWFkZXIgcmVtb3ZlcyB0aGVtLCBhbmQgZGVsZXRpbmcgaXQgd2l0aGRyYXdzIHRoZSByZXF1ZXN0EHN1Ym1pdHRlZENoYXJ0ZXIWChIEdHlwZRIGb2JqZWN0EhBkb2N1bWVudHNNdXRhYmxlEwASDGNhbkJlRGVsZXRlZBMAEiRyZXF1aXJlc0lkZW50aXR5RGVjcnlwdGlvbkJvdW5kZWRLZXkCARIHaW5kaWNlcxUCFgISBG5hbWUSEGJ5VGFyZ2V0Q29udHJhY3QSCnByb3BlcnRpZXMVAhYBEhB0YXJnZXRDb250cmFjdElkEgNhc2MWARIKJGNyZWF0ZWRBdBIDYXNjFgISBG5hbWUSB2J5T3duZXISCnByb3BlcnRpZXMVARYBEggkb3duZXJJZBIDYXNjEgpwcm9wZXJ0aWVzFgUSEHRhcmdldENvbnRyYWN0SWQWCBIEdHlwZRIFYXJyYXkSCWJ5dGVBcnJheRMBEghtaW5JdGVtcwIgEghtYXhJdGVtcwIgEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISCHJlZmVyc1RvFgISBHR5cGUSCGNvbnRyYWN0EhRjb250cmFjdFJlcXVpcmVtZW50cxYBEgptb2RlcmF0aW9uEgdlbGVjdGVkEgtkZXNjcmlwdGlvbhIYVGhlIGNvbnRyYWN0IHRvIG1vZGVyYXRlEghwb3NpdGlvbgIAEgtkZXNjcmlwdGlvbhYFEgR0eXBlEgZzdHJpbmcSCW1pbkxlbmd0aAIBEgltYXhMZW5ndGgC+xAAEghtYXhCeXRlcwL7EAASCHBvc2l0aW9uAgESB3JlYXNvbnMWBxIEdHlwZRIFYXJyYXkSCG1pbkl0ZW1zAgASCG1heEl0ZW1zAkASC3VuaXF1ZUl0ZW1zEwESBWl0ZW1zFgYSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIIbWluSXRlbXMCIBIIbWF4SXRlbXMCIBIQY29udGVudE1lZGlhVHlwZRIhYXBwbGljYXRpb24veC5kYXNoLmRwcC5pZGVudGlmaWVyEghyZWZlcnNUbxYCEgR0eXBlEhFwZXJtYW5lbnREb2N1bWVudBIMZG9jdW1lbnRUeXBlEgZyZWFzb24SC2Rlc2NyaXB0aW9uEjVSZWFzb25zIHRoZSB0ZWFtIG1heSBhY3Qgb247IG5vbmUgbWVhbnMgaXQgY2Fubm90IGFjdBIIcG9zaXRpb24CAhIPbW9kZXJhdG9yc1NoYXJlFgUSBHR5cGUSB2ludGVnZXISB21pbmltdW0CABIHbWF4aW11bQJkEgtkZXNjcmlwdGlvbhJPUGVyY2VudCBvZiB0aGUgZGVjbGFyZWQgbW9kZXJhdG9ycyBmZWUgdGFrZW47IGFic2VudCBpcyAxMDAsIDAgdGFrZXMgbm8gcmV3YXJkcxIIcG9zaXRpb24CAxILcmV3YXJkU3BsaXQWBhIEdHlwZRIGb2JqZWN0Egpwcm9wZXJ0aWVzFgMSBmxlYWRlchYEEgR0eXBlEgdpbnRlZ2VyEgdtaW5pbXVtAgASB21heGltdW0CZBIIcG9zaXRpb24CABIFZXF1YWwWBRIEdHlwZRIHaW50ZWdlchIHbWluaW11bQIAEgdtYXhpbXVtAmQSC2Rlc2NyaXB0aW9uEiVTcGxpdCBlcXVhbGx5IGFtb25nIHRoZSBvdGhlciBtZW1iZXJzEghwb3NpdGlvbgIBEgdhY3Rpb25zFgUSBHR5cGUSB2ludGVnZXISB21pbmltdW0CABIHbWF4aW11bQJkEgtkZXNjcmlwdGlvbhIVU3BsaXQgYnkgYWN0aW9uIGNvdW50Eghwb3NpdGlvbgICEghyZXF1aXJlZBUDEgZsZWFkZXISBWVxdWFsEgdhY3Rpb25zEhRhZGRpdGlvbmFsUHJvcGVydGllcxMAEgtkZXNjcmlwdGlvbhIaUGVyY2VudGFnZXMgc3VtbWluZyB0byAxMDASCHBvc2l0aW9uAgQSCHJlcXVpcmVkFQUSCiRjcmVhdGVkQXQSEHRhcmdldENvbnRyYWN0SWQSC2Rlc2NyaXB0aW9uEgdyZWFzb25zEgtyZXdhcmRTcGxpdBITcHJvcGVydHlDb25zdHJhaW50cxYBEhJyZXdhcmRTcGxpdElzV2hvbGUWARIFZXF1YWwVAhYBEgNhZGQVAxIScmV3YXJkU3BsaXQubGVhZGVyEhFyZXdhcmRTcGxpdC5lcXVhbBITcmV3YXJkU3BsaXQuYWN0aW9ucwJkEhRhZGRpdGlvbmFsUHJvcGVydGllcxMAEgtkZXNjcmlwdGlvbhIhQSBwcm9wb3NhbCB0byBtb2RlcmF0ZSBhIGNvbnRyYWN0AAAAAAAAAAAAAAcOYWRkZWRNb2RlcmF0b3IAAxBlbGVjdGVkQ2hhcnRlcklkEwMADmVsZWN0ZWRDaGFydGVyAggkb3duZXJJZAgkb3duZXJJZBJzdWJtaXR0ZWRDaGFydGVySWQSc3VibWl0dGVkQ2hhcnRlcklkAAEAAAAAAAASc3VibWl0dGVkQ2hhcnRlcklkDQEAAAAAAAAIbWVtYmVySWQTBgALam9pblJlcXVlc3QAAAIIJG93bmVySWQAEnN1Ym1pdHRlZENoYXJ0ZXJJZAISc3VibWl0dGVkQ2hhcnRlcklkAAAAAQAAAQAAAAADEGVsZWN0ZWRDaGFydGVySWQTAwAOZWxlY3RlZENoYXJ0ZXICCCRvd25lcklkCCRvd25lcklkEnN1Ym1pdHRlZENoYXJ0ZXJJZBJzdWJtaXR0ZWRDaGFydGVySWQAAQAAAAAAABJzdWJtaXR0ZWRDaGFydGVySWQNAQAAAAAAAAhtZW1iZXJJZBMGAAtqb2luUmVxdWVzdAAAAggkb3duZXJJZAASc3VibWl0dGVkQ2hhcnRlcklkAhJzdWJtaXR0ZWRDaGFydGVySWQAAAABAAABAAAAAAEWYnlFbGVjdGVkQ2hhcnRlck1lbWJlchZieUVsZWN0ZWRDaGFydGVyTWVtYmVyAhBlbGVjdGVkQ2hhcnRlcklkAQhtZW1iZXJJZAEBAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAMQZWxlY3RlZENoYXJ0ZXJJZAhtZW1iZXJJZBJzdWJtaXR0ZWRDaGFydGVySWQABAokY3JlYXRlZEF0EGVsZWN0ZWRDaGFydGVySWQIbWVtYmVySWQSc3VibWl0dGVkQ2hhcnRlcklkAAAAAAAAAAAAAAEAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAqQABAAIAAAEBAxBlbGVjdGVkQ2hhcnRlcklkAAEAAwAAAAEAAAEgASAAAQAAABJzdWJtaXR0ZWRDaGFydGVySWQAAQADAAAAAQAAASABIAABAAAACG1lbWJlcklkAAEAAwAAAAEAAAEgASAAAQAAAAEAAQMQZWxlY3RlZENoYXJ0ZXJJZBJzdWJtaXR0ZWRDaGFydGVySWQIbWVtYmVySWQAAAAAAAAAAAAOZWxlY3RlZENoYXJ0ZXIAAxB0YXJnZXRDb250cmFjdElkEwEBAQAAAAAAAAEAAAAAAAASc3VibWl0dGVkQ2hhcnRlcklkEwMAEHN1Ym1pdHRlZENoYXJ0ZXICCCRvd25lcklkCCRvd25lcklkEHRhcmdldENvbnRyYWN0SWQQdGFyZ2V0Q29udHJhY3RJZAABAAAAAAAAB21lbWJlcnMUEwYAC2pvaW5SZXF1ZXN0AAACCCRvd25lcklkABJzdWJtaXR0ZWRDaGFydGVySWQCEnN1Ym1pdHRlZENoYXJ0ZXJJZAAAAAAAAAEADwEBAAABAAAAAAMQdGFyZ2V0Q29udHJhY3RJZBMBAQEAAAAAAAABAAAAAAAAEnN1Ym1pdHRlZENoYXJ0ZXJJZBMDABBzdWJtaXR0ZWRDaGFydGVyAggkb3duZXJJZAgkb3duZXJJZBB0YXJnZXRDb250cmFjdElkEHRhcmdldENvbnRyYWN0SWQAAQAAAAAAAAdtZW1iZXJzFBMGAAtqb2luUmVxdWVzdAAAAggkb3duZXJJZAASc3VibWl0dGVkQ2hhcnRlcklkAhJzdWJtaXR0ZWRDaGFydGVySWQAAAAAAAABAA8BAQAAAQAAAAACEmJ5U3VibWl0dGVkQ2hhcnRlchJieVN1Ym1pdHRlZENoYXJ0ZXIBEnN1Ym1pdHRlZENoYXJ0ZXJJZAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAEGJ5VGFyZ2V0Q29udHJhY3QQYnlUYXJnZXRDb250cmFjdAEQdGFyZ2V0Q29udHJhY3RJZAEBAQEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAwltZW1iZXJzW10Sc3VibWl0dGVkQ2hhcnRlcklkEHRhcmdldENvbnRyYWN0SWQABAokY3JlYXRlZEF0B21lbWJlcnMSc3VibWl0dGVkQ2hhcnRlcklkEHRhcmdldENvbnRyYWN0SWQAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgC7AAEAAgAAAQEDEHRhcmdldENvbnRyYWN0SWQAAQADAAAAAQAAASABIAABAAAAEnN1Ym1pdHRlZENoYXJ0ZXJJZAABAAMAAAABAAABIAEgAAEAAAAHbWVtYmVycwABAAMAAAABAQABAAMAAAABAAABIAEgAAEAAAAAAQABDwEBAAAAAAEAAQMQdGFyZ2V0Q29udHJhY3RJZBJzdWJtaXR0ZWRDaGFydGVySWQHbWVtYmVycwAAAAAAAAAAAAtqb2luUmVxdWVzdAAFEnN1Ym1pdHRlZENoYXJ0ZXJJZBMDABBzdWJtaXR0ZWRDaGFydGVyAQtyZWNpcGllbnRJZAgkb3duZXJJZAABAAAAAAAAC3JlY2lwaWVudElkEwQOcmVjaXBpZW50S2V5SWQBAgEQc3VibWl0dGVkQ2hhcnRlcgEAAAAAAAAOcmVjaXBpZW50S2V5SWQEAQAAAAAAAAtzZW5kZXJLZXlJZBUAAQEBC2pvaW5SZXF1ZXN0AQAAAAAAABBlbmNyeXB0ZWRNZXNzYWdlDAEgAfsEEAEAAAABAQtyZWNpcGllbnRJZA5yZWNpcGllbnRLZXlJZAtzZW5kZXJLZXlJZAAAAAUSc3VibWl0dGVkQ2hhcnRlcklkEwMAEHN1Ym1pdHRlZENoYXJ0ZXIBC3JlY2lwaWVudElkCCRvd25lcklkAAEAAAAAAAALcmVjaXBpZW50SWQTBA5yZWNpcGllbnRLZXlJZAECARBzdWJtaXR0ZWRDaGFydGVyAQAAAAAAAA5yZWNpcGllbnRLZXlJZAQBAAAAAAAAC3NlbmRlcktleUlkFQABAQELam9pblJlcXVlc3QBAAAAAAAAEGVuY3J5cHRlZE1lc3NhZ2UMASAB+wQQAQAAAAEBC3JlY2lwaWVudElkDnJlY2lwaWVudEtleUlkC3NlbmRlcktleUlkAAAAAgdieU93bmVyB2J5T3duZXIBCCRvd25lcklkAQABAAAAAAAAAAAAAAAAAAAAAAAAAAASYnlTdWJtaXR0ZWRDaGFydGVyEmJ5U3VibWl0dGVkQ2hhcnRlcgISc3VibWl0dGVkQ2hhcnRlcklkAQgkb3duZXJJZAEBAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAILcmVjaXBpZW50SWQSc3VibWl0dGVkQ2hhcnRlcklkARBlbmNyeXB0ZWRNZXNzYWdlBgokY3JlYXRlZEF0EGVuY3J5cHRlZE1lc3NhZ2ULcmVjaXBpZW50SWQOcmVjaXBpZW50S2V5SWQLc2VuZGVyS2V5SWQSc3VibWl0dGVkQ2hhcnRlcklkAAAAAAAAAAAAAAAAAAAAAQEAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPsBQgABAAIAAAEBBRJzdWJtaXR0ZWRDaGFydGVySWQAAQADAAAAAQAAASABIAABAAAAC3JlY2lwaWVudElkAAEAAwAAAAEAAAEgASAAAQAAAA5yZWNpcGllbnRLZXlJZAABAAYAAAAAAAEBAgABAvz/////AAAAAAtzZW5kZXJLZXlJZAABAAYAAAAAAAEBAgABAvz/////AAAAABBlbmNyeXB0ZWRNZXNzYWdlAAEAAwAAAAEAAAEgAfsEEAABAAABAAEBC3JlY2lwaWVudElkDnJlY2lwaWVudEtleUlkC3NlbmRlcktleUlkAAEAAQUSc3VibWl0dGVkQ2hhcnRlcklkC3JlY2lwaWVudElkDnJlY2lwaWVudEtleUlkC3NlbmRlcktleUlkEGVuY3J5cHRlZE1lc3NhZ2UAAAAAAAAAAAAGcmVhc29uAAMEY29kZQsBAwEDAAEAAAAAAAAFbGFiZWwLAQEBQAABAAAAAAAAC2Rlc2NyaXB0aW9uCwEBAfsEAAAAAAAAAAAAAwRjb2RlCwEDAQMAAQAAAAAAAAVsYWJlbAsBAQFAAAEAAAAAAAALZGVzY3JpcHRpb24LAQEB+wQAAAAAAAAAAAABC2J5T3duZXJDb2RlC2J5T3duZXJDb2RlAggkb3duZXJJZAEEY29kZQEBAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwokY3JlYXRlZEF0BGNvZGUFbGFiZWwAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgB4AAEAAgAAAQEDBGNvZGUAAQAFAAAAAAEBAwEDAAEKXltBLVpdezN9JAAABWxhYmVsAAEABQAAAAABAQEBQAAAAAALZGVzY3JpcHRpb24AAQAFAAAAAAEBAQH7BAAAAAAAAQABAgRjb2RlBWxhYmVsAAAAAAAAAAAAEHJlbW92ZWRNb2RlcmF0b3IAAhBlbGVjdGVkQ2hhcnRlcklkEwMADmVsZWN0ZWRDaGFydGVyAQgkb3duZXJJZAgkb3duZXJJZAABAAAAAAAACG1lbWJlcklkEwkADmVsZWN0ZWRDaGFydGVyARBlbGVjdGVkQ2hhcnRlcklkAyRpZAAHbWVtYmVycwEAAAEAAAAAAhBlbGVjdGVkQ2hhcnRlcklkEwMADmVsZWN0ZWRDaGFydGVyAQgkb3duZXJJZAgkb3duZXJJZAABAAAAAAAACG1lbWJlcklkEwkADmVsZWN0ZWRDaGFydGVyARBlbGVjdGVkQ2hhcnRlcklkAyRpZAAHbWVtYmVycwEAAAEAAAAAARZieUVsZWN0ZWRDaGFydGVyTWVtYmVyFmJ5RWxlY3RlZENoYXJ0ZXJNZW1iZXICEGVsZWN0ZWRDaGFydGVySWQBCG1lbWJlcklkAQEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAhBlbGVjdGVkQ2hhcnRlcklkCG1lbWJlcklkAAMKJGNyZWF0ZWRBdBBlbGVjdGVkQ2hhcnRlcklkCG1lbWJlcklkAAAAAAAAAAAAAAEAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAcAABAAIAAAEBAhBlbGVjdGVkQ2hhcnRlcklkAAEAAwAAAAEAAAEgASAAAQAAAAhtZW1iZXJJZAABAAMAAAABAAABIAEgAAEAAAABAAECEGVsZWN0ZWRDaGFydGVySWQIbWVtYmVySWQAAAAAAAAAAAAScmVzaWduYXRpb25SZXF1ZXN0AAUQZWxlY3RlZENoYXJ0ZXJJZBMDAA5lbGVjdGVkQ2hhcnRlcgELcmVjaXBpZW50SWQIJG93bmVySWQAAQAAAAAAAAtyZWNpcGllbnRJZBMEDnJlY2lwaWVudEtleUlkAQIBEHN1Ym1pdHRlZENoYXJ0ZXIBAAAAAAAADnJlY2lwaWVudEtleUlkBAEAAAAAAAALc2VuZGVyS2V5SWQVAAEBAQtqb2luUmVxdWVzdAEAAAAAAAAQZW5jcnlwdGVkTWVzc2FnZQwBIAH7BBABAAAAAQELcmVjaXBpZW50SWQOcmVjaXBpZW50S2V5SWQLc2VuZGVyS2V5SWQAAAAFEGVsZWN0ZWRDaGFydGVySWQTAwAOZWxlY3RlZENoYXJ0ZXIBC3JlY2lwaWVudElkCCRvd25lcklkAAEAAAAAAAALcmVjaXBpZW50SWQTBA5yZWNpcGllbnRLZXlJZAECARBzdWJtaXR0ZWRDaGFydGVyAQAAAAAAAA5yZWNpcGllbnRLZXlJZAQBAAAAAAAAC3NlbmRlcktleUlkFQABAQELam9pblJlcXVlc3QBAAAAAAAAEGVuY3J5cHRlZE1lc3NhZ2UMASAB+wQQAQAAAAEBC3JlY2lwaWVudElkDnJlY2lwaWVudEtleUlkC3NlbmRlcktleUlkAAAAARVieUVsZWN0ZWRDaGFydGVyT3duZXIVYnlFbGVjdGVkQ2hhcnRlck93bmVyAhBlbGVjdGVkQ2hhcnRlcklkAQgkb3duZXJJZAEBAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAIQZWxlY3RlZENoYXJ0ZXJJZAtyZWNpcGllbnRJZAEQZW5jcnlwdGVkTWVzc2FnZQYKJGNyZWF0ZWRBdBBlbGVjdGVkQ2hhcnRlcklkEGVuY3J5cHRlZE1lc3NhZ2ULcmVjaXBpZW50SWQOcmVjaXBpZW50S2V5SWQLc2VuZGVyS2V5SWQAAAAAAAAAAAAAAQAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAQcCCQAOZWxlY3RlZENoYXJ0ZXIBEGVsZWN0ZWRDaGFydGVySWQDJGlkAAdtZW1iZXJzCgAOYWRkZWRNb2RlcmF0b3IAAAIQZWxlY3RlZENoYXJ0ZXJJZAIQZWxlY3RlZENoYXJ0ZXJJZAhtZW1iZXJJZAAAAAAAAAAAAAIA+wE+AAEAAgAAAQEFEGVsZWN0ZWRDaGFydGVySWQAAQADAAAAAQAAASABIAABAAAAC3JlY2lwaWVudElkAAEAAwAAAAEAAAEgASAAAQAAAA5yZWNpcGllbnRLZXlJZAABAAYAAAAAAAEBAgABAvz/////AAAAAAtzZW5kZXJLZXlJZAABAAYAAAAAAAEBAgABAvz/////AAAAABBlbmNyeXB0ZWRNZXNzYWdlAAEAAwAAAAEAAAEgAfsEEAABAAABAAEBC3JlY2lwaWVudElkDnJlY2lwaWVudEtleUlkC3NlbmRlcktleUlkAAEAAQUQZWxlY3RlZENoYXJ0ZXJJZAtyZWNpcGllbnRJZA5yZWNpcGllbnRLZXlJZAtzZW5kZXJLZXlJZBBlbmNyeXB0ZWRNZXNzYWdlAAAAAAAAAAAAEHN1Ym1pdHRlZENoYXJ0ZXIABRB0YXJnZXRDb250cmFjdElkEwEBAAAAAAAAAAEAAAAAAAALZGVzY3JpcHRpb24LAQEB+xAAAfsQAAEAAAAAAAAHcmVhc29ucxQTAwAGcmVhc29uAAAAAAABAEABAQAAAAAAAA9tb2RlcmF0b3JzU2hhcmUIAAAAAAAAAAtyZXdhcmRTcGxpdBADBmxlYWRlcggBAAAAAAAABWVxdWFsCAEAAAAAAAAHYWN0aW9ucwgBAAAAAAAAAQAAAAAAAAcQdGFyZ2V0Q29udHJhY3RJZBMBAQAAAAAAAAABAAAAAAAAC2Rlc2NyaXB0aW9uCwEBAfsQAAH7EAABAAAAAAAAB3JlYXNvbnMUEwMABnJlYXNvbgAAAAAAAQBAAQEAAAAAAAAPbW9kZXJhdG9yc1NoYXJlCAAAAAAAAAATcmV3YXJkU3BsaXQuYWN0aW9ucwgBAAAAAAAAEXJld2FyZFNwbGl0LmVxdWFsCAEAAAAAAAAScmV3YXJkU3BsaXQubGVhZGVyCAEAAAAAAAACB2J5T3duZXIHYnlPd25lcgEIJG93bmVySWQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAABBieVRhcmdldENvbnRyYWN0EGJ5VGFyZ2V0Q29udHJhY3QCEHRhcmdldENvbnRyYWN0SWQBCiRjcmVhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAACCXJlYXNvbnNbXRB0YXJnZXRDb250cmFjdElkAAgKJGNyZWF0ZWRBdAtkZXNjcmlwdGlvbgdyZWFzb25zC3Jld2FyZFNwbGl0E3Jld2FyZFNwbGl0LmFjdGlvbnMRcmV3YXJkU3BsaXQuZXF1YWwScmV3YXJkU3BsaXQubGVhZGVyEHRhcmdldENvbnRyYWN0SWQAAAAAAAAAAAAAAAAAAAAAAQECAAAAAAAAAAAAAAAAAAAAAAAAAAAAARJyZXdhcmRTcGxpdElzV2hvbGUAAAYDARJyZXdhcmRTcGxpdC5sZWFkZXIAARFyZXdhcmRTcGxpdC5lcXVhbAABE3Jld2FyZFNwbGl0LmFjdGlvbnMAAMgAAAACAPsBZgABAAIAAAEBBRB0YXJnZXRDb250cmFjdElkAAEAAwAAAAEAAAEgASAAAQAAAAtkZXNjcmlwdGlvbgABAAUAAAAAAQEBAfsQAAH7EAAAAAAHcmVhc29ucwABAAMAAAABAQABAAMAAAABAAABIAEgAAEAAAAAAQABQAEBAAAAAA9tb2RlcmF0b3JzU2hhcmUAAQAGAAAAAAABAQIAAQJkAAAAAAtyZXdhcmRTcGxpdAABAAIAAAEBAwZsZWFkZXIAAQAGAAAAAAABAQIAAQJkAAAAAAVlcXVhbAABAAYAAAAAAAEBAgABAmQAAAAAB2FjdGlvbnMAAQAGAAAAAAABAQIAAQJkAAAAAAEAAQMGbGVhZGVyBWVxdWFsB2FjdGlvbnMAAAAAAAAAAQABBBB0YXJnZXRDb250cmFjdElkC2Rlc2NyaXB0aW9uB3JlYXNvbnMLcmV3YXJkU3BsaXQAAAAAAAAAAAA="} -{"contract":"system DecisionProposals","sha256":"93a9cc9e6225997be16b2a76b638132d6df21532812fb6de5b3b664f34b31774","indexStructures":"0b3ce0e20cde7b45a89351adea5ee84da634ab0b36268c00891c7917a5969bc8","bytes":"AihRCgKmaz7aBrhvyubaqp21d2bTw3JcyFpYzR6uMcvIAgAAAAABAQAAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZjaG9pY2UWCRIEdHlwZRIGb2JqZWN0EhBkb2N1bWVudHNNdXRhYmxlEwASDGNhbkJlRGVsZXRlZBMAEgdpbmRpY2VzFQEWBBIEbmFtZRIKYnlQcm9wb3NhbBIKcHJvcGVydGllcxUBFgESCnByb3Bvc2FsSWQSA2FzYxIGdW5pcXVlEwESCWNvbnRlc3RlZBYCEgpyZXNvbHV0aW9uAgISC2Rlc2NyaXB0aW9uEn1UaGUgY2hvaWNlcyBlbnRlcmVkIGZvciBvbmUgbXVsdGlwbGUgY2hvaWNlIHByb3Bvc2FsIGNvbnRlbmQgaW4gYSBtYXN0ZXJub2RlIHZvdGUsIGVhY2ggb24gaXRzIG93bjsgYSBsb2NrIG1lYW5zIG5vbmUgb2YgdGhlbRIKcHJvcGVydGllcxYDEgpwcm9wb3NhbElkFggSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIIbWluSXRlbXMCIBIIbWF4SXRlbXMCIBIQY29udGVudE1lZGlhVHlwZRIhYXBwbGljYXRpb24veC5kYXNoLmRwcC5pZGVudGlmaWVyEghyZWZlcnNUbxYCEgR0eXBlEhFwZXJtYW5lbnREb2N1bWVudBIMZG9jdW1lbnRUeXBlEhZtdWx0aXBsZUNob2ljZVByb3Bvc2FsEgtkZXNjcmlwdGlvbhIxVGhlIG11bHRpcGxlIGNob2ljZSBwcm9wb3NhbCB0aGlzIGlzIGFuIGFuc3dlciB0bxIIcG9zaXRpb24CABIFdGl0bGUWBhIEdHlwZRIGc3RyaW5nEgltaW5MZW5ndGgCARIJbWF4TGVuZ3RoAoASCG1heEJ5dGVzAvsCABILZGVzY3JpcHRpb24SF1RoZSBhbnN3ZXIsIGluIG9uZSBsaW5lEghwb3NpdGlvbgIBEgtkZXNjcmlwdGlvbhYGEgR0eXBlEgZzdHJpbmcSCW1pbkxlbmd0aAIBEgltYXhMZW5ndGgC+xAAEghtYXhCeXRlcwL7EAASC2Rlc2NyaXB0aW9uEiFXaGF0IHZvdGluZyBmb3IgdGhpcyBhbnN3ZXIgbWVhbnMSCHBvc2l0aW9uAgISCHJlcXVpcmVkFQMSCiRjcmVhdGVkQXQSCnByb3Bvc2FsSWQSBXRpdGxlEhNwcm9wZXJ0eUNvbnN0cmFpbnRzFgESI2Nsb3NlZFByb3Bvc2FsVGFrZXNJdHNPd25lcnNDaG9pY2VzFgESBmlmVGhlbhUCFgESBWVxdWFsFQIWARIKcmVmZXJlbmNlZBUCEgpwcm9wb3NhbElkEgRvcGVuAgAWARIFZXF1YWwVAhYBEgpyZWZlcmVuY2VkFQISCnByb3Bvc2FsSWQSCCRvd25lcklkEggkb3duZXJJZBIUYWRkaXRpb25hbFByb3BlcnRpZXMTABILZGVzY3JpcHRpb24SxEFuIGFuc3dlciB0byBhIG11bHRpcGxlIGNob2ljZSBwcm9wb3NhbC4gRW50ZXJpbmcgb25lIG9wZW5zIHRoZSBtYXN0ZXJub2RlIHZvdGUgYW1vbmcgdGhlIHByb3Bvc2FsJ3MgY2hvaWNlcywgb3Igam9pbnMgaXQ7IGFuIGlkZW50aXR5IG1heSBlbnRlciBzZXZlcmFsLCBhbmQgdGhlIGNob2ljZSB3aXRoIHRoZSBtb3N0IHZvdGVzIGlzIGtlcHQWbXVsdGlwbGVDaG9pY2VQcm9wb3NhbBYKEgR0eXBlEgZvYmplY3QSEGRvY3VtZW50c011dGFibGUTARIUZG9jdW1lbnRzS2VlcEhpc3RvcnkTARIMY2FuQmVEZWxldGVkEwASB2luZGljZXMVAhYCEgRuYW1lEgdieU93bmVyEgpwcm9wZXJ0aWVzFQIWARIIJG93bmVySWQSA2FzYxYBEgokY3JlYXRlZEF0EgNhc2MWAhIEbmFtZRILYnlDcmVhdGVkQXQSCnByb3BlcnRpZXMVARYBEgokY3JlYXRlZEF0EgNhc2MSCnByb3BlcnRpZXMWBhIFdGl0bGUWBhIEdHlwZRIGc3RyaW5nEgltaW5MZW5ndGgCARIJbWF4TGVuZ3RoAoASCG1heEJ5dGVzAvsCABILZGVzY3JpcHRpb24SGVRoZSBxdWVzdGlvbiwgaW4gb25lIGxpbmUSCHBvc2l0aW9uAgASDWRlc2NyaXB0aW9uSWQWCBIEdHlwZRIFYXJyYXkSCWJ5dGVBcnJheRMBEghtaW5JdGVtcwIgEghtYXhJdGVtcwIgEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISCHJlZmVyc1RvFgQSBHR5cGUSEW1vZGVyYXRlZERvY3VtZW50Egpjb250cmFjdElkEhskc2VsZi5kZXNjcmlwdGlvbkNvbnRyYWN0SWQSDGRvY3VtZW50VHlwZRIdJHNlbGYuZGVzY3JpcHRpb25Eb2N1bWVudFR5cGUSGGRvY3VtZW50VHlwZVJlcXVpcmVtZW50cxYDEgR3aGVuFgESBWFsbE9mFQIWARIIY29udGFpbnMVAhIRJGVsZWN0ZWRBYmlsaXRpZXMWARIFY29uc3QSD2RlbGV0ZURvY3VtZW50cxYBEgVhbnlPZhUCFgESBmFic2VudBIgJG1vZGVyYXRvckFiaWxpdGllcy5kZWxldGVXaXRoaW4WARIHcHJlc2VudBIhJG1vZGVyYXRvckFiaWxpdGllcy5kZWxldGVTZXR0bGVkEgpwcm9wZXJ0aWVzFgISBGJvZHkWAxIEbmFtZRIEJGFueRIEdHlwZRIGc3RyaW5nEgR3aGVuFgESBWFsbE9mFQIWARIDbm90FgESCGNvbnRhaW5zFQISJSRtb2RlcmF0b3JBYmlsaXRpZXMuZGVsZXRlS2VlcHNGaWVsZHMSCSRwcm9wZXJ0eRYBEgNub3QWARIIY29udGFpbnMVAhIgJG1vZGVyYXRvckFiaWxpdGllcy5jaGFuZ2VGaWVsZHMSCSRwcm9wZXJ0eRIJYm9keUJ5dGVzFgQSBG5hbWUSBCRhbnkSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIEd2hlbhYBEgVhbGxPZhUCFgESA25vdBYBEghjb250YWlucxUCEiUkbW9kZXJhdG9yQWJpbGl0aWVzLmRlbGV0ZUtlZXBzRmllbGRzEgkkcHJvcGVydHkWARIDbm90FgESCGNvbnRhaW5zFQISICRtb2RlcmF0b3JBYmlsaXRpZXMuY2hhbmdlRmllbGRzEgkkcHJvcGVydHkSB3JlcXVpcmUWARIFYW55T2YVAhIEYm9keRIJYm9keUJ5dGVzEgtkZXNjcmlwdGlvbhLUVGhlIGRvY3VtZW50IHNheWluZyB3aGF0IGlzIGJlaW5nIGRlY2lkZWQ6IGEgZG9jdW1lbnQgb2YgYSBtb2RlcmF0ZWQgdHlwZSBpdHMgY29udHJhY3QncyBlbGVjdGVkIHRlYW0gbWF5IGRlbGV0ZSwgYXQgYW55IHRpbWUgYWZ0ZXIgaXQgaXMgd3JpdHRlbiwgaG9sZGluZyB0ZXh0IGl0cyBhdXRob3Igd3JvdGUgdGhhdCB0aGUgcmVtb3ZhbCB0YWtlcyBhd2F5IHdpdGggaXQSCHBvc2l0aW9uAgESFWRlc2NyaXB0aW9uQ29udHJhY3RJZBYIEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAiASCG1heEl0ZW1zAiASEGNvbnRlbnRNZWRpYVR5cGUSIWFwcGxpY2F0aW9uL3guZGFzaC5kcHAuaWRlbnRpZmllchIIcmVmZXJzVG8WAhIEdHlwZRIIY29udHJhY3QSFGNvbnRyYWN0UmVxdWlyZW1lbnRzFgISCm1vZGVyYXRpb24SB2VsZWN0ZWQSDm93bmVyUHJvdGVjdGVkEwASC2Rlc2NyaXB0aW9uEohUaGUgY29udHJhY3QgdGhlIGRlc2NyaXB0aW9uIGRvY3VtZW50IGxpdmVzIGluLCBvbmUgd2hvc2UgbW9kZXJhdG9ycyB0aGUgbWFzdGVybm9kZXMgZWxlY3QgYW5kIHdob3NlIG93bmVyIHRoZXkgbWF5IG1vZGVyYXRlIGxpa2UgYW55b25lEghwb3NpdGlvbgICEhdkZXNjcmlwdGlvbkRvY3VtZW50VHlwZRYFEgR0eXBlEgZzdHJpbmcSCW1pbkxlbmd0aAIBEgltYXhMZW5ndGgCQBILZGVzY3JpcHRpb24SLVRoZSBkb2N1bWVudCB0eXBlIG9mIHRoZSBkZXNjcmlwdGlvbiBkb2N1bWVudBIIcG9zaXRpb24CAxIDdXJsFgcSBHR5cGUSBnN0cmluZxIGZm9ybWF0EgN1cmkSCW1pbkxlbmd0aAIBEgltYXhMZW5ndGgC+wIAEghtYXhCeXRlcwL7AgASC2Rlc2NyaXB0aW9uEh9XaGVyZSB0aGUgcHJvcG9zYWwgaXMgZGlzY3Vzc2VkEghwb3NpdGlvbgIEEgRvcGVuFgMSBHR5cGUSB2Jvb2xlYW4SC2Rlc2NyaXB0aW9uElBXaGV0aGVyIGFueW9uZSBtYXkgZW50ZXIgY2hvaWNlczsgb25seSB0aGUgcHJvcG9zYWwncyBvd25lciBtYXkgd2hlbiBpdCBpcyBmYWxzZRIIcG9zaXRpb24CBRIIcmVxdWlyZWQVBxIKJGNyZWF0ZWRBdBIKJHVwZGF0ZWRBdBIFdGl0bGUSDWRlc2NyaXB0aW9uSWQSFWRlc2NyaXB0aW9uQ29udHJhY3RJZBIXZGVzY3JpcHRpb25Eb2N1bWVudFR5cGUSBG9wZW4SCWltbXV0YWJsZRUGEgRvcGVuFgISCHByb3BlcnR5EgV0aXRsZRIEd2hlbhYBEgtncmVhdGVyVGhhbhUCFgESCHN1YnRyYWN0FQISCiR1cGRhdGVkQXQSCiRjcmVhdGVkQXQC/ABt3QAWAhIIcHJvcGVydHkSDWRlc2NyaXB0aW9uSWQSBHdoZW4WARILZ3JlYXRlclRoYW4VAhYBEghzdWJ0cmFjdBUCEgokdXBkYXRlZEF0EgokY3JlYXRlZEF0AvwAbd0AFgISCHByb3BlcnR5EhVkZXNjcmlwdGlvbkNvbnRyYWN0SWQSBHdoZW4WARILZ3JlYXRlclRoYW4VAhYBEghzdWJ0cmFjdBUCEgokdXBkYXRlZEF0EgokY3JlYXRlZEF0AvwAbd0AFgISCHByb3BlcnR5EhdkZXNjcmlwdGlvbkRvY3VtZW50VHlwZRIEd2hlbhYBEgtncmVhdGVyVGhhbhUCFgESCHN1YnRyYWN0FQISCiR1cGRhdGVkQXQSCiRjcmVhdGVkQXQC/ABt3QAWAhIIcHJvcGVydHkSA3VybBIEd2hlbhYBEgtncmVhdGVyVGhhbhUCFgESCHN1YnRyYWN0FQISCiR1cGRhdGVkQXQSCiRjcmVhdGVkQXQC/ABt3QASFGFkZGl0aW9uYWxQcm9wZXJ0aWVzEwASC2Rlc2NyaXB0aW9uEvsB5UEgcXVlc3Rpb24gcHV0IHRvIHRoZSBtYXN0ZXJub2RlcyB3aG9zZSBhbnN3ZXJzIGFyZSB0aGUgY2hvaWNlcyBlbnRlcmVkIGZvciBpdC4gV2hhdCBpcyBiZWluZyBkZWNpZGVkIGlzIHNhaWQgYnkgYSBkb2N1bWVudCB0aGUgcHJvcG9zYWwgcG9pbnRzIGF0LCBvZiBhIG1vZGVyYXRlZCB0eXBlIG9mIGEgY29udHJhY3Qgd2hvc2UgbW9kZXJhdG9ycyB0aGUgbWFzdGVybm9kZXMgZWxlY3QsIHNvIHRoYXQgdGhleSBjYW4gdGFrZSBpdCBkb3duLiBBbnlvbmUgbWF5IGVudGVyIGNob2ljZXMgd2hlbiBpdCBpcyBvcGVuLCBhbmQgb25seSBpdHMgb3duZXIgd2hlbiBpdCBpcyBub3QuIEl0cyB0aXRsZSwgdXJsIGFuZCB0aGUgZGVzY3JpcHRpb24gaXQgcG9pbnRzIGF0IG1heSBiZSBjaGFuZ2VkIGZvciB0d28gaG91cnMgYWZ0ZXIgaXQgaXMgY3JlYXRlZDsgd2hldGhlciBpdCBpcyBvcGVuIG5ldmVyIGNoYW5nZXMsIGFuZCBpdCBpcyBuZXZlciBkZWxldGVkCHByb3Bvc2FsFgoSBHR5cGUSBm9iamVjdBIQZG9jdW1lbnRzTXV0YWJsZRMBEhRkb2N1bWVudHNLZWVwSGlzdG9yeRMBEgxjYW5CZURlbGV0ZWQTABIHaW5kaWNlcxUDFgISBG5hbWUSCWJ5RW5kRGF0ZRIKcHJvcGVydGllcxUCFgESB2VuZERhdGUSA2FzYxYBEgokY3JlYXRlZEF0EgNhc2MWAhIEbmFtZRIHYnlPd25lchIKcHJvcGVydGllcxUCFgESCCRvd25lcklkEgNhc2MWARIKJGNyZWF0ZWRBdBIDYXNjFgISBG5hbWUSC2J5Q3JlYXRlZEF0Egpwcm9wZXJ0aWVzFQEWARIKJGNyZWF0ZWRBdBIDYXNjEgpwcm9wZXJ0aWVzFgYSBXRpdGxlFgYSBHR5cGUSBnN0cmluZxIJbWluTGVuZ3RoAgESCW1heExlbmd0aAKAEghtYXhCeXRlcwL7AgASC2Rlc2NyaXB0aW9uEhlUaGUgcXVlc3Rpb24sIGluIG9uZSBsaW5lEghwb3NpdGlvbgIAEgtkZXNjcmlwdGlvbhYGEgR0eXBlEgZzdHJpbmcSCW1pbkxlbmd0aAIBEgltYXhMZW5ndGgC+xAAEghtYXhCeXRlcwL7EAASC2Rlc2NyaXB0aW9uEhhXaGF0IGEgeWVzIGFuZCBhIG5vIG1lYW4SCHBvc2l0aW9uAgESA3VybBYHEgR0eXBlEgZzdHJpbmcSBmZvcm1hdBIDdXJpEgltaW5MZW5ndGgCARIJbWF4TGVuZ3RoAvsCABIIbWF4Qnl0ZXMC+wIAEgtkZXNjcmlwdGlvbhIfV2hlcmUgdGhlIHByb3Bvc2FsIGlzIGRpc2N1c3NlZBIIcG9zaXRpb24CAhILcGFzc1BlcmNlbnQWBRIEdHlwZRIHaW50ZWdlchIHbWluaW11bQIyEgdtYXhpbXVtAlASC2Rlc2NyaXB0aW9uEnBUaGUgcHJvcG9zYWwgcGFzc2VzIHdoZW4gdGhlIHllcyB2b3RpbmcgcG93ZXIgaXMgc3RyaWN0bHkgbW9yZSB0aGFuIHRoaXMgcGVyY2VudCBvZiB0aGUgeWVzIHBsdXMgbm8gdm90aW5nIHBvd2VyEghwb3NpdGlvbgIDEhVtaW5pbXVtVHVybm91dFBlcmNlbnQWBRIEdHlwZRIHaW50ZWdlchIHbWluaW11bQIAEgdtYXhpbXVtAjISC2Rlc2NyaXB0aW9uEopUaGUgbGVhc3QgeWVzIHBsdXMgbm8gdm90aW5nIHBvd2VyIHRoZSBwcm9wb3NhbCBuZWVkcywgYXMgYSBwZXJjZW50IG9mIHRoZSBtYXN0ZXJub2RlIGxpc3QncyB0b3RhbCB2b3RpbmcgcG93ZXIgd2hlbiBpdCBjbG9zZXMsIHJvdW5kZWQgdXASCHBvc2l0aW9uAgQSB2VuZERhdGUWBBIEdHlwZRIHaW50ZWdlchIHbWluaW11bQIAEgtkZXNjcmlwdGlvbhI4V2hlbiB2b3RpbmcgY2xvc2VzLCBpbiBtaWxsaXNlY29uZHMgc2luY2UgdGhlIFVuaXggZXBvY2gSCHBvc2l0aW9uAgUSCHJlcXVpcmVkFQcSCiRjcmVhdGVkQXQSCiR1cGRhdGVkQXQSBXRpdGxlEgtkZXNjcmlwdGlvbhILcGFzc1BlcmNlbnQSFW1pbmltdW1UdXJub3V0UGVyY2VudBIHZW5kRGF0ZRIJaW1tdXRhYmxlFQYSC3Bhc3NQZXJjZW50EhVtaW5pbXVtVHVybm91dFBlcmNlbnQSB2VuZERhdGUWAhIIcHJvcGVydHkSBXRpdGxlEgR3aGVuFgESC2dyZWF0ZXJUaGFuFQIWARIIc3VidHJhY3QVAhIKJHVwZGF0ZWRBdBIKJGNyZWF0ZWRBdBYBEgNtaW4VAgL8BSZcABYBEgZkaXZpZGUVAhYBEghzdWJ0cmFjdBUCEgdlbmREYXRlEgokY3JlYXRlZEF0AgUWAhIIcHJvcGVydHkSC2Rlc2NyaXB0aW9uEgR3aGVuFgESC2dyZWF0ZXJUaGFuFQIWARIIc3VidHJhY3QVAhIKJHVwZGF0ZWRBdBIKJGNyZWF0ZWRBdBYBEgNtaW4VAgL8BSZcABYBEgZkaXZpZGUVAhYBEghzdWJ0cmFjdBUCEgdlbmREYXRlEgokY3JlYXRlZEF0AgUWAhIIcHJvcGVydHkSA3VybBIEd2hlbhYBEgtncmVhdGVyVGhhbhUCFgESCHN1YnRyYWN0FQISCiR1cGRhdGVkQXQSCiRjcmVhdGVkQXQWARIDbWluFQIC/AUmXAAWARIGZGl2aWRlFQIWARIIc3VidHJhY3QVAhIHZW5kRGF0ZRIKJGNyZWF0ZWRBdAIFEhRhZGRpdGlvbmFsUHJvcGVydGllcxMAEgtkZXNjcmlwdGlvbhL2QSB5ZXMvbm8gcXVlc3Rpb24gcHV0IHRvIHRoZSBtYXN0ZXJub2Rlcy4gQ3JlYXRpbmcgb25lIG9wZW5zIGl0cyB2b3RlIHBvbGwsIGZ1bmRlZCBieSBpdHMgb3duZXIuIEl0cyB0aXRsZSwgZGVzY3JpcHRpb24gYW5kIHVybCBtYXkgYmUgZWRpdGVkIGZvciBhIGRheSBhZnRlciBpdCBpcyBjcmVhdGVkLCBvciBhIGZpZnRoIG9mIHRoZSB0aW1lIGl0IHJ1bnMgaWYgdGhhdCBpcyBzaG9ydGVyOyBpdHMgcnVsZSBuZXZlciBjaGFuZ2VzAAAAAAAAAAAAAAMGY2hvaWNlAAMKcHJvcG9zYWxJZBMDABZtdWx0aXBsZUNob2ljZVByb3Bvc2FsAAABAAAAAAAABXRpdGxlCwEBAYAB+wIAAQAAAAAAAAtkZXNjcmlwdGlvbgsBAQH7EAAB+xAAAAAAAAAAAAMKcHJvcG9zYWxJZBMDABZtdWx0aXBsZUNob2ljZVByb3Bvc2FsAAABAAAAAAAABXRpdGxlCwEBAYAB+wIAAQAAAAAAAAtkZXNjcmlwdGlvbgsBAQH7EAAB+xAAAAAAAAAAAAEKYnlQcm9wb3NhbApieVByb3Bvc2FsAQpwcm9wb3NhbElkAQEBAQACAAAAAAAAAAAAAAAAAAAAAAAAAAABCnByb3Bvc2FsSWQAAwokY3JlYXRlZEF0CnByb3Bvc2FsSWQFdGl0bGUAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAABI2Nsb3NlZFByb3Bvc2FsVGFrZXNJdHNPd25lcnNDaG9pY2VzEAAAAR0kcmVmZXJlbmNlZC5wcm9wb3NhbElkLiQub3BlbgAAAAYAISRyZWZlcmVuY2VkLnByb3Bvc2FsSWQuJC4kb3duZXJJZAgkb3duZXJJZAAAAAACAIEAAQACAAABAQMKcHJvcG9zYWxJZAABAAMAAAABAAABIAEgAAEAAAAFdGl0bGUAAQAFAAAAAAEBAQGAAfsCAAAAAAtkZXNjcmlwdGlvbgABAAUAAAAAAQEBAfsQAAH7EAAAAAABAAECCnByb3Bvc2FsSWQFdGl0bGUAAAAAAAAAAAAWbXVsdGlwbGVDaG9pY2VQcm9wb3NhbAAGBXRpdGxlCwEBAYAB+wIAAQAAAAAAAA1kZXNjcmlwdGlvbklkEwwBFWRlc2NyaXB0aW9uQ29udHJhY3RJZBdkZXNjcmlwdGlvbkRvY3VtZW50VHlwZQECBGJvZHkAAAAAAQcCBQEEAAEFAQQBAQlib2R5Qnl0ZXMAAQAAAQcCBQEEAAEFAQQBAQEBAgAEYm9keQAJYm9keUJ5dGVzAQcCBAICD2RlbGV0ZURvY3VtZW50cwYCCwAKAQEAAAAAAAAVZGVzY3JpcHRpb25Db250cmFjdElkEwEBAAAAAAAAAQABAAAAAAAAF2Rlc2NyaXB0aW9uRG9jdW1lbnRUeXBlCwEBAUAAAQAAAAAAAAN1cmwLAQEB+wIAAfsCAAAAAAAAAAAEb3Blbg4BAAAAAAAABgV0aXRsZQsBAQGAAfsCAAEAAAAAAAANZGVzY3JpcHRpb25JZBMMARVkZXNjcmlwdGlvbkNvbnRyYWN0SWQXZGVzY3JpcHRpb25Eb2N1bWVudFR5cGUBAgRib2R5AAAAAAEHAgUBBAABBQEEAQEJYm9keUJ5dGVzAAEAAAEHAgUBBAABBQEEAQEBAQIABGJvZHkACWJvZHlCeXRlcwEHAgQCAg9kZWxldGVEb2N1bWVudHMGAgsACgEBAAAAAAAAFWRlc2NyaXB0aW9uQ29udHJhY3RJZBMBAQAAAAAAAAEAAQAAAAAAABdkZXNjcmlwdGlvbkRvY3VtZW50VHlwZQsBAQFAAAEAAAAAAAADdXJsCwEBAfsCAAH7AgAAAAAAAAAABG9wZW4OAQAAAAAAAAILYnlDcmVhdGVkQXQLYnlDcmVhdGVkQXQBCiRjcmVhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAdieU93bmVyB2J5T3duZXICCCRvd25lcklkAQokY3JlYXRlZEF0AQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAhVkZXNjcmlwdGlvbkNvbnRyYWN0SWQNZGVzY3JpcHRpb25JZAAHCiRjcmVhdGVkQXQKJHVwZGF0ZWRBdBVkZXNjcmlwdGlvbkNvbnRyYWN0SWQXZGVzY3JpcHRpb25Eb2N1bWVudFR5cGUNZGVzY3JpcHRpb25JZARvcGVuBXRpdGxlAAEEb3BlbgUVZGVzY3JpcHRpb25Db250cmFjdElkAAQIBQABAAUAAAAA/ADbugAXZGVzY3JpcHRpb25Eb2N1bWVudFR5cGUABAgFAAEABQAAAAD8ANu6AA1kZXNjcmlwdGlvbklkAAQIBQABAAUAAAAA/ADbugAFdGl0bGUABAgFAAEABQAAAAD8ANu6AAN1cmwABAgFAAEABQAAAAD8ANu6AAAAAQAAAAEAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPsBFwABAAIAAAEBBgV0aXRsZQABAAUAAAAAAQEBAYAB+wIAAAAADWRlc2NyaXB0aW9uSWQAAQADAAAAAQAAASABIAABAAAAFWRlc2NyaXB0aW9uQ29udHJhY3RJZAABAAMAAAABAAABIAEgAAEAAAAXZGVzY3JpcHRpb25Eb2N1bWVudFR5cGUAAQAFAAAAAAEBAQFAAAAAAAN1cmwAAQAFAAAAAAEBAQH7AgAB+wIAAAABAQkABG9wZW4AAQABAAAAAAAAAAEAAQUFdGl0bGUNZGVzY3JpcHRpb25JZBVkZXNjcmlwdGlvbkNvbnRyYWN0SWQXZGVzY3JpcHRpb25Eb2N1bWVudFR5cGUEb3BlbgAAAAAAAAAAAAhwcm9wb3NhbAAGBXRpdGxlCwEBAYAB+wIAAQAAAAAAAAtkZXNjcmlwdGlvbgsBAQH7EAAB+xAAAQAAAAAAAAN1cmwLAQEB+wIAAfsCAAAAAAAAAAALcGFzc1BlcmNlbnQIAQAAAAAAABVtaW5pbXVtVHVybm91dFBlcmNlbnQIAQAAAAAAAAdlbmREYXRlAgEAAAAAAAAGBXRpdGxlCwEBAYAB+wIAAQAAAAAAAAtkZXNjcmlwdGlvbgsBAQH7EAAB+xAAAQAAAAAAAAN1cmwLAQEB+wIAAfsCAAAAAAAAAAALcGFzc1BlcmNlbnQIAQAAAAAAABVtaW5pbXVtVHVybm91dFBlcmNlbnQIAQAAAAAAAAdlbmREYXRlAgEAAAAAAAADC2J5Q3JlYXRlZEF0C2J5Q3JlYXRlZEF0AQokY3JlYXRlZEF0AQABAAAAAAAAAAAAAAAAAAAAAAAAAAAJYnlFbmREYXRlCWJ5RW5kRGF0ZQIHZW5kRGF0ZQEKJGNyZWF0ZWRBdAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAB2J5T3duZXIHYnlPd25lcgIIJG93bmVySWQBCiRjcmVhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcKJGNyZWF0ZWRBdAokdXBkYXRlZEF0C2Rlc2NyaXB0aW9uB2VuZERhdGUVbWluaW11bVR1cm5vdXRQZXJjZW50C3Bhc3NQZXJjZW50BXRpdGxlAAMHZW5kRGF0ZRVtaW5pbXVtVHVybm91dFBlcmNlbnQLcGFzc1BlcmNlbnQDC2Rlc2NyaXB0aW9uAAQIBQABAAUAAAAMAgD8Cky4AAkIAQdlbmREYXRlAAUAAAAACgV0aXRsZQAECAUAAQAFAAAADAIA/ApMuAAJCAEHZW5kRGF0ZQAFAAAAAAoDdXJsAAQIBQABAAUAAAAMAgD8Cky4AAkIAQdlbmREYXRlAAUAAAAACgAAAQAAAAEAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPsBDwABAAIAAAEBBgV0aXRsZQABAAUAAAAAAQEBAYAB+wIAAAAAC2Rlc2NyaXB0aW9uAAEABQAAAAABAQEB+xAAAfsQAAAAAAN1cmwAAQAFAAAAAAEBAQH7AgAB+wIAAAABAQkAC3Bhc3NQZXJjZW50AAEABgAAAAAAAQECMgECUAAAAAAVbWluaW11bVR1cm5vdXRQZXJjZW50AAEABgAAAAAAAQECAAECMgAAAAAHZW5kRGF0ZQABAAYAAAAAAAEBAgAAAAAAAAEAAQUFdGl0bGULZGVzY3JpcHRpb24LcGFzc1BlcmNlbnQVbWluaW11bVR1cm5vdXRQZXJjZW50B2VuZERhdGUAAAAAAAAAAAA="} +{"contract":"system DecisionProposals","sha256":"a12c600de9a6a2b249c967c408207f82ae95df41e1900a1fd40a9a8ffbda03fd","indexStructures":"0b3ce0e20cde7b45a89351adea5ee84da634ab0b36268c00891c7917a5969bc8","bytes":"AihRCgKmaz7aBrhvyubaqp21d2bTw3JcyFpYzR6uMcvIAgAAAAABAQAAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwZjaG9pY2UWCRIEdHlwZRIGb2JqZWN0EhBkb2N1bWVudHNNdXRhYmxlEwASDGNhbkJlRGVsZXRlZBMAEgdpbmRpY2VzFQEWBBIEbmFtZRIKYnlQcm9wb3NhbBIKcHJvcGVydGllcxUBFgESCnByb3Bvc2FsSWQSA2FzYxIGdW5pcXVlEwESCWNvbnRlc3RlZBYCEgpyZXNvbHV0aW9uAgISC2Rlc2NyaXB0aW9uEn1UaGUgY2hvaWNlcyBlbnRlcmVkIGZvciBvbmUgbXVsdGlwbGUgY2hvaWNlIHByb3Bvc2FsIGNvbnRlbmQgaW4gYSBtYXN0ZXJub2RlIHZvdGUsIGVhY2ggb24gaXRzIG93bjsgYSBsb2NrIG1lYW5zIG5vbmUgb2YgdGhlbRIKcHJvcGVydGllcxYDEgpwcm9wb3NhbElkFggSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIIbWluSXRlbXMCIBIIbWF4SXRlbXMCIBIQY29udGVudE1lZGlhVHlwZRIhYXBwbGljYXRpb24veC5kYXNoLmRwcC5pZGVudGlmaWVyEghyZWZlcnNUbxYCEgR0eXBlEhFwZXJtYW5lbnREb2N1bWVudBIMZG9jdW1lbnRUeXBlEhZtdWx0aXBsZUNob2ljZVByb3Bvc2FsEgtkZXNjcmlwdGlvbhIxVGhlIG11bHRpcGxlIGNob2ljZSBwcm9wb3NhbCB0aGlzIGlzIGFuIGFuc3dlciB0bxIIcG9zaXRpb24CABIFdGl0bGUWBhIEdHlwZRIGc3RyaW5nEgltaW5MZW5ndGgCARIJbWF4TGVuZ3RoAoASCG1heEJ5dGVzAvsCABILZGVzY3JpcHRpb24SF1RoZSBhbnN3ZXIsIGluIG9uZSBsaW5lEghwb3NpdGlvbgIBEgtkZXNjcmlwdGlvbhYGEgR0eXBlEgZzdHJpbmcSCW1pbkxlbmd0aAIBEgltYXhMZW5ndGgC+xAAEghtYXhCeXRlcwL7EAASC2Rlc2NyaXB0aW9uEiFXaGF0IHZvdGluZyBmb3IgdGhpcyBhbnN3ZXIgbWVhbnMSCHBvc2l0aW9uAgISCHJlcXVpcmVkFQMSCiRjcmVhdGVkQXQSCnByb3Bvc2FsSWQSBXRpdGxlEhNwcm9wZXJ0eUNvbnN0cmFpbnRzFgESI2Nsb3NlZFByb3Bvc2FsVGFrZXNJdHNPd25lcnNDaG9pY2VzFgESBmlmVGhlbhUCFgESBWVxdWFsFQIWARIKcmVmZXJlbmNlZBUCEgpwcm9wb3NhbElkEgRvcGVuAgAWARIFZXF1YWwVAhYBEgpyZWZlcmVuY2VkFQISCnByb3Bvc2FsSWQSCCRvd25lcklkEggkb3duZXJJZBIUYWRkaXRpb25hbFByb3BlcnRpZXMTABILZGVzY3JpcHRpb24SxEFuIGFuc3dlciB0byBhIG11bHRpcGxlIGNob2ljZSBwcm9wb3NhbC4gRW50ZXJpbmcgb25lIG9wZW5zIHRoZSBtYXN0ZXJub2RlIHZvdGUgYW1vbmcgdGhlIHByb3Bvc2FsJ3MgY2hvaWNlcywgb3Igam9pbnMgaXQ7IGFuIGlkZW50aXR5IG1heSBlbnRlciBzZXZlcmFsLCBhbmQgdGhlIGNob2ljZSB3aXRoIHRoZSBtb3N0IHZvdGVzIGlzIGtlcHQWbXVsdGlwbGVDaG9pY2VQcm9wb3NhbBYKEgR0eXBlEgZvYmplY3QSEGRvY3VtZW50c011dGFibGUTARIUZG9jdW1lbnRzS2VlcEhpc3RvcnkTARIMY2FuQmVEZWxldGVkEwASB2luZGljZXMVAhYCEgRuYW1lEgdieU93bmVyEgpwcm9wZXJ0aWVzFQIWARIIJG93bmVySWQSA2FzYxYBEgokY3JlYXRlZEF0EgNhc2MWAhIEbmFtZRILYnlDcmVhdGVkQXQSCnByb3BlcnRpZXMVARYBEgokY3JlYXRlZEF0EgNhc2MSCnByb3BlcnRpZXMWBhIFdGl0bGUWBhIEdHlwZRIGc3RyaW5nEgltaW5MZW5ndGgCARIJbWF4TGVuZ3RoAoASCG1heEJ5dGVzAvsCABILZGVzY3JpcHRpb24SGVRoZSBxdWVzdGlvbiwgaW4gb25lIGxpbmUSCHBvc2l0aW9uAgASDWRlc2NyaXB0aW9uSWQWCBIEdHlwZRIFYXJyYXkSCWJ5dGVBcnJheRMBEghtaW5JdGVtcwIgEghtYXhJdGVtcwIgEhBjb250ZW50TWVkaWFUeXBlEiFhcHBsaWNhdGlvbi94LmRhc2guZHBwLmlkZW50aWZpZXISCHJlZmVyc1RvFgQSBHR5cGUSEW1vZGVyYXRlZERvY3VtZW50Egpjb250cmFjdElkEhskc2VsZi5kZXNjcmlwdGlvbkNvbnRyYWN0SWQSDGRvY3VtZW50VHlwZRIdJHNlbGYuZGVzY3JpcHRpb25Eb2N1bWVudFR5cGUSGGRvY3VtZW50VHlwZVJlcXVpcmVtZW50cxYDEgR3aGVuFgESBWFsbE9mFQIWARIIY29udGFpbnMVAhIRJGVsZWN0ZWRBYmlsaXRpZXMWARIFY29uc3QSD2RlbGV0ZURvY3VtZW50cxYBEgVhbnlPZhUCFgESBmFic2VudBIgJG1vZGVyYXRvckFiaWxpdGllcy5kZWxldGVXaXRoaW4WARIFYWxsT2YVAhYBEgdwcmVzZW50EiEkbW9kZXJhdG9yQWJpbGl0aWVzLmRlbGV0ZVNldHRsZWQWARIGYWJzZW50EjokbW9kZXJhdG9yQWJpbGl0aWVzLmRlbGV0ZVNldHRsZWQuYXBwcm92ZXJzUHJlZGF0ZURvY3VtZW50Egpwcm9wZXJ0aWVzFgISBGJvZHkWAxIEbmFtZRIEJGFueRIEdHlwZRIGc3RyaW5nEgR3aGVuFgESBWFsbE9mFQIWARIDbm90FgESCGNvbnRhaW5zFQISJSRtb2RlcmF0b3JBYmlsaXRpZXMuZGVsZXRlS2VlcHNGaWVsZHMSCSRwcm9wZXJ0eRYBEgNub3QWARIIY29udGFpbnMVAhIgJG1vZGVyYXRvckFiaWxpdGllcy5jaGFuZ2VGaWVsZHMSCSRwcm9wZXJ0eRIJYm9keUJ5dGVzFgQSBG5hbWUSBCRhbnkSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIEd2hlbhYBEgVhbGxPZhUCFgESA25vdBYBEghjb250YWlucxUCEiUkbW9kZXJhdG9yQWJpbGl0aWVzLmRlbGV0ZUtlZXBzRmllbGRzEgkkcHJvcGVydHkWARIDbm90FgESCGNvbnRhaW5zFQISICRtb2RlcmF0b3JBYmlsaXRpZXMuY2hhbmdlRmllbGRzEgkkcHJvcGVydHkSB3JlcXVpcmUWARIFYW55T2YVAhIEYm9keRIJYm9keUJ5dGVzEgtkZXNjcmlwdGlvbhLUVGhlIGRvY3VtZW50IHNheWluZyB3aGF0IGlzIGJlaW5nIGRlY2lkZWQ6IGEgZG9jdW1lbnQgb2YgYSBtb2RlcmF0ZWQgdHlwZSBpdHMgY29udHJhY3QncyBlbGVjdGVkIHRlYW0gbWF5IGRlbGV0ZSwgYXQgYW55IHRpbWUgYWZ0ZXIgaXQgaXMgd3JpdHRlbiwgaG9sZGluZyB0ZXh0IGl0cyBhdXRob3Igd3JvdGUgdGhhdCB0aGUgcmVtb3ZhbCB0YWtlcyBhd2F5IHdpdGggaXQSCHBvc2l0aW9uAgESFWRlc2NyaXB0aW9uQ29udHJhY3RJZBYIEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAiASCG1heEl0ZW1zAiASEGNvbnRlbnRNZWRpYVR5cGUSIWFwcGxpY2F0aW9uL3guZGFzaC5kcHAuaWRlbnRpZmllchIIcmVmZXJzVG8WAhIEdHlwZRIIY29udHJhY3QSFGNvbnRyYWN0UmVxdWlyZW1lbnRzFgISCm1vZGVyYXRpb24SB2VsZWN0ZWQSDm93bmVyUHJvdGVjdGVkEwASC2Rlc2NyaXB0aW9uEohUaGUgY29udHJhY3QgdGhlIGRlc2NyaXB0aW9uIGRvY3VtZW50IGxpdmVzIGluLCBvbmUgd2hvc2UgbW9kZXJhdG9ycyB0aGUgbWFzdGVybm9kZXMgZWxlY3QgYW5kIHdob3NlIG93bmVyIHRoZXkgbWF5IG1vZGVyYXRlIGxpa2UgYW55b25lEghwb3NpdGlvbgICEhdkZXNjcmlwdGlvbkRvY3VtZW50VHlwZRYFEgR0eXBlEgZzdHJpbmcSCW1pbkxlbmd0aAIBEgltYXhMZW5ndGgCQBILZGVzY3JpcHRpb24SLVRoZSBkb2N1bWVudCB0eXBlIG9mIHRoZSBkZXNjcmlwdGlvbiBkb2N1bWVudBIIcG9zaXRpb24CAxIDdXJsFgcSBHR5cGUSBnN0cmluZxIGZm9ybWF0EgN1cmkSCW1pbkxlbmd0aAIBEgltYXhMZW5ndGgC+wIAEghtYXhCeXRlcwL7AgASC2Rlc2NyaXB0aW9uEh9XaGVyZSB0aGUgcHJvcG9zYWwgaXMgZGlzY3Vzc2VkEghwb3NpdGlvbgIEEgRvcGVuFgMSBHR5cGUSB2Jvb2xlYW4SC2Rlc2NyaXB0aW9uElBXaGV0aGVyIGFueW9uZSBtYXkgZW50ZXIgY2hvaWNlczsgb25seSB0aGUgcHJvcG9zYWwncyBvd25lciBtYXkgd2hlbiBpdCBpcyBmYWxzZRIIcG9zaXRpb24CBRIIcmVxdWlyZWQVBxIKJGNyZWF0ZWRBdBIKJHVwZGF0ZWRBdBIFdGl0bGUSDWRlc2NyaXB0aW9uSWQSFWRlc2NyaXB0aW9uQ29udHJhY3RJZBIXZGVzY3JpcHRpb25Eb2N1bWVudFR5cGUSBG9wZW4SCWltbXV0YWJsZRUGEgRvcGVuFgISCHByb3BlcnR5EgV0aXRsZRIEd2hlbhYBEgtncmVhdGVyVGhhbhUCFgESCHN1YnRyYWN0FQISCiR1cGRhdGVkQXQSCiRjcmVhdGVkQXQC/ABt3QAWAhIIcHJvcGVydHkSDWRlc2NyaXB0aW9uSWQSBHdoZW4WARILZ3JlYXRlclRoYW4VAhYBEghzdWJ0cmFjdBUCEgokdXBkYXRlZEF0EgokY3JlYXRlZEF0AvwAbd0AFgISCHByb3BlcnR5EhVkZXNjcmlwdGlvbkNvbnRyYWN0SWQSBHdoZW4WARILZ3JlYXRlclRoYW4VAhYBEghzdWJ0cmFjdBUCEgokdXBkYXRlZEF0EgokY3JlYXRlZEF0AvwAbd0AFgISCHByb3BlcnR5EhdkZXNjcmlwdGlvbkRvY3VtZW50VHlwZRIEd2hlbhYBEgtncmVhdGVyVGhhbhUCFgESCHN1YnRyYWN0FQISCiR1cGRhdGVkQXQSCiRjcmVhdGVkQXQC/ABt3QAWAhIIcHJvcGVydHkSA3VybBIEd2hlbhYBEgtncmVhdGVyVGhhbhUCFgESCHN1YnRyYWN0FQISCiR1cGRhdGVkQXQSCiRjcmVhdGVkQXQC/ABt3QASFGFkZGl0aW9uYWxQcm9wZXJ0aWVzEwASC2Rlc2NyaXB0aW9uEvsB5UEgcXVlc3Rpb24gcHV0IHRvIHRoZSBtYXN0ZXJub2RlcyB3aG9zZSBhbnN3ZXJzIGFyZSB0aGUgY2hvaWNlcyBlbnRlcmVkIGZvciBpdC4gV2hhdCBpcyBiZWluZyBkZWNpZGVkIGlzIHNhaWQgYnkgYSBkb2N1bWVudCB0aGUgcHJvcG9zYWwgcG9pbnRzIGF0LCBvZiBhIG1vZGVyYXRlZCB0eXBlIG9mIGEgY29udHJhY3Qgd2hvc2UgbW9kZXJhdG9ycyB0aGUgbWFzdGVybm9kZXMgZWxlY3QsIHNvIHRoYXQgdGhleSBjYW4gdGFrZSBpdCBkb3duLiBBbnlvbmUgbWF5IGVudGVyIGNob2ljZXMgd2hlbiBpdCBpcyBvcGVuLCBhbmQgb25seSBpdHMgb3duZXIgd2hlbiBpdCBpcyBub3QuIEl0cyB0aXRsZSwgdXJsIGFuZCB0aGUgZGVzY3JpcHRpb24gaXQgcG9pbnRzIGF0IG1heSBiZSBjaGFuZ2VkIGZvciB0d28gaG91cnMgYWZ0ZXIgaXQgaXMgY3JlYXRlZDsgd2hldGhlciBpdCBpcyBvcGVuIG5ldmVyIGNoYW5nZXMsIGFuZCBpdCBpcyBuZXZlciBkZWxldGVkCHByb3Bvc2FsFgoSBHR5cGUSBm9iamVjdBIQZG9jdW1lbnRzTXV0YWJsZRMBEhRkb2N1bWVudHNLZWVwSGlzdG9yeRMBEgxjYW5CZURlbGV0ZWQTABIHaW5kaWNlcxUDFgISBG5hbWUSCWJ5RW5kRGF0ZRIKcHJvcGVydGllcxUCFgESB2VuZERhdGUSA2FzYxYBEgokY3JlYXRlZEF0EgNhc2MWAhIEbmFtZRIHYnlPd25lchIKcHJvcGVydGllcxUCFgESCCRvd25lcklkEgNhc2MWARIKJGNyZWF0ZWRBdBIDYXNjFgISBG5hbWUSC2J5Q3JlYXRlZEF0Egpwcm9wZXJ0aWVzFQEWARIKJGNyZWF0ZWRBdBIDYXNjEgpwcm9wZXJ0aWVzFgYSBXRpdGxlFgYSBHR5cGUSBnN0cmluZxIJbWluTGVuZ3RoAgESCW1heExlbmd0aAKAEghtYXhCeXRlcwL7AgASC2Rlc2NyaXB0aW9uEhlUaGUgcXVlc3Rpb24sIGluIG9uZSBsaW5lEghwb3NpdGlvbgIAEgtkZXNjcmlwdGlvbhYGEgR0eXBlEgZzdHJpbmcSCW1pbkxlbmd0aAIBEgltYXhMZW5ndGgC+xAAEghtYXhCeXRlcwL7EAASC2Rlc2NyaXB0aW9uEhhXaGF0IGEgeWVzIGFuZCBhIG5vIG1lYW4SCHBvc2l0aW9uAgESA3VybBYHEgR0eXBlEgZzdHJpbmcSBmZvcm1hdBIDdXJpEgltaW5MZW5ndGgCARIJbWF4TGVuZ3RoAvsCABIIbWF4Qnl0ZXMC+wIAEgtkZXNjcmlwdGlvbhIfV2hlcmUgdGhlIHByb3Bvc2FsIGlzIGRpc2N1c3NlZBIIcG9zaXRpb24CAhILcGFzc1BlcmNlbnQWBRIEdHlwZRIHaW50ZWdlchIHbWluaW11bQIyEgdtYXhpbXVtAlASC2Rlc2NyaXB0aW9uEnBUaGUgcHJvcG9zYWwgcGFzc2VzIHdoZW4gdGhlIHllcyB2b3RpbmcgcG93ZXIgaXMgc3RyaWN0bHkgbW9yZSB0aGFuIHRoaXMgcGVyY2VudCBvZiB0aGUgeWVzIHBsdXMgbm8gdm90aW5nIHBvd2VyEghwb3NpdGlvbgIDEhVtaW5pbXVtVHVybm91dFBlcmNlbnQWBRIEdHlwZRIHaW50ZWdlchIHbWluaW11bQIAEgdtYXhpbXVtAjISC2Rlc2NyaXB0aW9uEopUaGUgbGVhc3QgeWVzIHBsdXMgbm8gdm90aW5nIHBvd2VyIHRoZSBwcm9wb3NhbCBuZWVkcywgYXMgYSBwZXJjZW50IG9mIHRoZSBtYXN0ZXJub2RlIGxpc3QncyB0b3RhbCB2b3RpbmcgcG93ZXIgd2hlbiBpdCBjbG9zZXMsIHJvdW5kZWQgdXASCHBvc2l0aW9uAgQSB2VuZERhdGUWBBIEdHlwZRIHaW50ZWdlchIHbWluaW11bQIAEgtkZXNjcmlwdGlvbhI4V2hlbiB2b3RpbmcgY2xvc2VzLCBpbiBtaWxsaXNlY29uZHMgc2luY2UgdGhlIFVuaXggZXBvY2gSCHBvc2l0aW9uAgUSCHJlcXVpcmVkFQcSCiRjcmVhdGVkQXQSCiR1cGRhdGVkQXQSBXRpdGxlEgtkZXNjcmlwdGlvbhILcGFzc1BlcmNlbnQSFW1pbmltdW1UdXJub3V0UGVyY2VudBIHZW5kRGF0ZRIJaW1tdXRhYmxlFQYSC3Bhc3NQZXJjZW50EhVtaW5pbXVtVHVybm91dFBlcmNlbnQSB2VuZERhdGUWAhIIcHJvcGVydHkSBXRpdGxlEgR3aGVuFgESC2dyZWF0ZXJUaGFuFQIWARIIc3VidHJhY3QVAhIKJHVwZGF0ZWRBdBIKJGNyZWF0ZWRBdBYBEgNtaW4VAgL8BSZcABYBEgZkaXZpZGUVAhYBEghzdWJ0cmFjdBUCEgdlbmREYXRlEgokY3JlYXRlZEF0AgUWAhIIcHJvcGVydHkSC2Rlc2NyaXB0aW9uEgR3aGVuFgESC2dyZWF0ZXJUaGFuFQIWARIIc3VidHJhY3QVAhIKJHVwZGF0ZWRBdBIKJGNyZWF0ZWRBdBYBEgNtaW4VAgL8BSZcABYBEgZkaXZpZGUVAhYBEghzdWJ0cmFjdBUCEgdlbmREYXRlEgokY3JlYXRlZEF0AgUWAhIIcHJvcGVydHkSA3VybBIEd2hlbhYBEgtncmVhdGVyVGhhbhUCFgESCHN1YnRyYWN0FQISCiR1cGRhdGVkQXQSCiRjcmVhdGVkQXQWARIDbWluFQIC/AUmXAAWARIGZGl2aWRlFQIWARIIc3VidHJhY3QVAhIHZW5kRGF0ZRIKJGNyZWF0ZWRBdAIFEhRhZGRpdGlvbmFsUHJvcGVydGllcxMAEgtkZXNjcmlwdGlvbhL2QSB5ZXMvbm8gcXVlc3Rpb24gcHV0IHRvIHRoZSBtYXN0ZXJub2Rlcy4gQ3JlYXRpbmcgb25lIG9wZW5zIGl0cyB2b3RlIHBvbGwsIGZ1bmRlZCBieSBpdHMgb3duZXIuIEl0cyB0aXRsZSwgZGVzY3JpcHRpb24gYW5kIHVybCBtYXkgYmUgZWRpdGVkIGZvciBhIGRheSBhZnRlciBpdCBpcyBjcmVhdGVkLCBvciBhIGZpZnRoIG9mIHRoZSB0aW1lIGl0IHJ1bnMgaWYgdGhhdCBpcyBzaG9ydGVyOyBpdHMgcnVsZSBuZXZlciBjaGFuZ2VzAAAAAAAAAAAAAAMGY2hvaWNlAAMKcHJvcG9zYWxJZBMDABZtdWx0aXBsZUNob2ljZVByb3Bvc2FsAAABAAAAAAAABXRpdGxlCwEBAYAB+wIAAQAAAAAAAAtkZXNjcmlwdGlvbgsBAQH7EAAB+xAAAAAAAAAAAAMKcHJvcG9zYWxJZBMDABZtdWx0aXBsZUNob2ljZVByb3Bvc2FsAAABAAAAAAAABXRpdGxlCwEBAYAB+wIAAQAAAAAAAAtkZXNjcmlwdGlvbgsBAQH7EAAB+xAAAAAAAAAAAAEKYnlQcm9wb3NhbApieVByb3Bvc2FsAQpwcm9wb3NhbElkAQEBAQACAAAAAAAAAAAAAAAAAAAAAAAAAAABCnByb3Bvc2FsSWQAAwokY3JlYXRlZEF0CnByb3Bvc2FsSWQFdGl0bGUAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAABI2Nsb3NlZFByb3Bvc2FsVGFrZXNJdHNPd25lcnNDaG9pY2VzEAAAAR0kcmVmZXJlbmNlZC5wcm9wb3NhbElkLiQub3BlbgAAAAYAISRyZWZlcmVuY2VkLnByb3Bvc2FsSWQuJC4kb3duZXJJZAgkb3duZXJJZAAAAAACAIEAAQACAAABAQMKcHJvcG9zYWxJZAABAAMAAAABAAABIAEgAAEAAAAFdGl0bGUAAQAFAAAAAAEBAQGAAfsCAAAAAAtkZXNjcmlwdGlvbgABAAUAAAAAAQEBAfsQAAH7EAAAAAABAAECCnByb3Bvc2FsSWQFdGl0bGUAAAAAAAAAAAAWbXVsdGlwbGVDaG9pY2VQcm9wb3NhbAAGBXRpdGxlCwEBAYAB+wIAAQAAAAAAAA1kZXNjcmlwdGlvbklkEwwBFWRlc2NyaXB0aW9uQ29udHJhY3RJZBdkZXNjcmlwdGlvbkRvY3VtZW50VHlwZQECBGJvZHkAAAAAAQcCBQEEAAEFAQQBAQlib2R5Qnl0ZXMAAQAAAQcCBQEEAAEFAQQBAQEBAgAEYm9keQAJYm9keUJ5dGVzAQcCBAICD2RlbGV0ZURvY3VtZW50cwYCCwAHAgoBCwIBAAAAAAAAFWRlc2NyaXB0aW9uQ29udHJhY3RJZBMBAQAAAAAAAAEAAQAAAAAAABdkZXNjcmlwdGlvbkRvY3VtZW50VHlwZQsBAQFAAAEAAAAAAAADdXJsCwEBAfsCAAH7AgAAAAAAAAAABG9wZW4OAQAAAAAAAAYFdGl0bGULAQEBgAH7AgABAAAAAAAADWRlc2NyaXB0aW9uSWQTDAEVZGVzY3JpcHRpb25Db250cmFjdElkF2Rlc2NyaXB0aW9uRG9jdW1lbnRUeXBlAQIEYm9keQAAAAABBwIFAQQAAQUBBAEBCWJvZHlCeXRlcwABAAABBwIFAQQAAQUBBAEBAQECAARib2R5AAlib2R5Qnl0ZXMBBwIEAgIPZGVsZXRlRG9jdW1lbnRzBgILAAcCCgELAgEAAAAAAAAVZGVzY3JpcHRpb25Db250cmFjdElkEwEBAAAAAAAAAQABAAAAAAAAF2Rlc2NyaXB0aW9uRG9jdW1lbnRUeXBlCwEBAUAAAQAAAAAAAAN1cmwLAQEB+wIAAfsCAAAAAAAAAAAEb3Blbg4BAAAAAAAAAgtieUNyZWF0ZWRBdAtieUNyZWF0ZWRBdAEKJGNyZWF0ZWRBdAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAB2J5T3duZXIHYnlPd25lcgIIJG93bmVySWQBCiRjcmVhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAACFWRlc2NyaXB0aW9uQ29udHJhY3RJZA1kZXNjcmlwdGlvbklkAAcKJGNyZWF0ZWRBdAokdXBkYXRlZEF0FWRlc2NyaXB0aW9uQ29udHJhY3RJZBdkZXNjcmlwdGlvbkRvY3VtZW50VHlwZQ1kZXNjcmlwdGlvbklkBG9wZW4FdGl0bGUAAQRvcGVuBRVkZXNjcmlwdGlvbkNvbnRyYWN0SWQABAgFAAEABQAAAAD8ANu6ABdkZXNjcmlwdGlvbkRvY3VtZW50VHlwZQAECAUAAQAFAAAAAPwA27oADWRlc2NyaXB0aW9uSWQABAgFAAEABQAAAAD8ANu6AAV0aXRsZQAECAUAAQAFAAAAAPwA27oAA3VybAAECAUAAQAFAAAAAPwA27oAAAABAAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA+wEXAAEAAgAAAQEGBXRpdGxlAAEABQAAAAABAQEBgAH7AgAAAAANZGVzY3JpcHRpb25JZAABAAMAAAABAAABIAEgAAEAAAAVZGVzY3JpcHRpb25Db250cmFjdElkAAEAAwAAAAEAAAEgASAAAQAAABdkZXNjcmlwdGlvbkRvY3VtZW50VHlwZQABAAUAAAAAAQEBAUAAAAAAA3VybAABAAUAAAAAAQEBAfsCAAH7AgAAAAEBCQAEb3BlbgABAAEAAAAAAAAAAQABBQV0aXRsZQ1kZXNjcmlwdGlvbklkFWRlc2NyaXB0aW9uQ29udHJhY3RJZBdkZXNjcmlwdGlvbkRvY3VtZW50VHlwZQRvcGVuAAAAAAAAAAAACHByb3Bvc2FsAAYFdGl0bGULAQEBgAH7AgABAAAAAAAAC2Rlc2NyaXB0aW9uCwEBAfsQAAH7EAABAAAAAAAAA3VybAsBAQH7AgAB+wIAAAAAAAAAAAtwYXNzUGVyY2VudAgBAAAAAAAAFW1pbmltdW1UdXJub3V0UGVyY2VudAgBAAAAAAAAB2VuZERhdGUCAQAAAAAAAAYFdGl0bGULAQEBgAH7AgABAAAAAAAAC2Rlc2NyaXB0aW9uCwEBAfsQAAH7EAABAAAAAAAAA3VybAsBAQH7AgAB+wIAAAAAAAAAAAtwYXNzUGVyY2VudAgBAAAAAAAAFW1pbmltdW1UdXJub3V0UGVyY2VudAgBAAAAAAAAB2VuZERhdGUCAQAAAAAAAAMLYnlDcmVhdGVkQXQLYnlDcmVhdGVkQXQBCiRjcmVhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAlieUVuZERhdGUJYnlFbmREYXRlAgdlbmREYXRlAQokY3JlYXRlZEF0AQABAAAAAAAAAAAAAAAAAAAAAAAAAAAHYnlPd25lcgdieU93bmVyAggkb3duZXJJZAEKJGNyZWF0ZWRBdAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwokY3JlYXRlZEF0CiR1cGRhdGVkQXQLZGVzY3JpcHRpb24HZW5kRGF0ZRVtaW5pbXVtVHVybm91dFBlcmNlbnQLcGFzc1BlcmNlbnQFdGl0bGUAAwdlbmREYXRlFW1pbmltdW1UdXJub3V0UGVyY2VudAtwYXNzUGVyY2VudAMLZGVzY3JpcHRpb24ABAgFAAEABQAAAAwCAPwKTLgACQgBB2VuZERhdGUABQAAAAAKBXRpdGxlAAQIBQABAAUAAAAMAgD8Cky4AAkIAQdlbmREYXRlAAUAAAAACgN1cmwABAgFAAEABQAAAAwCAPwKTLgACQgBB2VuZERhdGUABQAAAAAKAAABAAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA+wEPAAEAAgAAAQEGBXRpdGxlAAEABQAAAAABAQEBgAH7AgAAAAALZGVzY3JpcHRpb24AAQAFAAAAAAEBAQH7EAAB+xAAAAAAA3VybAABAAUAAAAAAQEBAfsCAAH7AgAAAAEBCQALcGFzc1BlcmNlbnQAAQAGAAAAAAABAQIyAQJQAAAAABVtaW5pbXVtVHVybm91dFBlcmNlbnQAAQAGAAAAAAABAQIAAQIyAAAAAAdlbmREYXRlAAEABgAAAAAAAQECAAAAAAAAAQABBQV0aXRsZQtkZXNjcmlwdGlvbgtwYXNzUGVyY2VudBVtaW5pbXVtVHVybm91dFBlcmNlbnQHZW5kRGF0ZQAAAAAAAAAAAA=="} {"contract":"system TokenMarket","sha256":"a76774b65159a4ff93874a556e72cd29a7d7d3aa35fcd972e76e3b2e85cf2d0e","indexStructures":"8ff1e8772d7b8e32e036dc6ef3324be80c0bd02177c35fac5f18419a6926d279","bytes":"AokUfSEJcaCMnscCt0vmRybVw0ENgSAKW+J05qRNZQCTAgAAAAABAQAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgNhc2sWBxIEdHlwZRIGb2JqZWN0EgtkZXNjcmlwdGlvbhK5VG9rZW5zIGEgaG9sZGVyIG9mZmVycyBmb3Igc2FsZSBhdCBhIHByaWNlLiBXcml0dGVuIG9ubHkgYnkgdGhlIHRva2VuIG1hcmtldHBsYWNlIHRyYW5zaXRpb25zOiB0aGUgbGlzdGVkIHRva2VucyBhcmUgaGVsZCBpbiBlc2Nyb3cgdW5kZXIgdGhlIG9yZGVyJ3MgaWQgdW50aWwgaXQgaXMgZmlsbGVkIG9yIGNhbmNlbGxlZC4SF2NyZWF0aW9uUmVzdHJpY3Rpb25Nb2RlAgISB2luZGljZXMVAxYCEgRuYW1lEgRib29rEgpwcm9wZXJ0aWVzFQQWARIHdG9rZW5JZBIDYXNjFgESCGRlY2ltYWxzEgNhc2MWARIFcHJpY2USA2FzYxYBEgokY3JlYXRlZEF0EgNhc2MWBBIEbmFtZRIFZGVwdGgSCnByb3BlcnRpZXMVAxYBEgd0b2tlbklkEgNhc2MWARIIZGVjaW1hbHMSA2FzYxYBEgVwcmljZRIDYXNjEghzdW1tYWJsZRIJcmVtYWluaW5nEg1yYW5nZVN1bW1hYmxlEwEWAhIEbmFtZRIHYnlPd25lchIKcHJvcGVydGllcxUDFgESCCRvd25lcklkEgNhc2MWARIHdG9rZW5JZBIDYXNjFgESCiRjcmVhdGVkQXQSA2FzYxIKcHJvcGVydGllcxYFEgd0b2tlbklkFgcSBHR5cGUSBWFycmF5EglieXRlQXJyYXkTARIIbWluSXRlbXMCIBIIbWF4SXRlbXMCIBIQY29udGVudE1lZGlhVHlwZRIhYXBwbGljYXRpb24veC5kYXNoLmRwcC5pZGVudGlmaWVyEgtkZXNjcmlwdGlvbhIaVGhlIHRva2VuIHRoZSBvcmRlciBpcyBmb3ISCHBvc2l0aW9uAgASBXByaWNlFgQSBHR5cGUSB2ludGVnZXISB21pbmltdW0CARILZGVzY3JpcHRpb24SYFRoZSBjcmVkaXRzIHRoZSBzZWxsZXIgYXNrcyBwZXIgd2hvbGUgdG9rZW4gKDEwIHRvIHRoZSBwb3dlciBvZiB0aGUgb3JkZXIncyBkZWNpbWFscyBiYXNlIHVuaXRzKRIIcG9zaXRpb24CARIGYW1vdW50FgQSBHR5cGUSB2ludGVnZXISB21pbmltdW0CARILZGVzY3JpcHRpb24SQVRoZSB0b2tlbiBhbW91bnQsIGluIGJhc2UgdW5pdHMsIGxpc3RlZCB3aGVuIHRoZSBvcmRlciB3YXMgcGxhY2VkEghwb3NpdGlvbgICEglyZW1haW5pbmcWBBIEdHlwZRIHaW50ZWdlchIHbWluaW11bQIBEgtkZXNjcmlwdGlvbhJcVGhlIHRva2VuIGFtb3VudCwgaW4gYmFzZSB1bml0cywgc3RpbGwgb3Blbi4gVGhlIG9yZGVyIGlzIGRlbGV0ZWQgd2hlbiBpdCBpcyBmaWxsZWQgZW50aXJlbHkSCHBvc2l0aW9uAgMSCGRlY2ltYWxzFgYSBHR5cGUSB2ludGVnZXISB21pbmltdW0CABIHbWF4aW11bQL7AP8SC2Rlc2NyaXB0aW9uEsVUaGUgdG9rZW4ncyBkZWNpbWFscyB3aGVuIHRoZSBvcmRlciB3YXMgcGxhY2VkLCBnZW5lcmF0ZWQgZnJvbSB0b2tlbklkOiB0aGUgcHJpY2UgaXMgcGVyIDEwIHRvIHRoZSBwb3dlciBvZiB0aGlzIG1hbnkgYmFzZSB1bml0cyBmb3IgdGhlIGxpZmUgb2YgdGhlIG9yZGVyLCBldmVuIGlmIHRoZSB0b2tlbidzIGRlY2ltYWxzIGNoYW5nZSBsYXRlchINZ2VuZXJhdGVkRnJvbRYCEghmdW5jdGlvbhITc3lzLnRva2Vucy5kZWNpbWFscxIGcGFyYW1zFQESB3Rva2VuSWQSCHBvc2l0aW9uAgQSCHJlcXVpcmVkFQcSB3Rva2VuSWQSBXByaWNlEgZhbW91bnQSCXJlbWFpbmluZxIIZGVjaW1hbHMSCiRjcmVhdGVkQXQSCiR1cGRhdGVkQXQSFGFkZGl0aW9uYWxQcm9wZXJ0aWVzEwADYmlkFgcSBHR5cGUSBm9iamVjdBILZGVzY3JpcHRpb24Ss0FuIG9mZmVyIHRvIGJ1eSBhIHRva2VuIGF0IGEgcHJpY2UuIFdyaXR0ZW4gb25seSBieSB0aGUgdG9rZW4gbWFya2V0cGxhY2UgdHJhbnNpdGlvbnM6IHRoZSBvZmZlcmVkIGNyZWRpdHMgYXJlIGhlbGQgaW4gZXNjcm93IHVuZGVyIHRoZSBvcmRlcidzIGlkIHVudGlsIGl0IGlzIGZpbGxlZCBvciBjYW5jZWxsZWQuEhdjcmVhdGlvblJlc3RyaWN0aW9uTW9kZQICEgdpbmRpY2VzFQMWAhIEbmFtZRIEYm9vaxIKcHJvcGVydGllcxUEFgESB3Rva2VuSWQSA2FzYxYBEghkZWNpbWFscxIDYXNjFgESBXByaWNlEgNhc2MWARIKJGNyZWF0ZWRBdBIDYXNjFgQSBG5hbWUSBWRlcHRoEgpwcm9wZXJ0aWVzFQMWARIHdG9rZW5JZBIDYXNjFgESCGRlY2ltYWxzEgNhc2MWARIFcHJpY2USA2FzYxIIc3VtbWFibGUSBmVzY3JvdxINcmFuZ2VTdW1tYWJsZRMBFgISBG5hbWUSB2J5T3duZXISCnByb3BlcnRpZXMVAxYBEggkb3duZXJJZBIDYXNjFgESB3Rva2VuSWQSA2FzYxYBEgokY3JlYXRlZEF0EgNhc2MSCnByb3BlcnRpZXMWBhIHdG9rZW5JZBYHEgR0eXBlEgVhcnJheRIJYnl0ZUFycmF5EwESCG1pbkl0ZW1zAiASCG1heEl0ZW1zAiASEGNvbnRlbnRNZWRpYVR5cGUSIWFwcGxpY2F0aW9uL3guZGFzaC5kcHAuaWRlbnRpZmllchILZGVzY3JpcHRpb24SGlRoZSB0b2tlbiB0aGUgb3JkZXIgaXMgZm9yEghwb3NpdGlvbgIAEgVwcmljZRYEEgR0eXBlEgdpbnRlZ2VyEgdtaW5pbXVtAgESC2Rlc2NyaXB0aW9uEl9UaGUgY3JlZGl0cyB0aGUgYnV5ZXIgcGF5cyBwZXIgd2hvbGUgdG9rZW4gKDEwIHRvIHRoZSBwb3dlciBvZiB0aGUgb3JkZXIncyBkZWNpbWFscyBiYXNlIHVuaXRzKRIIcG9zaXRpb24CARIGYW1vdW50FgQSBHR5cGUSB2ludGVnZXISB21pbmltdW0CARILZGVzY3JpcHRpb24SQVRoZSB0b2tlbiBhbW91bnQsIGluIGJhc2UgdW5pdHMsIHdhbnRlZCB3aGVuIHRoZSBvcmRlciB3YXMgcGxhY2VkEghwb3NpdGlvbgICEglyZW1haW5pbmcWBBIEdHlwZRIHaW50ZWdlchIHbWluaW11bQIBEgtkZXNjcmlwdGlvbhJcVGhlIHRva2VuIGFtb3VudCwgaW4gYmFzZSB1bml0cywgc3RpbGwgb3Blbi4gVGhlIG9yZGVyIGlzIGRlbGV0ZWQgd2hlbiBpdCBpcyBmaWxsZWQgZW50aXJlbHkSCHBvc2l0aW9uAgMSBmVzY3JvdxYEEgR0eXBlEgdpbnRlZ2VyEgdtaW5pbXVtAgASC2Rlc2NyaXB0aW9uEqFUaGUgY3JlZGl0cyBzdGlsbCBoZWxkIGluIGVzY3JvdyBmb3IgdGhlIG9yZGVyLiBUaGV5IGNvdmVyIHdoYXQgdGhlIG9wZW4gYW1vdW50IGNvc3RzIGF0IHRoZSBvcmRlcidzIHByaWNlOyB3aGF0IGlzIGxlZnQgb25jZSBpdCBpcyBmaWxsZWQgZ29lcyBiYWNrIHRvIHRoZSBidXllchIIcG9zaXRpb24CBBIIZGVjaW1hbHMWBhIEdHlwZRIHaW50ZWdlchIHbWluaW11bQIAEgdtYXhpbXVtAvsA/xILZGVzY3JpcHRpb24SxVRoZSB0b2tlbidzIGRlY2ltYWxzIHdoZW4gdGhlIG9yZGVyIHdhcyBwbGFjZWQsIGdlbmVyYXRlZCBmcm9tIHRva2VuSWQ6IHRoZSBwcmljZSBpcyBwZXIgMTAgdG8gdGhlIHBvd2VyIG9mIHRoaXMgbWFueSBiYXNlIHVuaXRzIGZvciB0aGUgbGlmZSBvZiB0aGUgb3JkZXIsIGV2ZW4gaWYgdGhlIHRva2VuJ3MgZGVjaW1hbHMgY2hhbmdlIGxhdGVyEg1nZW5lcmF0ZWRGcm9tFgISCGZ1bmN0aW9uEhNzeXMudG9rZW5zLmRlY2ltYWxzEgZwYXJhbXMVARIHdG9rZW5JZBIIcG9zaXRpb24CBRIIcmVxdWlyZWQVCBIHdG9rZW5JZBIFcHJpY2USBmFtb3VudBIJcmVtYWluaW5nEghkZWNpbWFscxIGZXNjcm93EgokY3JlYXRlZEF0EgokdXBkYXRlZEF0EhRhZGRpdGlvbmFsUHJvcGVydGllcxMAAAAAAAAAAAAAAAIDYXNrAAUHdG9rZW5JZA0BAAAAAAAABXByaWNlAwEAAAAAAAAGYW1vdW50AwEAAAAAAAAJcmVtYWluaW5nAwEAAAAAAAAIZGVjaW1hbHMDAQAAAAABAgABB3Rva2VuSWQABQd0b2tlbklkDQEAAAAAAAAFcHJpY2UDAQAAAAAAAAZhbW91bnQDAQAAAAAAAAlyZW1haW5pbmcDAQAAAAAAAAhkZWNpbWFscwMBAAAAAAECAAEHdG9rZW5JZAADBGJvb2sEYm9vawQHdG9rZW5JZAEIZGVjaW1hbHMBBXByaWNlAQokY3JlYXRlZEF0AQABAAAAAAAAAAAAAAAAAAAAAAAAAAAHYnlPd25lcgdieU93bmVyAwgkb3duZXJJZAEHdG9rZW5JZAEKJGNyZWF0ZWRBdAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAABWRlcHRoBWRlcHRoAwd0b2tlbklkAQhkZWNpbWFscwEFcHJpY2UBAAEAAAABCXJlbWFpbmluZwEAAAAAAAAAAAAAAAAAAAAAAQd0b2tlbklkAAcKJGNyZWF0ZWRBdAokdXBkYXRlZEF0BmFtb3VudAhkZWNpbWFscwVwcmljZQlyZW1haW5pbmcHdG9rZW5JZAAAAAAAAAAAAAEBAAAAAgAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAMUAAQACAAABAQUHdG9rZW5JZAABAAMAAAABAAABIAEgAAEAAAAFcHJpY2UAAQAGAAAAAAABAQIBAAAAAAAGYW1vdW50AAEABgAAAAAAAQECAQAAAAAACXJlbWFpbmluZwABAAYAAAAAAAEBAgEAAAAAAAhkZWNpbWFscwABAAYAAAAAAAEBAgABAvsA/wAAAAABAAEFB3Rva2VuSWQFcHJpY2UGYW1vdW50CXJlbWFpbmluZwhkZWNpbWFscwAAAAAAAAAAAANiaWQABgd0b2tlbklkDQEAAAAAAAAFcHJpY2UDAQAAAAAAAAZhbW91bnQDAQAAAAAAAAlyZW1haW5pbmcDAQAAAAAAAAZlc2Nyb3cDAQAAAAAAAAhkZWNpbWFscwMBAAAAAAECAAEHdG9rZW5JZAAGB3Rva2VuSWQNAQAAAAAAAAVwcmljZQMBAAAAAAAABmFtb3VudAMBAAAAAAAACXJlbWFpbmluZwMBAAAAAAAABmVzY3JvdwMBAAAAAAAACGRlY2ltYWxzAwEAAAAAAQIAAQd0b2tlbklkAAMEYm9vawRib29rBAd0b2tlbklkAQhkZWNpbWFscwEFcHJpY2UBCiRjcmVhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAdieU93bmVyB2J5T3duZXIDCCRvd25lcklkAQd0b2tlbklkAQokY3JlYXRlZEF0AQABAAAAAAAAAAAAAAAAAAAAAAAAAAAFZGVwdGgFZGVwdGgDB3Rva2VuSWQBCGRlY2ltYWxzAQVwcmljZQEAAQAAAAEGZXNjcm93AQAAAAAAAAAAAAAAAAAAAAABB3Rva2VuSWQACAokY3JlYXRlZEF0CiR1cGRhdGVkQXQGYW1vdW50CGRlY2ltYWxzBmVzY3JvdwVwcmljZQlyZW1haW5pbmcHdG9rZW5JZAAAAAAAAAAAAAEBAAAAAgAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAOUAAQACAAABAQYHdG9rZW5JZAABAAMAAAABAAABIAEgAAEAAAAFcHJpY2UAAQAGAAAAAAABAQIBAAAAAAAGYW1vdW50AAEABgAAAAAAAQECAQAAAAAACXJlbWFpbmluZwABAAYAAAAAAAEBAgEAAAAAAAZlc2Nyb3cAAQAGAAAAAAABAQIAAAAAAAAIZGVjaW1hbHMAAQAGAAAAAAABAQIAAQL7AP8AAAAAAQABBgd0b2tlbklkBXByaWNlBmFtb3VudAlyZW1haW5pbmcIZGVjaW1hbHMGZXNjcm93AAAAAAAAAAAA"} {"contract":"mainnet 2NZwXdb3wGQzbWQeQZHFAaL4GuQzuzF2v8iQfcKuUC5q","sha256":"f88948650030b46642a88f5bbe753b611fea8bc426736536ed92fcc34f046775","indexStructures":"4fe79ced8e35b61d7ebb7b90bd5e022c8ce761fd5f1ad500616b65d7ead95eb0","bytes":"AhRiISPCxl4HHAwk/78Fz+U539mkTynR9H2sHE/5Ej8cAAAAAAABAQAAAThF4gNxUKAYvnAU+jjyexfXrK+scXqTTb3Iz5asr4nFAAEFb2ZmZXIWBRIEdHlwZRIGb2JqZWN0EgdpbmRpY2VzFQUWAxIEbmFtZRIYdG9NZXRvTWVWaWF0b1V0b1VWaWFVUEF0Egpwcm9wZXJ0aWVzFQUWARIEdG9NZRIDYXNjFgESB3RvTWVWaWESA2FzYxYBEgN0b1USA2FzYxYBEgZ0b1VWaWESA2FzYxYBEgokdXBkYXRlZEF0EgNhc2MSBnVuaXF1ZRMAFgMSBG5hbWUSEXRvTWV0b1V0b1VWaWFVUEFUEgpwcm9wZXJ0aWVzFQQWARIEdG9NZRIDYXNjFgESA3RvVRIDYXNjFgESBnRvVVZpYRIDYXNjFgESCiR1cGRhdGVkQXQSA2FzYxIGdW5pcXVlEwAWAxIEbmFtZRISdG9NZXRvTWVWaWF0b1VVUEFUEgpwcm9wZXJ0aWVzFQQWARIEdG9NZRIDYXNjFgESB3RvTWVWaWESA2FzYxYBEgN0b1USA2FzYxYBEgokdXBkYXRlZEF0EgNhc2MSBnVuaXF1ZRMAFgMSBG5hbWUSC3RvTWV0b1VVUEFUEgpwcm9wZXJ0aWVzFQMWARIEdG9NZRIDYXNjFgESA3RvVRIDYXNjFgESCiR1cGRhdGVkQXQSA2FzYxIGdW5pcXVlEwAWAxIEbmFtZRITb3duZXJJZEFuZGNyZWF0ZWRBdBIKcHJvcGVydGllcxUCFgESCCRvd25lcklkEgNhc2MWARIKJGNyZWF0ZWRBdBIDYXNjEgZ1bmlxdWUTABIKcHJvcGVydGllcxYLEgR0b01lFgQSBHR5cGUSBnN0cmluZxIJbWluTGVuZ3RoAwASCW1heExlbmd0aANEEghwb3NpdGlvbgMAEgd0b01lVmlhFgQSBHR5cGUSBnN0cmluZxIJbWluTGVuZ3RoAwASCW1heExlbmd0aANEEghwb3NpdGlvbgMCEgp0b01lSGFuZGxlFgQSBHR5cGUSBnN0cmluZxIJbWluTGVuZ3RoAwASCW1heExlbmd0aANEEghwb3NpdGlvbgMEEgN0b1UWBBIEdHlwZRIGc3RyaW5nEgltaW5MZW5ndGgDABIJbWF4TGVuZ3RoA0QSCHBvc2l0aW9uAwYSBnRvVVZpYRYEEgR0eXBlEgZzdHJpbmcSCW1pbkxlbmd0aAMAEgltYXhMZW5ndGgDRBIIcG9zaXRpb24DCBIGZXhSYXRlFgQSBHR5cGUSB2ludGVnZXISB21pbmltdW0DABIHbWF4aW11bQP9AAASMJzlQAASCHBvc2l0aW9uAwoSC2luc3RydWN0aW9uFgQSBHR5cGUSBnN0cmluZxIJbWluTGVuZ3RoAwASCW1heExlbmd0aAP7BLASCHBvc2l0aW9uAwwSBm1pbkFtdBYEEgR0eXBlEgdpbnRlZ2VyEgdtaW5pbXVtAwASB21heGltdW0D/QAAAdGpSiAAEghwb3NpdGlvbgMOEgZtYXhBbXQWBBIEdHlwZRIHaW50ZWdlchIHbWluaW11bQMAEgdtYXhpbXVtA/0AAAHRqUogABIIcG9zaXRpb24DEBIGYWN0aXZlFgISBHR5cGUSB2Jvb2xlYW4SCHBvc2l0aW9uAxISB215U3RvcmUWAhIEdHlwZRIHYm9vbGVhbhIIcG9zaXRpb24DFBIIcmVxdWlyZWQVDRIEdG9NZRIHdG9NZVZpYRIKdG9NZUhhbmRsZRIDdG9VEgZ0b1VWaWESBmV4UmF0ZRILaW5zdHJ1Y3Rpb24SBm1pbkFtdBIGbWF4QW10EgZhY3RpdmUSB215U3RvcmUSCiRjcmVhdGVkQXQSCiR1cGRhdGVkQXQSFGFkZGl0aW9uYWxQcm9wZXJ0aWVzEwAAAAAAAAAAAAAAAQVvZmZlcgALBHRvTWULAQABIgABAAAAAAAAB3RvTWVWaWELAQABIgABAAAAAAAACnRvTWVIYW5kbGULAQABIgABAAAAAAAAA3RvVQsBAAEiAAEAAAAAAAAGdG9VVmlhCwEAASIAAQAAAAAAAAZleFJhdGUDAQAAAAAAAAtpbnN0cnVjdGlvbgsBAAH7AlgAAQAAAAAAAAZtaW5BbXQDAQAAAAAAAAZtYXhBbXQDAQAAAAAAAAZhY3RpdmUOAQAAAAAAAAdteVN0b3JlDgEAAAAAAAALBHRvTWULAQABIgABAAAAAAAAB3RvTWVWaWELAQABIgABAAAAAAAACnRvTWVIYW5kbGULAQABIgABAAAAAAAAA3RvVQsBAAEiAAEAAAAAAAAGdG9VVmlhCwEAASIAAQAAAAAAAAZleFJhdGUDAQAAAAAAAAtpbnN0cnVjdGlvbgsBAAH7AlgAAQAAAAAAAAZtaW5BbXQDAQAAAAAAAAZtYXhBbXQDAQAAAAAAAAZhY3RpdmUOAQAAAAAAAAdteVN0b3JlDgEAAAAAAAAFE293bmVySWRBbmRjcmVhdGVkQXQTb3duZXJJZEFuZGNyZWF0ZWRBdAIIJG93bmVySWQBCiRjcmVhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAABJ0b01ldG9NZVZpYXRvVVVQQVQSdG9NZXRvTWVWaWF0b1VVUEFUBAR0b01lAQd0b01lVmlhAQN0b1UBCiR1cGRhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAABh0b01ldG9NZVZpYXRvVXRvVVZpYVVQQXQYdG9NZXRvTWVWaWF0b1V0b1VWaWFVUEF0BQR0b01lAQd0b01lVmlhAQN0b1UBBnRvVVZpYQEKJHVwZGF0ZWRBdAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAC3RvTWV0b1VVUEFUC3RvTWV0b1VVUEFUAwR0b01lAQN0b1UBCiR1cGRhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAABF0b01ldG9VdG9VVmlhVVBBVBF0b01ldG9VdG9VVmlhVVBBVAQEdG9NZQEDdG9VAQZ0b1VWaWEBCiR1cGRhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0KJGNyZWF0ZWRBdAokdXBkYXRlZEF0BmFjdGl2ZQZleFJhdGULaW5zdHJ1Y3Rpb24GbWF4QW10Bm1pbkFtdAdteVN0b3JlBHRvTWUKdG9NZUhhbmRsZQd0b01lVmlhA3RvVQZ0b1VWaWEAAAAAAAAAAAABAQAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD7AY4AAQACAAABAQsEdG9NZQABAAUAAAAAAQEAASIAAAAAB3RvTWVWaWEAAQAFAAAAAAEBAAEiAAAAAAp0b01lSGFuZGxlAAEABQAAAAABAQABIgAAAAADdG9VAAEABQAAAAABAQABIgAAAAAGdG9VVmlhAAEABQAAAAABAQABIgAAAAAGZXhSYXRlAAEABgAAAAAAAQECAAEC/QAACRhOcqAAAAAAAAtpbnN0cnVjdGlvbgABAAUAAAAAAQEAAfsCWAAAAAAGbWluQW10AAEABgAAAAAAAQECAAEC/QAAAOjUpRAAAAAAAAZtYXhBbXQAAQAGAAAAAAABAQIAAQL9AAAA6NSlEAAAAAAABmFjdGl2ZQABAAEAAAAAAAAAB215U3RvcmUAAQABAAAAAAAAAAEAAQsEdG9NZQd0b01lVmlhCnRvTWVIYW5kbGUDdG9VBnRvVVZpYQZleFJhdGULaW5zdHJ1Y3Rpb24GbWluQW10Bm1heEFtdAZhY3RpdmUHbXlTdG9yZQAAAAAAAAAAAA=="} {"contract":"mainnet 2UDyunHraJLbg1TqjYBHhVoaCetPKcy5aAVdGYxmWZga","sha256":"a3353b8902cb43493e3f2748ee49e9d30b41039ca87e7ef65c82b5b9cf7bcada","indexStructures":"74eaa293ce4381e8af811c0b929a7993cbe2ef218f2f7f9452e0f3dbf0d4ed03","bytes":"AhXVC/KBoXOMVjfhmUg5RJO8HV2kSxbGS8a+p1IpXs9fAAAAAAABAQAAASj+fLksRsQidDbOM1gGlYam9hkWlYmgY835HLI6FUqTAAEEbm90ZRYEEgR0eXBlEgZvYmplY3QSCnByb3BlcnRpZXMWARIHbWVzc2FnZRYDEgR0eXBlEgZzdHJpbmcSCW1heExlbmd0aAN+Eghwb3NpdGlvbgMAEgdpbmRpY2VzFQEWAxIEbmFtZRIHbWVzc2FnZRIKcHJvcGVydGllcxUBFgESB21lc3NhZ2USA2FzYxIGdW5pcXVlEwASFGFkZGl0aW9uYWxQcm9wZXJ0aWVzEwAAAAAAAAAAAAAAAQRub3RlAAEHbWVzc2FnZQsAAT8AAAAAAAAAAAEHbWVzc2FnZQsAAT8AAAAAAAAAAAEHbWVzc2FnZQdtZXNzYWdlAQdtZXNzYWdlAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIALQABAAIAAAEBAQdtZXNzYWdlAAEABQAAAAABAAE/AAAAAAEAAAAAAAAAAAAAAA=="} diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v3/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v3/mod.rs index 41ee58eae72..2817fb11f69 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v3/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v3/mod.rs @@ -256,7 +256,7 @@ mod tests { .unwrap(); assert_eq!( hex::encode(root), - "c320fa9fe6a942ee34cc802dec5d3bf78721ad6bb85f5bb6c84e866c75abeb59", + "4d8f2a9f55e6fdeb24307bea0c7faadcfa91baa93e0b5e72bd03865ed1803c69", "activation includes the current system contracts and retained-history migration", ); roots.push(root); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index e9d2d495129..6916e42eb49 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -731,7 +731,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "40f40fe12c6ad67443838d6c8fb64b7240e721263a57f43e600eb67ea7dd44eb" + "3af8b4d7945db95a0a5acbc5777278dc9d54b92247b2fa5381ce17525bf63ec2" ); assert_eq!( pool.platform @@ -2182,39 +2182,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "6a823c2db94612652926e6b9c1ae7feec9c2547dfd4cfc4e474318da1c16d59c", - "dfeec92d2a0d7658cd6498a64e58a4f494b6887ab1209f6ae820601fbcc6974e", + "2e364d1f94204d0e69a5deb1c151aae69bd8e8b0c6bfa2887aa3590eb778f9a3", ), "valid-payment" => ( "9388d5cf63c4a571b9fb009679e2e34d0b681acc88f0f5195e7287c7efbd92f7", - "9ff0a24bc5104f91f6c63dc71d2c9464a179c3c434a2ed364ab9e930cfb257cc", + "44dfbad3721901f165edbce5ea92c91a22d9b1e125cc801a52d4938bd01d3454", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "6b024ded4150416ee95e109df3e5fc21dff1857022acf1a11857f72ecfd60c2a", + "afa7559900bbecf9840d19cd4e9af013d887ab642fdfa37db7d7d4f75618aaed", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "48acbe951c777fd3cecfe2aa0c49a9bda12ddc12d76c6515e1606ae7cbddb771", + "045f516d45879d472b62a23eb1d8675d5d79777ac1f07af0712328292b9ae5aa", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "dbb2d44249fe6b4561e4e5e66764ba4b2876a56db09a909b634cfbbd00a9714f", + "b2d0b86d502164041844eb4ecfff816b4970dc1d0725df15a2117384541404a8", ), "open-mint-group" => ( "5c68381297c9bf68c5e85ebe6bb9e0f1289e2c07718eecfb5e6773d6009455bb", - "e3a869f4ad3210eebcdad74e939fe394d888d3345e6a22c11191d237fe74ba59", + "c9629605b64e1ee61ce490738253f604fea7794ad99a76f362ab9360cd4bbaf5", ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "26f9d7361c8c072ca94c9692a7a9e301cd0bf6728f38e9d1c1a7dfb501ef1396", + "39a37c39aef7699d72c2ac669c3bb177500fe7667f3bc13d61e010c6de52578b", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", - "30c397c4f57517a9da1bffc13b846777277b9f6685cd01b8079485a460682baf", + "3e6f0e1f5e8eec10b71dda07ca3c72b8dae61aa898fda7dbad9053319cbfbd32", ), "closing-burn-group" => ( "93e1732286f168c4a91d2765931e181c5721bfca1bfd5f93b8baacca03e00b2b", - "abfe985eb3634e18c67744f3ae0bee9746693a39ee733b7f8779de1f4b77075c", + "94ac1e01e41b10927a1fc6e3e9e3334ec41b89dd32fd3a37cbdae14fa5a78590", ), _ => unreachable!("enumerated characterization case"), }; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs index c64a4b175d7..6f9aa110f34 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs @@ -127,6 +127,10 @@ const UNGRANTED_TEXT: &str = "ungrantedText"; const WINDOWED_TEXT: &str = "windowedText"; /// The same, with a rule for deleting a document past the hour. const SETTLED_TEXT: &str = "settledText"; +/// The same, under a rule two members meet, which counts a member the leader added only +/// for documents created after its addition (the default once a rule asks for more than +/// one approval). +const PREDATED_TEXT: &str = "predatedText"; /// A document type holding a `text`, with `extra` merged into its schema. fn text_schema(extra: Value) -> Value { @@ -202,6 +206,7 @@ fn description_host_moderated_by( DELETABLE_TEXT, WINDOWED_TEXT, SETTLED_TEXT, + PREDATED_TEXT, ] .into_iter() .map(|name| { @@ -279,6 +284,18 @@ fn description_host_moderated_by( }, })), )); + document_types.push(( + PREDATED_TEXT, + text_schema(platform_value!({ + "canBeDeleted": false, + "required": ["text", "$createdAt", "$updatedAt"], + "moderatorAbilities": { + "delete": true, + "deleteWithin": 3600, + "deleteSettled": { "approvals": 2 }, + }, + })), + )); } for (name, schema) in document_types { contract @@ -1963,6 +1980,7 @@ async fn should_take_the_description_from_a_moderated_text_of_an_elected_contrac (&elected, SETTLED_TEXT), (&owner_moderated, TEXT), (&owner_protecting, TEXT), + (&elected, PREDATED_TEXT), ] { let id = describe( &platform, @@ -2095,9 +2113,16 @@ async fn should_take_the_description_from_a_moderated_text_of_an_elected_contrac ); // A moderated type the contract's elected team is given no deletion on: once a team is - // seated, nobody could remove the text. And one moderators delete only for an hour after - // its last change, with no rule for later: the text would outlast the hour for good - for unremovable in [UNGRANTED_TEXT, WINDOWED_TEXT] { + // seated, nobody could remove the text. One moderators delete only for an hour after + // its last change, with no rule for later: the text would outlast the hour for good. And + // one whose rule for later counts a member the leader added only for documents created + // after its addition: a text older than the members the rule takes would stay for good + assert!(elected + .document_type_for_name(PREDATED_TEXT) + .expect("expected the type") + .moderator_settled_deletion() + .is_some_and(|rule| rule.approvers_predate_document)); + for unremovable in [UNGRANTED_TEXT, WINDOWED_TEXT, PREDATED_TEXT] { let (transition, _) = create_transition( &contract, &mut proposer, @@ -2118,7 +2143,7 @@ async fn should_take_the_description_from_a_moderated_text_of_an_elected_contrac "{unremovable}: {error:?}" ); } - // while a window with a rule for deleting past it qualifies + // while a window with a rule for deleting past it that counts every member qualifies let (settled, _) = create_transition( &contract, &mut proposer, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 99f71d80570..fdcd1878e36 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -737,8 +737,9 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// once for the named type as a whole, reading no `$property`; a `when` may also look /// into `$electedAbilities` (the abilities the type's contract gives its elected /// moderation team on the type) and test with `present` or `absent` whether the type -/// declares `moderatorAbilities.deleteWithin` or `moderatorAbilities.deleteSettled`. The native schema check of note 7 admits the keys and meta-schema v3 refuses -/// them. It parses to `DocumentPropertyReferenceTarget::DynamicDocument`, +/// declares `moderatorAbilities.deleteWithin` or `moderatorAbilities.deleteSettled`, +/// or whether that rule sets `approversPredateDocument`. The native schema check of +/// note 7 admits the keys and meta-schema v3 refuses them. It parses to `DocumentPropertyReferenceTarget::DynamicDocument`, /// appended. Such a declaration carries nothing else (no `contractId`, `documentType`, /// `findBy`, `where`, `inList`, `consume` or `constraints`), is no operand of a reference /// expression, sits on no typed array's elements and in no `ownerRefersTo` or diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index 300bbdb0a0f..2b37baeecc1 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -360,6 +360,18 @@ export type DocumentTypeSlotConditionString = | '$property' | { const: string }; +/** + * A setting of the referenced document type a `present` or an `absent` + * tests: whether the type declares `moderatorAbilities.deleteWithin` or + * `moderatorAbilities.deleteSettled`, or whether that rule counts a member + * the team's leader added only for documents created after its addition + * (`approversPredateDocument`, its default applied). + */ +export type DocumentTypeSlotConditionSetting = + | '$moderatorAbilities.deleteWithin' + | '$moderatorAbilities.deleteSettled' + | '$moderatorAbilities.deleteSettled.approversPredateDocument'; + /** * A condition of a slot's `when`, in the forms of a `propertyConstraints` * condition, read against the referenced document type instead of a @@ -368,9 +380,8 @@ export type DocumentTypeSlotConditionString = * empty where the type declares none, or into `'$electedAbilities'`, the * abilities the type's contract gives its elected moderation team on the * type (`'deleteDocuments'`, `'changeDocumentFields'`, ...), empty where it - * elects none or leaves the type out. `present` and `absent` test whether - * the type declares `moderatorAbilities.deleteWithin` or - * `moderatorAbilities.deleteSettled`. + * elects none or leaves the type out. `present` and `absent` test a setting + * of the type (`DocumentTypeSlotConditionSetting`). */ export type DocumentTypeSlotCondition = | { equal: [DocumentTypeSlotConditionString, DocumentTypeSlotConditionString] } @@ -387,8 +398,8 @@ export type DocumentTypeSlotCondition = '$property' | { const: string }, ]; } - | { present: '$moderatorAbilities.deleteWithin' | '$moderatorAbilities.deleteSettled' } - | { absent: '$moderatorAbilities.deleteWithin' | '$moderatorAbilities.deleteSettled' } + | { present: DocumentTypeSlotConditionSetting } + | { absent: DocumentTypeSlotConditionSetting } | { not: DocumentTypeSlotCondition } | { anyOf: Array } | { allOf: Array } diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index 1230a57479f..8615bb3f7f8 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -1212,7 +1212,12 @@ describe('DataContract — refersTo declarations (v14)', () => { { anyOf: [ { absent: '$moderatorAbilities.deleteWithin' }, - { present: '$moderatorAbilities.deleteSettled' }, + { + allOf: [ + { present: '$moderatorAbilities.deleteSettled' }, + { absent: '$moderatorAbilities.deleteSettled.approversPredateDocument' }, + ], + }, ], }, ],