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/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/contract-keywords/refers-to-dynamic.md b/book/src/contract-keywords/refers-to-dynamic.md index fb03c340bc3..bbe1bb697d5 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,46 @@ 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. | +| `"$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 | |---|---| | `{ "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`; 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. | 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" }, + { "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 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. + ## 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: @@ -193,6 +221,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/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/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 358ec3ffd26..d3c3ca3b7bf 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,227 @@ 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` 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 +(`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 | +| `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", "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` | + +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", + "contractId": "$self.descriptionContractId", + "documentType": "$self.descriptionDocumentType", + "documentTypeRequirements": { + "when": , + "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", "ownerProtected": false } + } +} +``` + +where `` is + +```json +{ "allOf": [ + { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, + { "anyOf": [ + { "absent": "$moderatorAbilities.deleteWithin" }, + { "allOf": [ + { "present": "$moderatorAbilities.deleteSettled" }, + { "absent": "$moderatorAbilities.deleteSettled.approversPredateDocument" } + ] } + ] } +] } +``` + +and `` is + +```json +{ "allOf": [ + { "not": { "contains": ["$moderatorAbilities.deleteKeepsFields", "$property"] } }, + { "not": { "contains": ["$moderatorAbilities.changeFields", "$property"] } } +] } +``` + +Checked when the proposal is created, and again when an edit points it +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 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`), 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) | + +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. 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. +- 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. 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 + 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. + +| 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: 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 + 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, 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 +most-votes rule apply. + ## From the SDKs The Rust SDK (`dash_sdk::platform::decision_proposals`), the JavaScript SDKs @@ -130,3 +354,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..5df59368770 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,72 @@ 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` 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, 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 | +| --- | --- | --- | +| `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 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 | + +Indexes: `byOwner` (`$ownerId`, `$createdAt`) lists an identity's proposals; +`byCreatedAt` (`$createdAt`) lists the newest. + +## `choice` + +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 | +| --- | --- | --- | +| `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, 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 + 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, and among choices entered in the same block to the lower document + id. + 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..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 @@ -186,5 +186,351 @@ ], "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 + }, + "descriptionId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "moderatedDocument", + "contractId": "$self.descriptionContractId", + "documentType": "$self.descriptionDocumentType", + "documentTypeRequirements": { + "when": { + "allOf": [ + { + "contains": [ + "$electedAbilities", + { + "const": "deleteDocuments" + } + ] + }, + { + "anyOf": [ + { + "absent": "$moderatorAbilities.deleteWithin" + }, + { + "allOf": [ + { + "present": "$moderatorAbilities.deleteSettled" + }, + { + "absent": "$moderatorAbilities.deleteSettled.approversPredateDocument" + } + ] + } + ] + } + ] + }, + "properties": { + "body": { + "name": "$any", + "type": "string", + "when": { + "allOf": [ + { + "not": { + "contains": [ + "$moderatorAbilities.deleteKeepsFields", + "$property" + ] + } + }, + { + "not": { + "contains": [ + "$moderatorAbilities.changeFields", + "$property" + ] + } + } + ] + } + }, + "bodyBytes": { + "name": "$any", + "type": "array", + "byteArray": true, + "when": { + "allOf": [ + { + "not": { + "contains": [ + "$moderatorAbilities.deleteKeepsFields", + "$property" + ] + } + }, + { + "not": { + "contains": [ + "$moderatorAbilities.changeFields", + "$property" + ] + } + } + ] + } + } + }, + "require": { + "anyOf": [ + "body", + "bodyBytes" + ] + } + } + }, + "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": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "contract", + "contractRequirements": { + "moderation": "elected", + "ownerProtected": false + } + }, + "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": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "The document type of the description document", + "position": 3 + }, + "url": { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 512, + "maxBytes": 512, + "description": "Where the proposal is discussed", + "position": 4 + }, + "open": { + "type": "boolean", + "description": "Whether anyone may enter choices; only the proposal's owner may when it is false", + "position": 5 + } + }, + "required": [ + "$createdAt", + "$updatedAt", + "title", + "descriptionId", + "descriptionContractId", + "descriptionDocumentType", + "open" + ], + "immutable": [ + "open", + { + "property": "title", + "when": { + "greaterThan": [ + { + "subtract": [ + "$updatedAt", + "$createdAt" + ] + }, + 7200000 + ] + } + }, + { + "property": "descriptionId", + "when": { + "greaterThan": [ + { + "subtract": [ + "$updatedAt", + "$createdAt" + ] + }, + 7200000 + ] + } + }, + { + "property": "descriptionContractId", + "when": { + "greaterThan": [ + { + "subtract": [ + "$updatedAt", + "$createdAt" + ] + }, + 7200000 + ] + } + }, + { + "property": "descriptionDocumentType", + "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. 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", + "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..f5032f6e9a6 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,237 @@ 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 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)); + 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_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 + ), + ( + 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_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", "$property"] } }, + { "not": { "contains": ["$moderatorAbilities.changeFields", "$property"] } } + ] + }); + assert_eq!( + question["properties"][multiple_choice_proposal::properties::DESCRIPTION_ID] + ["refersTo"], + serde_json::json!({ + "type": "moderatedDocument", + "contractId": format!( + "$self.{}", + multiple_choice_proposal::properties::DESCRIPTION_CONTRACT_ID + ), + "documentType": format!( + "$self.{}", + 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 that counts + // every member of the team, whenever the document was created + "when": { "allOf": [ + { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, + { "anyOf": [ + { "absent": "$moderatorAbilities.deleteWithin" }, + { "allOf": [ + { "present": "$moderatorAbilities.deleteSettled" }, + { "absent": + "$moderatorAbilities.deleteSettled.approversPredateDocument" } + ] } + ] } + ] }, + "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, 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", "ownerProtected": false } + }) + ); + 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"], + "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..ed0f96862b7 100644 --- a/packages/decision-proposals-contract/src/v1/mod.rs +++ b/packages/decision-proposals-contract/src/v1/mod.rs @@ -31,6 +31,73 @@ 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 [`choice`]s when it is open, and only its owner when it is not; whoever + /// 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"; + /// 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, 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. + 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. + 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 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"; + + 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..639c6172f2c 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,265 @@ describe('Decision Proposals Contract', () => { }); }); }); + + describe('multipleChoiceProposal', () => { + // 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?', + descriptionId: await generateRandomIdentifier(), + descriptionContractId: await generateRandomIdentifier(), + descriptionDocumentType: 'post', + url: 'https://www.dash.org/forum/threads/block-time.1/', + open: true, + }); + + it('should be valid', async () => { + expect(validateType('multipleChoiceProposal', await rawQuestion()).isValid()).to.be.true(); + }); + + 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 and its description 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', + 'descriptionId', + 'descriptionContractId', + 'descriptionDocumentType', + 'url', + ].map((property) => ({ + property, + when: { greaterThan: [{ subtract: ['$updatedAt', '$createdAt'] }, 7200000] }, + })), + ]); + }); + + 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', '$property'] } }, + { not: { contains: ['$moderatorAbilities.changeFields', '$property'] } }, + ], + }; + + expect(properties.descriptionId.refersTo).to.deep.equal({ + type: 'moderatedDocument', + 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' }, + { + allOf: [ + { present: '$moderatorAbilities.deleteSettled' }, + { absent: '$moderatorAbilities.deleteSettled.approversPredateDocument' }, + ], + }, + ], + }, + ], + }, + 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', ownerProtected: false }, + }); + }); + + 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 = { ...question, open }; + + expect(validateType('multipleChoiceProposal', raw).isValid()).to.be.true(); + }); + }); + + it('should refuse an open that is not a boolean', async () => { + const raw = await 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', + 'descriptionId', + 'descriptionContractId', + 'descriptionDocumentType', + 'open', + ].forEach((property) => { + it(`should require ${property}`, async () => { + const raw = await 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', async () => { + const raw = await 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', async () => { + const raw = await 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/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/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index b3d868c94f8..51f73974ace 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)? @@ -1164,8 +1168,8 @@ fn parse_indices( .admit_range_countable_implies_countable, no_locking_resolution: ctx.generation.admit_index_no_locking_resolution, }, - ) - .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/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 4a98bd8aebc..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 @@ -3,14 +3,17 @@ //! 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, + SLOT_CONDITION_SETTLED_APPROVERS_PREDATE_DOCUMENT, }; use crate::data_contract::document_type::{property_names, DocumentTypeRef}; use crate::data_contract::errors::DataContractError; @@ -67,6 +70,16 @@ 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), + } + } +} + fn refused(message: String) -> DataContractError { DataContractError::InvalidContractStructure(message) } @@ -263,10 +276,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 @@ -321,7 +335,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( @@ -440,20 +481,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()?; @@ -553,7 +587,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 { @@ -650,15 +685,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 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: {}", + ModerationAbility::ALL + .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\": \ @@ -715,9 +772,30 @@ 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, + Some(SLOT_CONDITION_SETTLED_APPROVERS_PREDATE_DOCUMENT) => { + SlotConditionSetting::SettledApproversPredateDocument + } + _ => { + return Err(refused(format!( + "{at} when {key} tests \"{SLOT_CONDITION_DELETE_WITHIN}\", \ + \"{SLOT_CONDITION_DELETE_SETTLED}\" or \ + \"{SLOT_CONDITION_SETTLED_APPROVERS_PREDATE_DOCUMENT}\", 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/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/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..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 @@ -31,18 +31,28 @@ 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; 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. -const SLOT_CONDITION_KEYS: [&str; 10] = [ +const SLOT_CONDITION_KEYS: [&str; 12] = [ "equal", "notEqual", "in", "notIn", "contains", + "present", + "absent", "not", "anyOf", "allOf", @@ -376,7 +386,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 +404,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 +522,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 b8090fb7f6e..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 @@ -10,7 +10,9 @@ use super::reference_test_helpers::{ use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; 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, @@ -18,8 +20,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::{ @@ -147,7 +150,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) }) @@ -266,6 +269,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, }), } ); @@ -782,7 +786,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)); @@ -1145,7 +1149,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", @@ -1226,11 +1234,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"] }), @@ -1372,7 +1380,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 @@ -1985,3 +1993,173 @@ 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" }, + { "allOf": [ + { "present": "$moderatorAbilities.deleteSettled" }, + { "absent": "$moderatorAbilities.deleteSettled.approversPredateDocument" } + ] } + ] } + ] }) + }; + 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::AllOf(SlotConditions::new(vec![ + SlotCondition::Present(SlotConditionSetting::DeleteSettled), + SlotCondition::Absent(SlotConditionSetting::SettledApproversPredateDocument), + ])), + ])), + ]))) + ); + // 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 those that say how long a document stays deletable + refused( + json!({ "absent": "$moderatorAbilities.delete" }), + "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"] }), + "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/index/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs index 8f37704ea6c..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; @@ -23,7 +24,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; @@ -199,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))] @@ -211,6 +218,83 @@ 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. 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. + 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 { @@ -2064,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, @@ -2247,10 +2372,12 @@ 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()) - })?; + 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 @@ -6772,6 +6899,128 @@ mod tests { ); } + /// `"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_is_read_from_protocol_version_15() { + let index_map = contested_unique_index_map(2); + let unknown = "contested index resolution unknown: 2"; + 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()), + ), + ] { + let error = result.expect_err(parse); + assert!( + matches!(&error, DataContractError::ValueWrongType(message) if message.contains(unknown)), + "{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 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]); + 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 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![ 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..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 @@ -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,48 @@ 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(); + 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, + 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: settled_deletion.is_some(), + settled_approvers_predate_document: settled_deletion + .is_some_and(|rule| rule.approvers_predate_document), + }; + 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 +538,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..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 @@ -78,9 +78,12 @@ 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, + 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 369ac0c2910..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 @@ -47,6 +47,19 @@ 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 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 @@ -77,6 +90,41 @@ 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, + /// `$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 { + /// 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, + SlotConditionSetting::SettledApproversPredateDocument => { + SLOT_CONDITION_SETTLED_APPROVERS_PREDATE_DOCUMENT + } + } + } } impl SlotConditionList { @@ -85,6 +133,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 +162,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 +180,28 @@ 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, + /// 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<'_> { + fn declares(&self, setting: SlotConditionSetting) -> bool { + match setting { + SlotConditionSetting::DeleteWithin => self.deletion_window, + SlotConditionSetting::DeleteSettled => self.settled_deletion, + SlotConditionSetting::SettledApproversPredateDocument => { + self.settled_approvers_predate_document + } + } + } } impl SlotConditionValue { @@ -205,6 +280,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 +306,7 @@ impl SlotCondition { SlotConditionList::ModeratorChangeableFields => { subject.moderator_changeable_fields } + SlotConditionList::ElectedAbilities => subject.elected_abilities, }; list.contains(value.read(subject)) } @@ -254,6 +336,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 +372,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 +540,10 @@ mod tests { property_name, deletion_kept_fields: kept, moderator_changeable_fields: changeable, + elected_abilities: &none, + deletion_window: false, + settled_deletion: false, + settled_approvers_predate_document: false, }) }; @@ -464,6 +577,10 @@ mod tests { property_name: "body", deletion_kept_fields: &empty, moderator_changeable_fields: &empty, + elected_abilities: &empty, + deletion_window: false, + settled_deletion: false, + settled_approvers_predate_document: false, }; let name_is = |name: &str| { SlotCondition::Equal( @@ -525,6 +642,78 @@ 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 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![ + SlotCondition::Contains( + SlotConditionList::ElectedAbilities, + SlotConditionValue::Constant("deleteDocuments".to_string()), + ), + SlotCondition::AnyOf(SlotConditions::new(vec![ + SlotCondition::Absent(SlotConditionSetting::DeleteWithin), + SlotCondition::AllOf(SlotConditions::new(vec![ + SlotCondition::Present(SlotConditionSetting::DeleteSettled), + SlotCondition::Absent(SlotConditionSetting::SettledApproversPredateDocument), + ])), + ])), + ])); + 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, approvers_predate| { + condition.holds(&SlotConditionSubject { + kind: DocumentReferenceKind::Moderated, + property_name: "", + deletion_kept_fields: &empty, + moderator_changeable_fields: &empty, + elected_abilities: &kept(abilities), + deletion_window, + settled_deletion, + settled_approvers_predate_document: approvers_predate, + }) + }; + 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, false)); + assert!(!holds(&["changeDocumentFields"], false, false, false)); + // A window with nobody to delete past it + 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!( + condition + .to_schema_value() + .try_into_validating_json() + .expect("converts"), + serde_json::json!({ "allOf": [ + { "contains": ["$electedAbilities", { "const": "deleteDocuments" }] }, + { "anyOf": [ + { "absent": "$moderatorAbilities.deleteWithin" }, + { "allOf": [ + { "present": "$moderatorAbilities.deleteSettled" }, + { "absent": + "$moderatorAbilities.deleteSettled.approversPredateDocument" } + ] } + ] } + ] }) + ); + 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/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..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,11 +56,15 @@ 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, MasternodeVoteNoLocking, + /// Appended: the bincode discriminant is the variant position. + MasternodeVoteOnDocuments, } /// [`IndexCountability`]. @@ -195,6 +199,9 @@ impl From<&Index> for StoredIndex { ContestedIndexResolution::MasternodeVoteNoLocking => { StoredContestedResolution::MasternodeVoteNoLocking } + ContestedIndexResolution::MasternodeVoteOnDocuments => { + StoredContestedResolution::MasternodeVoteOnDocuments + } }, }), countable: match countable { @@ -304,6 +311,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..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 @@ -140,9 +140,13 @@ impl DataContractInSerializationFormat { .schema .document_type_schema, ); - if let Ok(index) = - Index::try_from_value_map(index_value_map.as_slice(), admissions) - { + // 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 { @@ -206,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 { @@ -264,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" + ); + } + } } 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-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-dpp/tests/fixtures/stored_contracts/stored.jsonl b/packages/rs-dpp/tests/fixtures/stored_contracts/stored.jsonl index df308a1da6a..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":"efad21f1588fdd49cd548ff58222a3d192431c7bf36f845424677b373a3225d5","indexStructures":"698c4be216b9f738aecb57d7637774536c0c6b955d5d71b8d9267fc05f658c6d","bytes":"AihRCgKmaz7aBrhvyubaqp21d2bTw3JcyFpYzR6uMcvIAgAAAAABAQAAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQhwcm9wb3NhbBYKEgR0eXBlEgZvYmplY3QSEGRvY3VtZW50c011dGFibGUTARIUZG9jdW1lbnRzS2VlcEhpc3RvcnkTARIMY2FuQmVEZWxldGVkEwASB2luZGljZXMVAxYCEgRuYW1lEglieUVuZERhdGUSCnByb3BlcnRpZXMVAhYBEgdlbmREYXRlEgNhc2MWARIKJGNyZWF0ZWRBdBIDYXNjFgISBG5hbWUSB2J5T3duZXISCnByb3BlcnRpZXMVAhYBEggkb3duZXJJZBIDYXNjFgESCiRjcmVhdGVkQXQSA2FzYxYCEgRuYW1lEgtieUNyZWF0ZWRBdBIKcHJvcGVydGllcxUBFgESCiRjcmVhdGVkQXQSA2FzYxIKcHJvcGVydGllcxYGEgV0aXRsZRYGEgR0eXBlEgZzdHJpbmcSCW1pbkxlbmd0aAIBEgltYXhMZW5ndGgCgBIIbWF4Qnl0ZXMC+wIAEgtkZXNjcmlwdGlvbhIZVGhlIHF1ZXN0aW9uLCBpbiBvbmUgbGluZRIIcG9zaXRpb24CABILZGVzY3JpcHRpb24WBhIEdHlwZRIGc3RyaW5nEgltaW5MZW5ndGgCARIJbWF4TGVuZ3RoAvsQABIIbWF4Qnl0ZXMC+xAAEgtkZXNjcmlwdGlvbhIYV2hhdCBhIHllcyBhbmQgYSBubyBtZWFuEghwb3NpdGlvbgIBEgN1cmwWBxIEdHlwZRIGc3RyaW5nEgZmb3JtYXQSA3VyaRIJbWluTGVuZ3RoAgESCW1heExlbmd0aAL7AgASCG1heEJ5dGVzAvsCABILZGVzY3JpcHRpb24SH1doZXJlIHRoZSBwcm9wb3NhbCBpcyBkaXNjdXNzZWQSCHBvc2l0aW9uAgISC3Bhc3NQZXJjZW50FgUSBHR5cGUSB2ludGVnZXISB21pbmltdW0CMhIHbWF4aW11bQJQEgtkZXNjcmlwdGlvbhJwVGhlIHByb3Bvc2FsIHBhc3NlcyB3aGVuIHRoZSB5ZXMgdm90aW5nIHBvd2VyIGlzIHN0cmljdGx5IG1vcmUgdGhhbiB0aGlzIHBlcmNlbnQgb2YgdGhlIHllcyBwbHVzIG5vIHZvdGluZyBwb3dlchIIcG9zaXRpb24CAxIVbWluaW11bVR1cm5vdXRQZXJjZW50FgUSBHR5cGUSB2ludGVnZXISB21pbmltdW0CABIHbWF4aW11bQIyEgtkZXNjcmlwdGlvbhKKVGhlIGxlYXN0IHllcyBwbHVzIG5vIHZvdGluZyBwb3dlciB0aGUgcHJvcG9zYWwgbmVlZHMsIGFzIGEgcGVyY2VudCBvZiB0aGUgbWFzdGVybm9kZSBsaXN0J3MgdG90YWwgdm90aW5nIHBvd2VyIHdoZW4gaXQgY2xvc2VzLCByb3VuZGVkIHVwEghwb3NpdGlvbgIEEgdlbmREYXRlFgQSBHR5cGUSB2ludGVnZXISB21pbmltdW0CABILZGVzY3JpcHRpb24SOFdoZW4gdm90aW5nIGNsb3NlcywgaW4gbWlsbGlzZWNvbmRzIHNpbmNlIHRoZSBVbml4IGVwb2NoEghwb3NpdGlvbgIFEghyZXF1aXJlZBUHEgokY3JlYXRlZEF0EgokdXBkYXRlZEF0EgV0aXRsZRILZGVzY3JpcHRpb24SC3Bhc3NQZXJjZW50EhVtaW5pbXVtVHVybm91dFBlcmNlbnQSB2VuZERhdGUSCWltbXV0YWJsZRUGEgtwYXNzUGVyY2VudBIVbWluaW11bVR1cm5vdXRQZXJjZW50EgdlbmREYXRlFgISCHByb3BlcnR5EgV0aXRsZRIEd2hlbhYBEgtncmVhdGVyVGhhbhUCFgESCHN1YnRyYWN0FQISCiR1cGRhdGVkQXQSCiRjcmVhdGVkQXQWARIDbWluFQIC/AUmXAAWARIGZGl2aWRlFQIWARIIc3VidHJhY3QVAhIHZW5kRGF0ZRIKJGNyZWF0ZWRBdAIFFgISCHByb3BlcnR5EgtkZXNjcmlwdGlvbhIEd2hlbhYBEgtncmVhdGVyVGhhbhUCFgESCHN1YnRyYWN0FQISCiR1cGRhdGVkQXQSCiRjcmVhdGVkQXQWARIDbWluFQIC/AUmXAAWARIGZGl2aWRlFQIWARIIc3VidHJhY3QVAhIHZW5kRGF0ZRIKJGNyZWF0ZWRBdAIFFgISCHByb3BlcnR5EgN1cmwSBHdoZW4WARILZ3JlYXRlclRoYW4VAhYBEghzdWJ0cmFjdBUCEgokdXBkYXRlZEF0EgokY3JlYXRlZEF0FgESA21pbhUCAvwFJlwAFgESBmRpdmlkZRUCFgESCHN1YnRyYWN0FQISB2VuZERhdGUSCiRjcmVhdGVkQXQCBRIUYWRkaXRpb25hbFByb3BlcnRpZXMTABILZGVzY3JpcHRpb24S9kEgeWVzL25vIHF1ZXN0aW9uIHB1dCB0byB0aGUgbWFzdGVybm9kZXMuIENyZWF0aW5nIG9uZSBvcGVucyBpdHMgdm90ZSBwb2xsLCBmdW5kZWQgYnkgaXRzIG93bmVyLiBJdHMgdGl0bGUsIGRlc2NyaXB0aW9uIGFuZCB1cmwgbWF5IGJlIGVkaXRlZCBmb3IgYSBkYXkgYWZ0ZXIgaXQgaXMgY3JlYXRlZCwgb3IgYSBmaWZ0aCBvZiB0aGUgdGltZSBpdCBydW5zIGlmIHRoYXQgaXMgc2hvcnRlcjsgaXRzIHJ1bGUgbmV2ZXIgY2hhbmdlcwAAAAAAAAAAAAABCHByb3Bvc2FsAAYFdGl0bGULAQEBgAH7AgABAAAAAAAAC2Rlc2NyaXB0aW9uCwEBAfsQAAH7EAABAAAAAAAAA3VybAsBAQH7AgAB+wIAAAAAAAAAAAtwYXNzUGVyY2VudAgBAAAAAAAAFW1pbmltdW1UdXJub3V0UGVyY2VudAgBAAAAAAAAB2VuZERhdGUCAQAAAAAAAAYFdGl0bGULAQEBgAH7AgABAAAAAAAAC2Rlc2NyaXB0aW9uCwEBAfsQAAH7EAABAAAAAAAAA3VybAsBAQH7AgAB+wIAAAAAAAAAAAtwYXNzUGVyY2VudAgBAAAAAAAAFW1pbmltdW1UdXJub3V0UGVyY2VudAgBAAAAAAAAB2VuZERhdGUCAQAAAAAAAAMLYnlDcmVhdGVkQXQLYnlDcmVhdGVkQXQBCiRjcmVhdGVkQXQBAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAlieUVuZERhdGUJYnlFbmREYXRlAgdlbmREYXRlAQokY3JlYXRlZEF0AQABAAAAAAAAAAAAAAAAAAAAAAAAAAAHYnlPd25lcgdieU93bmVyAggkb3duZXJJZAEKJGNyZWF0ZWRBdAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwokY3JlYXRlZEF0CiR1cGRhdGVkQXQLZGVzY3JpcHRpb24HZW5kRGF0ZRVtaW5pbXVtVHVybm91dFBlcmNlbnQLcGFzc1BlcmNlbnQFdGl0bGUAAwdlbmREYXRlFW1pbmltdW1UdXJub3V0UGVyY2VudAtwYXNzUGVyY2VudAMLZGVzY3JpcHRpb24ABAgFAAEABQAAAAwCAPwKTLgACQgBB2VuZERhdGUABQAAAAAKBXRpdGxlAAQIBQABAAUAAAAMAgD8Cky4AAkIAQdlbmREYXRlAAUAAAAACgN1cmwABAgFAAEABQAAAAwCAPwKTLgACQgBB2VuZERhdGUABQAAAAAKAAABAAAAAQAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA+wEPAAEAAgAAAQEGBXRpdGxlAAEABQAAAAABAQEBgAH7AgAAAAALZGVzY3JpcHRpb24AAQAFAAAAAAEBAQH7EAAB+xAAAAAAA3VybAABAAUAAAAAAQEBAfsCAAH7AgAAAAEBCQALcGFzc1BlcmNlbnQAAQAGAAAAAAABAQIyAQJQAAAAABVtaW5pbXVtVHVybm91dFBlcmNlbnQAAQAGAAAAAAABAQIAAQIyAAAAAAdlbmREYXRlAAEABgAAAAAAAQECAAAAAAAAAQABBQV0aXRsZQtkZXNjcmlwdGlvbgtwYXNzUGVyY2VudBVtaW5pbXVtVHVybm91dFBlcmNlbnQHZW5kRGF0ZQAAAAAAAAAAAA=="} +{"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/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/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 3a23fd16b06..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), - "6322587059f91602b9f4d32542cc9787a266ed8b83a25bd3ce8f17e000222f7d", + "4d8f2a9f55e6fdeb24307bea0c7faadcfa91baa93e0b5e72bd03865ed1803c69", "activation includes the current system contracts and retained-history migration", ); roots.push(root); 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/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 61d9fae6da3..f479ff3e9a4 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, }; @@ -1302,13 +1303,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/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) ), ] ); 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 38309a0ddc3..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() ), - "a78e27312332ff597d9bf527c061cf59e69a6b51563e0343400bfbef6e596c03" + "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", - "26456b2f2674b799c518535bc540b10958ced63cf02ca24ffe3575d5069cbfee", + "2e364d1f94204d0e69a5deb1c151aae69bd8e8b0c6bfa2887aa3590eb778f9a3", ), "valid-payment" => ( "9388d5cf63c4a571b9fb009679e2e34d0b681acc88f0f5195e7287c7efbd92f7", - "75eee0fb5505f957be07f65fd78158e36c451e10303c6b25a8d37ce799cebe81", + "44dfbad3721901f165edbce5ea92c91a22d9b1e125cc801a52d4938bd01d3454", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "5ebbe8df6322d78e259cfbac017308edec9f0faac1c6ba137135474d0ff0c1ef", + "afa7559900bbecf9840d19cd4e9af013d887ab642fdfa37db7d7d4f75618aaed", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "ffabe9541bddcbcfa55e223f2611e7aa4691d9bc43772c6b17e0305ed7d97121", + "045f516d45879d472b62a23eb1d8675d5d79777ac1f07af0712328292b9ae5aa", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "5205a8992fd53d30138484eb9f04e011a3b4c2f7a9fc464a04d6f1ce5ef34a63", + "b2d0b86d502164041844eb4ecfff816b4970dc1d0725df15a2117384541404a8", ), "open-mint-group" => ( "5c68381297c9bf68c5e85ebe6bb9e0f1289e2c07718eecfb5e6773d6009455bb", - "b316371c068be93be5a58fce59c175ef7c6c32028103b4b031d4fe45a373ac16", + "c9629605b64e1ee61ce490738253f604fea7794ad99a76f362ab9360cd4bbaf5", ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "1e6d20b67fcefef863533d5d3d1b708c2b3a2044c5397c4e0a9efbb01722cac7", + "39a37c39aef7699d72c2ac669c3bb177500fe7667f3bc13d61e010c6de52578b", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", - "04722561bf7430db3765880d08ea128b11b249e39c1ed607c4013e0f3ff17e34", + "3e6f0e1f5e8eec10b71dda07ca3c72b8dae61aa898fda7dbad9053319cbfbd32", ), "closing-burn-group" => ( "93e1732286f168c4a91d2765931e181c5721bfca1bfd5f93b8baacca03e00b2b", - "898114233f06416d17727a955501c2ba7154236af14b3e1671500759d4861336", + "94ac1e01e41b10927a1fc6e3e9e3334ec41b89dd32fd3a37cbdae14fa5a78590", ), _ => 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..6f9aa110f34 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/multiple_choice_proposal_tests.rs @@ -0,0 +1,2193 @@ +//! 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. +//! +//! 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, +}; +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; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::ConsensusError; +use dpp::dash_to_credits; +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; +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::{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::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, +}; +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; +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 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, BTreeSet}; +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(), + } +} + +/// 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"; +/// 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"; +/// 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 { + 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) { + 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 +} + +/// 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 { + 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 owner_protected = moderators == HostModerators::ElectedProtectingItsOwner; + 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, + DELETABLE_TEXT, + WINDOWED_TEXT, + SETTLED_TEXT, + PREDATED_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, + })) + } 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 + let mut document_types = vec![ + ( + 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!({}))), + ( + 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 }, + }, + })), + )); + 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 + .set_document_schema(name, schema, true, &mut vec![], platform_version) + .expect("expected to add the document type"); + } + contract +} + +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(); + for moderators in [ + HostModerators::Elected, + HostModerators::ElectedProtectingItsOwner, + HostModerators::Owner, + ] { + platform + .drive + .apply_contract( + &description_host_moderated_by(moderators, 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 + .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 { + // 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, + 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, + ) + .await; + process_valid(platform, transition, time_ms, platform_version); + 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, + 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") +} + +/// 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, +) -> 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_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(); + 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; + + // 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, + &mut proposer, + question(&platform), + &[ + ( + multiple_choice_proposal::properties::TITLE, + Value::Text("Which mascot for Platform?".to_string()), + ), + ( + multiple_choice_proposal::properties::DESCRIPTION_ID, + Value::Identifier(rewritten.to_buffer()), + ), + ( + 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())) + ); + assert_eq!( + edited.get(multiple_choice_proposal::properties::DESCRIPTION_ID), + Some(&Value::Identifier(rewritten.to_buffer())) + ); + + // 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, + 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, + Value::Text("https://example.org/bird".to_string()), + ), + ] { + let edit = edit_transition( + &contract, + &mut proposer, + question(&platform), + &[(property, value)], + 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 + ); + } +} + +/// 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); + let owner_protecting = + description_host_moderated_by(HostModerators::ElectedProtectingItsOwner, platform_version); + 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 + .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), + (&elected, DELETABLE_TEXT), + (&elected, UNGRANTED_TEXT), + (&elected, WINDOWED_TEXT), + (&elected, SETTLED_TEXT), + (&owner_moderated, TEXT), + (&owner_protecting, TEXT), + (&elected, PREDATED_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, 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, + 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::ReferencedDocumentTypeRequirementNotMetError(_)) + ), + "{error:?}" + ); + + // 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, + 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::ReferencedDocumentTypeRequirementNotMetError(_)) + ), + "{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 moderated type the contract's elected team is given no deletion on: once a team is + // 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, + 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 that counts every member 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, + &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-drive-proof-verifier/src/types.rs b/packages/rs-drive-proof-verifier/src/types.rs index 58f800dcd87..b0e9db5857a 100644 --- a/packages/rs-drive-proof-verifier/src/types.rs +++ b/packages/rs-drive-proof-verifier/src/types.rs @@ -176,7 +176,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( @@ -187,7 +190,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, @@ -196,7 +200,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-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index cdc8991afa6..6d41d119842 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -5185,13 +5185,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" @@ -5204,7 +5204,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 f0e8edf2ce7..fc4ddfb3445 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 @@ -215,6 +215,14 @@ 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, + /// `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 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 /// 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 5d2d3f47624..509422e82d8 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 @@ -24,6 +24,9 @@ use crate::version::dpp_versions::dpp_contract_versions::{ /// document reference on an identifier property may name its target through the /// referring document (`contractId` and `documentType` written `"$self."`), with what the named /// document type must look like (`documentTypeRequirements`); +/// - `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; @@ -50,6 +53,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 // changed: also reads a reference whose target the referring document names apply_property_reference: Some(1), diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index e98239c2ab4..fdcd1878e36 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -732,8 +732,14 @@ 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 -/// them. It parses to `DocumentPropertyReferenceTarget::DynamicDocument`, +/// 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`, +/// 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 @@ -750,8 +756,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. /// Version 1 takes this reading only for a declaration version 0 cannot read at all (a /// reference to a document whose `contractId` is written `"$self."`, or a `type` listing /// kinds), so a contract stored before 15 with `"$self."` keys on any other declaration @@ -785,6 +794,21 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// at the block time, so an expired bucket is never written again after its drop. Its /// estimates keep every bucket, as generation 2's do. Protocol v14 keeps /// `add_indices_for_top_index_level_for_contract_operations` 2, which writes every bucket. +/// 37. **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, add_reference_for_index_level_for_contract_operations 1, 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 selects the latest retained revision at or before a block time, and verify_proof_keep_serialized 1 verifies it (note 31); retained-history writers, readers, estimates and verifiers select the per-type revision layout (note 35); add_new_keys_to_masternode_identity 1 adds a masternode key whose hash the identity already holds a reference for (note 32); document method versions v5: add_indices_for_top_index_level_for_contract_operations 3 writes no entry in an expired time range bucket (note 36) 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..2b37baeecc1 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 = { @@ -354,12 +360,28 @@ 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 * 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 a setting + * of the type (`DocumentTypeSlotConditionSetting`). */ export type DocumentTypeSlotCondition = | { equal: [DocumentTypeSlotConditionString, DocumentTypeSlotConditionString] } @@ -371,10 +393,13 @@ export type DocumentTypeSlotCondition = ( | '$moderatorAbilities.deleteKeepsFields' | '$moderatorAbilities.changeFields' + | '$electedAbilities' ), '$property' | { const: string }, ]; } + | { present: DocumentTypeSlotConditionSetting } + | { absent: DocumentTypeSlotConditionSetting } | { not: DocumentTypeSlotCondition } | { anyOf: Array } | { allOf: Array } @@ -1190,6 +1215,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..8615bb3f7f8 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,23 @@ 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' }, + { + allOf: [ + { present: '$moderatorAbilities.deleteSettled' }, + { absent: '$moderatorAbilities.deleteSettled.approversPredateDocument' }, + ], + }, + ], + }, + ], + }; const eitherKind = structuredClone(namedSchemas); (eitherKind.note.properties.descriptionId as { refersTo: object }).refersTo = { // Given the other way round: reported permanent first @@ -1211,6 +1229,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 +1247,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: {