diff --git a/AGENTS.md b/AGENTS.md index 8b235bc094a..316d53322ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Platform uses data contracts to define application data schemas: Use LF, UTF‑8, and a final newline. - JS/TS: ESLint (Airbnb/TypeScript rules via package configs). Use camelCase for variables/functions, PascalCase for classes; prefer kebab-case filenames within JS packages. - Rust: Follow rustfmt defaults; keep code clippy-clean. Modules `snake_case`, types `PascalCase`, constants `SCREAMING_SNAKE_CASE`. -- Rust architecture rules live in The Dash Platform Book (`book/`). Read [book/src/contributing/coding-conventions.md](book/src/contributing/coding-conventions.md) before changing versioned behaviour, validation, errors, fees, or limits; it states each rule, why it exists, and links to the chapter with the mechanics. Key rules: shipped `vN` modules are frozen and new behaviour is a new `vN` selected only by the unreleased protocol version's tables; numbers go in `SystemLimits`, fees in named `FEE_VERSION*` schedules; `platform_version` is the last parameter; no `unwrap`/`expect` on block-execution paths (a panic halts the chain); imports at the top, no inline `crate::` paths; latest-generation tests use `PlatformVersion::latest()`. +- Rust architecture rules live in The Dash Platform Book (`book/`). Read [book/src/contributing/coding-conventions.md](book/src/contributing/coding-conventions.md) before changing versioned behaviour, validation, errors, fees, or limits; it states each rule, why it exists, and links to the chapter with the mechanics. Key rules: new behaviour is a new `vN` selected only by the unreleased protocol version's tables, and a shipped `vN` is edited in place only when the edit provably cannot modify consensus there, with the reason in the PR description's "In-place changes to shipped generations" section; numbers go in `SystemLimits`, fees in named `FEE_VERSION*` schedules; `platform_version` is the last parameter; no `unwrap`/`expect` on block-execution paths (a panic halts the chain); imports at the top, no inline `crate::` paths; latest-generation tests use `PlatformVersion::latest()`. ## Testing Guidelines - Unit/integration tests live alongside each package (e.g., `packages//tests`). E2E lives in `packages/platform-test-suite`. diff --git a/book/src/contributing/coding-conventions.md b/book/src/contributing/coding-conventions.md index 65d7ae9c579..1767435c069 100644 --- a/book/src/contributing/coding-conventions.md +++ b/book/src/contributing/coding-conventions.md @@ -72,25 +72,38 @@ The mechanics of `PlatformVersion`, feature version tables, and the dispatcher shape are covered in the [Versioning](../versioning/platform-version.md) chapters. The rules here are about what to do with those mechanics. -### Shipped generations are frozen +### Shipped generations are frozen unless the change cannot modify consensus A behaviour change to a versioned method means a new `vN` module selected only -by the tables of the unreleased protocol version. It never means editing a -shipped `vN`, and that includes "harmless" edits: threading a new parameter -through it, adding a version-table check inside it, or computing a gate that is -always false for old versions. Inside the new generation the capability is a -constant fact (`Index::try_from_value_map(map, true)`), not a runtime check. - -Why: replay safety becomes structural instead of something a reviewer has to -prove about a diff. A dead version check inside `v1` misleads the next reader -into thinking `v1` can take that path. Shipped files should stay byte-identical -to what shipped. - -How: copy the previous generation into the new module, make the change there, -move the tests that exercise the new behaviour into the new module, and bump -the method's number in the new protocol version's tables only. Duplication -between generations is the accepted cost; it is cheaper than a drift-prone -flag. +by the tables of the unreleased protocol version. A shipped `vN` may be edited +in place only when we are sure the edit cannot modify consensus at any protocol +version that selects it: the new code is unreachable there by construction +(the data it acts on cannot exist under those versions, such as a keyword every +one of their meta-schemas refuses and their parser ignores, judged through a +dpp method whose own gate is `None` there), or the edit is a pure refactor with +identical output. "Probably inert" is not enough. If the argument takes more +than a sentence, or rests on a runtime check inside the shipped module, add a +generation instead. Inside a new generation the capability is a constant fact +(`Index::try_from_value_map(map, true)`), not a runtime check. + +Why: replay safety is structural when a shipped file stays byte-identical, and +becomes a proof the reviewer has to check the moment it does not. An in-place +edit is acceptable when that proof is short and written down; a dead version +check inside `v1` that misleads the next reader into thinking `v1` can take +that path is not. + +How, new generation: copy the previous generation into the new module, make +the change there, move the tests that exercise the new behaviour into the new +module, and bump the method's number in the new protocol version's tables +only. Duplication between generations is the accepted cost; it is cheaper than +a drift-prone flag. + +How, in place: make the edit, leave a comment at the edited lines naming why +they are inert for every protocol version that selects the module, and give +the pull request description an "In-place changes to shipped generations" +section that lists each edited generation, the protocol versions that select +it, and the reason consensus cannot change there. Reviewers read that section +first. ### Table versions follow protocol-version boundaries, not PRs @@ -472,6 +485,17 @@ now does. `PlatformVersion::latest()`; pin `vN/`'s tests to `PlatformVersion::get(n)`. 5. Add a test that runs both versions through the dispatcher. +**Editing a shipped generation in place** + +1. Only when the edit cannot modify consensus at any protocol version that + selects the module: unreachable by construction there, or output-identical. +2. Comment the edited lines with why they are inert for those versions. +3. Add a test that runs the module at the last shipped protocol version and + shows the outcome unchanged. +4. Add an "In-place changes to shipped generations" section to the pull + request description: each edited generation, the versions that select it, + and the reason consensus cannot change there. + **Changing a limit or a fee** 1. A number: add or update the `SystemLimits` (or `*_constants`) field, backfill diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 582ce7969ea..d90df67f139 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -296,6 +296,8 @@ The declaration lives in `packages/rs-dpp/src/data_contract/config/moderation/el **Referencing an elected contract.** A document type that must point at a contract of this kind says so in its reference: `"refersTo": { "type": "contract", "contractRequirements": { "moderation": "elected" } }`. `contractRequirements` holds what the referenced contract must declare beyond existing, each key an aspect of the contract with a closed set of values or a bound: `moderation: "elected"`, or `moderation: "electionOpen"`, which also requires the contract's own election delay to have passed since its creation, or the contract to declare none (the delay between a contract's creation and the first charter against it, so a team cannot be seated before anyone has seen the contract, set by each contract for itself). Both have a user in the charter contract: a charter proposal only needs the target to be `elected`, so teams can form during the notice, and the charter that opens the contest needs its election `electionOpen`; `minimumAgeSeconds`, a number of seconds the reference fixes, which requires the contract's recorded creation time to be at least that far before the block time of the write; `minimumSecondsSinceUpdate`, the same of the later of the contract's creation and last update times (any update restarts the clock; an elected declaration can not be added by an update, so this one is for other uses than the charter); `owner`, `"self"` requiring the referenced contract to be owned by the writer of the referring document (its `$ownerId`, a write gate like the `$ownerId` property agreement of a document reference) and `"other"` by anyone else (so a charter may forbid an owner from chartering its own team); `readonly: true`, requiring the referenced contract's config to be read-only, one that can never be updated again (which makes `minimumSecondsSinceUpdate` moot for the same target); `keepsHistory: true`, requiring its config to keep history (only `true` is declarable for either flag); and `ownerProtected`, requiring the contract's elected moderation declaration to protect the owner from the team (`true`) or to leave it unprotected (`false`), which implies elected moderation without the schema having to say so, a contract without an elected declaration meeting neither value. A contract created before contracts recorded their creation time never meets a duration, its own election delay included. Consensus checks them when the referring document is written, against the contract it has already fetched for the existence check and the write itself (its owner and block time), so they cost no further read; a contract that exists but does not meet a requirement refuses the write, paid, with `ReferencedContractRequirementNotMetError` (40135) naming the requirement, where a contract that does not exist is still 40120. A changed `contractRequirements` is an incompatible schema change on update, like the rest of a `refersTo`. The charter system contract's `targetContractId` is the first user. +**Referencing an identity key with requirements.** The same shape serves the key references the charter contract needs: `"refersTo": { "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } }`. `keyRequirements` holds what the referenced key must be beyond existing and not being disabled, each key an aspect of the key: `purpose`, the key's purpose by its wire name (`authentication`, `encryption`, `decryption`, `transfer`, `voting` or `owner`; never `system`), and `boundTo`, the name of a document type of the declaring contract, which requires the key's contract bounds to be exactly the declaring contract and that document type; a whole-contract bound or a contract group bound never meets it, even where the group holds the type, since the check reads nothing beyond the key. Registration (`create_document_types_from_document_schemas` 1, a post-pass edited in place since it is inert before protocol version 14, under full validation like the meta-schema) checks that `boundTo` names a document type the contract has, so the write-time check never needs a second contract fetch, and that a key meeting the pair can exist at all: only authentication, encryption and decryption keys carry a document type bound, and Drive registers an encryption or decryption key bound to a document type only when that type declares `requiresIdentityEncryptionBoundedKey` or `requiresIdentityDecryptionBoundedKey`, so a `boundTo` paired with `transfer`, `voting` or `owner`, or with an encryption purpose on a type without the matching keyword, is refused as a requirement no key could ever meet. Consensus checks the requirements when the referring document is written, against the key it has already fetched for the existence check, so they cost no further read; a key that exists and is enabled but does not meet one refuses the write, paid, with `ReferencedIdentityKeyRequirementNotMetError` (40136) naming the document type, the property, the requirement and what the key has, where a missing key is still 40123 and a disabled one 40124. A replace that repoints the reference at another key, through either the identity id or the key id, re-checks them. A changed `keyRequirements` is an incompatible schema change on update, like the rest of a `refersTo`. New requirements (a security level, say) are new keys of the same object, never a new reference type. The charter contract's `joinRequest.recipientId` (a decryption key bound to `submittedCharter`) is the first user. + **What comes next.** The charter system contract, applications and the election (new vote poll kinds), the seated team under the contract with its per-ability powers, charter-priced moderators amounts within the maximums, and challenges and amendments. Issue #4865 holds the design. ## Versioning Touchpoints diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index c56d6a8cd78..29a42bec5a3 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -268,6 +268,73 @@ pub const INITIAL_REVISION: u64 = 1; Revision 0 is never used for active documents. This allows `0` to serve as a sentinel value meaning "no revision" in some contexts. +## Document References (`refersTo`) + +From protocol version 14 a property of a document type can declare what it points at, and consensus refuses a create or replace whose target does not exist when the document is written (the reference is a write-time constraint only; nothing resolves it for a reader). The keyword is `refersTo` on the property, its `type` one of `identity`, `contract` (optionally with `contractRequirements`, see [Contract Moderation](contract-moderation.md)), `token`, `permanentDocument`, `deletableDocument` and `identityPublicKey`. Every form sits on an identifier property, with one exception below. The parsed shape is `DocumentPropertyType::IdentifierWithReference(target)`, and any change to a declaration on contract update is an incompatible schema change. + +An `identityPublicKey` reference names one key of one identity, and comes in two forms that differ in which property carries what: + +- **On the identity property.** The identifier property carries the identity id and `keyIdProperty` names the sibling integer property carrying the key id: `"refersTo": { "type": "identityPublicKey", "keyIdProperty": "toKeyIndex" }`. Consensus fetches the named key of that identity. +- **On the key id property.** The integer property carries the key id and `identityProperty` names whose key it is: `"refersTo": { "type": "identityPublicKey", "identityProperty": "$ownerId" }`. The property must declare exactly the range of a `KeyID` (`"type": "integer", "minimum": 0, "maximum": 4294967295`) and the declaration takes no `keyIdProperty`. `identityProperty` is `$ownerId` (the writer), `$creatorId` (the document's creator, only on a document type that records creator ids: a transferable or tradeable type of a format-1 contract) or the path of an identifier property of the same document type (which must exist, be an identifier and not carry an `identityPublicKey` reference of its own); the last two are checked at contract registration. `keyRequirements` sit on this form exactly as on the identifier form. The parsed shape is `DocumentPropertyType::KeyIdWithReference(KeyIdReference)`, the identity source plus the requirements, sized, encoded and queried exactly as a plain `u32`. + +```json +"senderKeyId": { + "type": "integer", "minimum": 0, "maximum": 4294967295, + "refersTo": { "type": "identityPublicKey", "identityProperty": "$ownerId" }, + "position": 3 +} +``` + +Both forms share the state check (`validate_referenced_identity_key_v0` in the document reference validation): the key must exist and not be disabled, else the write is refused, paid, with `ReferencedIdentityKeyNotFoundError` (40123) or `ReferencedIdentityKeyDisabledError` (40124). Identity keys can be disabled but never removed, so a validated reference never dangles. The owner form's identity is the transition's signer, which the transition already proved exists, so the key fetch is its only read; a key id of some other identity's key is meaningless by construction, there is no property to name another identity. On replace the identity form is re-validated when either the identity property or its key id property changed. For the key id form it depends on where the identity comes from. `$ownerId` is the writer, transition metadata that never appears among the changed fields, and the document may have changed hands since the key id was written, so the reference is re-validated on every replace, touched or not, as the `$ownerId` writer gate is: after a transfer the new owner has to repoint the key id at one of its own keys. `$creatorId` never changes, so it is re-validated when the key id changed. A property path is re-validated when the key id or that property changed, and a key id set while the property is not is refused (`ReferencedKeyIdPropertyInvalidError`, 40125). A transfer itself is not checked in any form, so the reference governs writing, not holding. The declaring property must carry exactly the key id range in its schema, whatever the contract's integer sizing setting, and a `keyIdProperty` of the identity form may not name a property that carries this form, nor may a path name an identifier carrying an `identityPublicKey` reference: one pair is declared once (40125 at registration). The charter contract's `joinRequest.senderKeyId`, the owner's encryption key a shared secret is derived from, is the first user. + +### Resolved through a unique index (`lookup`) + +A `permanentDocument` reference normally holds the referenced document's id. It may instead carry a `lookup`: the property's value, or on the elements of a typed array each element (see [References on the Elements](#references-on-the-elements)), is then one part of a key, and the referenced document is the one a unique index of the referenced document type finds for that key. The reference holds if that document exists. The moderation charter's `members` is the first user: + +```json +"members": { + "type": "array", "minItems": 0, "maxItems": 15, "uniqueItems": true, + "items": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId", + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": "submittedCharterId", "$ownerId": "." } + } + } + }, + "position": 2 +} +``` + +reads: every member must be the owner of a `joinRequest` whose `submittedCharterId` equals this document's `submittedCharterId`. Without `lookup` the list would have to hold the join requests' ids, which the writer would have to find first, and which say nothing about who asked to join. The same form works on a scalar identifier property, where `"."` is the property's own value. + +A `deletableDocument` reference takes no `lookup`. Once the document a key found is deleted, a new document with the same key would make the reference resolve again, to different content, where an id is produced at most once and a dead id reference stays dead. + +`index` names an index of the referenced document type. `keys` maps every property of that index, by its name on the referenced side (system ones such as `$ownerId` included), in any order, to where its value comes from on the referring side: + +- a property path of the referring document type (`"submittedCharterId"`, `"meta.charterId"`); +- `"$ownerId"`, the referring document's owner, the writer; +- `"."`, the value of the property that carries the reference, or the element. It appears exactly once: without it every value would resolve to the same document. + +What is checked when the contract enters the chain, on registration and on update: + +- `lookup` is only allowed on `permanentDocument` references (meta-schema v3 and the parser, `apply_property_reference` 0), on the property or on the `items` of a typed array. +- Each property a key reads must exist on the referring type, be required (and so must every object around it), not be transient, and hold a single value, so a lookup never runs with a missing key part and a reader can assemble the same key from the stored document. A key that reads `"$ownerId"` needs a referring type whose documents can be neither transferred nor traded: the reference is judged when the document is written, and a transfer or purchase would move the writer part of its key without a write. These are properties of the referring type alone and are checked on every parse (generation 3). +- The index must exist and be unique, so the key finds at most one document; it may not bucket a timestamp with `timeRange`, and the referenced type may not be `indexOnly`. `keys` must cover each property of the index exactly once and nothing else, and each source must hold the same kind of value as the index property it fills (the rule of `propertyAgreement`, `DocumentPropertyType::value_kind`). +- The key must stay with the document it found, or the reference could dangle without the document being deleted: every schema property of the index must be fixed once written (the referenced type is immutable, or the property, or the top-level object holding it, is listed under `immutable`), `$ownerId` is only a key part on a type whose documents can be neither transferred nor traded, and the update and transfer times are refused where a replace, transfer or purchase moves them. `$id`, `$creatorId` and the creation times are always fixed. +- A changed, added or removed `lookup` is an incompatible schema change on update, like the rest of a `refersTo`. + +The checks on the referenced type run where that type is in hand. For a document type of the same contract the contract parse runs them under full validation (`create_document_types_from_document_schemas` 1, next to the `keyRequirements.boundTo` check), once every document type is parsed; a deletable target is left to registration, which refuses it for the `permanentDocument` reference (40122). For a type of another contract (`contractId`) the registration state validation runs them against that contract, where the other `refersTo` checks into another contract run, and refuses a declaration that cannot resolve with `ReferencedDocumentLookupInvalidError` (state code 40137). Index definitions cannot change on a contract update from protocol version 14 (`validate_update` 1 compares them by name), and neither can the flags the permanence rule reads, so the answer holds. + +When the referring document is created or replaced, the document reference validation assembles the key for each value and queries the index for at most one document, billed as a document fetch of the same kind as the id lookup (`fetch_document_through_lookup`). No document, or a key it cannot assemble, refuses the write, paid, with `ReferencedEntityNotFoundError` (40120) naming the property, or the element by its list path (`members[1]`); its target reads "found through unique index ``". A `propertyAgreement` beside the `lookup` is checked against the document the index found, exactly as for an id reference. A replace re-validates the reference when the property itself changed (for a list, the elements the stored list did not hold), and every value, every element included, when a property a key reads changed. Nothing else can move a key part: the writer is fixed on a type allowed to read it, and the referenced side's key is fixed by the rule above, so a validated lookup reference never dangles. + +Joins cannot go through a lookup reference: a chained query or a composite by-id join needs the join property's values to be the outer documents' ids, so both refuse such a property, and a `preallocated` index cannot be bound through one. In Rust the declaration is its own variant, `DocumentPropertyReferenceTarget::PermanentDocumentLookup`, appended to the enum rather than a field of `PermanentDocument`: the enum is embedded in the reference errors, so an id reference keeps its encoding, and code matching `PermanentDocument` as "the value is a document id" cannot mistake a lookup for one. The rules are on `DocumentReferenceLookup`. `as_document_reference` returns only references whose value is a document id, the accessor for joins; the validators use `as_any_document_reference`, whose declaration carries the lookup. + ## Immutable Properties on Mutable Document Types A document type either allows replaces (`documentsMutable: true`, the default) or freezes its documents entirely. Protocol version 14 adds a middle ground: the doctype-level `immutable` keyword lists top-level properties that are frozen at creation while the rest of the document stays replaceable. @@ -305,6 +372,125 @@ Enforcement lives in the replace action's state validation (generation 1). The a In Rust the lists are `DocumentTypeV2Getters::immutable_fields()` and `immutable_fields_allow_setting()`. Earlier document type generations return empty sets. +## Typed Arrays + +Up to protocol version 13 a `type: "array"` property had to be a byte array (`byteArray: true`). Protocol version 14 adds typed arrays: a list whose `items` schema says what every element is. + +```json +"reasons": { + "type": "array", + "minItems": 0, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 2 +} +``` + +- An element is a scalar: an integer, a number, a string (with `minLength` / `maxLength`), a boolean, a byte array (`byteArray: true`, whose `minItems` / `maxItems` count bytes) or an identifier. Objects and arrays of arrays are refused. An identifier element may carry `refersTo` (see [References on the Elements](#references-on-the-elements)). An element may be limited to allowed values with `enum`; `const` is refused on elements, since a one-value `enum` does the same and a contract update can still widen it. The parser reads an element's `enum`, `minimum` and `maximum` onto the typed array (`ArrayItemConstraints`), refusing on both parse paths an `enum` with no member or a member of another type, an `enum` on a byte array or identifier element, and a `minimum` above the `maximum`, so random document generation stays inside them; the JSON schema validator enforces them on every document. +- On the array itself `minItems` and `maxItems` count elements, not bytes. `maxItems` is required, `minItems` may not exceed it and `contentMediaType` belongs on the items; these hold on every parse. Contract registration also caps `maxItems` at `SystemLimits::max_typed_array_items` (1024), so a typed array's worst-case size, which fee estimation charges by, stays small. `uniqueItems: true` refuses a document that repeats an element. +- A byte array keeps its form and takes no `items`. On a plain byte array `uniqueItems` keeps its old meaning, no repeated byte, but an identifier (a byte array with the identifier `contentMediaType`) refuses it: an identifier is one value, and "no repeated byte" would refuse most of them. +- The document is validated against the JSON schema as always, so a list that is too long, too short, repeats an element under `uniqueItems` or holds a wrong-typed element fails with the usual `JsonSchemaError`. + +The array is stored inline in the document, like any other property: a varint element count followed by the elements, each encoded exactly as a required property of the element's type (see [Document Serialization](../serialization/document-serialization.md)). The `reasons` list above is therefore one count byte and 32 raw bytes per identifier, and an integer element bounded `0`..`100` takes one byte. Since the stored bytes depend on the element's type, a contract update may not change how an element encodes: raising an integer element's `maximum` (or adding an `enum` value) past its width, or unpinning a fixed-size byte array element, is refused with `DocumentTypeUpdateError`. A longer `maxLength`, a larger `maxItems` or a raised `maximum` that keeps the width are accepted. Identifier and byte array elements are conversion paths (`reasons[]`, `find_identifier_and_binary_paths` 1), so a document built from JSON or a value map converts every element, as it converts a scalar identifier. `ExtendedDocument::set_untrusted` converts every member of a list set at such a path. From protocol version 14 a property name and a document type name are word characters only (`^[a-zA-Z0-9_]{1,64}$`): every earlier generation also admitted `-`, which the path syntax (`a.b`, `list[]`) was never written for, and a census of every contract on mainnet and testnet found none using it. Nothing is written per element, so a typed array cannot be an index property (`InvalidIndexPropertyTypeError`), an indexOnly terminal or entry payload property, or one side of a `propertyAgreement`. + +In Rust a typed array parses to `DocumentPropertyType::TypedArray(TypedArrayProperty)`, whose `item_type` is the `DocumentPropertyType` the `items` schema parses to as a property schema (`try_from_value_map` with the contract's parsing options). The parse is the versioned `parse_typed_array` (`None` before protocol version 14, where an array that is not a byte array is refused as it always was). The older `DocumentPropertyType::Array` variant, whose elements are an `ArrayItemType` in their own length-prefixed encoding, is never produced by the parser. + +### References on the Elements + +An identifier element may carry a `refersTo` declaration, which every element of the list then declares. The moderation charters' `reasons` refers to `reason` documents this way: + +```json +"items": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "permanentDocument", "documentType": "reason" } +} +``` + +- The declaration takes every target and key a single identifier property's `refersTo` takes (`identity`, `contract` with `contractRequirements`, `token`, and `permanentDocument` and `deletableDocument` with `contractId`, `documentType` and `propertyAgreement`), with the same checks at contract registration, except `identityPublicKey` in either form: its `keyIdProperty` names one sibling key id, and the `identityProperty` form sits on the key id itself, neither of which can pair with many elements, so an element may not declare it. The declaration belongs on the `items`; on the array itself it is refused. +- When a document is created or replaced each element is checked as a single reference is, in list order: the target must exist, a referenced contract must meet the `contractRequirements`, a referenced document's type must be deletable or not as declared, and each `propertyAgreement` pair must hold. The referring side of a pair is still a property of the referring document or its `$ownerId`, the same for every element; the referenced side is a property of that element's referenced document. The first element that fails refuses the write with the error a single reference gives (`ReferencedEntityNotFoundError` 40120, `ReferencedDocumentPropertyMismatchError` 40127, `ReferencedContractRequirementNotMetError` 40135 and so on), whose `path` names the element by its list path: `reasons[2]` for the third. An empty or absent list checks nothing. Registration errors name the declaration `submittedCharter.reasons[]`. +- A replace follows the rules of a single reference, element by element. A changed list re-validates the elements the stored list did not hold (the replace action carries the stored value of each changed property, `stored_changed_values`); the ones it held are unchanged references and are left alone, as an unchanged single reference is. Every element is re-validated when a property bound by a `propertyAgreement` changed, and on every replace when an agreement is keyed by `$ownerId` or the elements are `deletableDocument` references. An element repeating an earlier one of the same list is not fetched again. +- Every read is billed as a single reference's is. A foreign contract holding the referenced document type is fetched once per list, not once per element. Contract registration caps the references one document of a type can carry at `SystemLimits::max_references_per_document` (256), counting one per property with `refersTo` (an identifier, or a key id carrying a key reference) and `maxItems` per typed array of referencing elements: `maxItems` alone would let a type declare many lists of up to 1024 references each, and each one is a read when a document is written. +- An `immutable` property may not hold a `deletableDocument` reference a replace could not clear: a typed array of them, at the top level or inside an immutable object, or a single one inside an immutable object. Every replace re-validates them, so once a target is deleted the property would have to change, which an immutable property cannot. A single `deletableDocument` reference that is itself the immutable top-level property has a way out, a replace may remove it once its target is gone, and that exception reads the one identifier the removed top-level property held. +- A changed element `refersTo` is an incompatible schema change on contract update, as a changed `refersTo` on a scalar is. + +In Rust the element parses to `DocumentPropertyType::IdentifierWithReference(target)` inside `item_type`, through the same versioned `apply_property_reference` a scalar identifier goes through. `DocumentPropertyType::reference()` reports a property's declaration as `PropertyReference::Value(target)` for a scalar and `PropertyReference::Elements(target)` for a typed array; the registration check (`validate_data_contract_references`) and the write-time check (`validate_document_references`) both enumerate references through it. + +## Distinct Identifier Properties + +Protocol version 14 adds the property-level `distinctFrom` keyword, a pure structure rule on identifier properties: the property's value must differ from the value of a named property of the same document, or from the document's `$ownerId`. It sits next to the reference keywords (`refersTo` and its `propertyAgreement`, which bind a property to another document's values) but reads nothing beyond the transition being written. + +```json +"delegateId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId", + "position": 0 +}, +"backupId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "delegateId", + "position": 1 +} +``` + +The value is `"$ownerId"` or the dotted path of another property of the document type (`"meta.reviewerId"` for a nested one). The parser (generation 3, meta-schema v3) checks the declaration when a contract enters the chain, on registration and on update: + +- The keyword is only allowed on identifier properties, enforced by the same dependent schema shape that restricts `refersTo`. +- A named property must exist on the document type, must itself be an identifier (the only kind the value can be compared with), and must not be the declaring property. `$ownerId` needs no check; no other system property is accepted. +- On contract update a changed, added or removed `distinctFrom` is an incompatible schema change, like a changed `refersTo`. +- A typed array of identifiers declares it on its `items`, and every element must then differ from the named value; the declaration is refused on the array itself and on elements of any other type. + +Enforcement lives in the structure validation of the document create and replace actions (create structure generation 1, introduced at protocol version 14, and replace structure generation 0, extended in place: the call is inert before 14, where no property can carry the keyword), after the schema validation of the document's properties, so every value compared is already a 32-byte identifier. The check reads the transition's data and the owner id it carries and never touches Drive; the declaring properties come from a list the parser built (`distinct_from_fields`), so a type without declarations costs nothing. An equal pair fails the write with `DocumentPropertyNotDistinctError` (basic code 10419), which names the document type, the property and what it collided with. When the named property is absent from the document there is nothing to differ from, so the rule passes. + +A transfer or purchase changes `$ownerId` without touching the data, so the transfer and purchase structure validations (generation 0, extended in place: the call is inert before protocol version 14, where no property can carry the keyword) judge the stored document against its new owner: a transfer to, or a purchase by, the identity a `$ownerId`-distinct property names is refused with the same error. Price updates change neither owner nor data and are not judged. + +In Rust the declaration is `DocumentProperty::distinct_from` (`Option`, absent on every property parsed before protocol version 14), the document check is `DocumentTypeV0Methods::validate_distinct_from_properties`, and `DistinctFrom::violation` judges one value on its own, which is how the elements of a typed array are judged one by one. + +## Encrypted Properties (`encryptedFor`) + +A byte array property may hold ciphertext that only one identity can read. Before protocol version 14 the contract said nothing about it, so every wallet had to learn the recipe (whose keys, which scheme, where the IV sits) from documentation or a side channel. From protocol version 14 the property declares it with the `encryptedFor` keyword, and wallets and SDKs read the recipe from the contract. + +```json +"encryptedMessage": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 1040, + "encryptedFor": { + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }, + "position": 4 +} +``` + +All four keys are required. `recipient` is the dotted path of an identifier property of the same document type whose value is the recipient identity's id, or `$ownerId` for a message the writer encrypts to themself. `recipientKey` and `senderKey` are dotted paths of integer properties of the same document type carrying the recipient's and the sender's identity key ids; each must declare `minimum` at least 0 and `maximum` at most 4294967295, read from the schema itself, so the rule holds whatever `sizedIntegerTypes` the contract sets. `scheme` is a closed set with one member today. + +The parser (generation 3, meta-schema v3) admits the keyword on byte array properties only, never on an identifier (`contentMediaType` set) or any other type, and checks at contract registration that the three named properties exist with those types, that none of them is `transient` (a transient property is stripped before storage, which would leave the stored ciphertext without its recipe), and that the byte array's own `maxItems` can hold the scheme's shortest ciphertext. A contract update that adds, removes or changes an `encryptedFor` declaration is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246): documents already written under the old recipe could not be read under the new one. Contracts parsed before protocol version 14 ignore the keyword entirely. + +### The `ecdh-secp256k1-aes256-cbc` layout + +This is the scheme the dashpay contact request already uses for `encryptedPublicKey` and `encryptedAccountLabel` (DIP-15), implemented in `packages/rs-platform-encryption`: + +1. The shared key is the libsecp256k1 ECDH of the sender's private key and the recipient's public key: `SHA256(parity || x)` of the product point, where `parity` is `0x02` for an even `y` and `0x03` for an odd one. The sender's key is the identity key with the id the `senderKey` property carries, on the document's `$ownerId` identity; the recipient's key is the one with the id the `recipientKey` property carries, on the identity the `recipient` property names (the owner itself for `$ownerId`). Either side derives the same 32 bytes from its own private key and the other's public key. +2. The writer draws a random 16-byte IV. +3. The value is the IV followed by the plaintext encrypted with AES-256-CBC under the shared key and that IV, with PKCS7 padding. + +So a ciphertext is `16 + 16 * ceil((len(plaintext) + 1) / 16)` bytes: at least 32, always a multiple of 16. The reader splits the first 16 bytes off as the IV, derives the shared key from its own private key and the sender's public key, and decrypts the rest. + +### What consensus checks, and what it cannot + +Consensus sees bytes, not keys. On every document create and replace, after the JSON schema validation of the document's properties, the structure validation (create structure generation 1, introduced at protocol version 14, and replace structure generation 0, extended in place: the call is inert before 14, where no property can carry the keyword) walks the document type's declared properties and, for each one the transition supplies, checks that its length is at least the scheme's IV plus one block and a multiple of the block length. A value that is not refuses the transition with `InvalidEncryptedPropertyShapeError` (basic code 10420), which names the property path, the scheme and the lengths involved. No state is read; the check runs in the mempool as well as in the block. The JSON schema's own `minItems` and `maxItems` are checked first, so a value outside them (a lone 16-byte IV against `minItems: 32`, say) is refused with the schema's error rather than 10420; the shape check only sees values the bounds already admit. + +Nothing else is verifiable on chain: not that the bytes decrypt, not that they decrypt under the keys the document names, not that the named key ids exist on the identities or have an encryption purpose, and not that the plaintext is what the document type means it to be. A writer can store any 32 bytes. Whether the keys exist and are of the right kind is what the reference keywords are for: a `refersTo` of type `identityPublicKey` with `keyIdProperty` on the recipient property makes consensus check that the recipient's key exists, and `encryptedFor` neither duplicates nor requires it. Readers must treat a value that fails to decrypt as a bad message, not as a protocol violation. + +In Rust the declaration is `DocumentProperty::encrypted_for` (`Option`), listed per document type by `DocumentTypeV0Getters::encrypted_properties()`, and the shape check is `DocumentTypeBasicMethods::validate_encrypted_property_shapes()`, versioned on the `validate_encrypted_property_shapes` method slot (`None` before protocol version 14, which is what keeps the in-place replace call inert). In JavaScript, `contract.documentTypeEncryptedProperties(name)` and `contract.documentEncryptedProperties` expose the same declarations, and the shape error reaches an app as `DocumentEncryptionErrorCode.InvalidEncryptedPropertyShape`. + ## Rules and Guidelines **Do:** diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 4b9a1c34d97..6d80d14d7a0 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -53,7 +53,7 @@ Error codes are organized into ranges that correspond to error categories and su | 10200-10277 | Data Contract | `DataContractMaxDepthExceedError` (10200), `DuplicateIndexError` (10201), `InvalidDataContractIdError` (10204), `DataContractInvalidRequiredFieldsUpdateError` (10276), `PreProgrammedDistributionAmountOverLimitError` (10277) | | 10350-10359 | Groups | `GroupPositionDoesNotExistError` (10350), `GroupExceedsMaxMembersError` (10354) | | 10360-10367 | Contract Groups | `ContractGroupMembershipsOverLimitError` (10360), `InvalidContractGroupAdminsError` (10364), `InvalidContractGroupDescriptionLengthError` (10367); 10365 unassigned | -| 10400-10418 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401) | +| 10400-10420 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401), `DocumentPropertyNotDistinctError` (10419), `InvalidEncryptedPropertyShapeError` (10420) | | 10450-10460 | Tokens | `InvalidTokenIdError` (10450), `TokenTransferToOurselfError` (10456) | | 10500-10535 | Identity | `DuplicatedIdentityPublicKeyBasicError` (10500), `InvalidIdentityPublicKeyDataError` (10511) | | 10600-10603 | State Transition | `InvalidStateTransitionTypeError` (10600), `StateTransitionMaxSizeExceededError` (10602) | @@ -109,7 +109,7 @@ The fee category currently has a single code. The 30000 range is reserved for fu | 40000-40009 | Data Contract | `DataContractAlreadyPresentError` (40000), `DataContractIsReadonlyError` (40001), `DataContractNotFoundError` (40008) | | 40100-40134 | Documents | `DocumentAlreadyPresentError` (40100), `DocumentNotFoundError` (40101), `DuplicateUniqueIndexError` (40105), `DocumentActionFeeAgreementNotSetError` (40132), `DocumentActionFeeAgreementMismatchError` (40133), `DocumentActionFeeMultiplierNotToleratedError` (40134) | | 40200-40217 | Identity | `IdentityAlreadyExistsError` (40200), `InvalidIdentityRevisionError` (40203), `IdentityInsufficientBalanceError` (40210) | -| 40300-40307 | Voting | `MasternodeNotFoundError` (40300), `MasternodeVoteAlreadyPresentError` (40304), `VoteChoiceNotAllowedForVotePollError` (40307) | +| 40300-40308 | Voting | `MasternodeNotFoundError` (40300), `MasternodeVoteAlreadyPresentError` (40304), `VoteChoiceNotAllowedForVotePollError` (40307), `YesNoVotePollNotAvailableForVotingError` (40308) | | 40400-40401 | Prefunded Balances | `PrefundedSpecializedBalanceInsufficientError` (40400) | | 40500-40502 | Data Triggers | `DataTriggerConditionError` (40500), `DataTriggerExecutionError` (40501) | | 40600-40603 | Addresses | `AddressDoesNotExistError` (40600), `AddressNotEnoughFundsError` (40601) | diff --git a/book/src/sdk/put-operations.md b/book/src/sdk/put-operations.md index 5c37b5413d0..dc3830158be 100644 --- a/book/src/sdk/put-operations.md +++ b/book/src/sdk/put-operations.md @@ -255,6 +255,22 @@ The wait includes full proof verification: the SDK verifies a GroveDB proof that the state transition was actually applied. This is not just checking a status flag -- it is cryptographic proof of inclusion. +From protocol version 14 the proof of an owned, fee-paying transition (document +and token batches, contract creates and updates, identity updates and key limit +updates, contract moderation) also carries the credit balance of the owner +after it, read from the same state as the result, so a wallet learns what the +write left it with without a second query. The verified +`StateTransitionProofOutcome` hands it out through `owner_balance()` (`None` +for a proof made at an earlier version or a transition without an owner); +`wait_for_document_and_owner_balance` and +`put_to_platform_and_wait_for_response_with_owner_balance` return it next to +the document. The balance is a snapshot at the proof's block: it may already +include later transitions of the same identity. A wait that asks for no proof +but sets `request_user_balance` gets the owner's balance back unverified, as +the response's `success_with_owner_balance` result, read from a Drive state at +or past the block that executed the write; that works for any transition with +an owner. + ### Timeout Handling `wait_for_response` supports an optional timeout: diff --git a/book/src/serialization/document-serialization.md b/book/src/serialization/document-serialization.md index 327588762b2..5f6b52230a3 100644 --- a/book/src/serialization/document-serialization.md +++ b/book/src/serialization/document-serialization.md @@ -184,7 +184,7 @@ All numeric values use **big-endian** byte order. | `byteArray` (variable size) | varint length prefix + raw bytes | | `identifier` | 32 bytes raw | | `date` | 8 bytes big-endian f64 (when optional: `0xff` prefix + 8 bytes) | -| `array` | varint element count + each element encoded in sequence | +| `array` (typed array, protocol v14) | varint element count + each element encoded exactly as a required property of the element's type (rows above): an identifier element is 32 raw bytes, an integer element takes the width its bounds give it, a fixed-size byte array element is raw, a string or variable-size byte array element has a varint length prefix. Elements never carry a presence byte | | `object` | Nested fields serialized recursively in their schema position order | **Note on date types**: User-property `date` fields are encoded as **f64** (8 bytes). System timestamps (`$createdAt`, `$updatedAt`, `$transferredAt`) are **u64** milliseconds. Both are 8 bytes big-endian but use different numeric representations. @@ -284,4 +284,6 @@ See `packages/rs-scripts/README.md` for full usage details. 6. **ByteArray encoding depends on size constraints.** Fixed-size byte arrays (where `minItems == maxItems` in the schema) have no length prefix. Variable-size byte arrays have a varint length prefix. Check the schema to know which encoding is used. -7. **In version 3, the same document type can produce different property layouts.** A property annotated with `requiredSince` is presence-flagged in documents stamped below the annotation and raw in documents stamped at or above it. Two version-3 documents of the same type may therefore differ in layout — always read the stamp varint and resolve each property's requiredness against it before decoding the properties section. +7. **A typed array's element width comes from its `items` schema.** Each element is laid out as a required property of the element's type, so an integer element bounded `0`..`100` is 1 byte and an unbounded one 8, and a fixed-size byte array or identifier element has no length prefix. Parse the `items` schema exactly as a property schema to know the width, including the contract's `sizedIntegerTypes` setting. + +8. **In version 3, the same document type can produce different property layouts.** A property annotated with `requiredSince` is presence-flagged in documents stamped below the annotation and raw in documents stamped at or above it. Two version-3 documents of the same type may therefore differ in layout — always read the stamp varint and resolve each property's requiredness against it before decoding the properties section. diff --git a/book/src/versioning/versioned-dispatch.md b/book/src/versioning/versioned-dispatch.md index 23505526aac..ba303a9a835 100644 --- a/book/src/versioning/versioned-dispatch.md +++ b/book/src/versioning/versioned-dispatch.md @@ -225,14 +225,16 @@ method, never the versioned implementation directly. The layout is the versioning contract made physical, and three rules follow from it: -- **One directory per generation, always.** A behaviour change to a versioned - method is a new `v1/` (or `v2/`, ...) directory with its own `mod.rs`, plus - a new match arm. It is never an edit inside `v0/`. That includes edits that - look harmless: threading a new parameter through `v0`, adding an - `if platform_version.protocol_version >= 14` inside it, or computing a - version-table gate that is always false for old versions. A shipped `vN/` - stays byte-identical to what shipped, so a reviewer never has to prove that - an in-place diff is inert for old blocks. +- **One directory per generation.** A behaviour change to a versioned method + is a new `v1/` (or `v2/`, ...) directory with its own `mod.rs`, plus a new + match arm. An edit inside a shipped `v0/` is allowed only when it cannot + modify consensus at any protocol version that selects `v0/`, because the + code it adds is unreachable there by construction or its output is + identical; the edited lines say why, and the pull request description + carries an "In-place changes to shipped generations" section (see the + [coding conventions](../contributing/coding-conventions.md)). An + `if platform_version.protocol_version >= 14` inside `v0/` is not that: it is + a runtime check the reader has to trust, so it gets a generation. - **Inside a generation, a capability is a constant fact, not a check.** If `v1` admits a new keyword, `v1` admits it unconditionally (`Index::try_from_value_map(map, true)`). The decision of whether the diff --git a/packages/dapi-grpc/clients/drive/v0/nodejs/drive_pbjs.js b/packages/dapi-grpc/clients/drive/v0/nodejs/drive_pbjs.js index daba28b97b4..0c4cfa4a527 100644 --- a/packages/dapi-grpc/clients/drive/v0/nodejs/drive_pbjs.js +++ b/packages/dapi-grpc/clients/drive/v0/nodejs/drive_pbjs.js @@ -50857,6 +50857,7 @@ $root.org = (function() { * @interface IWaitForStateTransitionResultRequestV0 * @property {Uint8Array|null} [stateTransitionHash] WaitForStateTransitionResultRequestV0 stateTransitionHash * @property {boolean|null} [prove] WaitForStateTransitionResultRequestV0 prove + * @property {boolean|null} [requestUserBalance] WaitForStateTransitionResultRequestV0 requestUserBalance */ /** @@ -50890,6 +50891,14 @@ $root.org = (function() { */ WaitForStateTransitionResultRequestV0.prototype.prove = false; + /** + * WaitForStateTransitionResultRequestV0 requestUserBalance. + * @member {boolean} requestUserBalance + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0 + * @instance + */ + WaitForStateTransitionResultRequestV0.prototype.requestUserBalance = false; + /** * Creates a new WaitForStateTransitionResultRequestV0 instance using the specified properties. * @function create @@ -50918,6 +50927,8 @@ $root.org = (function() { writer.uint32(/* id 1, wireType 2 =*/10).bytes(message.stateTransitionHash); if (message.prove != null && Object.hasOwnProperty.call(message, "prove")) writer.uint32(/* id 2, wireType 0 =*/16).bool(message.prove); + if (message.requestUserBalance != null && Object.hasOwnProperty.call(message, "requestUserBalance")) + writer.uint32(/* id 3, wireType 0 =*/24).bool(message.requestUserBalance); return writer; }; @@ -50958,6 +50969,9 @@ $root.org = (function() { case 2: message.prove = reader.bool(); break; + case 3: + message.requestUserBalance = reader.bool(); + break; default: reader.skipType(tag & 7); break; @@ -50999,6 +51013,9 @@ $root.org = (function() { if (message.prove != null && message.hasOwnProperty("prove")) if (typeof message.prove !== "boolean") return "prove: boolean expected"; + if (message.requestUserBalance != null && message.hasOwnProperty("requestUserBalance")) + if (typeof message.requestUserBalance !== "boolean") + return "requestUserBalance: boolean expected"; return null; }; @@ -51021,6 +51038,8 @@ $root.org = (function() { message.stateTransitionHash = object.stateTransitionHash; if (object.prove != null) message.prove = Boolean(object.prove); + if (object.requestUserBalance != null) + message.requestUserBalance = Boolean(object.requestUserBalance); return message; }; @@ -51046,11 +51065,14 @@ $root.org = (function() { object.stateTransitionHash = $util.newBuffer(object.stateTransitionHash); } object.prove = false; + object.requestUserBalance = false; } if (message.stateTransitionHash != null && message.hasOwnProperty("stateTransitionHash")) object.stateTransitionHash = options.bytes === String ? $util.base64.encode(message.stateTransitionHash, 0, message.stateTransitionHash.length) : options.bytes === Array ? Array.prototype.slice.call(message.stateTransitionHash) : message.stateTransitionHash; if (message.prove != null && message.hasOwnProperty("prove")) object.prove = message.prove; + if (message.requestUserBalance != null && message.hasOwnProperty("requestUserBalance")) + object.requestUserBalance = message.requestUserBalance; return object; }; @@ -51279,6 +51301,207 @@ $root.org = (function() { return this.constructor.toObject(this, $protobuf.util.toJSONOptions); }; + WaitForStateTransitionResultResponse.SuccessWithOwnerBalance = (function() { + + /** + * Properties of a SuccessWithOwnerBalance. + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse + * @interface ISuccessWithOwnerBalance + * @property {number|Long|null} [ownerBalance] SuccessWithOwnerBalance ownerBalance + */ + + /** + * Constructs a new SuccessWithOwnerBalance. + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse + * @classdesc Represents a SuccessWithOwnerBalance. + * @implements ISuccessWithOwnerBalance + * @constructor + * @param {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.ISuccessWithOwnerBalance=} [properties] Properties to set + */ + function SuccessWithOwnerBalance(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * SuccessWithOwnerBalance ownerBalance. + * @member {number|Long} ownerBalance + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @instance + */ + SuccessWithOwnerBalance.prototype.ownerBalance = $util.Long ? $util.Long.fromBits(0,0,true) : 0; + + /** + * Creates a new SuccessWithOwnerBalance instance using the specified properties. + * @function create + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.ISuccessWithOwnerBalance=} [properties] Properties to set + * @returns {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} SuccessWithOwnerBalance instance + */ + SuccessWithOwnerBalance.create = function create(properties) { + return new SuccessWithOwnerBalance(properties); + }; + + /** + * Encodes the specified SuccessWithOwnerBalance message. Does not implicitly {@link org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.verify|verify} messages. + * @function encode + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.ISuccessWithOwnerBalance} message SuccessWithOwnerBalance message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SuccessWithOwnerBalance.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.ownerBalance != null && Object.hasOwnProperty.call(message, "ownerBalance")) + writer.uint32(/* id 1, wireType 0 =*/8).uint64(message.ownerBalance); + return writer; + }; + + /** + * Encodes the specified SuccessWithOwnerBalance message, length delimited. Does not implicitly {@link org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.verify|verify} messages. + * @function encodeDelimited + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.ISuccessWithOwnerBalance} message SuccessWithOwnerBalance message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SuccessWithOwnerBalance.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a SuccessWithOwnerBalance message from the specified reader or buffer. + * @function decode + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} SuccessWithOwnerBalance + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SuccessWithOwnerBalance.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + message.ownerBalance = reader.uint64(); + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a SuccessWithOwnerBalance message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} SuccessWithOwnerBalance + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SuccessWithOwnerBalance.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a SuccessWithOwnerBalance message. + * @function verify + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + SuccessWithOwnerBalance.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.ownerBalance != null && message.hasOwnProperty("ownerBalance")) + if (!$util.isInteger(message.ownerBalance) && !(message.ownerBalance && $util.isInteger(message.ownerBalance.low) && $util.isInteger(message.ownerBalance.high))) + return "ownerBalance: integer|Long expected"; + return null; + }; + + /** + * Creates a SuccessWithOwnerBalance message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {Object.} object Plain object + * @returns {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} SuccessWithOwnerBalance + */ + SuccessWithOwnerBalance.fromObject = function fromObject(object) { + if (object instanceof $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance) + return object; + var message = new $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance(); + if (object.ownerBalance != null) + if ($util.Long) + (message.ownerBalance = $util.Long.fromValue(object.ownerBalance)).unsigned = true; + else if (typeof object.ownerBalance === "string") + message.ownerBalance = parseInt(object.ownerBalance, 10); + else if (typeof object.ownerBalance === "number") + message.ownerBalance = object.ownerBalance; + else if (typeof object.ownerBalance === "object") + message.ownerBalance = new $util.LongBits(object.ownerBalance.low >>> 0, object.ownerBalance.high >>> 0).toNumber(true); + return message; + }; + + /** + * Creates a plain object from a SuccessWithOwnerBalance message. Also converts values to other types if specified. + * @function toObject + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} message SuccessWithOwnerBalance + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + SuccessWithOwnerBalance.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) + if ($util.Long) { + var long = new $util.Long(0, 0, true); + object.ownerBalance = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.ownerBalance = options.longs === String ? "0" : 0; + if (message.ownerBalance != null && message.hasOwnProperty("ownerBalance")) + if (typeof message.ownerBalance === "number") + object.ownerBalance = options.longs === String ? String(message.ownerBalance) : message.ownerBalance; + else + object.ownerBalance = options.longs === String ? $util.Long.prototype.toString.call(message.ownerBalance) : options.longs === Number ? new $util.LongBits(message.ownerBalance.low >>> 0, message.ownerBalance.high >>> 0).toNumber(true) : message.ownerBalance; + return object; + }; + + /** + * Converts this SuccessWithOwnerBalance to JSON. + * @function toJSON + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @instance + * @returns {Object.} JSON object + */ + SuccessWithOwnerBalance.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return SuccessWithOwnerBalance; + })(); + WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0 = (function() { /** @@ -51287,6 +51510,7 @@ $root.org = (function() { * @interface IWaitForStateTransitionResultResponseV0 * @property {org.dash.platform.dapi.v0.IStateTransitionBroadcastError|null} [error] WaitForStateTransitionResultResponseV0 error * @property {org.dash.platform.dapi.v0.IProof|null} [proof] WaitForStateTransitionResultResponseV0 proof + * @property {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.ISuccessWithOwnerBalance|null} [successWithOwnerBalance] WaitForStateTransitionResultResponseV0 successWithOwnerBalance * @property {org.dash.platform.dapi.v0.IResponseMetadata|null} [metadata] WaitForStateTransitionResultResponseV0 metadata */ @@ -51321,6 +51545,14 @@ $root.org = (function() { */ WaitForStateTransitionResultResponseV0.prototype.proof = null; + /** + * WaitForStateTransitionResultResponseV0 successWithOwnerBalance. + * @member {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.ISuccessWithOwnerBalance|null|undefined} successWithOwnerBalance + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0 + * @instance + */ + WaitForStateTransitionResultResponseV0.prototype.successWithOwnerBalance = null; + /** * WaitForStateTransitionResultResponseV0 metadata. * @member {org.dash.platform.dapi.v0.IResponseMetadata|null|undefined} metadata @@ -51334,12 +51566,12 @@ $root.org = (function() { /** * WaitForStateTransitionResultResponseV0 result. - * @member {"error"|"proof"|undefined} result + * @member {"error"|"proof"|"successWithOwnerBalance"|undefined} result * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0 * @instance */ Object.defineProperty(WaitForStateTransitionResultResponseV0.prototype, "result", { - get: $util.oneOfGetter($oneOfFields = ["error", "proof"]), + get: $util.oneOfGetter($oneOfFields = ["error", "proof", "successWithOwnerBalance"]), set: $util.oneOfSetter($oneOfFields) }); @@ -51373,6 +51605,8 @@ $root.org = (function() { $root.org.dash.platform.dapi.v0.Proof.encode(message.proof, writer.uint32(/* id 2, wireType 2 =*/18).fork()).ldelim(); if (message.metadata != null && Object.hasOwnProperty.call(message, "metadata")) $root.org.dash.platform.dapi.v0.ResponseMetadata.encode(message.metadata, writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim(); + if (message.successWithOwnerBalance != null && Object.hasOwnProperty.call(message, "successWithOwnerBalance")) + $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.encode(message.successWithOwnerBalance, writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim(); return writer; }; @@ -51413,6 +51647,9 @@ $root.org = (function() { case 2: message.proof = $root.org.dash.platform.dapi.v0.Proof.decode(reader, reader.uint32()); break; + case 4: + message.successWithOwnerBalance = $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.decode(reader, reader.uint32()); + break; case 3: message.metadata = $root.org.dash.platform.dapi.v0.ResponseMetadata.decode(reader, reader.uint32()); break; @@ -51470,6 +51707,16 @@ $root.org = (function() { return "proof." + error; } } + if (message.successWithOwnerBalance != null && message.hasOwnProperty("successWithOwnerBalance")) { + if (properties.result === 1) + return "result: multiple values"; + properties.result = 1; + { + var error = $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.verify(message.successWithOwnerBalance); + if (error) + return "successWithOwnerBalance." + error; + } + } if (message.metadata != null && message.hasOwnProperty("metadata")) { var error = $root.org.dash.platform.dapi.v0.ResponseMetadata.verify(message.metadata); if (error) @@ -51500,6 +51747,11 @@ $root.org = (function() { throw TypeError(".org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.proof: object expected"); message.proof = $root.org.dash.platform.dapi.v0.Proof.fromObject(object.proof); } + if (object.successWithOwnerBalance != null) { + if (typeof object.successWithOwnerBalance !== "object") + throw TypeError(".org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.successWithOwnerBalance: object expected"); + message.successWithOwnerBalance = $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.fromObject(object.successWithOwnerBalance); + } if (object.metadata != null) { if (typeof object.metadata !== "object") throw TypeError(".org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.metadata: object expected"); @@ -51535,6 +51787,11 @@ $root.org = (function() { } if (message.metadata != null && message.hasOwnProperty("metadata")) object.metadata = $root.org.dash.platform.dapi.v0.ResponseMetadata.toObject(message.metadata, options); + if (message.successWithOwnerBalance != null && message.hasOwnProperty("successWithOwnerBalance")) { + object.successWithOwnerBalance = $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.toObject(message.successWithOwnerBalance, options); + if (options.oneofs) + object.result = "successWithOwnerBalance"; + } return object; }; diff --git a/packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js b/packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js index fa98490966b..fe1a9322bd1 100644 --- a/packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js +++ b/packages/dapi-grpc/clients/platform/v0/nodejs/platform_pbjs.js @@ -50349,6 +50349,7 @@ $root.org = (function() { * @interface IWaitForStateTransitionResultRequestV0 * @property {Uint8Array|null} [stateTransitionHash] WaitForStateTransitionResultRequestV0 stateTransitionHash * @property {boolean|null} [prove] WaitForStateTransitionResultRequestV0 prove + * @property {boolean|null} [requestUserBalance] WaitForStateTransitionResultRequestV0 requestUserBalance */ /** @@ -50382,6 +50383,14 @@ $root.org = (function() { */ WaitForStateTransitionResultRequestV0.prototype.prove = false; + /** + * WaitForStateTransitionResultRequestV0 requestUserBalance. + * @member {boolean} requestUserBalance + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0 + * @instance + */ + WaitForStateTransitionResultRequestV0.prototype.requestUserBalance = false; + /** * Creates a new WaitForStateTransitionResultRequestV0 instance using the specified properties. * @function create @@ -50410,6 +50419,8 @@ $root.org = (function() { writer.uint32(/* id 1, wireType 2 =*/10).bytes(message.stateTransitionHash); if (message.prove != null && Object.hasOwnProperty.call(message, "prove")) writer.uint32(/* id 2, wireType 0 =*/16).bool(message.prove); + if (message.requestUserBalance != null && Object.hasOwnProperty.call(message, "requestUserBalance")) + writer.uint32(/* id 3, wireType 0 =*/24).bool(message.requestUserBalance); return writer; }; @@ -50450,6 +50461,9 @@ $root.org = (function() { case 2: message.prove = reader.bool(); break; + case 3: + message.requestUserBalance = reader.bool(); + break; default: reader.skipType(tag & 7); break; @@ -50491,6 +50505,9 @@ $root.org = (function() { if (message.prove != null && message.hasOwnProperty("prove")) if (typeof message.prove !== "boolean") return "prove: boolean expected"; + if (message.requestUserBalance != null && message.hasOwnProperty("requestUserBalance")) + if (typeof message.requestUserBalance !== "boolean") + return "requestUserBalance: boolean expected"; return null; }; @@ -50513,6 +50530,8 @@ $root.org = (function() { message.stateTransitionHash = object.stateTransitionHash; if (object.prove != null) message.prove = Boolean(object.prove); + if (object.requestUserBalance != null) + message.requestUserBalance = Boolean(object.requestUserBalance); return message; }; @@ -50538,11 +50557,14 @@ $root.org = (function() { object.stateTransitionHash = $util.newBuffer(object.stateTransitionHash); } object.prove = false; + object.requestUserBalance = false; } if (message.stateTransitionHash != null && message.hasOwnProperty("stateTransitionHash")) object.stateTransitionHash = options.bytes === String ? $util.base64.encode(message.stateTransitionHash, 0, message.stateTransitionHash.length) : options.bytes === Array ? Array.prototype.slice.call(message.stateTransitionHash) : message.stateTransitionHash; if (message.prove != null && message.hasOwnProperty("prove")) object.prove = message.prove; + if (message.requestUserBalance != null && message.hasOwnProperty("requestUserBalance")) + object.requestUserBalance = message.requestUserBalance; return object; }; @@ -50771,6 +50793,207 @@ $root.org = (function() { return this.constructor.toObject(this, $protobuf.util.toJSONOptions); }; + WaitForStateTransitionResultResponse.SuccessWithOwnerBalance = (function() { + + /** + * Properties of a SuccessWithOwnerBalance. + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse + * @interface ISuccessWithOwnerBalance + * @property {number|Long|null} [ownerBalance] SuccessWithOwnerBalance ownerBalance + */ + + /** + * Constructs a new SuccessWithOwnerBalance. + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse + * @classdesc Represents a SuccessWithOwnerBalance. + * @implements ISuccessWithOwnerBalance + * @constructor + * @param {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.ISuccessWithOwnerBalance=} [properties] Properties to set + */ + function SuccessWithOwnerBalance(properties) { + if (properties) + for (var keys = Object.keys(properties), i = 0; i < keys.length; ++i) + if (properties[keys[i]] != null) + this[keys[i]] = properties[keys[i]]; + } + + /** + * SuccessWithOwnerBalance ownerBalance. + * @member {number|Long} ownerBalance + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @instance + */ + SuccessWithOwnerBalance.prototype.ownerBalance = $util.Long ? $util.Long.fromBits(0,0,true) : 0; + + /** + * Creates a new SuccessWithOwnerBalance instance using the specified properties. + * @function create + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.ISuccessWithOwnerBalance=} [properties] Properties to set + * @returns {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} SuccessWithOwnerBalance instance + */ + SuccessWithOwnerBalance.create = function create(properties) { + return new SuccessWithOwnerBalance(properties); + }; + + /** + * Encodes the specified SuccessWithOwnerBalance message. Does not implicitly {@link org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.verify|verify} messages. + * @function encode + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.ISuccessWithOwnerBalance} message SuccessWithOwnerBalance message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SuccessWithOwnerBalance.encode = function encode(message, writer) { + if (!writer) + writer = $Writer.create(); + if (message.ownerBalance != null && Object.hasOwnProperty.call(message, "ownerBalance")) + writer.uint32(/* id 1, wireType 0 =*/8).uint64(message.ownerBalance); + return writer; + }; + + /** + * Encodes the specified SuccessWithOwnerBalance message, length delimited. Does not implicitly {@link org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.verify|verify} messages. + * @function encodeDelimited + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.ISuccessWithOwnerBalance} message SuccessWithOwnerBalance message or plain object to encode + * @param {$protobuf.Writer} [writer] Writer to encode to + * @returns {$protobuf.Writer} Writer + */ + SuccessWithOwnerBalance.encodeDelimited = function encodeDelimited(message, writer) { + return this.encode(message, writer).ldelim(); + }; + + /** + * Decodes a SuccessWithOwnerBalance message from the specified reader or buffer. + * @function decode + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @param {number} [length] Message length if known beforehand + * @returns {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} SuccessWithOwnerBalance + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SuccessWithOwnerBalance.decode = function decode(reader, length) { + if (!(reader instanceof $Reader)) + reader = $Reader.create(reader); + var end = length === undefined ? reader.len : reader.pos + length, message = new $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance(); + while (reader.pos < end) { + var tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + message.ownerBalance = reader.uint64(); + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }; + + /** + * Decodes a SuccessWithOwnerBalance message from the specified reader or buffer, length delimited. + * @function decodeDelimited + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from + * @returns {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} SuccessWithOwnerBalance + * @throws {Error} If the payload is not a reader or valid buffer + * @throws {$protobuf.util.ProtocolError} If required fields are missing + */ + SuccessWithOwnerBalance.decodeDelimited = function decodeDelimited(reader) { + if (!(reader instanceof $Reader)) + reader = new $Reader(reader); + return this.decode(reader, reader.uint32()); + }; + + /** + * Verifies a SuccessWithOwnerBalance message. + * @function verify + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {Object.} message Plain object to verify + * @returns {string|null} `null` if valid, otherwise the reason why it is not + */ + SuccessWithOwnerBalance.verify = function verify(message) { + if (typeof message !== "object" || message === null) + return "object expected"; + if (message.ownerBalance != null && message.hasOwnProperty("ownerBalance")) + if (!$util.isInteger(message.ownerBalance) && !(message.ownerBalance && $util.isInteger(message.ownerBalance.low) && $util.isInteger(message.ownerBalance.high))) + return "ownerBalance: integer|Long expected"; + return null; + }; + + /** + * Creates a SuccessWithOwnerBalance message from a plain object. Also converts values to their respective internal types. + * @function fromObject + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {Object.} object Plain object + * @returns {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} SuccessWithOwnerBalance + */ + SuccessWithOwnerBalance.fromObject = function fromObject(object) { + if (object instanceof $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance) + return object; + var message = new $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance(); + if (object.ownerBalance != null) + if ($util.Long) + (message.ownerBalance = $util.Long.fromValue(object.ownerBalance)).unsigned = true; + else if (typeof object.ownerBalance === "string") + message.ownerBalance = parseInt(object.ownerBalance, 10); + else if (typeof object.ownerBalance === "number") + message.ownerBalance = object.ownerBalance; + else if (typeof object.ownerBalance === "object") + message.ownerBalance = new $util.LongBits(object.ownerBalance.low >>> 0, object.ownerBalance.high >>> 0).toNumber(true); + return message; + }; + + /** + * Creates a plain object from a SuccessWithOwnerBalance message. Also converts values to other types if specified. + * @function toObject + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @static + * @param {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} message SuccessWithOwnerBalance + * @param {$protobuf.IConversionOptions} [options] Conversion options + * @returns {Object.} Plain object + */ + SuccessWithOwnerBalance.toObject = function toObject(message, options) { + if (!options) + options = {}; + var object = {}; + if (options.defaults) + if ($util.Long) { + var long = new $util.Long(0, 0, true); + object.ownerBalance = options.longs === String ? long.toString() : options.longs === Number ? long.toNumber() : long; + } else + object.ownerBalance = options.longs === String ? "0" : 0; + if (message.ownerBalance != null && message.hasOwnProperty("ownerBalance")) + if (typeof message.ownerBalance === "number") + object.ownerBalance = options.longs === String ? String(message.ownerBalance) : message.ownerBalance; + else + object.ownerBalance = options.longs === String ? $util.Long.prototype.toString.call(message.ownerBalance) : options.longs === Number ? new $util.LongBits(message.ownerBalance.low >>> 0, message.ownerBalance.high >>> 0).toNumber(true) : message.ownerBalance; + return object; + }; + + /** + * Converts this SuccessWithOwnerBalance to JSON. + * @function toJSON + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance + * @instance + * @returns {Object.} JSON object + */ + SuccessWithOwnerBalance.prototype.toJSON = function toJSON() { + return this.constructor.toObject(this, $protobuf.util.toJSONOptions); + }; + + return SuccessWithOwnerBalance; + })(); + WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0 = (function() { /** @@ -50779,6 +51002,7 @@ $root.org = (function() { * @interface IWaitForStateTransitionResultResponseV0 * @property {org.dash.platform.dapi.v0.IStateTransitionBroadcastError|null} [error] WaitForStateTransitionResultResponseV0 error * @property {org.dash.platform.dapi.v0.IProof|null} [proof] WaitForStateTransitionResultResponseV0 proof + * @property {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.ISuccessWithOwnerBalance|null} [successWithOwnerBalance] WaitForStateTransitionResultResponseV0 successWithOwnerBalance * @property {org.dash.platform.dapi.v0.IResponseMetadata|null} [metadata] WaitForStateTransitionResultResponseV0 metadata */ @@ -50813,6 +51037,14 @@ $root.org = (function() { */ WaitForStateTransitionResultResponseV0.prototype.proof = null; + /** + * WaitForStateTransitionResultResponseV0 successWithOwnerBalance. + * @member {org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.ISuccessWithOwnerBalance|null|undefined} successWithOwnerBalance + * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0 + * @instance + */ + WaitForStateTransitionResultResponseV0.prototype.successWithOwnerBalance = null; + /** * WaitForStateTransitionResultResponseV0 metadata. * @member {org.dash.platform.dapi.v0.IResponseMetadata|null|undefined} metadata @@ -50826,12 +51058,12 @@ $root.org = (function() { /** * WaitForStateTransitionResultResponseV0 result. - * @member {"error"|"proof"|undefined} result + * @member {"error"|"proof"|"successWithOwnerBalance"|undefined} result * @memberof org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0 * @instance */ Object.defineProperty(WaitForStateTransitionResultResponseV0.prototype, "result", { - get: $util.oneOfGetter($oneOfFields = ["error", "proof"]), + get: $util.oneOfGetter($oneOfFields = ["error", "proof", "successWithOwnerBalance"]), set: $util.oneOfSetter($oneOfFields) }); @@ -50865,6 +51097,8 @@ $root.org = (function() { $root.org.dash.platform.dapi.v0.Proof.encode(message.proof, writer.uint32(/* id 2, wireType 2 =*/18).fork()).ldelim(); if (message.metadata != null && Object.hasOwnProperty.call(message, "metadata")) $root.org.dash.platform.dapi.v0.ResponseMetadata.encode(message.metadata, writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim(); + if (message.successWithOwnerBalance != null && Object.hasOwnProperty.call(message, "successWithOwnerBalance")) + $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.encode(message.successWithOwnerBalance, writer.uint32(/* id 4, wireType 2 =*/34).fork()).ldelim(); return writer; }; @@ -50905,6 +51139,9 @@ $root.org = (function() { case 2: message.proof = $root.org.dash.platform.dapi.v0.Proof.decode(reader, reader.uint32()); break; + case 4: + message.successWithOwnerBalance = $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.decode(reader, reader.uint32()); + break; case 3: message.metadata = $root.org.dash.platform.dapi.v0.ResponseMetadata.decode(reader, reader.uint32()); break; @@ -50962,6 +51199,16 @@ $root.org = (function() { return "proof." + error; } } + if (message.successWithOwnerBalance != null && message.hasOwnProperty("successWithOwnerBalance")) { + if (properties.result === 1) + return "result: multiple values"; + properties.result = 1; + { + var error = $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.verify(message.successWithOwnerBalance); + if (error) + return "successWithOwnerBalance." + error; + } + } if (message.metadata != null && message.hasOwnProperty("metadata")) { var error = $root.org.dash.platform.dapi.v0.ResponseMetadata.verify(message.metadata); if (error) @@ -50992,6 +51239,11 @@ $root.org = (function() { throw TypeError(".org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.proof: object expected"); message.proof = $root.org.dash.platform.dapi.v0.Proof.fromObject(object.proof); } + if (object.successWithOwnerBalance != null) { + if (typeof object.successWithOwnerBalance !== "object") + throw TypeError(".org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.successWithOwnerBalance: object expected"); + message.successWithOwnerBalance = $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.fromObject(object.successWithOwnerBalance); + } if (object.metadata != null) { if (typeof object.metadata !== "object") throw TypeError(".org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.metadata: object expected"); @@ -51027,6 +51279,11 @@ $root.org = (function() { } if (message.metadata != null && message.hasOwnProperty("metadata")) object.metadata = $root.org.dash.platform.dapi.v0.ResponseMetadata.toObject(message.metadata, options); + if (message.successWithOwnerBalance != null && message.hasOwnProperty("successWithOwnerBalance")) { + object.successWithOwnerBalance = $root.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.toObject(message.successWithOwnerBalance, options); + if (options.oneofs) + object.result = "successWithOwnerBalance"; + } return object; }; diff --git a/packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js b/packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js index 8fe2b482632..54dc934ca3e 100644 --- a/packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js +++ b/packages/dapi-grpc/clients/platform/v0/nodejs/platform_protoc.js @@ -728,6 +728,7 @@ goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultR goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.VersionCase', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse', null, { proto }); +goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.VersionCase', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.ResultCase', null, { proto }); @@ -4847,6 +4848,27 @@ if (goog.DEBUG && !COMPILED) { */ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.displayName = 'proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.displayName = 'proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -54661,7 +54683,8 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForState proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0.toObject = function(includeInstance, msg) { var f, obj = { stateTransitionHash: msg.getStateTransitionHash_asB64(), - prove: jspb.Message.getBooleanFieldWithDefault(msg, 2, false) + prove: jspb.Message.getBooleanFieldWithDefault(msg, 2, false), + requestUserBalance: jspb.Message.getBooleanFieldWithDefault(msg, 3, false) }; if (includeInstance) { @@ -54706,6 +54729,10 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForState var value = /** @type {boolean} */ (reader.readBool()); msg.setProve(value); break; + case 3: + var value = /** @type {boolean} */ (reader.readBool()); + msg.setRequestUserBalance(value); + break; default: reader.skipField(); break; @@ -54749,6 +54776,13 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForState f ); } + f = message.getRequestUserBalance(); + if (f) { + writer.writeBool( + 3, + f + ); + } }; @@ -54812,6 +54846,24 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForState }; +/** + * optional bool request_user_balance = 3; + * @return {boolean} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0.prototype.getRequestUserBalance = function() { + return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 3, false)); +}; + + +/** + * @param {boolean} value + * @return {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0} returns this + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0.prototype.setRequestUserBalance = function(value) { + return jspb.Message.setProto3BooleanField(this, 3, value); +}; + + /** * optional WaitForStateTransitionResultRequestV0 v0 = 1; * @return {?proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0} @@ -54989,6 +55041,136 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.serializeBi + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.prototype.toObject = function(opt_includeInstance) { + return proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.toObject = function(includeInstance, msg) { + var f, obj = { + ownerBalance: jspb.Message.getFieldWithDefault(msg, 1, "0") + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance; + return proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readUint64String()); + msg.setOwnerBalance(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getOwnerBalance(); + if (parseInt(f, 10) !== 0) { + writer.writeUint64String( + 1, + f + ); + } +}; + + +/** + * optional uint64 owner_balance = 1; + * @return {string} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.prototype.getOwnerBalance = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "0")); +}; + + +/** + * @param {string} value + * @return {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} returns this + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.prototype.setOwnerBalance = function(value) { + return jspb.Message.setProto3StringIntField(this, 1, value); +}; + + + /** * Oneof group definitions for this message. Each group defines the field * numbers belonging to that group. When of these fields' value is set, all @@ -54997,7 +55179,7 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.serializeBi * @private {!Array>} * @const */ -proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.oneofGroups_ = [[1,2]]; +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.oneofGroups_ = [[1,2,4]]; /** * @enum {number} @@ -55005,7 +55187,8 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStat proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.ResultCase = { RESULT_NOT_SET: 0, ERROR: 1, - PROOF: 2 + PROOF: 2, + SUCCESS_WITH_OWNER_BALANCE: 4 }; /** @@ -55048,6 +55231,7 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStat var f, obj = { error: (f = msg.getError()) && proto.org.dash.platform.dapi.v0.StateTransitionBroadcastError.toObject(includeInstance, f), proof: (f = msg.getProof()) && proto.org.dash.platform.dapi.v0.Proof.toObject(includeInstance, f), + successWithOwnerBalance: (f = msg.getSuccessWithOwnerBalance()) && proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.toObject(includeInstance, f), metadata: (f = msg.getMetadata()) && proto.org.dash.platform.dapi.v0.ResponseMetadata.toObject(includeInstance, f) }; @@ -55095,6 +55279,11 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStat reader.readMessage(value,proto.org.dash.platform.dapi.v0.Proof.deserializeBinaryFromReader); msg.setProof(value); break; + case 4: + var value = new proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance; + reader.readMessage(value,proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.deserializeBinaryFromReader); + msg.setSuccessWithOwnerBalance(value); + break; case 3: var value = new proto.org.dash.platform.dapi.v0.ResponseMetadata; reader.readMessage(value,proto.org.dash.platform.dapi.v0.ResponseMetadata.deserializeBinaryFromReader); @@ -55145,6 +55334,14 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStat proto.org.dash.platform.dapi.v0.Proof.serializeBinaryToWriter ); } + f = message.getSuccessWithOwnerBalance(); + if (f != null) { + writer.writeMessage( + 4, + f, + proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.serializeBinaryToWriter + ); + } f = message.getMetadata(); if (f != null) { writer.writeMessage( @@ -55230,6 +55427,43 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStat }; +/** + * optional SuccessWithOwnerBalance success_with_owner_balance = 4; + * @return {?proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.prototype.getSuccessWithOwnerBalance = function() { + return /** @type{?proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} */ ( + jspb.Message.getWrapperField(this, proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance, 4)); +}; + + +/** + * @param {?proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance|undefined} value + * @return {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0} returns this +*/ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.prototype.setSuccessWithOwnerBalance = function(value) { + return jspb.Message.setOneofWrapperField(this, 4, proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.oneofGroups_[0], value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0} returns this + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.prototype.clearSuccessWithOwnerBalance = function() { + return this.setSuccessWithOwnerBalance(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.prototype.hasSuccessWithOwnerBalance = function() { + return jspb.Message.getField(this, 4) != null; +}; + + /** * optional ResponseMetadata metadata = 3; * @return {?proto.org.dash.platform.dapi.v0.ResponseMetadata} diff --git a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h index f54c64a2361..3606e8786a6 100644 --- a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h +++ b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.h @@ -359,6 +359,7 @@ CF_EXTERN_C_BEGIN @class SpecificKeys; @class StateTransitionBroadcastError; @class WaitForStateTransitionResultRequest_WaitForStateTransitionResultRequestV0; +@class WaitForStateTransitionResultResponse_SuccessWithOwnerBalance; @class WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0; NS_ASSUME_NONNULL_BEGIN @@ -6770,6 +6771,7 @@ void WaitForStateTransitionResultRequest_ClearVersionOneOfCase(WaitForStateTrans typedef GPB_ENUM(WaitForStateTransitionResultRequest_WaitForStateTransitionResultRequestV0_FieldNumber) { WaitForStateTransitionResultRequest_WaitForStateTransitionResultRequestV0_FieldNumber_StateTransitionHash = 1, WaitForStateTransitionResultRequest_WaitForStateTransitionResultRequestV0_FieldNumber_Prove = 2, + WaitForStateTransitionResultRequest_WaitForStateTransitionResultRequestV0_FieldNumber_RequestUserBalance = 3, }; GPB_FINAL @interface WaitForStateTransitionResultRequest_WaitForStateTransitionResultRequestV0 : GPBMessage @@ -6780,6 +6782,16 @@ GPB_FINAL @interface WaitForStateTransitionResultRequest_WaitForStateTransitionR /** Flag to request a proof as the response */ @property(nonatomic, readwrite) BOOL prove; +/** + * Flag to request, without a proof, the credit balance of the identity + * that owns the transition after it executed. Ignored when a proof is + * requested: from protocol version 14 the proof of an owned, fee-paying + * transition (document and token batches, contract creates and updates, + * identity updates and key limit updates, contract moderation) carries + * the owner's balance itself. + **/ +@property(nonatomic, readwrite) BOOL requestUserBalance; + @end #pragma mark - WaitForStateTransitionResultResponse @@ -6806,18 +6818,39 @@ GPB_FINAL @interface WaitForStateTransitionResultResponse : GPBMessage **/ void WaitForStateTransitionResultResponse_ClearVersionOneOfCase(WaitForStateTransitionResultResponse *message); +#pragma mark - WaitForStateTransitionResultResponse_SuccessWithOwnerBalance + +typedef GPB_ENUM(WaitForStateTransitionResultResponse_SuccessWithOwnerBalance_FieldNumber) { + WaitForStateTransitionResultResponse_SuccessWithOwnerBalance_FieldNumber_OwnerBalance = 1, +}; + +/** + * The result of a successful wait that asked for the owner's balance and for no proof + **/ +GPB_FINAL @interface WaitForStateTransitionResultResponse_SuccessWithOwnerBalance : GPBMessage + +/** + * The credit balance of the identity that owns the transition after it + * executed, read from a Drive state at or past the block that executed it + **/ +@property(nonatomic, readwrite) uint64_t ownerBalance; + +@end + #pragma mark - WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0 typedef GPB_ENUM(WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0_FieldNumber) { WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0_FieldNumber_Error = 1, WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0_FieldNumber_Proof = 2, WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0_FieldNumber_Metadata = 3, + WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0_FieldNumber_SuccessWithOwnerBalance = 4, }; typedef GPB_ENUM(WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0_Result_OneOfCase) { WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0_Result_OneOfCase_GPBUnsetOneOfCase = 0, WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0_Result_OneOfCase_Error = 1, WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0_Result_OneOfCase_Proof = 2, + WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0_Result_OneOfCase_SuccessWithOwnerBalance = 4, }; GPB_FINAL @interface WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0 : GPBMessage @@ -6830,6 +6863,12 @@ GPB_FINAL @interface WaitForStateTransitionResultResponse_WaitForStateTransition /** Cryptographic proof for the state transition, if requested */ @property(nonatomic, readwrite, strong, null_resettable) Proof *proof; +/** + * Success with the owner's balance, unproved, when the request asked + * for it and for no proof + **/ +@property(nonatomic, readwrite, strong, null_resettable) WaitForStateTransitionResultResponse_SuccessWithOwnerBalance *successWithOwnerBalance; + /** Metadata about the blockchain state */ @property(nonatomic, readwrite, strong, null_resettable) ResponseMetadata *metadata; /** Test to see if @c metadata has been set. */ diff --git a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.m b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.m index 838e7fcfe14..0b15165621b 100644 --- a/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.m +++ b/packages/dapi-grpc/clients/platform/v0/objective-c/Platform.pbobjc.m @@ -495,6 +495,7 @@ GPBObjCClassDeclaration(WaitForStateTransitionResultRequest); GPBObjCClassDeclaration(WaitForStateTransitionResultRequest_WaitForStateTransitionResultRequestV0); GPBObjCClassDeclaration(WaitForStateTransitionResultResponse); +GPBObjCClassDeclaration(WaitForStateTransitionResultResponse_SuccessWithOwnerBalance); GPBObjCClassDeclaration(WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0); #pragma mark - PlatformRoot @@ -13738,6 +13739,7 @@ @implementation WaitForStateTransitionResultRequest_WaitForStateTransitionResult @dynamic stateTransitionHash; @dynamic prove; +@dynamic requestUserBalance; typedef struct WaitForStateTransitionResultRequest_WaitForStateTransitionResultRequestV0__storage_ { uint32_t _has_storage_[1]; @@ -13768,6 +13770,15 @@ + (GPBDescriptor *)descriptor { .flags = (GPBFieldFlags)(GPBFieldOptional | GPBFieldClearHasIvarOnZero), .dataType = GPBDataTypeBool, }, + { + .name = "requestUserBalance", + .dataTypeSpecific.clazz = Nil, + .number = WaitForStateTransitionResultRequest_WaitForStateTransitionResultRequestV0_FieldNumber_RequestUserBalance, + .hasIndex = 3, + .offset = 4, // Stored in _has_storage_ to save space. + .flags = (GPBFieldFlags)(GPBFieldOptional | GPBFieldClearHasIvarOnZero), + .dataType = GPBDataTypeBool, + }, }; GPBDescriptor *localDescriptor = [GPBDescriptor allocDescriptorForClass:[WaitForStateTransitionResultRequest_WaitForStateTransitionResultRequestV0 class] @@ -13845,6 +13856,52 @@ void WaitForStateTransitionResultResponse_ClearVersionOneOfCase(WaitForStateTran GPBOneofDescriptor *oneof = [descriptor.oneofs objectAtIndex:0]; GPBClearOneof(message, oneof); } +#pragma mark - WaitForStateTransitionResultResponse_SuccessWithOwnerBalance + +@implementation WaitForStateTransitionResultResponse_SuccessWithOwnerBalance + +@dynamic ownerBalance; + +typedef struct WaitForStateTransitionResultResponse_SuccessWithOwnerBalance__storage_ { + uint32_t _has_storage_[1]; + uint64_t ownerBalance; +} WaitForStateTransitionResultResponse_SuccessWithOwnerBalance__storage_; + +// This method is threadsafe because it is initially called +// in +initialize for each subclass. ++ (GPBDescriptor *)descriptor { + static GPBDescriptor *descriptor = nil; + if (!descriptor) { + static GPBMessageFieldDescription fields[] = { + { + .name = "ownerBalance", + .dataTypeSpecific.clazz = Nil, + .number = WaitForStateTransitionResultResponse_SuccessWithOwnerBalance_FieldNumber_OwnerBalance, + .hasIndex = 0, + .offset = (uint32_t)offsetof(WaitForStateTransitionResultResponse_SuccessWithOwnerBalance__storage_, ownerBalance), + .flags = (GPBFieldFlags)(GPBFieldOptional | GPBFieldClearHasIvarOnZero), + .dataType = GPBDataTypeUInt64, + }, + }; + GPBDescriptor *localDescriptor = + [GPBDescriptor allocDescriptorForClass:[WaitForStateTransitionResultResponse_SuccessWithOwnerBalance class] + rootClass:[PlatformRoot class] + file:PlatformRoot_FileDescriptor() + fields:fields + fieldCount:(uint32_t)(sizeof(fields) / sizeof(GPBMessageFieldDescription)) + storageSize:sizeof(WaitForStateTransitionResultResponse_SuccessWithOwnerBalance__storage_) + flags:(GPBDescriptorInitializationFlags)(GPBDescriptorInitializationFlag_UsesClassRefs | GPBDescriptorInitializationFlag_Proto3OptionalKnown)]; + [localDescriptor setupContainingMessageClass:GPBObjCClass(WaitForStateTransitionResultResponse)]; + #if defined(DEBUG) && DEBUG + NSAssert(descriptor == nil, @"Startup recursed!"); + #endif // DEBUG + descriptor = localDescriptor; + } + return descriptor; +} + +@end + #pragma mark - WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0 @implementation WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0 @@ -13852,6 +13909,7 @@ @implementation WaitForStateTransitionResultResponse_WaitForStateTransitionResul @dynamic resultOneOfCase; @dynamic error; @dynamic proof; +@dynamic successWithOwnerBalance; @dynamic hasMetadata, metadata; typedef struct WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0__storage_ { @@ -13859,6 +13917,7 @@ @implementation WaitForStateTransitionResultResponse_WaitForStateTransitionResul StateTransitionBroadcastError *error; Proof *proof; ResponseMetadata *metadata; + WaitForStateTransitionResultResponse_SuccessWithOwnerBalance *successWithOwnerBalance; } WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0__storage_; // This method is threadsafe because it is initially called @@ -13894,6 +13953,15 @@ + (GPBDescriptor *)descriptor { .flags = GPBFieldOptional, .dataType = GPBDataTypeMessage, }, + { + .name = "successWithOwnerBalance", + .dataTypeSpecific.clazz = GPBObjCClass(WaitForStateTransitionResultResponse_SuccessWithOwnerBalance), + .number = WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0_FieldNumber_SuccessWithOwnerBalance, + .hasIndex = -1, + .offset = (uint32_t)offsetof(WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0__storage_, successWithOwnerBalance), + .flags = GPBFieldOptional, + .dataType = GPBDataTypeMessage, + }, }; GPBDescriptor *localDescriptor = [GPBDescriptor allocDescriptorForClass:[WaitForStateTransitionResultResponse_WaitForStateTransitionResultResponseV0 class] diff --git a/packages/dapi-grpc/clients/platform/v0/python/platform_pb2.py b/packages/dapi-grpc/clients/platform/v0/python/platform_pb2.py index 56f0877d1ad..ba7846cf43d 100644 --- a/packages/dapi-grpc/clients/platform/v0/python/platform_pb2.py +++ b/packages/dapi-grpc/clients/platform/v0/python/platform_pb2.py @@ -23,7 +23,7 @@ syntax='proto3', serialized_options=None, create_key=_descriptor._internal_create_key, - serialized_pb=b'\n\x0eplatform.proto\x12\x19org.dash.platform.dapi.v0\x1a\x1egoogle/protobuf/wrappers.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x81\x01\n\x05Proof\x12\x15\n\rgrovedb_proof\x18\x01 \x01(\x0c\x12\x13\n\x0bquorum_hash\x18\x02 \x01(\x0c\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x12\r\n\x05round\x18\x04 \x01(\r\x12\x15\n\rblock_id_hash\x18\x05 \x01(\x0c\x12\x13\n\x0bquorum_type\x18\x06 \x01(\r\"\x98\x01\n\x10ResponseMetadata\x12\x12\n\x06height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12 \n\x18\x63ore_chain_locked_height\x18\x02 \x01(\r\x12\r\n\x05\x65poch\x18\x03 \x01(\r\x12\x13\n\x07time_ms\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x18\n\x10protocol_version\x18\x05 \x01(\r\x12\x10\n\x08\x63hain_id\x18\x06 \x01(\t\"L\n\x1dStateTransitionBroadcastError\x12\x0c\n\x04\x63ode\x18\x01 \x01(\r\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\";\n\x1f\x42roadcastStateTransitionRequest\x12\x18\n\x10state_transition\x18\x01 \x01(\x0c\"\"\n BroadcastStateTransitionResponse\"\xa4\x01\n\x12GetIdentityRequest\x12P\n\x02v0\x18\x01 \x01(\x0b\x32\x42.org.dash.platform.dapi.v0.GetIdentityRequest.GetIdentityRequestV0H\x00\x1a\x31\n\x14GetIdentityRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xc1\x01\n\x17GetIdentityNonceRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetIdentityNonceRequest.GetIdentityNonceRequestV0H\x00\x1a?\n\x19GetIdentityNonceRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf6\x01\n\x1fGetIdentityContractNonceRequest\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentityContractNonceRequest.GetIdentityContractNonceRequestV0H\x00\x1a\\\n!GetIdentityContractNonceRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63ontract_id\x18\x02 \x01(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xc0\x01\n\x19GetIdentityBalanceRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetIdentityBalanceRequest.GetIdentityBalanceRequestV0H\x00\x1a\x38\n\x1bGetIdentityBalanceRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xec\x01\n$GetIdentityBalanceAndRevisionRequest\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionRequest.GetIdentityBalanceAndRevisionRequestV0H\x00\x1a\x43\n&GetIdentityBalanceAndRevisionRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9e\x02\n\x13GetIdentityResponse\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetIdentityResponse.GetIdentityResponseV0H\x00\x1a\xa7\x01\n\x15GetIdentityResponseV0\x12\x12\n\x08identity\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbc\x02\n\x18GetIdentityNonceResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetIdentityNonceResponse.GetIdentityNonceResponseV0H\x00\x1a\xb6\x01\n\x1aGetIdentityNonceResponseV0\x12\x1c\n\x0eidentity_nonce\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xe5\x02\n GetIdentityContractNonceResponse\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentityContractNonceResponse.GetIdentityContractNonceResponseV0H\x00\x1a\xc7\x01\n\"GetIdentityContractNonceResponseV0\x12%\n\x17identity_contract_nonce\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\x8e\x02\n&GetIdentityKeysRemainingBudgetsRequest\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsRequest.GetIdentityKeysRemainingBudgetsRequestV0H\x00\x1a_\n(GetIdentityKeysRemainingBudgetsRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x0f\n\x07key_ids\x18\x02 \x03(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\x96\x06\n\'GetIdentityKeysRemainingBudgetsResponse\x12z\n\x02v0\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse.GetIdentityKeysRemainingBudgetsResponseV0H\x00\x1a\xe3\x04\n)GetIdentityKeysRemainingBudgetsResponseV0\x12\xa4\x01\n\x16keys_remaining_budgets\x18\x01 \x01(\x0b\x32\x81\x01.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse.GetIdentityKeysRemainingBudgetsResponseV0.KeysRemainingBudgetsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x61\n\x17KeyRemainingBudgetEntry\x12\x0e\n\x06key_id\x18\x01 \x01(\r\x12!\n\x10remaining_budget\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\x13\n\x11_remaining_budget\x1a\xaf\x01\n\x14KeysRemainingBudgets\x12\x96\x01\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse.GetIdentityKeysRemainingBudgetsResponseV0.KeyRemainingBudgetEntryB\x08\n\x06resultB\t\n\x07version\"\xbd\x02\n\x1aGetIdentityBalanceResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetIdentityBalanceResponse.GetIdentityBalanceResponseV0H\x00\x1a\xb1\x01\n\x1cGetIdentityBalanceResponseV0\x12\x15\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb1\x04\n%GetIdentityBalanceAndRevisionResponse\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse.GetIdentityBalanceAndRevisionResponseV0H\x00\x1a\x84\x03\n\'GetIdentityBalanceAndRevisionResponseV0\x12\x9b\x01\n\x14\x62\x61lance_and_revision\x18\x01 \x01(\x0b\x32{.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse.GetIdentityBalanceAndRevisionResponseV0.BalanceAndRevisionH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a?\n\x12\x42\x61lanceAndRevision\x12\x13\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x14\n\x08revision\x18\x02 \x01(\x04\x42\x02\x30\x01\x42\x08\n\x06resultB\t\n\x07version\"\xd1\x01\n\x0eKeyRequestType\x12\x36\n\x08\x61ll_keys\x18\x01 \x01(\x0b\x32\".org.dash.platform.dapi.v0.AllKeysH\x00\x12@\n\rspecific_keys\x18\x02 \x01(\x0b\x32\'.org.dash.platform.dapi.v0.SpecificKeysH\x00\x12:\n\nsearch_key\x18\x03 \x01(\x0b\x32$.org.dash.platform.dapi.v0.SearchKeyH\x00\x42\t\n\x07request\"\t\n\x07\x41llKeys\"\x1f\n\x0cSpecificKeys\x12\x0f\n\x07key_ids\x18\x01 \x03(\r\"\xb6\x01\n\tSearchKey\x12I\n\x0bpurpose_map\x18\x01 \x03(\x0b\x32\x34.org.dash.platform.dapi.v0.SearchKey.PurposeMapEntry\x1a^\n\x0fPurposeMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12:\n\x05value\x18\x02 \x01(\x0b\x32+.org.dash.platform.dapi.v0.SecurityLevelMap:\x02\x38\x01\"\xbf\x02\n\x10SecurityLevelMap\x12]\n\x12security_level_map\x18\x01 \x03(\x0b\x32\x41.org.dash.platform.dapi.v0.SecurityLevelMap.SecurityLevelMapEntry\x1aw\n\x15SecurityLevelMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12M\n\x05value\x18\x02 \x01(\x0e\x32>.org.dash.platform.dapi.v0.SecurityLevelMap.KeyKindRequestType:\x02\x38\x01\"S\n\x12KeyKindRequestType\x12\x1f\n\x1b\x43URRENT_KEY_OF_KIND_REQUEST\x10\x00\x12\x1c\n\x18\x41LL_KEYS_OF_KIND_REQUEST\x10\x01\"\xda\x02\n\x16GetIdentityKeysRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetIdentityKeysRequest.GetIdentityKeysRequestV0H\x00\x1a\xda\x01\n\x18GetIdentityKeysRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12?\n\x0crequest_type\x18\x02 \x01(\x0b\x32).org.dash.platform.dapi.v0.KeyRequestType\x12+\n\x05limit\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\x99\x03\n\x17GetIdentityKeysResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetIdentityKeysResponse.GetIdentityKeysResponseV0H\x00\x1a\x96\x02\n\x19GetIdentityKeysResponseV0\x12\x61\n\x04keys\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetIdentityKeysResponse.GetIdentityKeysResponseV0.KeysH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1a\n\x04Keys\x12\x12\n\nkeys_bytes\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xef\x02\n GetIdentitiesContractKeysRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentitiesContractKeysRequest.GetIdentitiesContractKeysRequestV0H\x00\x1a\xd1\x01\n\"GetIdentitiesContractKeysRequestV0\x12\x16\n\x0eidentities_ids\x18\x01 \x03(\x0c\x12\x13\n\x0b\x63ontract_id\x18\x02 \x01(\x0c\x12\x1f\n\x12\x64ocument_type_name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x37\n\x08purposes\x18\x04 \x03(\x0e\x32%.org.dash.platform.dapi.v0.KeyPurpose\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x15\n\x13_document_type_nameB\t\n\x07version\"\xdf\x06\n!GetIdentitiesContractKeysResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0H\x00\x1a\xbe\x05\n#GetIdentitiesContractKeysResponseV0\x12\x8a\x01\n\x0fidentities_keys\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.IdentitiesKeysH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aY\n\x0bPurposeKeys\x12\x36\n\x07purpose\x18\x01 \x01(\x0e\x32%.org.dash.platform.dapi.v0.KeyPurpose\x12\x12\n\nkeys_bytes\x18\x02 \x03(\x0c\x1a\x9f\x01\n\x0cIdentityKeys\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12z\n\x04keys\x18\x02 \x03(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.PurposeKeys\x1a\x90\x01\n\x0eIdentitiesKeys\x12~\n\x07\x65ntries\x18\x01 \x03(\x0b\x32m.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.IdentityKeysB\x08\n\x06resultB\t\n\x07version\"\xa4\x02\n*GetEvonodesProposedEpochBlocksByIdsRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByIdsRequest.GetEvonodesProposedEpochBlocksByIdsRequestV0H\x00\x1ah\n,GetEvonodesProposedEpochBlocksByIdsRequestV0\x12\x12\n\x05\x65poch\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x0b\n\x03ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\x08\n\x06_epochB\t\n\x07version\"\x92\x06\n&GetEvonodesProposedEpochBlocksResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0H\x00\x1a\xe2\x04\n(GetEvonodesProposedEpochBlocksResponseV0\x12\xb1\x01\n#evonodes_proposed_block_counts_info\x18\x01 \x01(\x0b\x32\x81\x01.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0.EvonodesProposedBlocksH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a?\n\x15\x45vonodeProposedBlocks\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x02 \x01(\x04\x42\x02\x30\x01\x1a\xc4\x01\n\x16\x45vonodesProposedBlocks\x12\xa9\x01\n\x1e\x65vonodes_proposed_block_counts\x18\x01 \x03(\x0b\x32\x80\x01.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0.EvonodeProposedBlocksB\x08\n\x06resultB\t\n\x07version\"\xf2\x02\n,GetEvonodesProposedEpochBlocksByRangeRequest\x12\x84\x01\n\x02v0\x18\x01 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByRangeRequest.GetEvonodesProposedEpochBlocksByRangeRequestV0H\x00\x1a\xaf\x01\n.GetEvonodesProposedEpochBlocksByRangeRequestV0\x12\x12\n\x05\x65poch\x18\x01 \x01(\rH\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x02 \x01(\rH\x02\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x03 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x04 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x07\n\x05startB\x08\n\x06_epochB\x08\n\x06_limitB\t\n\x07version\"\xcd\x01\n\x1cGetIdentitiesBalancesRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetIdentitiesBalancesRequest.GetIdentitiesBalancesRequestV0H\x00\x1a<\n\x1eGetIdentitiesBalancesRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9f\x05\n\x1dGetIdentitiesBalancesResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0H\x00\x1a\x8a\x04\n\x1fGetIdentitiesBalancesResponseV0\x12\x8a\x01\n\x13identities_balances\x18\x01 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0.IdentitiesBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aL\n\x0fIdentityBalance\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x18\n\x07\x62\x61lance\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\x8f\x01\n\x12IdentitiesBalances\x12y\n\x07\x65ntries\x18\x01 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0.IdentityBalanceB\x08\n\x06resultB\t\n\x07version\"\xb4\x01\n\x16GetDataContractRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetDataContractRequest.GetDataContractRequestV0H\x00\x1a\x35\n\x18GetDataContractRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xb3\x02\n\x17GetDataContractResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetDataContractResponse.GetDataContractResponseV0H\x00\x1a\xb0\x01\n\x19GetDataContractResponseV0\x12\x17\n\rdata_contract\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\x8c\x02\n%GetDataContractsLatestVersionsRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsRequest.GetDataContractsLatestVersionsRequestV0H\x00\x1a`\n\'GetDataContractsLatestVersionsRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\x19\n\x11include_contracts\x18\x02 \x01(\x08\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xfa\x05\n&GetDataContractsLatestVersionsResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse.GetDataContractsLatestVersionsResponseV0H\x00\x1a\x84\x01\n\x1e\x44\x61taContractLatestVersionEntry\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x14\n\x07version\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x1a\n\rdata_contract\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x42\n\n\x08_versionB\x10\n\x0e_data_contract\x1a\x90\x01\n\x1b\x44\x61taContractsLatestVersions\x12q\n\x07\x65ntries\x18\x01 \x03(\x0b\x32`.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse.DataContractLatestVersionEntry\x1a\xb0\x02\n(GetDataContractsLatestVersionsResponseV0\x12\x87\x01\n\x1e\x64\x61ta_contracts_latest_versions\x18\x01 \x01(\x0b\x32].org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse.DataContractsLatestVersionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"R\n\x1f\x43ontractGroupDocumentTypeMember\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\"G\n\x18\x43ontractGroupTokenMember\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x16\n\x0etoken_position\x18\x02 \x01(\r\"\xd7\x01\n\x1bGetContractGroupInfoRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetContractGroupInfoRequest.GetContractGroupInfoRequestV0H\x00\x1aI\n\x1dGetContractGroupInfoRequestV0\x12\x19\n\x11\x63ontract_group_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x98\x04\n\x1cGetContractGroupInfoResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetContractGroupInfoResponse.GetContractGroupInfoResponseV0H\x00\x1a~\n\x11\x43ontractGroupInfo\x12\x10\n\x08owner_id\x18\x01 \x01(\x0c\x12\x11\n\tadmin_ids\x18\x02 \x03(\x0c\x12\x11\n\x04name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x04 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05_nameB\x0e\n\x0c_description\x1a\x86\x02\n\x1eGetContractGroupInfoResponseV0\x12h\n\x13\x63ontract_group_info\x18\x01 \x01(\x0b\x32I.org.dash.platform.dapi.v0.GetContractGroupInfoResponse.ContractGroupInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf8\x06\n\x1eGetContractGroupMembersRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.GetContractGroupMembersRequestV0H\x00\x1a@\n\x14\x43ontractMembersQuery\x12\x18\n\x0bstart_after\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x42\x0e\n\x0c_start_after\x1a\x80\x01\n\x18\x44ocumentTypeMembersQuery\x12T\n\x0bstart_after\x18\x01 \x01(\x0b\x32:.org.dash.platform.dapi.v0.ContractGroupDocumentTypeMemberH\x00\x88\x01\x01\x42\x0e\n\x0c_start_after\x1ar\n\x11TokenMembersQuery\x12M\n\x0bstart_after\x18\x01 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractGroupTokenMemberH\x00\x88\x01\x01\x42\x0e\n\x0c_start_after\x1a\xa7\x03\n GetContractGroupMembersRequestV0\x12\x19\n\x11\x63ontract_group_id\x18\x01 \x01(\x0c\x12\x63\n\tcontracts\x18\x02 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.ContractMembersQueryH\x00\x12l\n\x0e\x64ocument_types\x18\x03 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.DocumentTypeMembersQueryH\x00\x12]\n\x06tokens\x18\x04 \x01(\x0b\x32K.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.TokenMembersQueryH\x00\x12\x12\n\x05limit\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x42\t\n\x07membersB\x08\n\x06_limitB\t\n\x07version\"\xc9\x06\n\x1fGetContractGroupMembersResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.GetContractGroupMembersResponseV0H\x00\x1a\'\n\x0f\x43ontractMembers\x12\x14\n\x0c\x63ontract_ids\x18\x01 \x03(\x0c\x1ai\n\x13\x44ocumentTypeMembers\x12R\n\x0e\x64ocument_types\x18\x01 \x03(\x0b\x32:.org.dash.platform.dapi.v0.ContractGroupDocumentTypeMember\x1aS\n\x0cTokenMembers\x12\x43\n\x06tokens\x18\x01 \x03(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractGroupTokenMember\x1a\xc5\x03\n!GetContractGroupMembersResponseV0\x12_\n\tcontracts\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.ContractMembersH\x00\x12h\n\x0e\x64ocument_types\x18\x02 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.DocumentTypeMembersH\x00\x12Y\n\x06tokens\x18\x03 \x01(\x0b\x32G.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.TokenMembersH\x00\x12\x31\n\x05proof\x18\x04 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x05 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"M\n\x1a\x43ontractModerationDocument\x12\x1a\n\x12\x64ocument_type_name\x18\x01 \x01(\t\x12\x13\n\x0b\x64ocument_id\x18\x02 \x01(\x0c\"\x8e\x01\n\x18\x43ontractModerationReason\x12\x11\n\x04\x63ode\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x0c\n\x04text\x18\x02 \x01(\t\x12H\n\tdocuments\x18\x03 \x03(\x0b\x32\x35.org.dash.platform.dapi.v0.ContractModerationDocumentB\x07\n\x05_code\"i\n\x0f\x43ontractWarning\x12\x11\n\twarned_at\x18\x01 \x01(\x04\x12\x43\n\x06reason\x18\x02 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReason\"\xc5\x02\n\"GetContractModerationStatusRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetContractModerationStatusRequest.GetContractModerationStatusRequestV0H\x00\x1a\xa1\x01\n$GetContractModerationStatusRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x13\n\x0bidentity_id\x18\x02 \x01(\x0c\x12@\n\x05lists\x18\x03 \x03(\x0e\x32\x31.org.dash.platform.dapi.v0.ContractModerationList\x12\r\n\x05prove\x18\x04 \x01(\x08\x42\t\n\x07version\"\xec\x06\n#GetContractModerationStatusResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.GetContractModerationStatusResponseV0H\x00\x1a\xb4\x03\n\x18\x43ontractModerationStatus\x12\x13\n\x06\x62\x61nned\x18\x01 \x01(\x08H\x00\x88\x01\x01\x12\x1c\n\x0fsuspended_until\x18\x02 \x01(\x04H\x01\x88\x01\x01\x12@\n\x05lists\x18\x03 \x03(\x0e\x32\x31.org.dash.platform.dapi.v0.ContractModerationList\x12L\n\nban_reason\x18\x04 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReasonH\x02\x88\x01\x01\x12S\n\x11suspension_reason\x18\x05 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReasonH\x03\x88\x01\x01\x12<\n\x08warnings\x18\x06 \x03(\x0b\x32*.org.dash.platform.dapi.v0.ContractWarningB\t\n\x07_bannedB\x12\n\x10_suspended_untilB\r\n\x0b_ban_reasonB\x14\n\x12_suspension_reason\x1a\x8e\x02\n%GetContractModerationStatusResponseV0\x12i\n\x06status\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatusH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xfb\x02\n#GetContractModerationEntriesRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractModerationEntriesRequest.GetContractModerationEntriesRequestV0H\x00\x1a\xd4\x01\n%GetContractModerationEntriesRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12?\n\x04list\x18\x02 \x01(\x0e\x32\x31.org.dash.platform.dapi.v0.ContractModerationList\x12\x18\n\x0bstart_after\x18\x03 \x01(\x0cH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x04 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x0e\n\x0c_start_afterB\x08\n\x06_limitB\t\n\x07version\"\x96\x06\n$GetContractModerationEntriesResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.GetContractModerationEntriesResponseV0H\x00\x1a\xcf\x01\n\x17\x43ontractModerationEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x12\n\x05until\x18\x02 \x01(\x04H\x00\x88\x01\x01\x12\x43\n\x06reason\x18\x03 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReason\x12<\n\x08warnings\x18\x04 \x03(\x0b\x32*.org.dash.platform.dapi.v0.ContractWarningB\x08\n\x06_until\x1a\x85\x01\n\x19\x43ontractModerationEntries\x12h\n\x07\x65ntries\x18\x01 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry\x1a\x92\x02\n&GetContractModerationEntriesResponseV0\x12l\n\x07\x65ntries\x18\x01 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc3\x04\n\"GetContractDocumentRemovalsRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest.GetContractDocumentRemovalsRequestV0H\x00\x1a#\n\x0b\x44ocumentIds\x12\x14\n\x0c\x64ocument_ids\x18\x01 \x03(\x0c\x1aN\n\x04Page\x12\x18\n\x0bstart_after\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x02 \x01(\rH\x01\x88\x01\x01\x42\x0e\n\x0c_start_afterB\x08\n\x06_limit\x1a\xaa\x02\n$GetContractDocumentRemovalsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x61\n\x0c\x64ocument_ids\x18\x03 \x01(\x0b\x32I.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest.DocumentIdsH\x00\x12R\n\x04page\x18\x04 \x01(\x0b\x32\x42.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest.PageH\x00\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x0b\n\tselectionB\t\n\x07version\"\xcb\x07\n#GetContractDocumentRemovalsResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.GetContractDocumentRemovalsResponseV0H\x00\x1aH\n\x1b\x43ontractDocumentRestoration\x12\x14\n\x0cmoderator_id\x18\x01 \x01(\x0c\x12\x13\n\x0brestored_at\x18\x02 \x01(\x04\x1a\xc0\x02\n\x17\x43ontractDocumentRemoval\x12\x13\n\x0b\x64ocument_id\x18\x01 \x01(\x0c\x12\x19\n\x11\x64ocument_owner_id\x18\x02 \x01(\x0c\x12\x14\n\x0cmoderator_id\x18\x03 \x01(\x0c\x12\x12\n\nremoved_at\x18\x04 \x01(\x04\x12\x43\n\x06reason\x18\x05 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReason\x12\x15\n\rdocument_hash\x18\x06 \x01(\x0c\x12o\n\x0brestoration\x18\x07 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration\x1a\x84\x01\n\x18\x43ontractDocumentRemovals\x12h\n\x08removals\x18\x01 \x03(\x0b\x32V.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval\x1a\x90\x02\n%GetContractDocumentRemovalsResponseV0\x12k\n\x08removals\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemovalsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc9\x01\n\x19GetContractFeePotsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetContractFeePotsRequest.GetContractFeePotsRequestV0H\x00\x1a\x41\n\x1bGetContractFeePotsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9f\x06\n\x1aGetContractFeePotsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetContractFeePotsResponse.GetContractFeePotsResponseV0H\x00\x1aR\n\x17\x43ontractFeePotLastClaim\x12\r\n\x05\x65poch\x18\x01 \x01(\r\x12\x13\n\x07time_ms\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x13\n\x0b\x63laimant_id\x18\x03 \x01(\x0c\x1a\x88\x01\n\x0e\x43ontractFeePot\x12\x13\n\x07\x63redits\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x61\n\nlast_claim\x18\x02 \x01(\x0b\x32M.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePotLastClaim\x1a\xc0\x01\n\x0f\x43ontractFeePots\x12S\n\x05owner\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePot\x12X\n\nmoderators\x18\x02 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePot\x1a\xf1\x01\n\x1cGetContractFeePotsResponseV0\x12U\n\x04pots\x18\x01 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePotsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf1\x01\n#GetContractGroupsForContractRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractGroupsForContractRequest.GetContractGroupsForContractRequestV0H\x00\x1aK\n%GetContractGroupsForContractRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf5\x06\n$GetContractGroupsForContractResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.GetContractGroupsForContractResponseV0H\x00\x1aQ\n\x17\x44ocumentTypeMemberships\x12\x1a\n\x12\x64ocument_type_name\x18\x01 \x01(\t\x12\x1a\n\x12\x63ontract_group_ids\x18\x02 \x03(\x0c\x1a\x46\n\x10TokenMemberships\x12\x16\n\x0etoken_position\x18\x01 \x01(\r\x12\x1a\n\x12\x63ontract_group_ids\x18\x02 \x03(\x0c\x1a\x89\x02\n\x18\x43ontractGroupMemberships\x12\x1a\n\x12\x63ontract_group_ids\x18\x01 \x03(\x0c\x12o\n\x0e\x64ocument_types\x18\x02 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.DocumentTypeMemberships\x12`\n\x06tokens\x18\x03 \x03(\x0b\x32P.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.TokenMemberships\x1a\xa4\x02\n&GetContractGroupsForContractResponseV0\x12~\n\x1a\x63ontract_group_memberships\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.ContractGroupMembershipsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb9\x01\n\x17GetDataContractsRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetDataContractsRequest.GetDataContractsRequestV0H\x00\x1a\x37\n\x19GetDataContractsRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xad\x02\n\x1eGetDataContractsByRangeRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetDataContractsByRangeRequest.GetDataContractsByRangeRequestV0H\x00\x1a\x95\x01\n GetDataContractsByRangeRequestV0\x12\x12\n\x05limit\x18\x01 \x01(\rH\x01\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x02 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x03 \x01(\x0cH\x00\x12\x10\n\x08ids_only\x18\x04 \x01(\x08\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x07\n\x05startB\x08\n\x06_limitB\t\n\x07version\"\xcf\x04\n\x18GetDataContractsResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetDataContractsResponse.GetDataContractsResponseV0H\x00\x1a[\n\x11\x44\x61taContractEntry\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x32\n\rdata_contract\x18\x02 \x01(\x0b\x32\x1b.google.protobuf.BytesValue\x1au\n\rDataContracts\x12\x64\n\x15\x64\x61ta_contract_entries\x18\x01 \x03(\x0b\x32\x45.org.dash.platform.dapi.v0.GetDataContractsResponse.DataContractEntry\x1a\xf5\x01\n\x1aGetDataContractsResponseV0\x12[\n\x0e\x64\x61ta_contracts\x18\x01 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDataContractsResponse.DataContractsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc5\x02\n\x1dGetDataContractHistoryRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetDataContractHistoryRequest.GetDataContractHistoryRequestV0H\x00\x1a\xb0\x01\n\x1fGetDataContractHistoryRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12+\n\x05limit\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0bstart_at_ms\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\xb2\x05\n\x1eGetDataContractHistoryResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0H\x00\x1a\x9a\x04\n GetDataContractHistoryResponseV0\x12\x8f\x01\n\x15\x64\x61ta_contract_history\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0.DataContractHistoryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a;\n\x18\x44\x61taContractHistoryEntry\x12\x10\n\x04\x64\x61te\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05value\x18\x02 \x01(\x0c\x1a\xaa\x01\n\x13\x44\x61taContractHistory\x12\x92\x01\n\x15\x64\x61ta_contract_entries\x18\x01 \x03(\x0b\x32s.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0.DataContractHistoryEntryB\x08\n\x06resultB\t\n\x07version\"\xe0\x1f\n\x13GetDocumentsRequest\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV0H\x00\x12R\n\x02v1\x18\x02 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1H\x00\x1a\xfe\x02\n\x12\x44ocumentFieldValue\x12\x14\n\nbool_value\x18\x01 \x01(\x08H\x00\x12\x19\n\x0bint64_value\x18\x02 \x01(\x12\x42\x02\x30\x01H\x00\x12\x1a\n\x0cuint64_value\x18\x03 \x01(\x04\x42\x02\x30\x01H\x00\x12\x16\n\x0c\x64ouble_value\x18\x04 \x01(\x01H\x00\x12\x0e\n\x04text\x18\x05 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_value\x18\x06 \x01(\x0cH\x00\x12[\n\x04list\x18\x07 \x01(\x0b\x32K.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValue.ValueListH\x00\x12\x14\n\nnull_value\x18\x08 \x01(\x08H\x00\x1a^\n\tValueList\x12Q\n\x06values\x18\x01 \x03(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValueB\t\n\x07variant\x1a\xe2\x02\n\x12TimeRangeSelection\x12\\\n\x08selector\x18\x01 \x01(\x0e\x32J.org.dash.platform.dapi.v0.GetDocumentsRequest.TimeRangeSelection.Selector\x12\x19\n\x08start_ms\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x12T\n\x04grid\x18\x03 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsRequest.TimeRangeSelection.Grid\x1a>\n\x04Grid\x12\x11\n\x05range\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x10\n\x04step\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x11\n\x05phase\x18\x03 \x01(\x04\x42\x02\x30\x01\"0\n\x08Selector\x12\n\n\x06NEWEST\x10\x00\x12\n\n\x06OLDEST\x10\x01\x12\x0c\n\x08\x42Y_START\x10\x02\x42\x0b\n\t_start_ms\x1a\x95\x02\n\x0bWhereClause\x12\r\n\x05\x66ield\x18\x01 \x01(\t\x12N\n\x08operator\x18\x02 \x01(\x0e\x32<.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereOperator\x12P\n\x05value\x18\x03 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValue\x12U\n\ntime_range\x18\x04 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.TimeRangeSelection\x1a\xa4\x01\n\x0fHavingAggregate\x12Y\n\x08\x66unction\x18\x01 \x01(\x0e\x32G.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregate.Function\x12\r\n\x05\x66ield\x18\x02 \x01(\t\"\'\n\x08\x46unction\x12\t\n\x05\x43OUNT\x10\x00\x12\x07\n\x03SUM\x10\x01\x12\x07\n\x03\x41VG\x10\x02\x1a\xf9\x03\n\x0cHavingClause\x12Q\n\taggregate\x18\x01 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregate\x12V\n\x08operator\x18\x02 \x01(\x0e\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingClause.Operator\x12R\n\x05value\x18\x03 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValueH\x00\"\xe0\x01\n\x08Operator\x12\t\n\x05\x45QUAL\x10\x00\x12\r\n\tNOT_EQUAL\x10\x01\x12\x10\n\x0cGREATER_THAN\x10\x02\x12\x1a\n\x16GREATER_THAN_OR_EQUALS\x10\x03\x12\r\n\tLESS_THAN\x10\x04\x12\x17\n\x13LESS_THAN_OR_EQUALS\x10\x05\x12\x0b\n\x07\x42\x45TWEEN\x10\x06\x12\x1a\n\x16\x42\x45TWEEN_EXCLUDE_BOUNDS\x10\x07\x12\x18\n\x14\x42\x45TWEEN_EXCLUDE_LEFT\x10\x08\x12\x19\n\x15\x42\x45TWEEN_EXCLUDE_RIGHT\x10\t\x12\x06\n\x02IN\x10\nB\x07\n\x05right\x1a\x90\x01\n\x0bOrderClause\x12\x0f\n\x05\x66ield\x18\x01 \x01(\tH\x00\x12S\n\taggregate\x18\x03 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregateH\x00\x12\x11\n\tascending\x18\x02 \x01(\x08\x42\x08\n\x06target\x1a\xbb\x01\n\x15GetDocumentsRequestV0\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12\r\n\x05where\x18\x03 \x01(\x0c\x12\x10\n\x08order_by\x18\x04 \x01(\x0c\x12\r\n\x05limit\x18\x05 \x01(\r\x12\x15\n\x0bstart_after\x18\x06 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x07 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x08 \x01(\x08\x42\x07\n\x05start\x1a\xa6\x0c\n\x15GetDocumentsRequestV1\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12Q\n\rwhere_clauses\x18\x03 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereClause\x12L\n\x08order_by\x18\x04 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.OrderClause\x12\x12\n\x05limit\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x06 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x07 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x08 \x01(\x08\x12\\\n\x07selects\x18\t \x03(\x0b\x32K.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.Select\x12\x10\n\x08group_by\x18\n \x03(\t\x12K\n\x06having\x18\x0b \x03(\x0b\x32;.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingClause\x12\x13\n\x06offset\x18\x0c \x01(\rH\x02\x88\x01\x01\x12\x61\n\x07\x63hained\x18\r \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.ChainedJoin\x12\x62\n\x0bsub_queries\x18\x0e \x03(\x0b\x32M.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.SubQuery\x1a\xc9\x01\n\x06Select\x12\x66\n\x08\x66unction\x18\x01 \x01(\x0e\x32T.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.Select.Function\x12\r\n\x05\x66ield\x18\x02 \x01(\t\"H\n\x08\x46unction\x12\r\n\tDOCUMENTS\x10\x00\x12\t\n\x05\x43OUNT\x10\x01\x12\x07\n\x03SUM\x10\x02\x12\x07\n\x03\x41VG\x10\x03\x12\x07\n\x03MIN\x10\x04\x12\x07\n\x03MAX\x10\x05\x1a\x41\n\x0b\x43hainedJoin\x12\x15\n\rjoin_property\x18\x01 \x01(\t\x12\x1b\n\x13outer_document_type\x18\x02 \x01(\t\x1a\xa6\x04\n\x08SubQuery\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12Q\n\rwhere_clauses\x18\x03 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereClause\x12L\n\x08order_by\x18\x04 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.OrderClause\x12\x12\n\x05limit\x18\x05 \x01(\rH\x00\x88\x01\x01\x12`\n\x04kind\x18\x06 \x01(\x0e\x32R.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.SubQuery.Kind\x12\x63\n\x04\x62ind\x18\x07 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.SubQuery.Binding\x1a\x41\n\x07\x42inding\x12\x0e\n\x06source\x18\x01 \x01(\r\x12\x17\n\x0fsource_property\x18\x02 \x01(\t\x12\r\n\x05\x66ield\x18\x03 \x01(\t\" \n\x04Kind\x12\r\n\tDOCUMENTS\x10\x00\x12\t\n\x05\x43OUNT\x10\x01\x42\x08\n\x06_limitB\x07\n\x05startB\x08\n\x06_limitB\t\n\x07_offset\"\xfa\x01\n\rWhereOperator\x12\t\n\x05\x45QUAL\x10\x00\x12\x10\n\x0cGREATER_THAN\x10\x01\x12\x1a\n\x16GREATER_THAN_OR_EQUALS\x10\x02\x12\r\n\tLESS_THAN\x10\x03\x12\x17\n\x13LESS_THAN_OR_EQUALS\x10\x04\x12\x0b\n\x07\x42\x45TWEEN\x10\x05\x12\x1a\n\x16\x42\x45TWEEN_EXCLUDE_BOUNDS\x10\x06\x12\x18\n\x14\x42\x45TWEEN_EXCLUDE_LEFT\x10\x07\x12\x19\n\x15\x42\x45TWEEN_EXCLUDE_RIGHT\x10\x08\x12\x06\n\x02IN\x10\t\x12\x0f\n\x0bSTARTS_WITH\x10\n\x12\x11\n\rIN_TIME_RANGE\x10\x0b\x42\t\n\x07version\"\xf2\x1b\n\x14GetDocumentsResponse\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV0H\x00\x12T\n\x02v1\x18\x02 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1H\x00\x1a\x9b\x02\n\x16GetDocumentsResponseV0\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV0.DocumentsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1e\n\tDocuments\x12\x11\n\tdocuments\x18\x01 \x03(\x0c\x42\x08\n\x06result\x1a\x84\x18\n\x16GetDocumentsResponseV1\x12\x61\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.ResultDataH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1e\n\tDocuments\x12\x11\n\tdocuments\x18\x01 \x03(\x0c\x1aL\n\nCountEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x03 \x01(\x04\x42\x02\x30\x01\x42\t\n\x07_in_key\x1ar\n\x0c\x43ountEntries\x12\x62\n\x07\x65ntries\x18\x01 \x03(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntry\x1a\xa0\x01\n\x0c\x43ountResults\x12\x1d\n\x0f\x61ggregate_count\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x66\n\x07\x65ntries\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntriesH\x00\x42\t\n\x07variant\x1aH\n\x08SumEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x0f\n\x03sum\x18\x03 \x01(\x12\x42\x02\x30\x01\x42\t\n\x07_in_key\x1an\n\nSumEntries\x12`\n\x07\x65ntries\x18\x01 \x03(\x0b\x32O.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumEntry\x1a\x9a\x01\n\nSumResults\x12\x1b\n\raggregate_sum\x18\x01 \x01(\x12\x42\x02\x30\x01H\x00\x12\x64\n\x07\x65ntries\x18\x02 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumEntriesH\x00\x42\t\n\x07variant\x1a_\n\x0c\x41verageEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x03 \x01(\x04\x42\x02\x30\x01\x12\x0f\n\x03sum\x18\x04 \x01(\x12\x42\x02\x30\x01\x42\t\n\x07_in_key\x1av\n\x0e\x41verageEntries\x12\x64\n\x07\x65ntries\x18\x01 \x03(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageEntry\x1a\x36\n\x10\x41verageAggregate\x12\x11\n\x05\x63ount\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x0f\n\x03sum\x18\x02 \x01(\x12\x42\x02\x30\x01\x1a\xfb\x01\n\x0e\x41verageResults\x12t\n\x11\x61ggregate_average\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageAggregateH\x00\x12h\n\x07\x65ntries\x18\x02 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageEntriesH\x00\x42\t\n\x07variant\x1az\n\x0bRankedEntry\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\x13\n\x05\x63ount\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x11\n\x03sum\x18\x03 \x01(\x12\x42\x02\x30\x01H\x00\x12\r\n\x03\x61vg\x18\x04 \x01(\x01H\x00\x12\x13\n\x06in_key\x18\x05 \x01(\x0cH\x01\x88\x01\x01\x42\x07\n\x05valueB\t\n\x07_in_key\x1a\x9a\x01\n\rRankedEntries\x12\x63\n\x07\x65ntries\x18\x01 \x03(\x0b\x32R.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.RankedEntry\x12\x18\n\x07skipped\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\n\n\x08_skipped\x1a\xf7\x05\n\nResultData\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.DocumentsH\x00\x12\x65\n\x06\x63ounts\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountResultsH\x00\x12\x61\n\x04sums\x18\x03 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumResultsH\x00\x12i\n\x08\x61verages\x18\x04 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageResultsH\x00\x12\x66\n\x06ranked\x18\x05 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.RankedEntriesH\x00\x12j\n\x07\x63hained\x18\x06 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.ChainedDocumentsH\x00\x12n\n\tcomposite\x18\x07 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CompositeDocumentsH\x00\x42\t\n\x07variant\x1a_\n\x10\x43hainedDocuments\x12\x17\n\x0finner_documents\x18\x01 \x03(\x0c\x12\x17\n\x0fouter_documents\x18\x02 \x03(\x0c\x12\x19\n\x11missing_outer_ids\x18\x03 \x03(\x0c\x1a\xab\x03\n\x12\x43ompositeDocuments\x12\x16\n\x0epage_documents\x18\x01 \x03(\x0c\x12}\n\x0bsub_results\x18\x02 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CompositeDocuments.SubQueryResult\x1a\xfd\x01\n\x0eSubQueryResult\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.DocumentsH\x00\x12\x65\n\x06\x63ounts\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntriesH\x00\x12\x13\n\x0bmissing_ids\x18\x03 \x03(\x0c\x42\x08\n\x06resultB\x08\n\x06resultB\t\n\x07version\"\xf4\x02\n\x19GetDocumentHistoryRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentHistoryRequest.GetDocumentHistoryRequestV0H\x00\x1a\xeb\x01\n\x1bGetDocumentHistoryRequestV0\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x13\n\x0b\x64ocument_id\x18\x03 \x01(\x0c\x12+\n\x05limit\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0bstart_at_ms\x18\x06 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x07 \x01(\x08\x42\t\n\x07version\"\xf7\x04\n\x1aGetDocumentHistoryResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0H\x00\x1a\xeb\x03\n\x1cGetDocumentHistoryResponseV0\x12~\n\x10\x64ocument_history\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0.DocumentHistoryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x37\n\x14\x44ocumentHistoryEntry\x12\x10\n\x04\x64\x61te\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05value\x18\x02 \x01(\x0c\x1a\x95\x01\n\x0f\x44ocumentHistory\x12\x81\x01\n\x10\x64ocument_entries\x18\x01 \x03(\x0b\x32g.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0.DocumentHistoryEntryB\x08\n\x06resultB\t\n\x07version\"\xed\x01\n!GetIdentityByPublicKeyHashRequest\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashRequest.GetIdentityByPublicKeyHashRequestV0H\x00\x1aM\n#GetIdentityByPublicKeyHashRequestV0\x12\x17\n\x0fpublic_key_hash\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xda\x02\n\"GetIdentityByPublicKeyHashResponse\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashResponse.GetIdentityByPublicKeyHashResponseV0H\x00\x1a\xb6\x01\n$GetIdentityByPublicKeyHashResponseV0\x12\x12\n\x08identity\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbd\x02\n*GetIdentityByNonUniquePublicKeyHashRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashRequest.GetIdentityByNonUniquePublicKeyHashRequestV0H\x00\x1a\x80\x01\n,GetIdentityByNonUniquePublicKeyHashRequestV0\x12\x17\n\x0fpublic_key_hash\x18\x01 \x01(\x0c\x12\x18\n\x0bstart_after\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\x0e\n\x0c_start_afterB\t\n\x07version\"\xd6\x06\n+GetIdentityByNonUniquePublicKeyHashResponse\x12\x82\x01\n\x02v0\x18\x01 \x01(\x0b\x32t.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0H\x00\x1a\x96\x05\n-GetIdentityByNonUniquePublicKeyHashResponseV0\x12\x9a\x01\n\x08identity\x18\x01 \x01(\x0b\x32\x85\x01.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0.IdentityResponseH\x00\x12\x9d\x01\n\x05proof\x18\x02 \x01(\x0b\x32\x8b\x01.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0.IdentityProvedResponseH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x36\n\x10IdentityResponse\x12\x15\n\x08identity\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x42\x0b\n\t_identity\x1a\xa6\x01\n\x16IdentityProvedResponse\x12P\n&grovedb_identity_public_key_hash_proof\x18\x01 \x01(\x0b\x32 .org.dash.platform.dapi.v0.Proof\x12!\n\x14identity_proof_bytes\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x42\x17\n\x15_identity_proof_bytesB\x08\n\x06resultB\t\n\x07version\"\xfb\x01\n#WaitForStateTransitionResultRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0H\x00\x1aU\n%WaitForStateTransitionResultRequestV0\x12\x1d\n\x15state_transition_hash\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x99\x03\n$WaitForStateTransitionResultResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0H\x00\x1a\xef\x01\n&WaitForStateTransitionResultResponseV0\x12I\n\x05\x65rror\x18\x01 \x01(\x0b\x32\x38.org.dash.platform.dapi.v0.StateTransitionBroadcastErrorH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc4\x01\n\x19GetConsensusParamsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetConsensusParamsRequest.GetConsensusParamsRequestV0H\x00\x1a<\n\x1bGetConsensusParamsRequestV0\x12\x0e\n\x06height\x18\x01 \x01(\x05\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9c\x04\n\x1aGetConsensusParamsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetConsensusParamsResponse.GetConsensusParamsResponseV0H\x00\x1aP\n\x14\x43onsensusParamsBlock\x12\x11\n\tmax_bytes\x18\x01 \x01(\t\x12\x0f\n\x07max_gas\x18\x02 \x01(\t\x12\x14\n\x0ctime_iota_ms\x18\x03 \x01(\t\x1a\x62\n\x17\x43onsensusParamsEvidence\x12\x1a\n\x12max_age_num_blocks\x18\x01 \x01(\t\x12\x18\n\x10max_age_duration\x18\x02 \x01(\t\x12\x11\n\tmax_bytes\x18\x03 \x01(\t\x1a\xda\x01\n\x1cGetConsensusParamsResponseV0\x12Y\n\x05\x62lock\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetConsensusParamsResponse.ConsensusParamsBlock\x12_\n\x08\x65vidence\x18\x02 \x01(\x0b\x32M.org.dash.platform.dapi.v0.GetConsensusParamsResponse.ConsensusParamsEvidenceB\t\n\x07version\"\xe4\x01\n%GetProtocolVersionUpgradeStateRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateRequest.GetProtocolVersionUpgradeStateRequestV0H\x00\x1a\x38\n\'GetProtocolVersionUpgradeStateRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xb5\x05\n&GetProtocolVersionUpgradeStateResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0H\x00\x1a\x85\x04\n(GetProtocolVersionUpgradeStateResponseV0\x12\x87\x01\n\x08versions\x18\x01 \x01(\x0b\x32s.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0.VersionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x96\x01\n\x08Versions\x12\x89\x01\n\x08versions\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0.VersionEntry\x1a:\n\x0cVersionEntry\x12\x16\n\x0eversion_number\x18\x01 \x01(\r\x12\x12\n\nvote_count\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xa3\x02\n*GetProtocolVersionUpgradeVoteStatusRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusRequest.GetProtocolVersionUpgradeVoteStatusRequestV0H\x00\x1ag\n,GetProtocolVersionUpgradeVoteStatusRequestV0\x12\x19\n\x11start_pro_tx_hash\x18\x01 \x01(\x0c\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xef\x05\n+GetProtocolVersionUpgradeVoteStatusResponse\x12\x82\x01\n\x02v0\x18\x01 \x01(\x0b\x32t.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0H\x00\x1a\xaf\x04\n-GetProtocolVersionUpgradeVoteStatusResponseV0\x12\x98\x01\n\x08versions\x18\x01 \x01(\x0b\x32\x83\x01.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0.VersionSignalsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xaf\x01\n\x0eVersionSignals\x12\x9c\x01\n\x0fversion_signals\x18\x01 \x03(\x0b\x32\x82\x01.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0.VersionSignal\x1a\x35\n\rVersionSignal\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x0f\n\x07version\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xf5\x01\n\x14GetEpochsInfoRequest\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetEpochsInfoRequest.GetEpochsInfoRequestV0H\x00\x1a|\n\x16GetEpochsInfoRequestV0\x12\x31\n\x0bstart_epoch\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\x11\n\tascending\x18\x03 \x01(\x08\x12\r\n\x05prove\x18\x04 \x01(\x08\x42\t\n\x07version\"\x99\x05\n\x15GetEpochsInfoResponse\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0H\x00\x1a\x9c\x04\n\x17GetEpochsInfoResponseV0\x12\x65\n\x06\x65pochs\x18\x01 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0.EpochInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1au\n\nEpochInfos\x12g\n\x0b\x65poch_infos\x18\x01 \x03(\x0b\x32R.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0.EpochInfo\x1a\xa6\x01\n\tEpochInfo\x12\x0e\n\x06number\x18\x01 \x01(\r\x12\x1e\n\x12\x66irst_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x1f\n\x17\x66irst_core_block_height\x18\x03 \x01(\r\x12\x16\n\nstart_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x0e\x66\x65\x65_multiplier\x18\x05 \x01(\x01\x12\x18\n\x10protocol_version\x18\x06 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xbf\x02\n\x1dGetFinalizedEpochInfosRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetFinalizedEpochInfosRequest.GetFinalizedEpochInfosRequestV0H\x00\x1a\xaa\x01\n\x1fGetFinalizedEpochInfosRequestV0\x12\x19\n\x11start_epoch_index\x18\x01 \x01(\r\x12\"\n\x1astart_epoch_index_included\x18\x02 \x01(\x08\x12\x17\n\x0f\x65nd_epoch_index\x18\x03 \x01(\r\x12 \n\x18\x65nd_epoch_index_included\x18\x04 \x01(\x08\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\xbd\t\n\x1eGetFinalizedEpochInfosResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0H\x00\x1a\xa5\x08\n GetFinalizedEpochInfosResponseV0\x12\x80\x01\n\x06\x65pochs\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.FinalizedEpochInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xa4\x01\n\x13\x46inalizedEpochInfos\x12\x8c\x01\n\x15\x66inalized_epoch_infos\x18\x01 \x03(\x0b\x32m.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.FinalizedEpochInfo\x1a\x9f\x04\n\x12\x46inalizedEpochInfo\x12\x0e\n\x06number\x18\x01 \x01(\r\x12\x1e\n\x12\x66irst_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x1f\n\x17\x66irst_core_block_height\x18\x03 \x01(\r\x12\x1c\n\x10\x66irst_block_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x0e\x66\x65\x65_multiplier\x18\x05 \x01(\x01\x12\x18\n\x10protocol_version\x18\x06 \x01(\r\x12!\n\x15total_blocks_in_epoch\x18\x07 \x01(\x04\x42\x02\x30\x01\x12*\n\"next_epoch_start_core_block_height\x18\x08 \x01(\r\x12!\n\x15total_processing_fees\x18\t \x01(\x04\x42\x02\x30\x01\x12*\n\x1etotal_distributed_storage_fees\x18\n \x01(\x04\x42\x02\x30\x01\x12&\n\x1atotal_created_storage_fees\x18\x0b \x01(\x04\x42\x02\x30\x01\x12\x1e\n\x12\x63ore_block_rewards\x18\x0c \x01(\x04\x42\x02\x30\x01\x12\x81\x01\n\x0f\x62lock_proposers\x18\r \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.BlockProposer\x1a\x39\n\rBlockProposer\x12\x13\n\x0bproposer_id\x18\x01 \x01(\x0c\x12\x13\n\x0b\x62lock_count\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xde\x04\n\x1cGetContestedResourcesRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetContestedResourcesRequest.GetContestedResourcesRequestV0H\x00\x1a\xcc\x03\n\x1eGetContestedResourcesRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x1a\n\x12start_index_values\x18\x04 \x03(\x0c\x12\x18\n\x10\x65nd_index_values\x18\x05 \x03(\x0c\x12\x89\x01\n\x13start_at_value_info\x18\x06 \x01(\x0b\x32g.org.dash.platform.dapi.v0.GetContestedResourcesRequest.GetContestedResourcesRequestV0.StartAtValueInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x07 \x01(\rH\x01\x88\x01\x01\x12\x17\n\x0forder_ascending\x18\x08 \x01(\x08\x12\r\n\x05prove\x18\t \x01(\x08\x1a\x45\n\x10StartAtValueInfo\x12\x13\n\x0bstart_value\x18\x01 \x01(\x0c\x12\x1c\n\x14start_value_included\x18\x02 \x01(\x08\x42\x16\n\x14_start_at_value_infoB\x08\n\x06_countB\t\n\x07version\"\x88\x04\n\x1dGetContestedResourcesResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetContestedResourcesResponse.GetContestedResourcesResponseV0H\x00\x1a\xf3\x02\n\x1fGetContestedResourcesResponseV0\x12\x95\x01\n\x19\x63ontested_resource_values\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetContestedResourcesResponse.GetContestedResourcesResponseV0.ContestedResourceValuesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a<\n\x17\x43ontestedResourceValues\x12!\n\x19\x63ontested_resource_values\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xd2\x05\n\x1cGetVotePollsByEndDateRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0H\x00\x1a\xc0\x04\n\x1eGetVotePollsByEndDateRequestV0\x12\x84\x01\n\x0fstart_time_info\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0.StartAtTimeInfoH\x00\x88\x01\x01\x12\x80\x01\n\rend_time_info\x18\x02 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0.EndAtTimeInfoH\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\rH\x02\x88\x01\x01\x12\x13\n\x06offset\x18\x04 \x01(\rH\x03\x88\x01\x01\x12\x11\n\tascending\x18\x05 \x01(\x08\x12\r\n\x05prove\x18\x06 \x01(\x08\x1aI\n\x0fStartAtTimeInfo\x12\x19\n\rstart_time_ms\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x13start_time_included\x18\x02 \x01(\x08\x1a\x43\n\rEndAtTimeInfo\x12\x17\n\x0b\x65nd_time_ms\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x19\n\x11\x65nd_time_included\x18\x02 \x01(\x08\x42\x12\n\x10_start_time_infoB\x10\n\x0e_end_time_infoB\x08\n\x06_limitB\t\n\x07_offsetB\t\n\x07version\"\x83\x06\n\x1dGetVotePollsByEndDateResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0H\x00\x1a\xee\x04\n\x1fGetVotePollsByEndDateResponseV0\x12\x9c\x01\n\x18vote_polls_by_timestamps\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0.SerializedVotePollsByTimestampsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aV\n\x1eSerializedVotePollsByTimestamp\x12\x15\n\ttimestamp\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1d\n\x15serialized_vote_polls\x18\x02 \x03(\x0c\x1a\xd7\x01\n\x1fSerializedVotePollsByTimestamps\x12\x99\x01\n\x18vote_polls_by_timestamps\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0.SerializedVotePollsByTimestamp\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x42\x08\n\x06resultB\t\n\x07version\"\xff\x06\n$GetContestedResourceVoteStateRequest\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0H\x00\x1a\xd5\x05\n&GetContestedResourceVoteStateRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x14\n\x0cindex_values\x18\x04 \x03(\x0c\x12\x86\x01\n\x0bresult_type\x18\x05 \x01(\x0e\x32q.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0.ResultType\x12\x36\n.allow_include_locked_and_abstaining_vote_tally\x18\x06 \x01(\x08\x12\xa3\x01\n\x18start_at_identifier_info\x18\x07 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0.StartAtIdentifierInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x08 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\t \x01(\x08\x1aT\n\x15StartAtIdentifierInfo\x12\x18\n\x10start_identifier\x18\x01 \x01(\x0c\x12!\n\x19start_identifier_included\x18\x02 \x01(\x08\"I\n\nResultType\x12\r\n\tDOCUMENTS\x10\x00\x12\x0e\n\nVOTE_TALLY\x10\x01\x12\x1c\n\x18\x44OCUMENTS_AND_VOTE_TALLY\x10\x02\x42\x1b\n\x19_start_at_identifier_infoB\x08\n\x06_countB\t\n\x07version\"\x94\x0c\n%GetContestedResourceVoteStateResponse\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0H\x00\x1a\xe7\n\n\'GetContestedResourceVoteStateResponseV0\x12\xae\x01\n\x1d\x63ontested_resource_contenders\x18\x01 \x01(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.ContestedResourceContendersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xda\x03\n\x10\x46inishedVoteInfo\x12\xad\x01\n\x15\x66inished_vote_outcome\x18\x01 \x01(\x0e\x32\x8d\x01.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.FinishedVoteInfo.FinishedVoteOutcome\x12\x1f\n\x12won_by_identity_id\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12$\n\x18\x66inished_at_block_height\x18\x03 \x01(\x04\x42\x02\x30\x01\x12%\n\x1d\x66inished_at_core_block_height\x18\x04 \x01(\r\x12%\n\x19\x66inished_at_block_time_ms\x18\x05 \x01(\x04\x42\x02\x30\x01\x12\x19\n\x11\x66inished_at_epoch\x18\x06 \x01(\r\"O\n\x13\x46inishedVoteOutcome\x12\x14\n\x10TOWARDS_IDENTITY\x10\x00\x12\n\n\x06LOCKED\x10\x01\x12\x16\n\x12NO_PREVIOUS_WINNER\x10\x02\x42\x15\n\x13_won_by_identity_id\x1a\xc4\x03\n\x1b\x43ontestedResourceContenders\x12\x86\x01\n\ncontenders\x18\x01 \x03(\x0b\x32r.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.Contender\x12\x1f\n\x12\x61\x62stain_vote_tally\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x1c\n\x0flock_vote_tally\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\x9a\x01\n\x12\x66inished_vote_info\x18\x04 \x01(\x0b\x32y.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.FinishedVoteInfoH\x02\x88\x01\x01\x42\x15\n\x13_abstain_vote_tallyB\x12\n\x10_lock_vote_tallyB\x15\n\x13_finished_vote_info\x1ak\n\tContender\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x17\n\nvote_count\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08\x64ocument\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x42\r\n\x0b_vote_countB\x0b\n\t_documentB\x08\n\x06resultB\t\n\x07version\"\xd5\x05\n,GetContestedResourceVotersForIdentityRequest\x12\x84\x01\n\x02v0\x18\x01 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest.GetContestedResourceVotersForIdentityRequestV0H\x00\x1a\x92\x04\n.GetContestedResourceVotersForIdentityRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x14\n\x0cindex_values\x18\x04 \x03(\x0c\x12\x15\n\rcontestant_id\x18\x05 \x01(\x0c\x12\xb4\x01\n\x18start_at_identifier_info\x18\x06 \x01(\x0b\x32\x8c\x01.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest.GetContestedResourceVotersForIdentityRequestV0.StartAtIdentifierInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x07 \x01(\rH\x01\x88\x01\x01\x12\x17\n\x0forder_ascending\x18\x08 \x01(\x08\x12\r\n\x05prove\x18\t \x01(\x08\x1aT\n\x15StartAtIdentifierInfo\x12\x18\n\x10start_identifier\x18\x01 \x01(\x0c\x12!\n\x19start_identifier_included\x18\x02 \x01(\x08\x42\x1b\n\x19_start_at_identifier_infoB\x08\n\x06_countB\t\n\x07version\"\xf1\x04\n-GetContestedResourceVotersForIdentityResponse\x12\x86\x01\n\x02v0\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse.GetContestedResourceVotersForIdentityResponseV0H\x00\x1a\xab\x03\n/GetContestedResourceVotersForIdentityResponseV0\x12\xb6\x01\n\x19\x63ontested_resource_voters\x18\x01 \x01(\x0b\x32\x90\x01.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse.GetContestedResourceVotersForIdentityResponseV0.ContestedResourceVotersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x43\n\x17\x43ontestedResourceVoters\x12\x0e\n\x06voters\x18\x01 \x03(\x0c\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x42\x08\n\x06resultB\t\n\x07version\"\xad\x05\n(GetContestedResourceIdentityVotesRequest\x12|\n\x02v0\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest.GetContestedResourceIdentityVotesRequestV0H\x00\x1a\xf7\x03\n*GetContestedResourceIdentityVotesRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12+\n\x05limit\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0forder_ascending\x18\x04 \x01(\x08\x12\xae\x01\n\x1astart_at_vote_poll_id_info\x18\x05 \x01(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest.GetContestedResourceIdentityVotesRequestV0.StartAtVotePollIdInfoH\x00\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x1a\x61\n\x15StartAtVotePollIdInfo\x12 \n\x18start_at_poll_identifier\x18\x01 \x01(\x0c\x12&\n\x1estart_poll_identifier_included\x18\x02 \x01(\x08\x42\x1d\n\x1b_start_at_vote_poll_id_infoB\t\n\x07version\"\xc8\n\n)GetContestedResourceIdentityVotesResponse\x12~\n\x02v0\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0H\x00\x1a\x8f\t\n+GetContestedResourceIdentityVotesResponseV0\x12\xa1\x01\n\x05votes\x18\x01 \x01(\x0b\x32\x8f\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ContestedResourceIdentityVotesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xf7\x01\n\x1e\x43ontestedResourceIdentityVotes\x12\xba\x01\n!contested_resource_identity_votes\x18\x01 \x03(\x0b\x32\x8e\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ContestedResourceIdentityVote\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x1a\xad\x02\n\x12ResourceVoteChoice\x12\xad\x01\n\x10vote_choice_type\x18\x01 \x01(\x0e\x32\x92\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ResourceVoteChoice.VoteChoiceType\x12\x18\n\x0bidentity_id\x18\x02 \x01(\x0cH\x00\x88\x01\x01\"=\n\x0eVoteChoiceType\x12\x14\n\x10TOWARDS_IDENTITY\x10\x00\x12\x0b\n\x07\x41\x42STAIN\x10\x01\x12\x08\n\x04LOCK\x10\x02\x42\x0e\n\x0c_identity_id\x1a\x95\x02\n\x1d\x43ontestedResourceIdentityVote\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\'\n\x1fserialized_index_storage_values\x18\x03 \x03(\x0c\x12\x99\x01\n\x0bvote_choice\x18\x04 \x01(\x0b\x32\x83\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ResourceVoteChoiceB\x08\n\x06resultB\t\n\x07version\"\xf0\x01\n%GetPrefundedSpecializedBalanceRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceRequest.GetPrefundedSpecializedBalanceRequestV0H\x00\x1a\x44\n\'GetPrefundedSpecializedBalanceRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xed\x02\n&GetPrefundedSpecializedBalanceResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceResponse.GetPrefundedSpecializedBalanceResponseV0H\x00\x1a\xbd\x01\n(GetPrefundedSpecializedBalanceResponseV0\x12\x15\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xd0\x01\n GetTotalCreditsInPlatformRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformRequest.GetTotalCreditsInPlatformRequestV0H\x00\x1a\x33\n\"GetTotalCreditsInPlatformRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xd9\x02\n!GetTotalCreditsInPlatformResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformResponse.GetTotalCreditsInPlatformResponseV0H\x00\x1a\xb8\x01\n#GetTotalCreditsInPlatformResponseV0\x12\x15\n\x07\x63redits\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc4\x01\n\x16GetPathElementsRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetPathElementsRequest.GetPathElementsRequestV0H\x00\x1a\x45\n\x18GetPathElementsRequestV0\x12\x0c\n\x04path\x18\x01 \x03(\x0c\x12\x0c\n\x04keys\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xa3\x03\n\x17GetPathElementsResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetPathElementsResponse.GetPathElementsResponseV0H\x00\x1a\xa0\x02\n\x19GetPathElementsResponseV0\x12i\n\x08\x65lements\x18\x01 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetPathElementsResponse.GetPathElementsResponseV0.ElementsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1c\n\x08\x45lements\x12\x10\n\x08\x65lements\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\x81\x01\n\x10GetStatusRequest\x12L\n\x02v0\x18\x01 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetStatusRequest.GetStatusRequestV0H\x00\x1a\x14\n\x12GetStatusRequestV0B\t\n\x07version\"\xe4\x10\n\x11GetStatusResponse\x12N\n\x02v0\x18\x01 \x01(\x0b\x32@.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0H\x00\x1a\xf3\x0f\n\x13GetStatusResponseV0\x12Y\n\x07version\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version\x12S\n\x04node\x18\x02 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Node\x12U\n\x05\x63hain\x18\x03 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Chain\x12Y\n\x07network\x18\x04 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Network\x12^\n\nstate_sync\x18\x05 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.StateSync\x12S\n\x04time\x18\x06 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Time\x1a\x82\x05\n\x07Version\x12\x63\n\x08software\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Software\x12\x63\n\x08protocol\x18\x02 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol\x1a^\n\x08Software\x12\x0c\n\x04\x64\x61pi\x18\x01 \x01(\t\x12\x12\n\x05\x64rive\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x17\n\ntenderdash\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x08\n\x06_driveB\r\n\x0b_tenderdash\x1a\xcc\x02\n\x08Protocol\x12p\n\ntenderdash\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol.Tenderdash\x12\x66\n\x05\x64rive\x18\x02 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol.Drive\x1a(\n\nTenderdash\x12\x0b\n\x03p2p\x18\x01 \x01(\r\x12\r\n\x05\x62lock\x18\x02 \x01(\r\x1a<\n\x05\x44rive\x12\x0e\n\x06latest\x18\x03 \x01(\r\x12\x0f\n\x07\x63urrent\x18\x04 \x01(\r\x12\x12\n\nnext_epoch\x18\x05 \x01(\r\x1a\x7f\n\x04Time\x12\x11\n\x05local\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x05\x62lock\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x12\x18\n\x07genesis\x18\x03 \x01(\x04\x42\x02\x30\x01H\x01\x88\x01\x01\x12\x12\n\x05\x65poch\x18\x04 \x01(\rH\x02\x88\x01\x01\x42\x08\n\x06_blockB\n\n\x08_genesisB\x08\n\x06_epoch\x1a<\n\x04Node\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\x18\n\x0bpro_tx_hash\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x42\x0e\n\x0c_pro_tx_hash\x1a\xb3\x02\n\x05\x43hain\x12\x13\n\x0b\x63\x61tching_up\x18\x01 \x01(\x08\x12\x19\n\x11latest_block_hash\x18\x02 \x01(\x0c\x12\x17\n\x0flatest_app_hash\x18\x03 \x01(\x0c\x12\x1f\n\x13latest_block_height\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x13\x65\x61rliest_block_hash\x18\x05 \x01(\x0c\x12\x19\n\x11\x65\x61rliest_app_hash\x18\x06 \x01(\x0c\x12!\n\x15\x65\x61rliest_block_height\x18\x07 \x01(\x04\x42\x02\x30\x01\x12!\n\x15max_peer_block_height\x18\t \x01(\x04\x42\x02\x30\x01\x12%\n\x18\x63ore_chain_locked_height\x18\n \x01(\rH\x00\x88\x01\x01\x42\x1b\n\x19_core_chain_locked_height\x1a\x43\n\x07Network\x12\x10\n\x08\x63hain_id\x18\x01 \x01(\t\x12\x13\n\x0bpeers_count\x18\x02 \x01(\r\x12\x11\n\tlistening\x18\x03 \x01(\x08\x1a\x85\x02\n\tStateSync\x12\x1d\n\x11total_synced_time\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1a\n\x0eremaining_time\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x17\n\x0ftotal_snapshots\x18\x03 \x01(\r\x12\"\n\x16\x63hunk_process_avg_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x0fsnapshot_height\x18\x05 \x01(\x04\x42\x02\x30\x01\x12!\n\x15snapshot_chunks_count\x18\x06 \x01(\x04\x42\x02\x30\x01\x12\x1d\n\x11\x62\x61\x63kfilled_blocks\x18\x07 \x01(\x04\x42\x02\x30\x01\x12!\n\x15\x62\x61\x63kfill_blocks_total\x18\x08 \x01(\x04\x42\x02\x30\x01\x42\t\n\x07version\"\xb1\x01\n\x1cGetCurrentQuorumsInfoRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoRequest.GetCurrentQuorumsInfoRequestV0H\x00\x1a \n\x1eGetCurrentQuorumsInfoRequestV0B\t\n\x07version\"\xa1\x05\n\x1dGetCurrentQuorumsInfoResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.GetCurrentQuorumsInfoResponseV0H\x00\x1a\x46\n\x0bValidatorV0\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x0f\n\x07node_ip\x18\x02 \x01(\t\x12\x11\n\tis_banned\x18\x03 \x01(\x08\x1a\xaf\x01\n\x0eValidatorSetV0\x12\x13\n\x0bquorum_hash\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63ore_height\x18\x02 \x01(\r\x12U\n\x07members\x18\x03 \x03(\x0b\x32\x44.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.ValidatorV0\x12\x1c\n\x14threshold_public_key\x18\x04 \x01(\x0c\x1a\x92\x02\n\x1fGetCurrentQuorumsInfoResponseV0\x12\x15\n\rquorum_hashes\x18\x01 \x03(\x0c\x12\x1b\n\x13\x63urrent_quorum_hash\x18\x02 \x01(\x0c\x12_\n\x0evalidator_sets\x18\x03 \x03(\x0b\x32G.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.ValidatorSetV0\x12\x1b\n\x13last_block_proposer\x18\x04 \x01(\x0c\x12=\n\x08metadata\x18\x05 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\t\n\x07version\"\xf4\x01\n\x1fGetIdentityTokenBalancesRequest\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentityTokenBalancesRequest.GetIdentityTokenBalancesRequestV0H\x00\x1aZ\n!GetIdentityTokenBalancesRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x11\n\ttoken_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xad\x05\n GetIdentityTokenBalancesResponse\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0H\x00\x1a\x8f\x04\n\"GetIdentityTokenBalancesResponseV0\x12\x86\x01\n\x0etoken_balances\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0.TokenBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aG\n\x11TokenBalanceEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x07\x62\x61lance\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\x9a\x01\n\rTokenBalances\x12\x88\x01\n\x0etoken_balances\x18\x01 \x03(\x0b\x32p.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0.TokenBalanceEntryB\x08\n\x06resultB\t\n\x07version\"\xfc\x01\n!GetIdentitiesTokenBalancesRequest\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesRequest.GetIdentitiesTokenBalancesRequestV0H\x00\x1a\\\n#GetIdentitiesTokenBalancesRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x0cidentity_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xf2\x05\n\"GetIdentitiesTokenBalancesResponse\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0H\x00\x1a\xce\x04\n$GetIdentitiesTokenBalancesResponseV0\x12\x9b\x01\n\x17identity_token_balances\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0.IdentityTokenBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aR\n\x19IdentityTokenBalanceEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x14\n\x07\x62\x61lance\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\xb7\x01\n\x15IdentityTokenBalances\x12\x9d\x01\n\x17identity_token_balances\x18\x01 \x03(\x0b\x32|.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0.IdentityTokenBalanceEntryB\x08\n\x06resultB\t\n\x07version\"\xe8\x01\n\x1cGetIdentityTokenInfosRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetIdentityTokenInfosRequest.GetIdentityTokenInfosRequestV0H\x00\x1aW\n\x1eGetIdentityTokenInfosRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x11\n\ttoken_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\x98\x06\n\x1dGetIdentityTokenInfosResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0H\x00\x1a\x83\x05\n\x1fGetIdentityTokenInfosResponseV0\x12z\n\x0btoken_infos\x18\x01 \x01(\x0b\x32\x63.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a(\n\x16TokenIdentityInfoEntry\x12\x0e\n\x06\x66rozen\x18\x01 \x01(\x08\x1a\xb0\x01\n\x0eTokenInfoEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x82\x01\n\x04info\x18\x02 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenIdentityInfoEntryH\x00\x88\x01\x01\x42\x07\n\x05_info\x1a\x8a\x01\n\nTokenInfos\x12|\n\x0btoken_infos\x18\x01 \x03(\x0b\x32g.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xf0\x01\n\x1eGetIdentitiesTokenInfosRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosRequest.GetIdentitiesTokenInfosRequestV0H\x00\x1aY\n GetIdentitiesTokenInfosRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x0cidentity_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xca\x06\n\x1fGetIdentitiesTokenInfosResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0H\x00\x1a\xaf\x05\n!GetIdentitiesTokenInfosResponseV0\x12\x8f\x01\n\x14identity_token_infos\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.IdentityTokenInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a(\n\x16TokenIdentityInfoEntry\x12\x0e\n\x06\x66rozen\x18\x01 \x01(\x08\x1a\xb7\x01\n\x0eTokenInfoEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x86\x01\n\x04info\x18\x02 \x01(\x0b\x32s.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.TokenIdentityInfoEntryH\x00\x88\x01\x01\x42\x07\n\x05_info\x1a\x97\x01\n\x12IdentityTokenInfos\x12\x80\x01\n\x0btoken_infos\x18\x01 \x03(\x0b\x32k.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.TokenInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xbf\x01\n\x17GetTokenStatusesRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetTokenStatusesRequest.GetTokenStatusesRequestV0H\x00\x1a=\n\x19GetTokenStatusesRequestV0\x12\x11\n\ttoken_ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xe7\x04\n\x18GetTokenStatusesResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0H\x00\x1a\xe1\x03\n\x1aGetTokenStatusesResponseV0\x12v\n\x0etoken_statuses\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0.TokenStatusesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x44\n\x10TokenStatusEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x13\n\x06paused\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\t\n\x07_paused\x1a\x88\x01\n\rTokenStatuses\x12w\n\x0etoken_statuses\x18\x01 \x03(\x0b\x32_.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0.TokenStatusEntryB\x08\n\x06resultB\t\n\x07version\"\xef\x01\n#GetTokenDirectPurchasePricesRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesRequest.GetTokenDirectPurchasePricesRequestV0H\x00\x1aI\n%GetTokenDirectPurchasePricesRequestV0\x12\x11\n\ttoken_ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x8b\t\n$GetTokenDirectPurchasePricesResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0H\x00\x1a\xe1\x07\n&GetTokenDirectPurchasePricesResponseV0\x12\xa9\x01\n\x1ctoken_direct_purchase_prices\x18\x01 \x01(\x0b\x32\x80\x01.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.TokenDirectPurchasePricesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x33\n\x10PriceForQuantity\x12\x10\n\x08quantity\x18\x01 \x01(\x04\x12\r\n\x05price\x18\x02 \x01(\x04\x1a\xa7\x01\n\x0fPricingSchedule\x12\x93\x01\n\x12price_for_quantity\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.PriceForQuantity\x1a\xe4\x01\n\x1dTokenDirectPurchasePriceEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x15\n\x0b\x66ixed_price\x18\x02 \x01(\x04H\x00\x12\x90\x01\n\x0evariable_price\x18\x03 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.PricingScheduleH\x00\x42\x07\n\x05price\x1a\xc8\x01\n\x19TokenDirectPurchasePrices\x12\xaa\x01\n\x1btoken_direct_purchase_price\x18\x01 \x03(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.TokenDirectPurchasePriceEntryB\x08\n\x06resultB\t\n\x07version\"\xce\x01\n\x1bGetTokenContractInfoRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetTokenContractInfoRequest.GetTokenContractInfoRequestV0H\x00\x1a@\n\x1dGetTokenContractInfoRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xfb\x03\n\x1cGetTokenContractInfoResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetTokenContractInfoResponse.GetTokenContractInfoResponseV0H\x00\x1a\xe9\x02\n\x1eGetTokenContractInfoResponseV0\x12|\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetTokenContractInfoResponse.GetTokenContractInfoResponseV0.TokenContractInfoDataH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aM\n\x15TokenContractInfoData\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17token_contract_position\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xef\x04\n)GetTokenPreProgrammedDistributionsRequest\x12~\n\x02v0\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest.GetTokenPreProgrammedDistributionsRequestV0H\x00\x1a\xb6\x03\n+GetTokenPreProgrammedDistributionsRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x98\x01\n\rstart_at_info\x18\x02 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest.GetTokenPreProgrammedDistributionsRequestV0.StartAtInfoH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x04 \x01(\x08\x1a\x9a\x01\n\x0bStartAtInfo\x12\x15\n\rstart_time_ms\x18\x01 \x01(\x04\x12\x1c\n\x0fstart_recipient\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12%\n\x18start_recipient_included\x18\x03 \x01(\x08H\x01\x88\x01\x01\x42\x12\n\x10_start_recipientB\x1b\n\x19_start_recipient_includedB\x10\n\x0e_start_at_infoB\x08\n\x06_limitB\t\n\x07version\"\xec\x07\n*GetTokenPreProgrammedDistributionsResponse\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0H\x00\x1a\xaf\x06\n,GetTokenPreProgrammedDistributionsResponseV0\x12\xa5\x01\n\x13token_distributions\x18\x01 \x01(\x0b\x32\x85\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenDistributionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a>\n\x16TokenDistributionEntry\x12\x14\n\x0crecipient_id\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x1a\xd4\x01\n\x1bTokenTimedDistributionEntry\x12\x11\n\ttimestamp\x18\x01 \x01(\x04\x12\xa1\x01\n\rdistributions\x18\x02 \x03(\x0b\x32\x89\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenDistributionEntry\x1a\xc3\x01\n\x12TokenDistributions\x12\xac\x01\n\x13token_distributions\x18\x01 \x03(\x0b\x32\x8e\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenTimedDistributionEntryB\x08\n\x06resultB\t\n\x07version\"\x82\x04\n-GetTokenPerpetualDistributionLastClaimRequest\x12\x86\x01\n\x02v0\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest.GetTokenPerpetualDistributionLastClaimRequestV0H\x00\x1aI\n\x11\x43ontractTokenInfo\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17token_contract_position\x18\x02 \x01(\r\x1a\xf1\x01\n/GetTokenPerpetualDistributionLastClaimRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12v\n\rcontract_info\x18\x02 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest.ContractTokenInfoH\x00\x88\x01\x01\x12\x13\n\x0bidentity_id\x18\x04 \x01(\x0c\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x10\n\x0e_contract_infoB\t\n\x07version\"\x93\x05\n.GetTokenPerpetualDistributionLastClaimResponse\x12\x88\x01\n\x02v0\x18\x01 \x01(\x0b\x32z.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse.GetTokenPerpetualDistributionLastClaimResponseV0H\x00\x1a\xca\x03\n0GetTokenPerpetualDistributionLastClaimResponseV0\x12\x9f\x01\n\nlast_claim\x18\x01 \x01(\x0b\x32\x88\x01.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse.GetTokenPerpetualDistributionLastClaimResponseV0.LastClaimInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1ax\n\rLastClaimInfo\x12\x1a\n\x0ctimestamp_ms\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x1a\n\x0c\x62lock_height\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x0f\n\x05\x65poch\x18\x03 \x01(\rH\x00\x12\x13\n\traw_bytes\x18\x04 \x01(\x0cH\x00\x42\t\n\x07paid_atB\x08\n\x06resultB\t\n\x07version\"\xca\x01\n\x1aGetTokenTotalSupplyRequest\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetTokenTotalSupplyRequest.GetTokenTotalSupplyRequestV0H\x00\x1a?\n\x1cGetTokenTotalSupplyRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xaf\x04\n\x1bGetTokenTotalSupplyResponse\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse.GetTokenTotalSupplyResponseV0H\x00\x1a\xa0\x03\n\x1dGetTokenTotalSupplyResponseV0\x12\x88\x01\n\x12token_total_supply\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse.GetTokenTotalSupplyResponseV0.TokenTotalSupplyEntryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1ax\n\x15TokenTotalSupplyEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x30\n(total_aggregated_amount_in_user_accounts\x18\x02 \x01(\x04\x12\x1b\n\x13total_system_amount\x18\x03 \x01(\x04\x42\x08\n\x06resultB\t\n\x07version\"\xd2\x01\n\x13GetGroupInfoRequest\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetGroupInfoRequest.GetGroupInfoRequestV0H\x00\x1a\\\n\x15GetGroupInfoRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xd4\x05\n\x14GetGroupInfoResponse\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0H\x00\x1a\xda\x04\n\x16GetGroupInfoResponseV0\x12\x66\n\ngroup_info\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x04 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x34\n\x10GroupMemberEntry\x12\x11\n\tmember_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\x98\x01\n\x0eGroupInfoEntry\x12h\n\x07members\x18\x01 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupMemberEntry\x12\x1c\n\x14group_required_power\x18\x02 \x01(\r\x1a\x8a\x01\n\tGroupInfo\x12n\n\ngroup_info\x18\x01 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupInfoEntryH\x00\x88\x01\x01\x42\r\n\x0b_group_infoB\x08\n\x06resultB\t\n\x07version\"\xed\x03\n\x14GetGroupInfosRequest\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetGroupInfosRequest.GetGroupInfosRequestV0H\x00\x1au\n\x1cStartAtGroupContractPosition\x12%\n\x1dstart_group_contract_position\x18\x01 \x01(\r\x12.\n&start_group_contract_position_included\x18\x02 \x01(\x08\x1a\xfc\x01\n\x16GetGroupInfosRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12{\n start_at_group_contract_position\x18\x02 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetGroupInfosRequest.StartAtGroupContractPositionH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x04 \x01(\x08\x42#\n!_start_at_group_contract_positionB\x08\n\x06_countB\t\n\x07version\"\xff\x05\n\x15GetGroupInfosResponse\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0H\x00\x1a\x82\x05\n\x17GetGroupInfosResponseV0\x12j\n\x0bgroup_infos\x18\x01 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x04 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x34\n\x10GroupMemberEntry\x12\x11\n\tmember_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\xc3\x01\n\x16GroupPositionInfoEntry\x12\x1f\n\x17group_contract_position\x18\x01 \x01(\r\x12j\n\x07members\x18\x02 \x03(\x0b\x32Y.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupMemberEntry\x12\x1c\n\x14group_required_power\x18\x03 \x01(\r\x1a\x82\x01\n\nGroupInfos\x12t\n\x0bgroup_infos\x18\x01 \x03(\x0b\x32_.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupPositionInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xbe\x04\n\x16GetGroupActionsRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetGroupActionsRequest.GetGroupActionsRequestV0H\x00\x1aL\n\x0fStartAtActionId\x12\x17\n\x0fstart_action_id\x18\x01 \x01(\x0c\x12 \n\x18start_action_id_included\x18\x02 \x01(\x08\x1a\xc8\x02\n\x18GetGroupActionsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12N\n\x06status\x18\x03 \x01(\x0e\x32>.org.dash.platform.dapi.v0.GetGroupActionsRequest.ActionStatus\x12\x62\n\x12start_at_action_id\x18\x04 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetGroupActionsRequest.StartAtActionIdH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x42\x15\n\x13_start_at_action_idB\x08\n\x06_count\"&\n\x0c\x41\x63tionStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\n\n\x06\x43LOSED\x10\x01\x42\t\n\x07version\"\xd6\x1e\n\x17GetGroupActionsResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0H\x00\x1a\xd3\x1d\n\x19GetGroupActionsResponseV0\x12r\n\rgroup_actions\x18\x01 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a[\n\tMintEvent\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x14\n\x0crecipient_id\x18\x02 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a[\n\tBurnEvent\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x14\n\x0c\x62urn_from_id\x18\x02 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1aJ\n\x0b\x46reezeEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1aL\n\rUnfreezeEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\x66\n\x17\x44\x65stroyFrozenFundsEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\x64\n\x13SharedEncryptedNote\x12\x18\n\x10sender_key_index\x18\x01 \x01(\r\x12\x1b\n\x13recipient_key_index\x18\x02 \x01(\r\x12\x16\n\x0e\x65ncrypted_data\x18\x03 \x01(\x0c\x1a{\n\x15PersonalEncryptedNote\x12!\n\x19root_encryption_key_index\x18\x01 \x01(\r\x12\'\n\x1f\x64\x65rivation_encryption_key_index\x18\x02 \x01(\r\x12\x16\n\x0e\x65ncrypted_data\x18\x03 \x01(\x0c\x1a\xe9\x01\n\x14\x45mergencyActionEvent\x12\x81\x01\n\x0b\x61\x63tion_type\x18\x01 \x01(\x0e\x32l.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.EmergencyActionEvent.ActionType\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\"#\n\nActionType\x12\t\n\x05PAUSE\x10\x00\x12\n\n\x06RESUME\x10\x01\x42\x0e\n\x0c_public_note\x1a\x64\n\x16TokenConfigUpdateEvent\x12 \n\x18token_config_update_item\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\xe6\x03\n\x1eUpdateDirectPurchasePriceEvent\x12\x15\n\x0b\x66ixed_price\x18\x01 \x01(\x04H\x00\x12\x95\x01\n\x0evariable_price\x18\x02 \x01(\x0b\x32{.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEvent.PricingScheduleH\x00\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x01\x88\x01\x01\x1a\x33\n\x10PriceForQuantity\x12\x10\n\x08quantity\x18\x01 \x01(\x04\x12\r\n\x05price\x18\x02 \x01(\x04\x1a\xac\x01\n\x0fPricingSchedule\x12\x98\x01\n\x12price_for_quantity\x18\x01 \x03(\x0b\x32|.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEvent.PriceForQuantityB\x07\n\x05priceB\x0e\n\x0c_public_note\x1a\xfc\x02\n\x10GroupActionEvent\x12n\n\x0btoken_event\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.TokenEventH\x00\x12t\n\x0e\x64ocument_event\x18\x02 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DocumentEventH\x00\x12t\n\x0e\x63ontract_event\x18\x03 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.ContractEventH\x00\x42\x0c\n\nevent_type\x1a\x8b\x01\n\rDocumentEvent\x12r\n\x06\x63reate\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DocumentCreateEventH\x00\x42\x06\n\x04type\x1a/\n\x13\x44ocumentCreateEvent\x12\x18\n\x10\x63reated_document\x18\x01 \x01(\x0c\x1a/\n\x13\x43ontractUpdateEvent\x12\x18\n\x10updated_contract\x18\x01 \x01(\x0c\x1a\x8b\x01\n\rContractEvent\x12r\n\x06update\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.ContractUpdateEventH\x00\x42\x06\n\x04type\x1a\xd1\x07\n\nTokenEvent\x12\x66\n\x04mint\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.MintEventH\x00\x12\x66\n\x04\x62urn\x18\x02 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.BurnEventH\x00\x12j\n\x06\x66reeze\x18\x03 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.FreezeEventH\x00\x12n\n\x08unfreeze\x18\x04 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UnfreezeEventH\x00\x12\x84\x01\n\x14\x64\x65stroy_frozen_funds\x18\x05 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DestroyFrozenFundsEventH\x00\x12}\n\x10\x65mergency_action\x18\x06 \x01(\x0b\x32\x61.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.EmergencyActionEventH\x00\x12\x82\x01\n\x13token_config_update\x18\x07 \x01(\x0b\x32\x63.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.TokenConfigUpdateEventH\x00\x12\x83\x01\n\x0cupdate_price\x18\x08 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEventH\x00\x42\x06\n\x04type\x1a\x93\x01\n\x10GroupActionEntry\x12\x11\n\taction_id\x18\x01 \x01(\x0c\x12l\n\x05\x65vent\x18\x02 \x01(\x0b\x32].org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionEvent\x1a\x84\x01\n\x0cGroupActions\x12t\n\rgroup_actions\x18\x01 \x03(\x0b\x32].org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionEntryB\x08\n\x06resultB\t\n\x07version\"\x88\x03\n\x1cGetGroupActionSignersRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionSignersRequest.GetGroupActionSignersRequestV0H\x00\x1a\xce\x01\n\x1eGetGroupActionSignersRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12T\n\x06status\x18\x03 \x01(\x0e\x32\x44.org.dash.platform.dapi.v0.GetGroupActionSignersRequest.ActionStatus\x12\x11\n\taction_id\x18\x04 \x01(\x0c\x12\r\n\x05prove\x18\x05 \x01(\x08\"&\n\x0c\x41\x63tionStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\n\n\x06\x43LOSED\x10\x01\x42\t\n\x07version\"\x8b\x05\n\x1dGetGroupActionSignersResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0H\x00\x1a\xf6\x03\n\x1fGetGroupActionSignersResponseV0\x12\x8b\x01\n\x14group_action_signers\x18\x01 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0.GroupActionSignersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x35\n\x11GroupActionSigner\x12\x11\n\tsigner_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\x91\x01\n\x12GroupActionSigners\x12{\n\x07signers\x18\x01 \x03(\x0b\x32j.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0.GroupActionSignerB\x08\n\x06resultB\t\n\x07version\"\xb5\x01\n\x15GetAddressInfoRequest\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetAddressInfoRequest.GetAddressInfoRequestV0H\x00\x1a\x39\n\x17GetAddressInfoRequestV0\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x85\x01\n\x10\x41\x64\x64ressInfoEntry\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12J\n\x11\x62\x61lance_and_nonce\x18\x02 \x01(\x0b\x32*.org.dash.platform.dapi.v0.BalanceAndNonceH\x00\x88\x01\x01\x42\x14\n\x12_balance_and_nonce\"1\n\x0f\x42\x61lanceAndNonce\x12\x0f\n\x07\x62\x61lance\x18\x01 \x01(\x04\x12\r\n\x05nonce\x18\x02 \x01(\r\"_\n\x12\x41\x64\x64ressInfoEntries\x12I\n\x14\x61\x64\x64ress_info_entries\x18\x01 \x03(\x0b\x32+.org.dash.platform.dapi.v0.AddressInfoEntry\"m\n\x14\x41\x64\x64ressBalanceChange\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x19\n\x0bset_balance\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x1c\n\x0e\x61\x64\x64_to_balance\x18\x03 \x01(\x04\x42\x02\x30\x01H\x00\x42\x0b\n\toperation\"x\n\x1a\x42lockAddressBalanceChanges\x12\x18\n\x0c\x62lock_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12@\n\x07\x63hanges\x18\x02 \x03(\x0b\x32/.org.dash.platform.dapi.v0.AddressBalanceChange\"k\n\x1b\x41\x64\x64ressBalanceUpdateEntries\x12L\n\rblock_changes\x18\x01 \x03(\x0b\x32\x35.org.dash.platform.dapi.v0.BlockAddressBalanceChanges\"\xe1\x02\n\x16GetAddressInfoResponse\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetAddressInfoResponse.GetAddressInfoResponseV0H\x00\x1a\xe1\x01\n\x18GetAddressInfoResponseV0\x12I\n\x12\x61\x64\x64ress_info_entry\x18\x01 \x01(\x0b\x32+.org.dash.platform.dapi.v0.AddressInfoEntryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc3\x01\n\x18GetAddressesInfosRequest\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetAddressesInfosRequest.GetAddressesInfosRequestV0H\x00\x1a>\n\x1aGetAddressesInfosRequestV0\x12\x11\n\taddresses\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf1\x02\n\x19GetAddressesInfosResponse\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetAddressesInfosResponse.GetAddressesInfosResponseV0H\x00\x1a\xe8\x01\n\x1bGetAddressesInfosResponseV0\x12M\n\x14\x61\x64\x64ress_info_entries\x18\x01 \x01(\x0b\x32-.org.dash.platform.dapi.v0.AddressInfoEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb5\x01\n\x1dGetAddressesTrunkStateRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetAddressesTrunkStateRequest.GetAddressesTrunkStateRequestV0H\x00\x1a!\n\x1fGetAddressesTrunkStateRequestV0B\t\n\x07version\"\xaa\x02\n\x1eGetAddressesTrunkStateResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetAddressesTrunkStateResponse.GetAddressesTrunkStateResponseV0H\x00\x1a\x92\x01\n GetAddressesTrunkStateResponseV0\x12/\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.Proof\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\t\n\x07version\"\xf0\x01\n\x1eGetAddressesBranchStateRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetAddressesBranchStateRequest.GetAddressesBranchStateRequestV0H\x00\x1aY\n GetAddressesBranchStateRequestV0\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\r\n\x05\x64\x65pth\x18\x02 \x01(\r\x12\x19\n\x11\x63heckpoint_height\x18\x03 \x01(\x04\x42\t\n\x07version\"\xd1\x01\n\x1fGetAddressesBranchStateResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetAddressesBranchStateResponse.GetAddressesBranchStateResponseV0H\x00\x1a\x37\n!GetAddressesBranchStateResponseV0\x12\x12\n\nmerk_proof\x18\x02 \x01(\x0c\x42\t\n\x07version\"\x9e\x02\n%GetRecentAddressBalanceChangesRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesRequest.GetRecentAddressBalanceChangesRequestV0H\x00\x1ar\n\'GetRecentAddressBalanceChangesRequestV0\x12\x18\n\x0cstart_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x02 \x01(\x08\x12\x1e\n\x16start_height_exclusive\x18\x03 \x01(\x08\x42\t\n\x07version\"\xb8\x03\n&GetRecentAddressBalanceChangesResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesResponse.GetRecentAddressBalanceChangesResponseV0H\x00\x1a\x88\x02\n(GetRecentAddressBalanceChangesResponseV0\x12`\n\x1e\x61\x64\x64ress_balance_update_entries\x18\x01 \x01(\x0b\x32\x36.org.dash.platform.dapi.v0.AddressBalanceUpdateEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"G\n\x16\x42lockHeightCreditEntry\x12\x18\n\x0c\x62lock_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x13\n\x07\x63redits\x18\x02 \x01(\x04\x42\x02\x30\x01\"\xb0\x01\n\x1d\x43ompactedAddressBalanceChange\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x19\n\x0bset_credits\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12V\n\x19\x61\x64\x64_to_credits_operations\x18\x03 \x01(\x0b\x32\x31.org.dash.platform.dapi.v0.AddToCreditsOperationsH\x00\x42\x0b\n\toperation\"\\\n\x16\x41\x64\x64ToCreditsOperations\x12\x42\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x31.org.dash.platform.dapi.v0.BlockHeightCreditEntry\"\xae\x01\n#CompactedBlockAddressBalanceChanges\x12\x1e\n\x12start_block_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1c\n\x10\x65nd_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12I\n\x07\x63hanges\x18\x03 \x03(\x0b\x32\x38.org.dash.platform.dapi.v0.CompactedAddressBalanceChange\"\x87\x01\n$CompactedAddressBalanceUpdateEntries\x12_\n\x17\x63ompacted_block_changes\x18\x01 \x03(\x0b\x32>.org.dash.platform.dapi.v0.CompactedBlockAddressBalanceChanges\"\xa9\x02\n.GetRecentCompactedAddressBalanceChangesRequest\x12\x88\x01\n\x02v0\x18\x01 \x01(\x0b\x32z.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesRequest.GetRecentCompactedAddressBalanceChangesRequestV0H\x00\x1a\x61\n0GetRecentCompactedAddressBalanceChangesRequestV0\x12\x1e\n\x12start_block_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf0\x03\n/GetRecentCompactedAddressBalanceChangesResponse\x12\x8a\x01\n\x02v0\x18\x01 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesResponse.GetRecentCompactedAddressBalanceChangesResponseV0H\x00\x1a\xa4\x02\n1GetRecentCompactedAddressBalanceChangesResponseV0\x12s\n(compacted_address_balance_update_entries\x18\x01 \x01(\x0b\x32?.org.dash.platform.dapi.v0.CompactedAddressBalanceUpdateEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf4\x01\n GetShieldedEncryptedNotesRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesRequest.GetShieldedEncryptedNotesRequestV0H\x00\x1aW\n\"GetShieldedEncryptedNotesRequestV0\x12\x13\n\x0bstart_index\x18\x01 \x01(\x04\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xbc\x05\n!GetShieldedEncryptedNotesResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0H\x00\x1a\x9b\x04\n#GetShieldedEncryptedNotesResponseV0\x12\x8a\x01\n\x0f\x65ncrypted_notes\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0.EncryptedNotesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aW\n\rEncryptedNote\x12\x11\n\tnullifier\x18\x01 \x01(\x0c\x12\x0b\n\x03\x63mx\x18\x02 \x01(\x0c\x12\x16\n\x0e\x65ncrypted_note\x18\x03 \x01(\x0c\x12\x0e\n\x06\x63v_net\x18\x04 \x01(\x0c\x1a\x91\x01\n\x0e\x45ncryptedNotes\x12\x7f\n\x07\x65ntries\x18\x01 \x03(\x0b\x32n.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0.EncryptedNoteB\x08\n\x06resultB\t\n\x07version\"\xb4\x01\n\x19GetShieldedAnchorsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetShieldedAnchorsRequest.GetShieldedAnchorsRequestV0H\x00\x1a,\n\x1bGetShieldedAnchorsRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xb1\x03\n\x1aGetShieldedAnchorsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse.GetShieldedAnchorsResponseV0H\x00\x1a\xa5\x02\n\x1cGetShieldedAnchorsResponseV0\x12m\n\x07\x61nchors\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse.GetShieldedAnchorsResponseV0.AnchorsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1a\n\x07\x41nchors\x12\x0f\n\x07\x61nchors\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xd8\x01\n\"GetMostRecentShieldedAnchorRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorRequest.GetMostRecentShieldedAnchorRequestV0H\x00\x1a\x35\n$GetMostRecentShieldedAnchorRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xdc\x02\n#GetMostRecentShieldedAnchorResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorResponse.GetMostRecentShieldedAnchorResponseV0H\x00\x1a\xb5\x01\n%GetMostRecentShieldedAnchorResponseV0\x12\x10\n\x06\x61nchor\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbc\x01\n\x1bGetShieldedPoolStateRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetShieldedPoolStateRequest.GetShieldedPoolStateRequestV0H\x00\x1a.\n\x1dGetShieldedPoolStateRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xcb\x02\n\x1cGetShieldedPoolStateResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedPoolStateResponse.GetShieldedPoolStateResponseV0H\x00\x1a\xb9\x01\n\x1eGetShieldedPoolStateResponseV0\x12\x1b\n\rtotal_balance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc0\x01\n\x1cGetShieldedNotesCountRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedNotesCountRequest.GetShieldedNotesCountRequestV0H\x00\x1a/\n\x1eGetShieldedNotesCountRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xd3\x02\n\x1dGetShieldedNotesCountResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetShieldedNotesCountResponse.GetShieldedNotesCountResponseV0H\x00\x1a\xbe\x01\n\x1fGetShieldedNotesCountResponseV0\x12\x1f\n\x11total_notes_count\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xd4\x01\n\x1cGetShieldedNullifiersRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedNullifiersRequest.GetShieldedNullifiersRequestV0H\x00\x1a\x43\n\x1eGetShieldedNullifiersRequestV0\x12\x12\n\nnullifiers\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x86\x05\n\x1dGetShieldedNullifiersResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0H\x00\x1a\xf1\x03\n\x1fGetShieldedNullifiersResponseV0\x12\x88\x01\n\x12nullifier_statuses\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0.NullifierStatusesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x36\n\x0fNullifierStatus\x12\x11\n\tnullifier\x18\x01 \x01(\x0c\x12\x10\n\x08is_spent\x18\x02 \x01(\x08\x1a\x8e\x01\n\x11NullifierStatuses\x12y\n\x07\x65ntries\x18\x01 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0.NullifierStatusB\x08\n\x06resultB\t\n\x07version*Z\n\nKeyPurpose\x12\x12\n\x0e\x41UTHENTICATION\x10\x00\x12\x0e\n\nENCRYPTION\x10\x01\x12\x0e\n\nDECRYPTION\x10\x02\x12\x0c\n\x08TRANSFER\x10\x03\x12\n\n\x06VOTING\x10\x05*\xb9\x01\n\x16\x43ontractModerationList\x12(\n$CONTRACT_MODERATION_LIST_UNSPECIFIED\x10\x00\x12$\n CONTRACT_MODERATION_LIST_BANLIST\x10\x01\x12(\n$CONTRACT_MODERATION_LIST_SUSPENSIONS\x10\x02\x12%\n!CONTRACT_MODERATION_LIST_WARNINGS\x10\x03\x32\xd0P\n\x08Platform\x12\x93\x01\n\x18\x62roadcastStateTransition\x12:.org.dash.platform.dapi.v0.BroadcastStateTransitionRequest\x1a;.org.dash.platform.dapi.v0.BroadcastStateTransitionResponse\x12l\n\x0bgetIdentity\x12-.org.dash.platform.dapi.v0.GetIdentityRequest\x1a..org.dash.platform.dapi.v0.GetIdentityResponse\x12x\n\x0fgetIdentityKeys\x12\x31.org.dash.platform.dapi.v0.GetIdentityKeysRequest\x1a\x32.org.dash.platform.dapi.v0.GetIdentityKeysResponse\x12\x96\x01\n\x19getIdentitiesContractKeys\x12;.org.dash.platform.dapi.v0.GetIdentitiesContractKeysRequest\x1a<.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse\x12{\n\x10getIdentityNonce\x12\x32.org.dash.platform.dapi.v0.GetIdentityNonceRequest\x1a\x33.org.dash.platform.dapi.v0.GetIdentityNonceResponse\x12\x93\x01\n\x18getIdentityContractNonce\x12:.org.dash.platform.dapi.v0.GetIdentityContractNonceRequest\x1a;.org.dash.platform.dapi.v0.GetIdentityContractNonceResponse\x12\xa8\x01\n\x1fgetIdentityKeysRemainingBudgets\x12\x41.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsRequest\x1a\x42.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse\x12\x81\x01\n\x12getIdentityBalance\x12\x34.org.dash.platform.dapi.v0.GetIdentityBalanceRequest\x1a\x35.org.dash.platform.dapi.v0.GetIdentityBalanceResponse\x12\x8a\x01\n\x15getIdentitiesBalances\x12\x37.org.dash.platform.dapi.v0.GetIdentitiesBalancesRequest\x1a\x38.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse\x12\xa2\x01\n\x1dgetIdentityBalanceAndRevision\x12?.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionRequest\x1a@.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse\x12\xaf\x01\n#getEvonodesProposedEpochBlocksByIds\x12\x45.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByIdsRequest\x1a\x41.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse\x12\xb3\x01\n%getEvonodesProposedEpochBlocksByRange\x12G.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByRangeRequest\x1a\x41.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse\x12x\n\x0fgetDataContract\x12\x31.org.dash.platform.dapi.v0.GetDataContractRequest\x1a\x32.org.dash.platform.dapi.v0.GetDataContractResponse\x12\x8d\x01\n\x16getDataContractHistory\x12\x38.org.dash.platform.dapi.v0.GetDataContractHistoryRequest\x1a\x39.org.dash.platform.dapi.v0.GetDataContractHistoryResponse\x12\xa5\x01\n\x1egetDataContractsLatestVersions\x12@.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsRequest\x1a\x41.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse\x12{\n\x10getDataContracts\x12\x32.org.dash.platform.dapi.v0.GetDataContractsRequest\x1a\x33.org.dash.platform.dapi.v0.GetDataContractsResponse\x12\x89\x01\n\x17getDataContractsByRange\x12\x39.org.dash.platform.dapi.v0.GetDataContractsByRangeRequest\x1a\x33.org.dash.platform.dapi.v0.GetDataContractsResponse\x12\x87\x01\n\x14getContractGroupInfo\x12\x36.org.dash.platform.dapi.v0.GetContractGroupInfoRequest\x1a\x37.org.dash.platform.dapi.v0.GetContractGroupInfoResponse\x12\x90\x01\n\x17getContractGroupMembers\x12\x39.org.dash.platform.dapi.v0.GetContractGroupMembersRequest\x1a:.org.dash.platform.dapi.v0.GetContractGroupMembersResponse\x12\x9f\x01\n\x1cgetContractGroupsForContract\x12>.org.dash.platform.dapi.v0.GetContractGroupsForContractRequest\x1a?.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse\x12\x9c\x01\n\x1bgetContractModerationStatus\x12=.org.dash.platform.dapi.v0.GetContractModerationStatusRequest\x1a>.org.dash.platform.dapi.v0.GetContractModerationStatusResponse\x12\x9f\x01\n\x1cgetContractModerationEntries\x12>.org.dash.platform.dapi.v0.GetContractModerationEntriesRequest\x1a?.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse\x12\x9c\x01\n\x1bgetContractDocumentRemovals\x12=.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest\x1a>.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse\x12\x81\x01\n\x12getContractFeePots\x12\x34.org.dash.platform.dapi.v0.GetContractFeePotsRequest\x1a\x35.org.dash.platform.dapi.v0.GetContractFeePotsResponse\x12\x81\x01\n\x12getDocumentHistory\x12\x34.org.dash.platform.dapi.v0.GetDocumentHistoryRequest\x1a\x35.org.dash.platform.dapi.v0.GetDocumentHistoryResponse\x12o\n\x0cgetDocuments\x12..org.dash.platform.dapi.v0.GetDocumentsRequest\x1a/.org.dash.platform.dapi.v0.GetDocumentsResponse\x12\x99\x01\n\x1agetIdentityByPublicKeyHash\x12<.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashRequest\x1a=.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashResponse\x12\xb4\x01\n#getIdentityByNonUniquePublicKeyHash\x12\x45.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashRequest\x1a\x46.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse\x12\x9f\x01\n\x1cwaitForStateTransitionResult\x12>.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest\x1a?.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse\x12\x81\x01\n\x12getConsensusParams\x12\x34.org.dash.platform.dapi.v0.GetConsensusParamsRequest\x1a\x35.org.dash.platform.dapi.v0.GetConsensusParamsResponse\x12\xa5\x01\n\x1egetProtocolVersionUpgradeState\x12@.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateRequest\x1a\x41.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse\x12\xb4\x01\n#getProtocolVersionUpgradeVoteStatus\x12\x45.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusRequest\x1a\x46.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse\x12r\n\rgetEpochsInfo\x12/.org.dash.platform.dapi.v0.GetEpochsInfoRequest\x1a\x30.org.dash.platform.dapi.v0.GetEpochsInfoResponse\x12\x8d\x01\n\x16getFinalizedEpochInfos\x12\x38.org.dash.platform.dapi.v0.GetFinalizedEpochInfosRequest\x1a\x39.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse\x12\x8a\x01\n\x15getContestedResources\x12\x37.org.dash.platform.dapi.v0.GetContestedResourcesRequest\x1a\x38.org.dash.platform.dapi.v0.GetContestedResourcesResponse\x12\xa2\x01\n\x1dgetContestedResourceVoteState\x12?.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest\x1a@.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse\x12\xba\x01\n%getContestedResourceVotersForIdentity\x12G.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest\x1aH.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse\x12\xae\x01\n!getContestedResourceIdentityVotes\x12\x43.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest\x1a\x44.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse\x12\x8a\x01\n\x15getVotePollsByEndDate\x12\x37.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest\x1a\x38.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse\x12\xa5\x01\n\x1egetPrefundedSpecializedBalance\x12@.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceRequest\x1a\x41.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceResponse\x12\x96\x01\n\x19getTotalCreditsInPlatform\x12;.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformRequest\x1a<.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformResponse\x12x\n\x0fgetPathElements\x12\x31.org.dash.platform.dapi.v0.GetPathElementsRequest\x1a\x32.org.dash.platform.dapi.v0.GetPathElementsResponse\x12\x66\n\tgetStatus\x12+.org.dash.platform.dapi.v0.GetStatusRequest\x1a,.org.dash.platform.dapi.v0.GetStatusResponse\x12\x8a\x01\n\x15getCurrentQuorumsInfo\x12\x37.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoRequest\x1a\x38.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse\x12\x93\x01\n\x18getIdentityTokenBalances\x12:.org.dash.platform.dapi.v0.GetIdentityTokenBalancesRequest\x1a;.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse\x12\x99\x01\n\x1agetIdentitiesTokenBalances\x12<.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesRequest\x1a=.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse\x12\x8a\x01\n\x15getIdentityTokenInfos\x12\x37.org.dash.platform.dapi.v0.GetIdentityTokenInfosRequest\x1a\x38.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse\x12\x90\x01\n\x17getIdentitiesTokenInfos\x12\x39.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosRequest\x1a:.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse\x12{\n\x10getTokenStatuses\x12\x32.org.dash.platform.dapi.v0.GetTokenStatusesRequest\x1a\x33.org.dash.platform.dapi.v0.GetTokenStatusesResponse\x12\x9f\x01\n\x1cgetTokenDirectPurchasePrices\x12>.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesRequest\x1a?.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse\x12\x87\x01\n\x14getTokenContractInfo\x12\x36.org.dash.platform.dapi.v0.GetTokenContractInfoRequest\x1a\x37.org.dash.platform.dapi.v0.GetTokenContractInfoResponse\x12\xb1\x01\n\"getTokenPreProgrammedDistributions\x12\x44.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest\x1a\x45.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse\x12\xbd\x01\n&getTokenPerpetualDistributionLastClaim\x12H.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest\x1aI.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse\x12\x84\x01\n\x13getTokenTotalSupply\x12\x35.org.dash.platform.dapi.v0.GetTokenTotalSupplyRequest\x1a\x36.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse\x12o\n\x0cgetGroupInfo\x12..org.dash.platform.dapi.v0.GetGroupInfoRequest\x1a/.org.dash.platform.dapi.v0.GetGroupInfoResponse\x12r\n\rgetGroupInfos\x12/.org.dash.platform.dapi.v0.GetGroupInfosRequest\x1a\x30.org.dash.platform.dapi.v0.GetGroupInfosResponse\x12x\n\x0fgetGroupActions\x12\x31.org.dash.platform.dapi.v0.GetGroupActionsRequest\x1a\x32.org.dash.platform.dapi.v0.GetGroupActionsResponse\x12\x8a\x01\n\x15getGroupActionSigners\x12\x37.org.dash.platform.dapi.v0.GetGroupActionSignersRequest\x1a\x38.org.dash.platform.dapi.v0.GetGroupActionSignersResponse\x12u\n\x0egetAddressInfo\x12\x30.org.dash.platform.dapi.v0.GetAddressInfoRequest\x1a\x31.org.dash.platform.dapi.v0.GetAddressInfoResponse\x12~\n\x11getAddressesInfos\x12\x33.org.dash.platform.dapi.v0.GetAddressesInfosRequest\x1a\x34.org.dash.platform.dapi.v0.GetAddressesInfosResponse\x12\x8d\x01\n\x16getAddressesTrunkState\x12\x38.org.dash.platform.dapi.v0.GetAddressesTrunkStateRequest\x1a\x39.org.dash.platform.dapi.v0.GetAddressesTrunkStateResponse\x12\x90\x01\n\x17getAddressesBranchState\x12\x39.org.dash.platform.dapi.v0.GetAddressesBranchStateRequest\x1a:.org.dash.platform.dapi.v0.GetAddressesBranchStateResponse\x12\xa5\x01\n\x1egetRecentAddressBalanceChanges\x12@.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesRequest\x1a\x41.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesResponse\x12\xc0\x01\n\'getRecentCompactedAddressBalanceChanges\x12I.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesRequest\x1aJ.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesResponse\x12\x96\x01\n\x19getShieldedEncryptedNotes\x12;.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesRequest\x1a<.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse\x12\x81\x01\n\x12getShieldedAnchors\x12\x34.org.dash.platform.dapi.v0.GetShieldedAnchorsRequest\x1a\x35.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse\x12\x9c\x01\n\x1bgetMostRecentShieldedAnchor\x12=.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorRequest\x1a>.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorResponse\x12\x87\x01\n\x14getShieldedPoolState\x12\x36.org.dash.platform.dapi.v0.GetShieldedPoolStateRequest\x1a\x37.org.dash.platform.dapi.v0.GetShieldedPoolStateResponse\x12\x8a\x01\n\x15getShieldedNotesCount\x12\x37.org.dash.platform.dapi.v0.GetShieldedNotesCountRequest\x1a\x38.org.dash.platform.dapi.v0.GetShieldedNotesCountResponse\x12\x8a\x01\n\x15getShieldedNullifiers\x12\x37.org.dash.platform.dapi.v0.GetShieldedNullifiersRequest\x1a\x38.org.dash.platform.dapi.v0.GetShieldedNullifiersResponseb\x06proto3' + serialized_pb=b'\n\x0eplatform.proto\x12\x19org.dash.platform.dapi.v0\x1a\x1egoogle/protobuf/wrappers.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x81\x01\n\x05Proof\x12\x15\n\rgrovedb_proof\x18\x01 \x01(\x0c\x12\x13\n\x0bquorum_hash\x18\x02 \x01(\x0c\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x12\r\n\x05round\x18\x04 \x01(\r\x12\x15\n\rblock_id_hash\x18\x05 \x01(\x0c\x12\x13\n\x0bquorum_type\x18\x06 \x01(\r\"\x98\x01\n\x10ResponseMetadata\x12\x12\n\x06height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12 \n\x18\x63ore_chain_locked_height\x18\x02 \x01(\r\x12\r\n\x05\x65poch\x18\x03 \x01(\r\x12\x13\n\x07time_ms\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x18\n\x10protocol_version\x18\x05 \x01(\r\x12\x10\n\x08\x63hain_id\x18\x06 \x01(\t\"L\n\x1dStateTransitionBroadcastError\x12\x0c\n\x04\x63ode\x18\x01 \x01(\r\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c\";\n\x1f\x42roadcastStateTransitionRequest\x12\x18\n\x10state_transition\x18\x01 \x01(\x0c\"\"\n BroadcastStateTransitionResponse\"\xa4\x01\n\x12GetIdentityRequest\x12P\n\x02v0\x18\x01 \x01(\x0b\x32\x42.org.dash.platform.dapi.v0.GetIdentityRequest.GetIdentityRequestV0H\x00\x1a\x31\n\x14GetIdentityRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xc1\x01\n\x17GetIdentityNonceRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetIdentityNonceRequest.GetIdentityNonceRequestV0H\x00\x1a?\n\x19GetIdentityNonceRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf6\x01\n\x1fGetIdentityContractNonceRequest\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentityContractNonceRequest.GetIdentityContractNonceRequestV0H\x00\x1a\\\n!GetIdentityContractNonceRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63ontract_id\x18\x02 \x01(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xc0\x01\n\x19GetIdentityBalanceRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetIdentityBalanceRequest.GetIdentityBalanceRequestV0H\x00\x1a\x38\n\x1bGetIdentityBalanceRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xec\x01\n$GetIdentityBalanceAndRevisionRequest\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionRequest.GetIdentityBalanceAndRevisionRequestV0H\x00\x1a\x43\n&GetIdentityBalanceAndRevisionRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9e\x02\n\x13GetIdentityResponse\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetIdentityResponse.GetIdentityResponseV0H\x00\x1a\xa7\x01\n\x15GetIdentityResponseV0\x12\x12\n\x08identity\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbc\x02\n\x18GetIdentityNonceResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetIdentityNonceResponse.GetIdentityNonceResponseV0H\x00\x1a\xb6\x01\n\x1aGetIdentityNonceResponseV0\x12\x1c\n\x0eidentity_nonce\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xe5\x02\n GetIdentityContractNonceResponse\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentityContractNonceResponse.GetIdentityContractNonceResponseV0H\x00\x1a\xc7\x01\n\"GetIdentityContractNonceResponseV0\x12%\n\x17identity_contract_nonce\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\x8e\x02\n&GetIdentityKeysRemainingBudgetsRequest\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsRequest.GetIdentityKeysRemainingBudgetsRequestV0H\x00\x1a_\n(GetIdentityKeysRemainingBudgetsRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x0f\n\x07key_ids\x18\x02 \x03(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\x96\x06\n\'GetIdentityKeysRemainingBudgetsResponse\x12z\n\x02v0\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse.GetIdentityKeysRemainingBudgetsResponseV0H\x00\x1a\xe3\x04\n)GetIdentityKeysRemainingBudgetsResponseV0\x12\xa4\x01\n\x16keys_remaining_budgets\x18\x01 \x01(\x0b\x32\x81\x01.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse.GetIdentityKeysRemainingBudgetsResponseV0.KeysRemainingBudgetsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x61\n\x17KeyRemainingBudgetEntry\x12\x0e\n\x06key_id\x18\x01 \x01(\r\x12!\n\x10remaining_budget\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\x13\n\x11_remaining_budget\x1a\xaf\x01\n\x14KeysRemainingBudgets\x12\x96\x01\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse.GetIdentityKeysRemainingBudgetsResponseV0.KeyRemainingBudgetEntryB\x08\n\x06resultB\t\n\x07version\"\xbd\x02\n\x1aGetIdentityBalanceResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetIdentityBalanceResponse.GetIdentityBalanceResponseV0H\x00\x1a\xb1\x01\n\x1cGetIdentityBalanceResponseV0\x12\x15\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb1\x04\n%GetIdentityBalanceAndRevisionResponse\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse.GetIdentityBalanceAndRevisionResponseV0H\x00\x1a\x84\x03\n\'GetIdentityBalanceAndRevisionResponseV0\x12\x9b\x01\n\x14\x62\x61lance_and_revision\x18\x01 \x01(\x0b\x32{.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse.GetIdentityBalanceAndRevisionResponseV0.BalanceAndRevisionH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a?\n\x12\x42\x61lanceAndRevision\x12\x13\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x14\n\x08revision\x18\x02 \x01(\x04\x42\x02\x30\x01\x42\x08\n\x06resultB\t\n\x07version\"\xd1\x01\n\x0eKeyRequestType\x12\x36\n\x08\x61ll_keys\x18\x01 \x01(\x0b\x32\".org.dash.platform.dapi.v0.AllKeysH\x00\x12@\n\rspecific_keys\x18\x02 \x01(\x0b\x32\'.org.dash.platform.dapi.v0.SpecificKeysH\x00\x12:\n\nsearch_key\x18\x03 \x01(\x0b\x32$.org.dash.platform.dapi.v0.SearchKeyH\x00\x42\t\n\x07request\"\t\n\x07\x41llKeys\"\x1f\n\x0cSpecificKeys\x12\x0f\n\x07key_ids\x18\x01 \x03(\r\"\xb6\x01\n\tSearchKey\x12I\n\x0bpurpose_map\x18\x01 \x03(\x0b\x32\x34.org.dash.platform.dapi.v0.SearchKey.PurposeMapEntry\x1a^\n\x0fPurposeMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12:\n\x05value\x18\x02 \x01(\x0b\x32+.org.dash.platform.dapi.v0.SecurityLevelMap:\x02\x38\x01\"\xbf\x02\n\x10SecurityLevelMap\x12]\n\x12security_level_map\x18\x01 \x03(\x0b\x32\x41.org.dash.platform.dapi.v0.SecurityLevelMap.SecurityLevelMapEntry\x1aw\n\x15SecurityLevelMapEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12M\n\x05value\x18\x02 \x01(\x0e\x32>.org.dash.platform.dapi.v0.SecurityLevelMap.KeyKindRequestType:\x02\x38\x01\"S\n\x12KeyKindRequestType\x12\x1f\n\x1b\x43URRENT_KEY_OF_KIND_REQUEST\x10\x00\x12\x1c\n\x18\x41LL_KEYS_OF_KIND_REQUEST\x10\x01\"\xda\x02\n\x16GetIdentityKeysRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetIdentityKeysRequest.GetIdentityKeysRequestV0H\x00\x1a\xda\x01\n\x18GetIdentityKeysRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12?\n\x0crequest_type\x18\x02 \x01(\x0b\x32).org.dash.platform.dapi.v0.KeyRequestType\x12+\n\x05limit\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\x99\x03\n\x17GetIdentityKeysResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetIdentityKeysResponse.GetIdentityKeysResponseV0H\x00\x1a\x96\x02\n\x19GetIdentityKeysResponseV0\x12\x61\n\x04keys\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetIdentityKeysResponse.GetIdentityKeysResponseV0.KeysH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1a\n\x04Keys\x12\x12\n\nkeys_bytes\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xef\x02\n GetIdentitiesContractKeysRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentitiesContractKeysRequest.GetIdentitiesContractKeysRequestV0H\x00\x1a\xd1\x01\n\"GetIdentitiesContractKeysRequestV0\x12\x16\n\x0eidentities_ids\x18\x01 \x03(\x0c\x12\x13\n\x0b\x63ontract_id\x18\x02 \x01(\x0c\x12\x1f\n\x12\x64ocument_type_name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x37\n\x08purposes\x18\x04 \x03(\x0e\x32%.org.dash.platform.dapi.v0.KeyPurpose\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x15\n\x13_document_type_nameB\t\n\x07version\"\xdf\x06\n!GetIdentitiesContractKeysResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0H\x00\x1a\xbe\x05\n#GetIdentitiesContractKeysResponseV0\x12\x8a\x01\n\x0fidentities_keys\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.IdentitiesKeysH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aY\n\x0bPurposeKeys\x12\x36\n\x07purpose\x18\x01 \x01(\x0e\x32%.org.dash.platform.dapi.v0.KeyPurpose\x12\x12\n\nkeys_bytes\x18\x02 \x03(\x0c\x1a\x9f\x01\n\x0cIdentityKeys\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12z\n\x04keys\x18\x02 \x03(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.PurposeKeys\x1a\x90\x01\n\x0eIdentitiesKeys\x12~\n\x07\x65ntries\x18\x01 \x03(\x0b\x32m.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse.GetIdentitiesContractKeysResponseV0.IdentityKeysB\x08\n\x06resultB\t\n\x07version\"\xa4\x02\n*GetEvonodesProposedEpochBlocksByIdsRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByIdsRequest.GetEvonodesProposedEpochBlocksByIdsRequestV0H\x00\x1ah\n,GetEvonodesProposedEpochBlocksByIdsRequestV0\x12\x12\n\x05\x65poch\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x0b\n\x03ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\x08\n\x06_epochB\t\n\x07version\"\x92\x06\n&GetEvonodesProposedEpochBlocksResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0H\x00\x1a\xe2\x04\n(GetEvonodesProposedEpochBlocksResponseV0\x12\xb1\x01\n#evonodes_proposed_block_counts_info\x18\x01 \x01(\x0b\x32\x81\x01.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0.EvonodesProposedBlocksH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a?\n\x15\x45vonodeProposedBlocks\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x02 \x01(\x04\x42\x02\x30\x01\x1a\xc4\x01\n\x16\x45vonodesProposedBlocks\x12\xa9\x01\n\x1e\x65vonodes_proposed_block_counts\x18\x01 \x03(\x0b\x32\x80\x01.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse.GetEvonodesProposedEpochBlocksResponseV0.EvonodeProposedBlocksB\x08\n\x06resultB\t\n\x07version\"\xf2\x02\n,GetEvonodesProposedEpochBlocksByRangeRequest\x12\x84\x01\n\x02v0\x18\x01 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByRangeRequest.GetEvonodesProposedEpochBlocksByRangeRequestV0H\x00\x1a\xaf\x01\n.GetEvonodesProposedEpochBlocksByRangeRequestV0\x12\x12\n\x05\x65poch\x18\x01 \x01(\rH\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x02 \x01(\rH\x02\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x03 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x04 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x07\n\x05startB\x08\n\x06_epochB\x08\n\x06_limitB\t\n\x07version\"\xcd\x01\n\x1cGetIdentitiesBalancesRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetIdentitiesBalancesRequest.GetIdentitiesBalancesRequestV0H\x00\x1a<\n\x1eGetIdentitiesBalancesRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9f\x05\n\x1dGetIdentitiesBalancesResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0H\x00\x1a\x8a\x04\n\x1fGetIdentitiesBalancesResponseV0\x12\x8a\x01\n\x13identities_balances\x18\x01 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0.IdentitiesBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aL\n\x0fIdentityBalance\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x18\n\x07\x62\x61lance\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\x8f\x01\n\x12IdentitiesBalances\x12y\n\x07\x65ntries\x18\x01 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse.GetIdentitiesBalancesResponseV0.IdentityBalanceB\x08\n\x06resultB\t\n\x07version\"\xb4\x01\n\x16GetDataContractRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetDataContractRequest.GetDataContractRequestV0H\x00\x1a\x35\n\x18GetDataContractRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xb3\x02\n\x17GetDataContractResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetDataContractResponse.GetDataContractResponseV0H\x00\x1a\xb0\x01\n\x19GetDataContractResponseV0\x12\x17\n\rdata_contract\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\x8c\x02\n%GetDataContractsLatestVersionsRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsRequest.GetDataContractsLatestVersionsRequestV0H\x00\x1a`\n\'GetDataContractsLatestVersionsRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\x19\n\x11include_contracts\x18\x02 \x01(\x08\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xfa\x05\n&GetDataContractsLatestVersionsResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse.GetDataContractsLatestVersionsResponseV0H\x00\x1a\x84\x01\n\x1e\x44\x61taContractLatestVersionEntry\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x14\n\x07version\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x1a\n\rdata_contract\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x42\n\n\x08_versionB\x10\n\x0e_data_contract\x1a\x90\x01\n\x1b\x44\x61taContractsLatestVersions\x12q\n\x07\x65ntries\x18\x01 \x03(\x0b\x32`.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse.DataContractLatestVersionEntry\x1a\xb0\x02\n(GetDataContractsLatestVersionsResponseV0\x12\x87\x01\n\x1e\x64\x61ta_contracts_latest_versions\x18\x01 \x01(\x0b\x32].org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse.DataContractsLatestVersionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"R\n\x1f\x43ontractGroupDocumentTypeMember\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\"G\n\x18\x43ontractGroupTokenMember\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x16\n\x0etoken_position\x18\x02 \x01(\r\"\xd7\x01\n\x1bGetContractGroupInfoRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetContractGroupInfoRequest.GetContractGroupInfoRequestV0H\x00\x1aI\n\x1dGetContractGroupInfoRequestV0\x12\x19\n\x11\x63ontract_group_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x98\x04\n\x1cGetContractGroupInfoResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetContractGroupInfoResponse.GetContractGroupInfoResponseV0H\x00\x1a~\n\x11\x43ontractGroupInfo\x12\x10\n\x08owner_id\x18\x01 \x01(\x0c\x12\x11\n\tadmin_ids\x18\x02 \x03(\x0c\x12\x11\n\x04name\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x04 \x01(\tH\x01\x88\x01\x01\x42\x07\n\x05_nameB\x0e\n\x0c_description\x1a\x86\x02\n\x1eGetContractGroupInfoResponseV0\x12h\n\x13\x63ontract_group_info\x18\x01 \x01(\x0b\x32I.org.dash.platform.dapi.v0.GetContractGroupInfoResponse.ContractGroupInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf8\x06\n\x1eGetContractGroupMembersRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.GetContractGroupMembersRequestV0H\x00\x1a@\n\x14\x43ontractMembersQuery\x12\x18\n\x0bstart_after\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x42\x0e\n\x0c_start_after\x1a\x80\x01\n\x18\x44ocumentTypeMembersQuery\x12T\n\x0bstart_after\x18\x01 \x01(\x0b\x32:.org.dash.platform.dapi.v0.ContractGroupDocumentTypeMemberH\x00\x88\x01\x01\x42\x0e\n\x0c_start_after\x1ar\n\x11TokenMembersQuery\x12M\n\x0bstart_after\x18\x01 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractGroupTokenMemberH\x00\x88\x01\x01\x42\x0e\n\x0c_start_after\x1a\xa7\x03\n GetContractGroupMembersRequestV0\x12\x19\n\x11\x63ontract_group_id\x18\x01 \x01(\x0c\x12\x63\n\tcontracts\x18\x02 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.ContractMembersQueryH\x00\x12l\n\x0e\x64ocument_types\x18\x03 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.DocumentTypeMembersQueryH\x00\x12]\n\x06tokens\x18\x04 \x01(\x0b\x32K.org.dash.platform.dapi.v0.GetContractGroupMembersRequest.TokenMembersQueryH\x00\x12\x12\n\x05limit\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x42\t\n\x07membersB\x08\n\x06_limitB\t\n\x07version\"\xc9\x06\n\x1fGetContractGroupMembersResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.GetContractGroupMembersResponseV0H\x00\x1a\'\n\x0f\x43ontractMembers\x12\x14\n\x0c\x63ontract_ids\x18\x01 \x03(\x0c\x1ai\n\x13\x44ocumentTypeMembers\x12R\n\x0e\x64ocument_types\x18\x01 \x03(\x0b\x32:.org.dash.platform.dapi.v0.ContractGroupDocumentTypeMember\x1aS\n\x0cTokenMembers\x12\x43\n\x06tokens\x18\x01 \x03(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractGroupTokenMember\x1a\xc5\x03\n!GetContractGroupMembersResponseV0\x12_\n\tcontracts\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.ContractMembersH\x00\x12h\n\x0e\x64ocument_types\x18\x02 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.DocumentTypeMembersH\x00\x12Y\n\x06tokens\x18\x03 \x01(\x0b\x32G.org.dash.platform.dapi.v0.GetContractGroupMembersResponse.TokenMembersH\x00\x12\x31\n\x05proof\x18\x04 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x05 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"M\n\x1a\x43ontractModerationDocument\x12\x1a\n\x12\x64ocument_type_name\x18\x01 \x01(\t\x12\x13\n\x0b\x64ocument_id\x18\x02 \x01(\x0c\"\x8e\x01\n\x18\x43ontractModerationReason\x12\x11\n\x04\x63ode\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x0c\n\x04text\x18\x02 \x01(\t\x12H\n\tdocuments\x18\x03 \x03(\x0b\x32\x35.org.dash.platform.dapi.v0.ContractModerationDocumentB\x07\n\x05_code\"i\n\x0f\x43ontractWarning\x12\x11\n\twarned_at\x18\x01 \x01(\x04\x12\x43\n\x06reason\x18\x02 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReason\"\xc5\x02\n\"GetContractModerationStatusRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetContractModerationStatusRequest.GetContractModerationStatusRequestV0H\x00\x1a\xa1\x01\n$GetContractModerationStatusRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x13\n\x0bidentity_id\x18\x02 \x01(\x0c\x12@\n\x05lists\x18\x03 \x03(\x0e\x32\x31.org.dash.platform.dapi.v0.ContractModerationList\x12\r\n\x05prove\x18\x04 \x01(\x08\x42\t\n\x07version\"\xec\x06\n#GetContractModerationStatusResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.GetContractModerationStatusResponseV0H\x00\x1a\xb4\x03\n\x18\x43ontractModerationStatus\x12\x13\n\x06\x62\x61nned\x18\x01 \x01(\x08H\x00\x88\x01\x01\x12\x1c\n\x0fsuspended_until\x18\x02 \x01(\x04H\x01\x88\x01\x01\x12@\n\x05lists\x18\x03 \x03(\x0e\x32\x31.org.dash.platform.dapi.v0.ContractModerationList\x12L\n\nban_reason\x18\x04 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReasonH\x02\x88\x01\x01\x12S\n\x11suspension_reason\x18\x05 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReasonH\x03\x88\x01\x01\x12<\n\x08warnings\x18\x06 \x03(\x0b\x32*.org.dash.platform.dapi.v0.ContractWarningB\t\n\x07_bannedB\x12\n\x10_suspended_untilB\r\n\x0b_ban_reasonB\x14\n\x12_suspension_reason\x1a\x8e\x02\n%GetContractModerationStatusResponseV0\x12i\n\x06status\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetContractModerationStatusResponse.ContractModerationStatusH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xfb\x02\n#GetContractModerationEntriesRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractModerationEntriesRequest.GetContractModerationEntriesRequestV0H\x00\x1a\xd4\x01\n%GetContractModerationEntriesRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12?\n\x04list\x18\x02 \x01(\x0e\x32\x31.org.dash.platform.dapi.v0.ContractModerationList\x12\x18\n\x0bstart_after\x18\x03 \x01(\x0cH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x04 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x0e\n\x0c_start_afterB\x08\n\x06_limitB\t\n\x07version\"\x96\x06\n$GetContractModerationEntriesResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.GetContractModerationEntriesResponseV0H\x00\x1a\xcf\x01\n\x17\x43ontractModerationEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x12\n\x05until\x18\x02 \x01(\x04H\x00\x88\x01\x01\x12\x43\n\x06reason\x18\x03 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReason\x12<\n\x08warnings\x18\x04 \x03(\x0b\x32*.org.dash.platform.dapi.v0.ContractWarningB\x08\n\x06_until\x1a\x85\x01\n\x19\x43ontractModerationEntries\x12h\n\x07\x65ntries\x18\x01 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntry\x1a\x92\x02\n&GetContractModerationEntriesResponseV0\x12l\n\x07\x65ntries\x18\x01 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse.ContractModerationEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc3\x04\n\"GetContractDocumentRemovalsRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest.GetContractDocumentRemovalsRequestV0H\x00\x1a#\n\x0b\x44ocumentIds\x12\x14\n\x0c\x64ocument_ids\x18\x01 \x03(\x0c\x1aN\n\x04Page\x12\x18\n\x0bstart_after\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x02 \x01(\rH\x01\x88\x01\x01\x42\x0e\n\x0c_start_afterB\x08\n\x06_limit\x1a\xaa\x02\n$GetContractDocumentRemovalsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x61\n\x0c\x64ocument_ids\x18\x03 \x01(\x0b\x32I.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest.DocumentIdsH\x00\x12R\n\x04page\x18\x04 \x01(\x0b\x32\x42.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest.PageH\x00\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x0b\n\tselectionB\t\n\x07version\"\xcb\x07\n#GetContractDocumentRemovalsResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.GetContractDocumentRemovalsResponseV0H\x00\x1aH\n\x1b\x43ontractDocumentRestoration\x12\x14\n\x0cmoderator_id\x18\x01 \x01(\x0c\x12\x13\n\x0brestored_at\x18\x02 \x01(\x04\x1a\xc0\x02\n\x17\x43ontractDocumentRemoval\x12\x13\n\x0b\x64ocument_id\x18\x01 \x01(\x0c\x12\x19\n\x11\x64ocument_owner_id\x18\x02 \x01(\x0c\x12\x14\n\x0cmoderator_id\x18\x03 \x01(\x0c\x12\x12\n\nremoved_at\x18\x04 \x01(\x04\x12\x43\n\x06reason\x18\x05 \x01(\x0b\x32\x33.org.dash.platform.dapi.v0.ContractModerationReason\x12\x15\n\rdocument_hash\x18\x06 \x01(\x0c\x12o\n\x0brestoration\x18\x07 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRestoration\x1a\x84\x01\n\x18\x43ontractDocumentRemovals\x12h\n\x08removals\x18\x01 \x03(\x0b\x32V.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemoval\x1a\x90\x02\n%GetContractDocumentRemovalsResponseV0\x12k\n\x08removals\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse.ContractDocumentRemovalsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc9\x01\n\x19GetContractFeePotsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetContractFeePotsRequest.GetContractFeePotsRequestV0H\x00\x1a\x41\n\x1bGetContractFeePotsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9f\x06\n\x1aGetContractFeePotsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetContractFeePotsResponse.GetContractFeePotsResponseV0H\x00\x1aR\n\x17\x43ontractFeePotLastClaim\x12\r\n\x05\x65poch\x18\x01 \x01(\r\x12\x13\n\x07time_ms\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x13\n\x0b\x63laimant_id\x18\x03 \x01(\x0c\x1a\x88\x01\n\x0e\x43ontractFeePot\x12\x13\n\x07\x63redits\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x61\n\nlast_claim\x18\x02 \x01(\x0b\x32M.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePotLastClaim\x1a\xc0\x01\n\x0f\x43ontractFeePots\x12S\n\x05owner\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePot\x12X\n\nmoderators\x18\x02 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePot\x1a\xf1\x01\n\x1cGetContractFeePotsResponseV0\x12U\n\x04pots\x18\x01 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetContractFeePotsResponse.ContractFeePotsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf1\x01\n#GetContractGroupsForContractRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetContractGroupsForContractRequest.GetContractGroupsForContractRequestV0H\x00\x1aK\n%GetContractGroupsForContractRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf5\x06\n$GetContractGroupsForContractResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.GetContractGroupsForContractResponseV0H\x00\x1aQ\n\x17\x44ocumentTypeMemberships\x12\x1a\n\x12\x64ocument_type_name\x18\x01 \x01(\t\x12\x1a\n\x12\x63ontract_group_ids\x18\x02 \x03(\x0c\x1a\x46\n\x10TokenMemberships\x12\x16\n\x0etoken_position\x18\x01 \x01(\r\x12\x1a\n\x12\x63ontract_group_ids\x18\x02 \x03(\x0c\x1a\x89\x02\n\x18\x43ontractGroupMemberships\x12\x1a\n\x12\x63ontract_group_ids\x18\x01 \x03(\x0c\x12o\n\x0e\x64ocument_types\x18\x02 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.DocumentTypeMemberships\x12`\n\x06tokens\x18\x03 \x03(\x0b\x32P.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.TokenMemberships\x1a\xa4\x02\n&GetContractGroupsForContractResponseV0\x12~\n\x1a\x63ontract_group_memberships\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse.ContractGroupMembershipsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb9\x01\n\x17GetDataContractsRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetDataContractsRequest.GetDataContractsRequestV0H\x00\x1a\x37\n\x19GetDataContractsRequestV0\x12\x0b\n\x03ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xad\x02\n\x1eGetDataContractsByRangeRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetDataContractsByRangeRequest.GetDataContractsByRangeRequestV0H\x00\x1a\x95\x01\n GetDataContractsByRangeRequestV0\x12\x12\n\x05limit\x18\x01 \x01(\rH\x01\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x02 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x03 \x01(\x0cH\x00\x12\x10\n\x08ids_only\x18\x04 \x01(\x08\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x07\n\x05startB\x08\n\x06_limitB\t\n\x07version\"\xcf\x04\n\x18GetDataContractsResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetDataContractsResponse.GetDataContractsResponseV0H\x00\x1a[\n\x11\x44\x61taContractEntry\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x32\n\rdata_contract\x18\x02 \x01(\x0b\x32\x1b.google.protobuf.BytesValue\x1au\n\rDataContracts\x12\x64\n\x15\x64\x61ta_contract_entries\x18\x01 \x03(\x0b\x32\x45.org.dash.platform.dapi.v0.GetDataContractsResponse.DataContractEntry\x1a\xf5\x01\n\x1aGetDataContractsResponseV0\x12[\n\x0e\x64\x61ta_contracts\x18\x01 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDataContractsResponse.DataContractsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc5\x02\n\x1dGetDataContractHistoryRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetDataContractHistoryRequest.GetDataContractHistoryRequestV0H\x00\x1a\xb0\x01\n\x1fGetDataContractHistoryRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12+\n\x05limit\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0bstart_at_ms\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\xb2\x05\n\x1eGetDataContractHistoryResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0H\x00\x1a\x9a\x04\n GetDataContractHistoryResponseV0\x12\x8f\x01\n\x15\x64\x61ta_contract_history\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0.DataContractHistoryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a;\n\x18\x44\x61taContractHistoryEntry\x12\x10\n\x04\x64\x61te\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05value\x18\x02 \x01(\x0c\x1a\xaa\x01\n\x13\x44\x61taContractHistory\x12\x92\x01\n\x15\x64\x61ta_contract_entries\x18\x01 \x03(\x0b\x32s.org.dash.platform.dapi.v0.GetDataContractHistoryResponse.GetDataContractHistoryResponseV0.DataContractHistoryEntryB\x08\n\x06resultB\t\n\x07version\"\xe0\x1f\n\x13GetDocumentsRequest\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV0H\x00\x12R\n\x02v1\x18\x02 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1H\x00\x1a\xfe\x02\n\x12\x44ocumentFieldValue\x12\x14\n\nbool_value\x18\x01 \x01(\x08H\x00\x12\x19\n\x0bint64_value\x18\x02 \x01(\x12\x42\x02\x30\x01H\x00\x12\x1a\n\x0cuint64_value\x18\x03 \x01(\x04\x42\x02\x30\x01H\x00\x12\x16\n\x0c\x64ouble_value\x18\x04 \x01(\x01H\x00\x12\x0e\n\x04text\x18\x05 \x01(\tH\x00\x12\x15\n\x0b\x62ytes_value\x18\x06 \x01(\x0cH\x00\x12[\n\x04list\x18\x07 \x01(\x0b\x32K.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValue.ValueListH\x00\x12\x14\n\nnull_value\x18\x08 \x01(\x08H\x00\x1a^\n\tValueList\x12Q\n\x06values\x18\x01 \x03(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValueB\t\n\x07variant\x1a\xe2\x02\n\x12TimeRangeSelection\x12\\\n\x08selector\x18\x01 \x01(\x0e\x32J.org.dash.platform.dapi.v0.GetDocumentsRequest.TimeRangeSelection.Selector\x12\x19\n\x08start_ms\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x12T\n\x04grid\x18\x03 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsRequest.TimeRangeSelection.Grid\x1a>\n\x04Grid\x12\x11\n\x05range\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x10\n\x04step\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x11\n\x05phase\x18\x03 \x01(\x04\x42\x02\x30\x01\"0\n\x08Selector\x12\n\n\x06NEWEST\x10\x00\x12\n\n\x06OLDEST\x10\x01\x12\x0c\n\x08\x42Y_START\x10\x02\x42\x0b\n\t_start_ms\x1a\x95\x02\n\x0bWhereClause\x12\r\n\x05\x66ield\x18\x01 \x01(\t\x12N\n\x08operator\x18\x02 \x01(\x0e\x32<.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereOperator\x12P\n\x05value\x18\x03 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValue\x12U\n\ntime_range\x18\x04 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.TimeRangeSelection\x1a\xa4\x01\n\x0fHavingAggregate\x12Y\n\x08\x66unction\x18\x01 \x01(\x0e\x32G.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregate.Function\x12\r\n\x05\x66ield\x18\x02 \x01(\t\"\'\n\x08\x46unction\x12\t\n\x05\x43OUNT\x10\x00\x12\x07\n\x03SUM\x10\x01\x12\x07\n\x03\x41VG\x10\x02\x1a\xf9\x03\n\x0cHavingClause\x12Q\n\taggregate\x18\x01 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregate\x12V\n\x08operator\x18\x02 \x01(\x0e\x32\x44.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingClause.Operator\x12R\n\x05value\x18\x03 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetDocumentsRequest.DocumentFieldValueH\x00\"\xe0\x01\n\x08Operator\x12\t\n\x05\x45QUAL\x10\x00\x12\r\n\tNOT_EQUAL\x10\x01\x12\x10\n\x0cGREATER_THAN\x10\x02\x12\x1a\n\x16GREATER_THAN_OR_EQUALS\x10\x03\x12\r\n\tLESS_THAN\x10\x04\x12\x17\n\x13LESS_THAN_OR_EQUALS\x10\x05\x12\x0b\n\x07\x42\x45TWEEN\x10\x06\x12\x1a\n\x16\x42\x45TWEEN_EXCLUDE_BOUNDS\x10\x07\x12\x18\n\x14\x42\x45TWEEN_EXCLUDE_LEFT\x10\x08\x12\x19\n\x15\x42\x45TWEEN_EXCLUDE_RIGHT\x10\t\x12\x06\n\x02IN\x10\nB\x07\n\x05right\x1a\x90\x01\n\x0bOrderClause\x12\x0f\n\x05\x66ield\x18\x01 \x01(\tH\x00\x12S\n\taggregate\x18\x03 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingAggregateH\x00\x12\x11\n\tascending\x18\x02 \x01(\x08\x42\x08\n\x06target\x1a\xbb\x01\n\x15GetDocumentsRequestV0\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12\r\n\x05where\x18\x03 \x01(\x0c\x12\x10\n\x08order_by\x18\x04 \x01(\x0c\x12\r\n\x05limit\x18\x05 \x01(\r\x12\x15\n\x0bstart_after\x18\x06 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x07 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x08 \x01(\x08\x42\x07\n\x05start\x1a\xa6\x0c\n\x15GetDocumentsRequestV1\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12Q\n\rwhere_clauses\x18\x03 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereClause\x12L\n\x08order_by\x18\x04 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.OrderClause\x12\x12\n\x05limit\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\x15\n\x0bstart_after\x18\x06 \x01(\x0cH\x00\x12\x12\n\x08start_at\x18\x07 \x01(\x0cH\x00\x12\r\n\x05prove\x18\x08 \x01(\x08\x12\\\n\x07selects\x18\t \x03(\x0b\x32K.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.Select\x12\x10\n\x08group_by\x18\n \x03(\t\x12K\n\x06having\x18\x0b \x03(\x0b\x32;.org.dash.platform.dapi.v0.GetDocumentsRequest.HavingClause\x12\x13\n\x06offset\x18\x0c \x01(\rH\x02\x88\x01\x01\x12\x61\n\x07\x63hained\x18\r \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.ChainedJoin\x12\x62\n\x0bsub_queries\x18\x0e \x03(\x0b\x32M.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.SubQuery\x1a\xc9\x01\n\x06Select\x12\x66\n\x08\x66unction\x18\x01 \x01(\x0e\x32T.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.Select.Function\x12\r\n\x05\x66ield\x18\x02 \x01(\t\"H\n\x08\x46unction\x12\r\n\tDOCUMENTS\x10\x00\x12\t\n\x05\x43OUNT\x10\x01\x12\x07\n\x03SUM\x10\x02\x12\x07\n\x03\x41VG\x10\x03\x12\x07\n\x03MIN\x10\x04\x12\x07\n\x03MAX\x10\x05\x1a\x41\n\x0b\x43hainedJoin\x12\x15\n\rjoin_property\x18\x01 \x01(\t\x12\x1b\n\x13outer_document_type\x18\x02 \x01(\t\x1a\xa6\x04\n\x08SubQuery\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x15\n\rdocument_type\x18\x02 \x01(\t\x12Q\n\rwhere_clauses\x18\x03 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.WhereClause\x12L\n\x08order_by\x18\x04 \x03(\x0b\x32:.org.dash.platform.dapi.v0.GetDocumentsRequest.OrderClause\x12\x12\n\x05limit\x18\x05 \x01(\rH\x00\x88\x01\x01\x12`\n\x04kind\x18\x06 \x01(\x0e\x32R.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.SubQuery.Kind\x12\x63\n\x04\x62ind\x18\x07 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsRequest.GetDocumentsRequestV1.SubQuery.Binding\x1a\x41\n\x07\x42inding\x12\x0e\n\x06source\x18\x01 \x01(\r\x12\x17\n\x0fsource_property\x18\x02 \x01(\t\x12\r\n\x05\x66ield\x18\x03 \x01(\t\" \n\x04Kind\x12\r\n\tDOCUMENTS\x10\x00\x12\t\n\x05\x43OUNT\x10\x01\x42\x08\n\x06_limitB\x07\n\x05startB\x08\n\x06_limitB\t\n\x07_offset\"\xfa\x01\n\rWhereOperator\x12\t\n\x05\x45QUAL\x10\x00\x12\x10\n\x0cGREATER_THAN\x10\x01\x12\x1a\n\x16GREATER_THAN_OR_EQUALS\x10\x02\x12\r\n\tLESS_THAN\x10\x03\x12\x17\n\x13LESS_THAN_OR_EQUALS\x10\x04\x12\x0b\n\x07\x42\x45TWEEN\x10\x05\x12\x1a\n\x16\x42\x45TWEEN_EXCLUDE_BOUNDS\x10\x06\x12\x18\n\x14\x42\x45TWEEN_EXCLUDE_LEFT\x10\x07\x12\x19\n\x15\x42\x45TWEEN_EXCLUDE_RIGHT\x10\x08\x12\x06\n\x02IN\x10\t\x12\x0f\n\x0bSTARTS_WITH\x10\n\x12\x11\n\rIN_TIME_RANGE\x10\x0b\x42\t\n\x07version\"\xf2\x1b\n\x14GetDocumentsResponse\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV0H\x00\x12T\n\x02v1\x18\x02 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1H\x00\x1a\x9b\x02\n\x16GetDocumentsResponseV0\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV0.DocumentsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1e\n\tDocuments\x12\x11\n\tdocuments\x18\x01 \x03(\x0c\x42\x08\n\x06result\x1a\x84\x18\n\x16GetDocumentsResponseV1\x12\x61\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.ResultDataH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1e\n\tDocuments\x12\x11\n\tdocuments\x18\x01 \x03(\x0c\x1aL\n\nCountEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x03 \x01(\x04\x42\x02\x30\x01\x42\t\n\x07_in_key\x1ar\n\x0c\x43ountEntries\x12\x62\n\x07\x65ntries\x18\x01 \x03(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntry\x1a\xa0\x01\n\x0c\x43ountResults\x12\x1d\n\x0f\x61ggregate_count\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x66\n\x07\x65ntries\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntriesH\x00\x42\t\n\x07variant\x1aH\n\x08SumEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x0f\n\x03sum\x18\x03 \x01(\x12\x42\x02\x30\x01\x42\t\n\x07_in_key\x1an\n\nSumEntries\x12`\n\x07\x65ntries\x18\x01 \x03(\x0b\x32O.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumEntry\x1a\x9a\x01\n\nSumResults\x12\x1b\n\raggregate_sum\x18\x01 \x01(\x12\x42\x02\x30\x01H\x00\x12\x64\n\x07\x65ntries\x18\x02 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumEntriesH\x00\x42\t\n\x07variant\x1a_\n\x0c\x41verageEntry\x12\x13\n\x06in_key\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x12\x0b\n\x03key\x18\x02 \x01(\x0c\x12\x11\n\x05\x63ount\x18\x03 \x01(\x04\x42\x02\x30\x01\x12\x0f\n\x03sum\x18\x04 \x01(\x12\x42\x02\x30\x01\x42\t\n\x07_in_key\x1av\n\x0e\x41verageEntries\x12\x64\n\x07\x65ntries\x18\x01 \x03(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageEntry\x1a\x36\n\x10\x41verageAggregate\x12\x11\n\x05\x63ount\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x0f\n\x03sum\x18\x02 \x01(\x12\x42\x02\x30\x01\x1a\xfb\x01\n\x0e\x41verageResults\x12t\n\x11\x61ggregate_average\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageAggregateH\x00\x12h\n\x07\x65ntries\x18\x02 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageEntriesH\x00\x42\t\n\x07variant\x1az\n\x0bRankedEntry\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\x13\n\x05\x63ount\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x11\n\x03sum\x18\x03 \x01(\x12\x42\x02\x30\x01H\x00\x12\r\n\x03\x61vg\x18\x04 \x01(\x01H\x00\x12\x13\n\x06in_key\x18\x05 \x01(\x0cH\x01\x88\x01\x01\x42\x07\n\x05valueB\t\n\x07_in_key\x1a\x9a\x01\n\rRankedEntries\x12\x63\n\x07\x65ntries\x18\x01 \x03(\x0b\x32R.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.RankedEntry\x12\x18\n\x07skipped\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x42\n\n\x08_skipped\x1a\xf7\x05\n\nResultData\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.DocumentsH\x00\x12\x65\n\x06\x63ounts\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountResultsH\x00\x12\x61\n\x04sums\x18\x03 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.SumResultsH\x00\x12i\n\x08\x61verages\x18\x04 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.AverageResultsH\x00\x12\x66\n\x06ranked\x18\x05 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.RankedEntriesH\x00\x12j\n\x07\x63hained\x18\x06 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.ChainedDocumentsH\x00\x12n\n\tcomposite\x18\x07 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CompositeDocumentsH\x00\x42\t\n\x07variant\x1a_\n\x10\x43hainedDocuments\x12\x17\n\x0finner_documents\x18\x01 \x03(\x0c\x12\x17\n\x0fouter_documents\x18\x02 \x03(\x0c\x12\x19\n\x11missing_outer_ids\x18\x03 \x03(\x0c\x1a\xab\x03\n\x12\x43ompositeDocuments\x12\x16\n\x0epage_documents\x18\x01 \x03(\x0c\x12}\n\x0bsub_results\x18\x02 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CompositeDocuments.SubQueryResult\x1a\xfd\x01\n\x0eSubQueryResult\x12\x65\n\tdocuments\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.DocumentsH\x00\x12\x65\n\x06\x63ounts\x18\x02 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetDocumentsResponse.GetDocumentsResponseV1.CountEntriesH\x00\x12\x13\n\x0bmissing_ids\x18\x03 \x03(\x0c\x42\x08\n\x06resultB\x08\n\x06resultB\t\n\x07version\"\xf4\x02\n\x19GetDocumentHistoryRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetDocumentHistoryRequest.GetDocumentHistoryRequestV0H\x00\x1a\xeb\x01\n\x1bGetDocumentHistoryRequestV0\x12\x18\n\x10\x64\x61ta_contract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x13\n\x0b\x64ocument_id\x18\x03 \x01(\x0c\x12+\n\x05limit\x18\x04 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x05 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0bstart_at_ms\x18\x06 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x07 \x01(\x08\x42\t\n\x07version\"\xf7\x04\n\x1aGetDocumentHistoryResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0H\x00\x1a\xeb\x03\n\x1cGetDocumentHistoryResponseV0\x12~\n\x10\x64ocument_history\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0.DocumentHistoryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x37\n\x14\x44ocumentHistoryEntry\x12\x10\n\x04\x64\x61te\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05value\x18\x02 \x01(\x0c\x1a\x95\x01\n\x0f\x44ocumentHistory\x12\x81\x01\n\x10\x64ocument_entries\x18\x01 \x03(\x0b\x32g.org.dash.platform.dapi.v0.GetDocumentHistoryResponse.GetDocumentHistoryResponseV0.DocumentHistoryEntryB\x08\n\x06resultB\t\n\x07version\"\xed\x01\n!GetIdentityByPublicKeyHashRequest\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashRequest.GetIdentityByPublicKeyHashRequestV0H\x00\x1aM\n#GetIdentityByPublicKeyHashRequestV0\x12\x17\n\x0fpublic_key_hash\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xda\x02\n\"GetIdentityByPublicKeyHashResponse\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashResponse.GetIdentityByPublicKeyHashResponseV0H\x00\x1a\xb6\x01\n$GetIdentityByPublicKeyHashResponseV0\x12\x12\n\x08identity\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbd\x02\n*GetIdentityByNonUniquePublicKeyHashRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashRequest.GetIdentityByNonUniquePublicKeyHashRequestV0H\x00\x1a\x80\x01\n,GetIdentityByNonUniquePublicKeyHashRequestV0\x12\x17\n\x0fpublic_key_hash\x18\x01 \x01(\x0c\x12\x18\n\x0bstart_after\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\x0e\n\x0c_start_afterB\t\n\x07version\"\xd6\x06\n+GetIdentityByNonUniquePublicKeyHashResponse\x12\x82\x01\n\x02v0\x18\x01 \x01(\x0b\x32t.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0H\x00\x1a\x96\x05\n-GetIdentityByNonUniquePublicKeyHashResponseV0\x12\x9a\x01\n\x08identity\x18\x01 \x01(\x0b\x32\x85\x01.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0.IdentityResponseH\x00\x12\x9d\x01\n\x05proof\x18\x02 \x01(\x0b\x32\x8b\x01.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse.GetIdentityByNonUniquePublicKeyHashResponseV0.IdentityProvedResponseH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x36\n\x10IdentityResponse\x12\x15\n\x08identity\x18\x01 \x01(\x0cH\x00\x88\x01\x01\x42\x0b\n\t_identity\x1a\xa6\x01\n\x16IdentityProvedResponse\x12P\n&grovedb_identity_public_key_hash_proof\x18\x01 \x01(\x0b\x32 .org.dash.platform.dapi.v0.Proof\x12!\n\x14identity_proof_bytes\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x42\x17\n\x15_identity_proof_bytesB\x08\n\x06resultB\t\n\x07version\"\x99\x02\n#WaitForStateTransitionResultRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0H\x00\x1as\n%WaitForStateTransitionResultRequestV0\x12\x1d\n\x15state_transition_hash\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x12\x1c\n\x14request_user_balance\x18\x03 \x01(\x08\x42\t\n\x07version\"\xce\x04\n$WaitForStateTransitionResultResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0H\x00\x1a\x34\n\x17SuccessWithOwnerBalance\x12\x19\n\rowner_balance\x18\x01 \x01(\x04\x42\x02\x30\x01\x1a\xee\x02\n&WaitForStateTransitionResultResponseV0\x12I\n\x05\x65rror\x18\x01 \x01(\x0b\x32\x38.org.dash.platform.dapi.v0.StateTransitionBroadcastErrorH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12}\n\x1asuccess_with_owner_balance\x18\x04 \x01(\x0b\x32W.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalanceH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc4\x01\n\x19GetConsensusParamsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetConsensusParamsRequest.GetConsensusParamsRequestV0H\x00\x1a<\n\x1bGetConsensusParamsRequestV0\x12\x0e\n\x06height\x18\x01 \x01(\x05\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x9c\x04\n\x1aGetConsensusParamsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetConsensusParamsResponse.GetConsensusParamsResponseV0H\x00\x1aP\n\x14\x43onsensusParamsBlock\x12\x11\n\tmax_bytes\x18\x01 \x01(\t\x12\x0f\n\x07max_gas\x18\x02 \x01(\t\x12\x14\n\x0ctime_iota_ms\x18\x03 \x01(\t\x1a\x62\n\x17\x43onsensusParamsEvidence\x12\x1a\n\x12max_age_num_blocks\x18\x01 \x01(\t\x12\x18\n\x10max_age_duration\x18\x02 \x01(\t\x12\x11\n\tmax_bytes\x18\x03 \x01(\t\x1a\xda\x01\n\x1cGetConsensusParamsResponseV0\x12Y\n\x05\x62lock\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetConsensusParamsResponse.ConsensusParamsBlock\x12_\n\x08\x65vidence\x18\x02 \x01(\x0b\x32M.org.dash.platform.dapi.v0.GetConsensusParamsResponse.ConsensusParamsEvidenceB\t\n\x07version\"\xe4\x01\n%GetProtocolVersionUpgradeStateRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateRequest.GetProtocolVersionUpgradeStateRequestV0H\x00\x1a\x38\n\'GetProtocolVersionUpgradeStateRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xb5\x05\n&GetProtocolVersionUpgradeStateResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0H\x00\x1a\x85\x04\n(GetProtocolVersionUpgradeStateResponseV0\x12\x87\x01\n\x08versions\x18\x01 \x01(\x0b\x32s.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0.VersionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x96\x01\n\x08Versions\x12\x89\x01\n\x08versions\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse.GetProtocolVersionUpgradeStateResponseV0.VersionEntry\x1a:\n\x0cVersionEntry\x12\x16\n\x0eversion_number\x18\x01 \x01(\r\x12\x12\n\nvote_count\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xa3\x02\n*GetProtocolVersionUpgradeVoteStatusRequest\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusRequest.GetProtocolVersionUpgradeVoteStatusRequestV0H\x00\x1ag\n,GetProtocolVersionUpgradeVoteStatusRequestV0\x12\x19\n\x11start_pro_tx_hash\x18\x01 \x01(\x0c\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xef\x05\n+GetProtocolVersionUpgradeVoteStatusResponse\x12\x82\x01\n\x02v0\x18\x01 \x01(\x0b\x32t.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0H\x00\x1a\xaf\x04\n-GetProtocolVersionUpgradeVoteStatusResponseV0\x12\x98\x01\n\x08versions\x18\x01 \x01(\x0b\x32\x83\x01.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0.VersionSignalsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xaf\x01\n\x0eVersionSignals\x12\x9c\x01\n\x0fversion_signals\x18\x01 \x03(\x0b\x32\x82\x01.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse.GetProtocolVersionUpgradeVoteStatusResponseV0.VersionSignal\x1a\x35\n\rVersionSignal\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x0f\n\x07version\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xf5\x01\n\x14GetEpochsInfoRequest\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetEpochsInfoRequest.GetEpochsInfoRequestV0H\x00\x1a|\n\x16GetEpochsInfoRequestV0\x12\x31\n\x0bstart_epoch\x18\x01 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\x11\n\tascending\x18\x03 \x01(\x08\x12\r\n\x05prove\x18\x04 \x01(\x08\x42\t\n\x07version\"\x99\x05\n\x15GetEpochsInfoResponse\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0H\x00\x1a\x9c\x04\n\x17GetEpochsInfoResponseV0\x12\x65\n\x06\x65pochs\x18\x01 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0.EpochInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1au\n\nEpochInfos\x12g\n\x0b\x65poch_infos\x18\x01 \x03(\x0b\x32R.org.dash.platform.dapi.v0.GetEpochsInfoResponse.GetEpochsInfoResponseV0.EpochInfo\x1a\xa6\x01\n\tEpochInfo\x12\x0e\n\x06number\x18\x01 \x01(\r\x12\x1e\n\x12\x66irst_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x1f\n\x17\x66irst_core_block_height\x18\x03 \x01(\r\x12\x16\n\nstart_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x0e\x66\x65\x65_multiplier\x18\x05 \x01(\x01\x12\x18\n\x10protocol_version\x18\x06 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xbf\x02\n\x1dGetFinalizedEpochInfosRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetFinalizedEpochInfosRequest.GetFinalizedEpochInfosRequestV0H\x00\x1a\xaa\x01\n\x1fGetFinalizedEpochInfosRequestV0\x12\x19\n\x11start_epoch_index\x18\x01 \x01(\r\x12\"\n\x1astart_epoch_index_included\x18\x02 \x01(\x08\x12\x17\n\x0f\x65nd_epoch_index\x18\x03 \x01(\r\x12 \n\x18\x65nd_epoch_index_included\x18\x04 \x01(\x08\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\t\n\x07version\"\xbd\t\n\x1eGetFinalizedEpochInfosResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0H\x00\x1a\xa5\x08\n GetFinalizedEpochInfosResponseV0\x12\x80\x01\n\x06\x65pochs\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.FinalizedEpochInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xa4\x01\n\x13\x46inalizedEpochInfos\x12\x8c\x01\n\x15\x66inalized_epoch_infos\x18\x01 \x03(\x0b\x32m.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.FinalizedEpochInfo\x1a\x9f\x04\n\x12\x46inalizedEpochInfo\x12\x0e\n\x06number\x18\x01 \x01(\r\x12\x1e\n\x12\x66irst_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x1f\n\x17\x66irst_core_block_height\x18\x03 \x01(\r\x12\x1c\n\x10\x66irst_block_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x0e\x66\x65\x65_multiplier\x18\x05 \x01(\x01\x12\x18\n\x10protocol_version\x18\x06 \x01(\r\x12!\n\x15total_blocks_in_epoch\x18\x07 \x01(\x04\x42\x02\x30\x01\x12*\n\"next_epoch_start_core_block_height\x18\x08 \x01(\r\x12!\n\x15total_processing_fees\x18\t \x01(\x04\x42\x02\x30\x01\x12*\n\x1etotal_distributed_storage_fees\x18\n \x01(\x04\x42\x02\x30\x01\x12&\n\x1atotal_created_storage_fees\x18\x0b \x01(\x04\x42\x02\x30\x01\x12\x1e\n\x12\x63ore_block_rewards\x18\x0c \x01(\x04\x42\x02\x30\x01\x12\x81\x01\n\x0f\x62lock_proposers\x18\r \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse.GetFinalizedEpochInfosResponseV0.BlockProposer\x1a\x39\n\rBlockProposer\x12\x13\n\x0bproposer_id\x18\x01 \x01(\x0c\x12\x13\n\x0b\x62lock_count\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xde\x04\n\x1cGetContestedResourcesRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetContestedResourcesRequest.GetContestedResourcesRequestV0H\x00\x1a\xcc\x03\n\x1eGetContestedResourcesRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x1a\n\x12start_index_values\x18\x04 \x03(\x0c\x12\x18\n\x10\x65nd_index_values\x18\x05 \x03(\x0c\x12\x89\x01\n\x13start_at_value_info\x18\x06 \x01(\x0b\x32g.org.dash.platform.dapi.v0.GetContestedResourcesRequest.GetContestedResourcesRequestV0.StartAtValueInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x07 \x01(\rH\x01\x88\x01\x01\x12\x17\n\x0forder_ascending\x18\x08 \x01(\x08\x12\r\n\x05prove\x18\t \x01(\x08\x1a\x45\n\x10StartAtValueInfo\x12\x13\n\x0bstart_value\x18\x01 \x01(\x0c\x12\x1c\n\x14start_value_included\x18\x02 \x01(\x08\x42\x16\n\x14_start_at_value_infoB\x08\n\x06_countB\t\n\x07version\"\x88\x04\n\x1dGetContestedResourcesResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetContestedResourcesResponse.GetContestedResourcesResponseV0H\x00\x1a\xf3\x02\n\x1fGetContestedResourcesResponseV0\x12\x95\x01\n\x19\x63ontested_resource_values\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetContestedResourcesResponse.GetContestedResourcesResponseV0.ContestedResourceValuesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a<\n\x17\x43ontestedResourceValues\x12!\n\x19\x63ontested_resource_values\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xd2\x05\n\x1cGetVotePollsByEndDateRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0H\x00\x1a\xc0\x04\n\x1eGetVotePollsByEndDateRequestV0\x12\x84\x01\n\x0fstart_time_info\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0.StartAtTimeInfoH\x00\x88\x01\x01\x12\x80\x01\n\rend_time_info\x18\x02 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest.GetVotePollsByEndDateRequestV0.EndAtTimeInfoH\x01\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\rH\x02\x88\x01\x01\x12\x13\n\x06offset\x18\x04 \x01(\rH\x03\x88\x01\x01\x12\x11\n\tascending\x18\x05 \x01(\x08\x12\r\n\x05prove\x18\x06 \x01(\x08\x1aI\n\x0fStartAtTimeInfo\x12\x19\n\rstart_time_ms\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x13start_time_included\x18\x02 \x01(\x08\x1a\x43\n\rEndAtTimeInfo\x12\x17\n\x0b\x65nd_time_ms\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x19\n\x11\x65nd_time_included\x18\x02 \x01(\x08\x42\x12\n\x10_start_time_infoB\x10\n\x0e_end_time_infoB\x08\n\x06_limitB\t\n\x07_offsetB\t\n\x07version\"\x83\x06\n\x1dGetVotePollsByEndDateResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0H\x00\x1a\xee\x04\n\x1fGetVotePollsByEndDateResponseV0\x12\x9c\x01\n\x18vote_polls_by_timestamps\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0.SerializedVotePollsByTimestampsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aV\n\x1eSerializedVotePollsByTimestamp\x12\x15\n\ttimestamp\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1d\n\x15serialized_vote_polls\x18\x02 \x03(\x0c\x1a\xd7\x01\n\x1fSerializedVotePollsByTimestamps\x12\x99\x01\n\x18vote_polls_by_timestamps\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse.GetVotePollsByEndDateResponseV0.SerializedVotePollsByTimestamp\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x42\x08\n\x06resultB\t\n\x07version\"\xff\x06\n$GetContestedResourceVoteStateRequest\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0H\x00\x1a\xd5\x05\n&GetContestedResourceVoteStateRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x14\n\x0cindex_values\x18\x04 \x03(\x0c\x12\x86\x01\n\x0bresult_type\x18\x05 \x01(\x0e\x32q.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0.ResultType\x12\x36\n.allow_include_locked_and_abstaining_vote_tally\x18\x06 \x01(\x08\x12\xa3\x01\n\x18start_at_identifier_info\x18\x07 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest.GetContestedResourceVoteStateRequestV0.StartAtIdentifierInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x08 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\t \x01(\x08\x1aT\n\x15StartAtIdentifierInfo\x12\x18\n\x10start_identifier\x18\x01 \x01(\x0c\x12!\n\x19start_identifier_included\x18\x02 \x01(\x08\"I\n\nResultType\x12\r\n\tDOCUMENTS\x10\x00\x12\x0e\n\nVOTE_TALLY\x10\x01\x12\x1c\n\x18\x44OCUMENTS_AND_VOTE_TALLY\x10\x02\x42\x1b\n\x19_start_at_identifier_infoB\x08\n\x06_countB\t\n\x07version\"\x94\x0c\n%GetContestedResourceVoteStateResponse\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0H\x00\x1a\xe7\n\n\'GetContestedResourceVoteStateResponseV0\x12\xae\x01\n\x1d\x63ontested_resource_contenders\x18\x01 \x01(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.ContestedResourceContendersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xda\x03\n\x10\x46inishedVoteInfo\x12\xad\x01\n\x15\x66inished_vote_outcome\x18\x01 \x01(\x0e\x32\x8d\x01.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.FinishedVoteInfo.FinishedVoteOutcome\x12\x1f\n\x12won_by_identity_id\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12$\n\x18\x66inished_at_block_height\x18\x03 \x01(\x04\x42\x02\x30\x01\x12%\n\x1d\x66inished_at_core_block_height\x18\x04 \x01(\r\x12%\n\x19\x66inished_at_block_time_ms\x18\x05 \x01(\x04\x42\x02\x30\x01\x12\x19\n\x11\x66inished_at_epoch\x18\x06 \x01(\r\"O\n\x13\x46inishedVoteOutcome\x12\x14\n\x10TOWARDS_IDENTITY\x10\x00\x12\n\n\x06LOCKED\x10\x01\x12\x16\n\x12NO_PREVIOUS_WINNER\x10\x02\x42\x15\n\x13_won_by_identity_id\x1a\xc4\x03\n\x1b\x43ontestedResourceContenders\x12\x86\x01\n\ncontenders\x18\x01 \x03(\x0b\x32r.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.Contender\x12\x1f\n\x12\x61\x62stain_vote_tally\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x1c\n\x0flock_vote_tally\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\x9a\x01\n\x12\x66inished_vote_info\x18\x04 \x01(\x0b\x32y.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse.GetContestedResourceVoteStateResponseV0.FinishedVoteInfoH\x02\x88\x01\x01\x42\x15\n\x13_abstain_vote_tallyB\x12\n\x10_lock_vote_tallyB\x15\n\x13_finished_vote_info\x1ak\n\tContender\x12\x12\n\nidentifier\x18\x01 \x01(\x0c\x12\x17\n\nvote_count\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08\x64ocument\x18\x03 \x01(\x0cH\x01\x88\x01\x01\x42\r\n\x0b_vote_countB\x0b\n\t_documentB\x08\n\x06resultB\t\n\x07version\"\xd5\x05\n,GetContestedResourceVotersForIdentityRequest\x12\x84\x01\n\x02v0\x18\x01 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest.GetContestedResourceVotersForIdentityRequestV0H\x00\x1a\x92\x04\n.GetContestedResourceVotersForIdentityRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\x12\n\nindex_name\x18\x03 \x01(\t\x12\x14\n\x0cindex_values\x18\x04 \x03(\x0c\x12\x15\n\rcontestant_id\x18\x05 \x01(\x0c\x12\xb4\x01\n\x18start_at_identifier_info\x18\x06 \x01(\x0b\x32\x8c\x01.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest.GetContestedResourceVotersForIdentityRequestV0.StartAtIdentifierInfoH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x07 \x01(\rH\x01\x88\x01\x01\x12\x17\n\x0forder_ascending\x18\x08 \x01(\x08\x12\r\n\x05prove\x18\t \x01(\x08\x1aT\n\x15StartAtIdentifierInfo\x12\x18\n\x10start_identifier\x18\x01 \x01(\x0c\x12!\n\x19start_identifier_included\x18\x02 \x01(\x08\x42\x1b\n\x19_start_at_identifier_infoB\x08\n\x06_countB\t\n\x07version\"\xf1\x04\n-GetContestedResourceVotersForIdentityResponse\x12\x86\x01\n\x02v0\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse.GetContestedResourceVotersForIdentityResponseV0H\x00\x1a\xab\x03\n/GetContestedResourceVotersForIdentityResponseV0\x12\xb6\x01\n\x19\x63ontested_resource_voters\x18\x01 \x01(\x0b\x32\x90\x01.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse.GetContestedResourceVotersForIdentityResponseV0.ContestedResourceVotersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x43\n\x17\x43ontestedResourceVoters\x12\x0e\n\x06voters\x18\x01 \x03(\x0c\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x42\x08\n\x06resultB\t\n\x07version\"\xad\x05\n(GetContestedResourceIdentityVotesRequest\x12|\n\x02v0\x18\x01 \x01(\x0b\x32n.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest.GetContestedResourceIdentityVotesRequestV0H\x00\x1a\xf7\x03\n*GetContestedResourceIdentityVotesRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12+\n\x05limit\x18\x02 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12,\n\x06offset\x18\x03 \x01(\x0b\x32\x1c.google.protobuf.UInt32Value\x12\x17\n\x0forder_ascending\x18\x04 \x01(\x08\x12\xae\x01\n\x1astart_at_vote_poll_id_info\x18\x05 \x01(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest.GetContestedResourceIdentityVotesRequestV0.StartAtVotePollIdInfoH\x00\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x1a\x61\n\x15StartAtVotePollIdInfo\x12 \n\x18start_at_poll_identifier\x18\x01 \x01(\x0c\x12&\n\x1estart_poll_identifier_included\x18\x02 \x01(\x08\x42\x1d\n\x1b_start_at_vote_poll_id_infoB\t\n\x07version\"\xc8\n\n)GetContestedResourceIdentityVotesResponse\x12~\n\x02v0\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0H\x00\x1a\x8f\t\n+GetContestedResourceIdentityVotesResponseV0\x12\xa1\x01\n\x05votes\x18\x01 \x01(\x0b\x32\x8f\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ContestedResourceIdentityVotesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\xf7\x01\n\x1e\x43ontestedResourceIdentityVotes\x12\xba\x01\n!contested_resource_identity_votes\x18\x01 \x03(\x0b\x32\x8e\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ContestedResourceIdentityVote\x12\x18\n\x10\x66inished_results\x18\x02 \x01(\x08\x1a\xad\x02\n\x12ResourceVoteChoice\x12\xad\x01\n\x10vote_choice_type\x18\x01 \x01(\x0e\x32\x92\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ResourceVoteChoice.VoteChoiceType\x12\x18\n\x0bidentity_id\x18\x02 \x01(\x0cH\x00\x88\x01\x01\"=\n\x0eVoteChoiceType\x12\x14\n\x10TOWARDS_IDENTITY\x10\x00\x12\x0b\n\x07\x41\x42STAIN\x10\x01\x12\x08\n\x04LOCK\x10\x02\x42\x0e\n\x0c_identity_id\x1a\x95\x02\n\x1d\x43ontestedResourceIdentityVote\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1a\n\x12\x64ocument_type_name\x18\x02 \x01(\t\x12\'\n\x1fserialized_index_storage_values\x18\x03 \x03(\x0c\x12\x99\x01\n\x0bvote_choice\x18\x04 \x01(\x0b\x32\x83\x01.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse.GetContestedResourceIdentityVotesResponseV0.ResourceVoteChoiceB\x08\n\x06resultB\t\n\x07version\"\xf0\x01\n%GetPrefundedSpecializedBalanceRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceRequest.GetPrefundedSpecializedBalanceRequestV0H\x00\x1a\x44\n\'GetPrefundedSpecializedBalanceRequestV0\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xed\x02\n&GetPrefundedSpecializedBalanceResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceResponse.GetPrefundedSpecializedBalanceResponseV0H\x00\x1a\xbd\x01\n(GetPrefundedSpecializedBalanceResponseV0\x12\x15\n\x07\x62\x61lance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xd0\x01\n GetTotalCreditsInPlatformRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformRequest.GetTotalCreditsInPlatformRequestV0H\x00\x1a\x33\n\"GetTotalCreditsInPlatformRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xd9\x02\n!GetTotalCreditsInPlatformResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformResponse.GetTotalCreditsInPlatformResponseV0H\x00\x1a\xb8\x01\n#GetTotalCreditsInPlatformResponseV0\x12\x15\n\x07\x63redits\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc4\x01\n\x16GetPathElementsRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetPathElementsRequest.GetPathElementsRequestV0H\x00\x1a\x45\n\x18GetPathElementsRequestV0\x12\x0c\n\x04path\x18\x01 \x03(\x0c\x12\x0c\n\x04keys\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xa3\x03\n\x17GetPathElementsResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetPathElementsResponse.GetPathElementsResponseV0H\x00\x1a\xa0\x02\n\x19GetPathElementsResponseV0\x12i\n\x08\x65lements\x18\x01 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetPathElementsResponse.GetPathElementsResponseV0.ElementsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1c\n\x08\x45lements\x12\x10\n\x08\x65lements\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\x81\x01\n\x10GetStatusRequest\x12L\n\x02v0\x18\x01 \x01(\x0b\x32>.org.dash.platform.dapi.v0.GetStatusRequest.GetStatusRequestV0H\x00\x1a\x14\n\x12GetStatusRequestV0B\t\n\x07version\"\xe4\x10\n\x11GetStatusResponse\x12N\n\x02v0\x18\x01 \x01(\x0b\x32@.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0H\x00\x1a\xf3\x0f\n\x13GetStatusResponseV0\x12Y\n\x07version\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version\x12S\n\x04node\x18\x02 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Node\x12U\n\x05\x63hain\x18\x03 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Chain\x12Y\n\x07network\x18\x04 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Network\x12^\n\nstate_sync\x18\x05 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.StateSync\x12S\n\x04time\x18\x06 \x01(\x0b\x32\x45.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Time\x1a\x82\x05\n\x07Version\x12\x63\n\x08software\x18\x01 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Software\x12\x63\n\x08protocol\x18\x02 \x01(\x0b\x32Q.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol\x1a^\n\x08Software\x12\x0c\n\x04\x64\x61pi\x18\x01 \x01(\t\x12\x12\n\x05\x64rive\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x17\n\ntenderdash\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x08\n\x06_driveB\r\n\x0b_tenderdash\x1a\xcc\x02\n\x08Protocol\x12p\n\ntenderdash\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol.Tenderdash\x12\x66\n\x05\x64rive\x18\x02 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetStatusResponse.GetStatusResponseV0.Version.Protocol.Drive\x1a(\n\nTenderdash\x12\x0b\n\x03p2p\x18\x01 \x01(\r\x12\r\n\x05\x62lock\x18\x02 \x01(\r\x1a<\n\x05\x44rive\x12\x0e\n\x06latest\x18\x03 \x01(\r\x12\x0f\n\x07\x63urrent\x18\x04 \x01(\r\x12\x12\n\nnext_epoch\x18\x05 \x01(\r\x1a\x7f\n\x04Time\x12\x11\n\x05local\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x16\n\x05\x62lock\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x88\x01\x01\x12\x18\n\x07genesis\x18\x03 \x01(\x04\x42\x02\x30\x01H\x01\x88\x01\x01\x12\x12\n\x05\x65poch\x18\x04 \x01(\rH\x02\x88\x01\x01\x42\x08\n\x06_blockB\n\n\x08_genesisB\x08\n\x06_epoch\x1a<\n\x04Node\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\x18\n\x0bpro_tx_hash\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x42\x0e\n\x0c_pro_tx_hash\x1a\xb3\x02\n\x05\x43hain\x12\x13\n\x0b\x63\x61tching_up\x18\x01 \x01(\x08\x12\x19\n\x11latest_block_hash\x18\x02 \x01(\x0c\x12\x17\n\x0flatest_app_hash\x18\x03 \x01(\x0c\x12\x1f\n\x13latest_block_height\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x13\x65\x61rliest_block_hash\x18\x05 \x01(\x0c\x12\x19\n\x11\x65\x61rliest_app_hash\x18\x06 \x01(\x0c\x12!\n\x15\x65\x61rliest_block_height\x18\x07 \x01(\x04\x42\x02\x30\x01\x12!\n\x15max_peer_block_height\x18\t \x01(\x04\x42\x02\x30\x01\x12%\n\x18\x63ore_chain_locked_height\x18\n \x01(\rH\x00\x88\x01\x01\x42\x1b\n\x19_core_chain_locked_height\x1a\x43\n\x07Network\x12\x10\n\x08\x63hain_id\x18\x01 \x01(\t\x12\x13\n\x0bpeers_count\x18\x02 \x01(\r\x12\x11\n\tlistening\x18\x03 \x01(\x08\x1a\x85\x02\n\tStateSync\x12\x1d\n\x11total_synced_time\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1a\n\x0eremaining_time\x18\x02 \x01(\x04\x42\x02\x30\x01\x12\x17\n\x0ftotal_snapshots\x18\x03 \x01(\r\x12\"\n\x16\x63hunk_process_avg_time\x18\x04 \x01(\x04\x42\x02\x30\x01\x12\x1b\n\x0fsnapshot_height\x18\x05 \x01(\x04\x42\x02\x30\x01\x12!\n\x15snapshot_chunks_count\x18\x06 \x01(\x04\x42\x02\x30\x01\x12\x1d\n\x11\x62\x61\x63kfilled_blocks\x18\x07 \x01(\x04\x42\x02\x30\x01\x12!\n\x15\x62\x61\x63kfill_blocks_total\x18\x08 \x01(\x04\x42\x02\x30\x01\x42\t\n\x07version\"\xb1\x01\n\x1cGetCurrentQuorumsInfoRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoRequest.GetCurrentQuorumsInfoRequestV0H\x00\x1a \n\x1eGetCurrentQuorumsInfoRequestV0B\t\n\x07version\"\xa1\x05\n\x1dGetCurrentQuorumsInfoResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.GetCurrentQuorumsInfoResponseV0H\x00\x1a\x46\n\x0bValidatorV0\x12\x13\n\x0bpro_tx_hash\x18\x01 \x01(\x0c\x12\x0f\n\x07node_ip\x18\x02 \x01(\t\x12\x11\n\tis_banned\x18\x03 \x01(\x08\x1a\xaf\x01\n\x0eValidatorSetV0\x12\x13\n\x0bquorum_hash\x18\x01 \x01(\x0c\x12\x13\n\x0b\x63ore_height\x18\x02 \x01(\r\x12U\n\x07members\x18\x03 \x03(\x0b\x32\x44.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.ValidatorV0\x12\x1c\n\x14threshold_public_key\x18\x04 \x01(\x0c\x1a\x92\x02\n\x1fGetCurrentQuorumsInfoResponseV0\x12\x15\n\rquorum_hashes\x18\x01 \x03(\x0c\x12\x1b\n\x13\x63urrent_quorum_hash\x18\x02 \x01(\x0c\x12_\n\x0evalidator_sets\x18\x03 \x03(\x0b\x32G.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse.ValidatorSetV0\x12\x1b\n\x13last_block_proposer\x18\x04 \x01(\x0c\x12=\n\x08metadata\x18\x05 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\t\n\x07version\"\xf4\x01\n\x1fGetIdentityTokenBalancesRequest\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentityTokenBalancesRequest.GetIdentityTokenBalancesRequestV0H\x00\x1aZ\n!GetIdentityTokenBalancesRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x11\n\ttoken_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xad\x05\n GetIdentityTokenBalancesResponse\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0H\x00\x1a\x8f\x04\n\"GetIdentityTokenBalancesResponseV0\x12\x86\x01\n\x0etoken_balances\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0.TokenBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aG\n\x11TokenBalanceEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x07\x62\x61lance\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\x9a\x01\n\rTokenBalances\x12\x88\x01\n\x0etoken_balances\x18\x01 \x03(\x0b\x32p.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse.GetIdentityTokenBalancesResponseV0.TokenBalanceEntryB\x08\n\x06resultB\t\n\x07version\"\xfc\x01\n!GetIdentitiesTokenBalancesRequest\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesRequest.GetIdentitiesTokenBalancesRequestV0H\x00\x1a\\\n#GetIdentitiesTokenBalancesRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x0cidentity_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xf2\x05\n\"GetIdentitiesTokenBalancesResponse\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0H\x00\x1a\xce\x04\n$GetIdentitiesTokenBalancesResponseV0\x12\x9b\x01\n\x17identity_token_balances\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0.IdentityTokenBalancesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aR\n\x19IdentityTokenBalanceEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x14\n\x07\x62\x61lance\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\n\n\x08_balance\x1a\xb7\x01\n\x15IdentityTokenBalances\x12\x9d\x01\n\x17identity_token_balances\x18\x01 \x03(\x0b\x32|.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse.GetIdentitiesTokenBalancesResponseV0.IdentityTokenBalanceEntryB\x08\n\x06resultB\t\n\x07version\"\xe8\x01\n\x1cGetIdentityTokenInfosRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetIdentityTokenInfosRequest.GetIdentityTokenInfosRequestV0H\x00\x1aW\n\x1eGetIdentityTokenInfosRequestV0\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x11\n\ttoken_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\x98\x06\n\x1dGetIdentityTokenInfosResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0H\x00\x1a\x83\x05\n\x1fGetIdentityTokenInfosResponseV0\x12z\n\x0btoken_infos\x18\x01 \x01(\x0b\x32\x63.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a(\n\x16TokenIdentityInfoEntry\x12\x0e\n\x06\x66rozen\x18\x01 \x01(\x08\x1a\xb0\x01\n\x0eTokenInfoEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x82\x01\n\x04info\x18\x02 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenIdentityInfoEntryH\x00\x88\x01\x01\x42\x07\n\x05_info\x1a\x8a\x01\n\nTokenInfos\x12|\n\x0btoken_infos\x18\x01 \x03(\x0b\x32g.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse.GetIdentityTokenInfosResponseV0.TokenInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xf0\x01\n\x1eGetIdentitiesTokenInfosRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosRequest.GetIdentitiesTokenInfosRequestV0H\x00\x1aY\n GetIdentitiesTokenInfosRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x14\n\x0cidentity_ids\x18\x02 \x03(\x0c\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xca\x06\n\x1fGetIdentitiesTokenInfosResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0H\x00\x1a\xaf\x05\n!GetIdentitiesTokenInfosResponseV0\x12\x8f\x01\n\x14identity_token_infos\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.IdentityTokenInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a(\n\x16TokenIdentityInfoEntry\x12\x0e\n\x06\x66rozen\x18\x01 \x01(\x08\x1a\xb7\x01\n\x0eTokenInfoEntry\x12\x13\n\x0bidentity_id\x18\x01 \x01(\x0c\x12\x86\x01\n\x04info\x18\x02 \x01(\x0b\x32s.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.TokenIdentityInfoEntryH\x00\x88\x01\x01\x42\x07\n\x05_info\x1a\x97\x01\n\x12IdentityTokenInfos\x12\x80\x01\n\x0btoken_infos\x18\x01 \x03(\x0b\x32k.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse.GetIdentitiesTokenInfosResponseV0.TokenInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xbf\x01\n\x17GetTokenStatusesRequest\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetTokenStatusesRequest.GetTokenStatusesRequestV0H\x00\x1a=\n\x19GetTokenStatusesRequestV0\x12\x11\n\ttoken_ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xe7\x04\n\x18GetTokenStatusesResponse\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0H\x00\x1a\xe1\x03\n\x1aGetTokenStatusesResponseV0\x12v\n\x0etoken_statuses\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0.TokenStatusesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x44\n\x10TokenStatusEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x13\n\x06paused\x18\x02 \x01(\x08H\x00\x88\x01\x01\x42\t\n\x07_paused\x1a\x88\x01\n\rTokenStatuses\x12w\n\x0etoken_statuses\x18\x01 \x03(\x0b\x32_.org.dash.platform.dapi.v0.GetTokenStatusesResponse.GetTokenStatusesResponseV0.TokenStatusEntryB\x08\n\x06resultB\t\n\x07version\"\xef\x01\n#GetTokenDirectPurchasePricesRequest\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesRequest.GetTokenDirectPurchasePricesRequestV0H\x00\x1aI\n%GetTokenDirectPurchasePricesRequestV0\x12\x11\n\ttoken_ids\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x8b\t\n$GetTokenDirectPurchasePricesResponse\x12t\n\x02v0\x18\x01 \x01(\x0b\x32\x66.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0H\x00\x1a\xe1\x07\n&GetTokenDirectPurchasePricesResponseV0\x12\xa9\x01\n\x1ctoken_direct_purchase_prices\x18\x01 \x01(\x0b\x32\x80\x01.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.TokenDirectPurchasePricesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x33\n\x10PriceForQuantity\x12\x10\n\x08quantity\x18\x01 \x01(\x04\x12\r\n\x05price\x18\x02 \x01(\x04\x1a\xa7\x01\n\x0fPricingSchedule\x12\x93\x01\n\x12price_for_quantity\x18\x01 \x03(\x0b\x32w.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.PriceForQuantity\x1a\xe4\x01\n\x1dTokenDirectPurchasePriceEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x15\n\x0b\x66ixed_price\x18\x02 \x01(\x04H\x00\x12\x90\x01\n\x0evariable_price\x18\x03 \x01(\x0b\x32v.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.PricingScheduleH\x00\x42\x07\n\x05price\x1a\xc8\x01\n\x19TokenDirectPurchasePrices\x12\xaa\x01\n\x1btoken_direct_purchase_price\x18\x01 \x03(\x0b\x32\x84\x01.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse.GetTokenDirectPurchasePricesResponseV0.TokenDirectPurchasePriceEntryB\x08\n\x06resultB\t\n\x07version\"\xce\x01\n\x1bGetTokenContractInfoRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetTokenContractInfoRequest.GetTokenContractInfoRequestV0H\x00\x1a@\n\x1dGetTokenContractInfoRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xfb\x03\n\x1cGetTokenContractInfoResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetTokenContractInfoResponse.GetTokenContractInfoResponseV0H\x00\x1a\xe9\x02\n\x1eGetTokenContractInfoResponseV0\x12|\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32l.org.dash.platform.dapi.v0.GetTokenContractInfoResponse.GetTokenContractInfoResponseV0.TokenContractInfoDataH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aM\n\x15TokenContractInfoData\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17token_contract_position\x18\x02 \x01(\rB\x08\n\x06resultB\t\n\x07version\"\xef\x04\n)GetTokenPreProgrammedDistributionsRequest\x12~\n\x02v0\x18\x01 \x01(\x0b\x32p.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest.GetTokenPreProgrammedDistributionsRequestV0H\x00\x1a\xb6\x03\n+GetTokenPreProgrammedDistributionsRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x98\x01\n\rstart_at_info\x18\x02 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest.GetTokenPreProgrammedDistributionsRequestV0.StartAtInfoH\x00\x88\x01\x01\x12\x12\n\x05limit\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x04 \x01(\x08\x1a\x9a\x01\n\x0bStartAtInfo\x12\x15\n\rstart_time_ms\x18\x01 \x01(\x04\x12\x1c\n\x0fstart_recipient\x18\x02 \x01(\x0cH\x00\x88\x01\x01\x12%\n\x18start_recipient_included\x18\x03 \x01(\x08H\x01\x88\x01\x01\x42\x12\n\x10_start_recipientB\x1b\n\x19_start_recipient_includedB\x10\n\x0e_start_at_infoB\x08\n\x06_limitB\t\n\x07version\"\xec\x07\n*GetTokenPreProgrammedDistributionsResponse\x12\x80\x01\n\x02v0\x18\x01 \x01(\x0b\x32r.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0H\x00\x1a\xaf\x06\n,GetTokenPreProgrammedDistributionsResponseV0\x12\xa5\x01\n\x13token_distributions\x18\x01 \x01(\x0b\x32\x85\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenDistributionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a>\n\x16TokenDistributionEntry\x12\x14\n\x0crecipient_id\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x1a\xd4\x01\n\x1bTokenTimedDistributionEntry\x12\x11\n\ttimestamp\x18\x01 \x01(\x04\x12\xa1\x01\n\rdistributions\x18\x02 \x03(\x0b\x32\x89\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenDistributionEntry\x1a\xc3\x01\n\x12TokenDistributions\x12\xac\x01\n\x13token_distributions\x18\x01 \x03(\x0b\x32\x8e\x01.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse.GetTokenPreProgrammedDistributionsResponseV0.TokenTimedDistributionEntryB\x08\n\x06resultB\t\n\x07version\"\x82\x04\n-GetTokenPerpetualDistributionLastClaimRequest\x12\x86\x01\n\x02v0\x18\x01 \x01(\x0b\x32x.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest.GetTokenPerpetualDistributionLastClaimRequestV0H\x00\x1aI\n\x11\x43ontractTokenInfo\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17token_contract_position\x18\x02 \x01(\r\x1a\xf1\x01\n/GetTokenPerpetualDistributionLastClaimRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12v\n\rcontract_info\x18\x02 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest.ContractTokenInfoH\x00\x88\x01\x01\x12\x13\n\x0bidentity_id\x18\x04 \x01(\x0c\x12\r\n\x05prove\x18\x05 \x01(\x08\x42\x10\n\x0e_contract_infoB\t\n\x07version\"\x93\x05\n.GetTokenPerpetualDistributionLastClaimResponse\x12\x88\x01\n\x02v0\x18\x01 \x01(\x0b\x32z.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse.GetTokenPerpetualDistributionLastClaimResponseV0H\x00\x1a\xca\x03\n0GetTokenPerpetualDistributionLastClaimResponseV0\x12\x9f\x01\n\nlast_claim\x18\x01 \x01(\x0b\x32\x88\x01.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse.GetTokenPerpetualDistributionLastClaimResponseV0.LastClaimInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1ax\n\rLastClaimInfo\x12\x1a\n\x0ctimestamp_ms\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x1a\n\x0c\x62lock_height\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x0f\n\x05\x65poch\x18\x03 \x01(\rH\x00\x12\x13\n\traw_bytes\x18\x04 \x01(\x0cH\x00\x42\t\n\x07paid_atB\x08\n\x06resultB\t\n\x07version\"\xca\x01\n\x1aGetTokenTotalSupplyRequest\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetTokenTotalSupplyRequest.GetTokenTotalSupplyRequestV0H\x00\x1a?\n\x1cGetTokenTotalSupplyRequestV0\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xaf\x04\n\x1bGetTokenTotalSupplyResponse\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse.GetTokenTotalSupplyResponseV0H\x00\x1a\xa0\x03\n\x1dGetTokenTotalSupplyResponseV0\x12\x88\x01\n\x12token_total_supply\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse.GetTokenTotalSupplyResponseV0.TokenTotalSupplyEntryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1ax\n\x15TokenTotalSupplyEntry\x12\x10\n\x08token_id\x18\x01 \x01(\x0c\x12\x30\n(total_aggregated_amount_in_user_accounts\x18\x02 \x01(\x04\x12\x1b\n\x13total_system_amount\x18\x03 \x01(\x04\x42\x08\n\x06resultB\t\n\x07version\"\xd2\x01\n\x13GetGroupInfoRequest\x12R\n\x02v0\x18\x01 \x01(\x0b\x32\x44.org.dash.platform.dapi.v0.GetGroupInfoRequest.GetGroupInfoRequestV0H\x00\x1a\\\n\x15GetGroupInfoRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xd4\x05\n\x14GetGroupInfoResponse\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0H\x00\x1a\xda\x04\n\x16GetGroupInfoResponseV0\x12\x66\n\ngroup_info\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupInfoH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x04 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x34\n\x10GroupMemberEntry\x12\x11\n\tmember_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\x98\x01\n\x0eGroupInfoEntry\x12h\n\x07members\x18\x01 \x03(\x0b\x32W.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupMemberEntry\x12\x1c\n\x14group_required_power\x18\x02 \x01(\r\x1a\x8a\x01\n\tGroupInfo\x12n\n\ngroup_info\x18\x01 \x01(\x0b\x32U.org.dash.platform.dapi.v0.GetGroupInfoResponse.GetGroupInfoResponseV0.GroupInfoEntryH\x00\x88\x01\x01\x42\r\n\x0b_group_infoB\x08\n\x06resultB\t\n\x07version\"\xed\x03\n\x14GetGroupInfosRequest\x12T\n\x02v0\x18\x01 \x01(\x0b\x32\x46.org.dash.platform.dapi.v0.GetGroupInfosRequest.GetGroupInfosRequestV0H\x00\x1au\n\x1cStartAtGroupContractPosition\x12%\n\x1dstart_group_contract_position\x18\x01 \x01(\r\x12.\n&start_group_contract_position_included\x18\x02 \x01(\x08\x1a\xfc\x01\n\x16GetGroupInfosRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12{\n start_at_group_contract_position\x18\x02 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetGroupInfosRequest.StartAtGroupContractPositionH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x03 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x04 \x01(\x08\x42#\n!_start_at_group_contract_positionB\x08\n\x06_countB\t\n\x07version\"\xff\x05\n\x15GetGroupInfosResponse\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0H\x00\x1a\x82\x05\n\x17GetGroupInfosResponseV0\x12j\n\x0bgroup_infos\x18\x01 \x01(\x0b\x32S.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupInfosH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x04 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x34\n\x10GroupMemberEntry\x12\x11\n\tmember_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\xc3\x01\n\x16GroupPositionInfoEntry\x12\x1f\n\x17group_contract_position\x18\x01 \x01(\r\x12j\n\x07members\x18\x02 \x03(\x0b\x32Y.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupMemberEntry\x12\x1c\n\x14group_required_power\x18\x03 \x01(\r\x1a\x82\x01\n\nGroupInfos\x12t\n\x0bgroup_infos\x18\x01 \x03(\x0b\x32_.org.dash.platform.dapi.v0.GetGroupInfosResponse.GetGroupInfosResponseV0.GroupPositionInfoEntryB\x08\n\x06resultB\t\n\x07version\"\xbe\x04\n\x16GetGroupActionsRequest\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetGroupActionsRequest.GetGroupActionsRequestV0H\x00\x1aL\n\x0fStartAtActionId\x12\x17\n\x0fstart_action_id\x18\x01 \x01(\x0c\x12 \n\x18start_action_id_included\x18\x02 \x01(\x08\x1a\xc8\x02\n\x18GetGroupActionsRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12N\n\x06status\x18\x03 \x01(\x0e\x32>.org.dash.platform.dapi.v0.GetGroupActionsRequest.ActionStatus\x12\x62\n\x12start_at_action_id\x18\x04 \x01(\x0b\x32\x41.org.dash.platform.dapi.v0.GetGroupActionsRequest.StartAtActionIdH\x00\x88\x01\x01\x12\x12\n\x05\x63ount\x18\x05 \x01(\rH\x01\x88\x01\x01\x12\r\n\x05prove\x18\x06 \x01(\x08\x42\x15\n\x13_start_at_action_idB\x08\n\x06_count\"&\n\x0c\x41\x63tionStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\n\n\x06\x43LOSED\x10\x01\x42\t\n\x07version\"\xd6\x1e\n\x17GetGroupActionsResponse\x12Z\n\x02v0\x18\x01 \x01(\x0b\x32L.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0H\x00\x1a\xd3\x1d\n\x19GetGroupActionsResponseV0\x12r\n\rgroup_actions\x18\x01 \x01(\x0b\x32Y.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a[\n\tMintEvent\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x14\n\x0crecipient_id\x18\x02 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a[\n\tBurnEvent\x12\x0e\n\x06\x61mount\x18\x01 \x01(\x04\x12\x14\n\x0c\x62urn_from_id\x18\x02 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1aJ\n\x0b\x46reezeEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1aL\n\rUnfreezeEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\x66\n\x17\x44\x65stroyFrozenFundsEvent\x12\x11\n\tfrozen_id\x18\x01 \x01(\x0c\x12\x0e\n\x06\x61mount\x18\x02 \x01(\x04\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\x64\n\x13SharedEncryptedNote\x12\x18\n\x10sender_key_index\x18\x01 \x01(\r\x12\x1b\n\x13recipient_key_index\x18\x02 \x01(\r\x12\x16\n\x0e\x65ncrypted_data\x18\x03 \x01(\x0c\x1a{\n\x15PersonalEncryptedNote\x12!\n\x19root_encryption_key_index\x18\x01 \x01(\r\x12\'\n\x1f\x64\x65rivation_encryption_key_index\x18\x02 \x01(\r\x12\x16\n\x0e\x65ncrypted_data\x18\x03 \x01(\x0c\x1a\xe9\x01\n\x14\x45mergencyActionEvent\x12\x81\x01\n\x0b\x61\x63tion_type\x18\x01 \x01(\x0e\x32l.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.EmergencyActionEvent.ActionType\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\"#\n\nActionType\x12\t\n\x05PAUSE\x10\x00\x12\n\n\x06RESUME\x10\x01\x42\x0e\n\x0c_public_note\x1a\x64\n\x16TokenConfigUpdateEvent\x12 \n\x18token_config_update_item\x18\x01 \x01(\x0c\x12\x18\n\x0bpublic_note\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x0e\n\x0c_public_note\x1a\xe6\x03\n\x1eUpdateDirectPurchasePriceEvent\x12\x15\n\x0b\x66ixed_price\x18\x01 \x01(\x04H\x00\x12\x95\x01\n\x0evariable_price\x18\x02 \x01(\x0b\x32{.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEvent.PricingScheduleH\x00\x12\x18\n\x0bpublic_note\x18\x03 \x01(\tH\x01\x88\x01\x01\x1a\x33\n\x10PriceForQuantity\x12\x10\n\x08quantity\x18\x01 \x01(\x04\x12\r\n\x05price\x18\x02 \x01(\x04\x1a\xac\x01\n\x0fPricingSchedule\x12\x98\x01\n\x12price_for_quantity\x18\x01 \x03(\x0b\x32|.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEvent.PriceForQuantityB\x07\n\x05priceB\x0e\n\x0c_public_note\x1a\xfc\x02\n\x10GroupActionEvent\x12n\n\x0btoken_event\x18\x01 \x01(\x0b\x32W.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.TokenEventH\x00\x12t\n\x0e\x64ocument_event\x18\x02 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DocumentEventH\x00\x12t\n\x0e\x63ontract_event\x18\x03 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.ContractEventH\x00\x42\x0c\n\nevent_type\x1a\x8b\x01\n\rDocumentEvent\x12r\n\x06\x63reate\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DocumentCreateEventH\x00\x42\x06\n\x04type\x1a/\n\x13\x44ocumentCreateEvent\x12\x18\n\x10\x63reated_document\x18\x01 \x01(\x0c\x1a/\n\x13\x43ontractUpdateEvent\x12\x18\n\x10updated_contract\x18\x01 \x01(\x0c\x1a\x8b\x01\n\rContractEvent\x12r\n\x06update\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.ContractUpdateEventH\x00\x42\x06\n\x04type\x1a\xd1\x07\n\nTokenEvent\x12\x66\n\x04mint\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.MintEventH\x00\x12\x66\n\x04\x62urn\x18\x02 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.BurnEventH\x00\x12j\n\x06\x66reeze\x18\x03 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.FreezeEventH\x00\x12n\n\x08unfreeze\x18\x04 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UnfreezeEventH\x00\x12\x84\x01\n\x14\x64\x65stroy_frozen_funds\x18\x05 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.DestroyFrozenFundsEventH\x00\x12}\n\x10\x65mergency_action\x18\x06 \x01(\x0b\x32\x61.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.EmergencyActionEventH\x00\x12\x82\x01\n\x13token_config_update\x18\x07 \x01(\x0b\x32\x63.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.TokenConfigUpdateEventH\x00\x12\x83\x01\n\x0cupdate_price\x18\x08 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.UpdateDirectPurchasePriceEventH\x00\x42\x06\n\x04type\x1a\x93\x01\n\x10GroupActionEntry\x12\x11\n\taction_id\x18\x01 \x01(\x0c\x12l\n\x05\x65vent\x18\x02 \x01(\x0b\x32].org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionEvent\x1a\x84\x01\n\x0cGroupActions\x12t\n\rgroup_actions\x18\x01 \x03(\x0b\x32].org.dash.platform.dapi.v0.GetGroupActionsResponse.GetGroupActionsResponseV0.GroupActionEntryB\x08\n\x06resultB\t\n\x07version\"\x88\x03\n\x1cGetGroupActionSignersRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetGroupActionSignersRequest.GetGroupActionSignersRequestV0H\x00\x1a\xce\x01\n\x1eGetGroupActionSignersRequestV0\x12\x13\n\x0b\x63ontract_id\x18\x01 \x01(\x0c\x12\x1f\n\x17group_contract_position\x18\x02 \x01(\r\x12T\n\x06status\x18\x03 \x01(\x0e\x32\x44.org.dash.platform.dapi.v0.GetGroupActionSignersRequest.ActionStatus\x12\x11\n\taction_id\x18\x04 \x01(\x0c\x12\r\n\x05prove\x18\x05 \x01(\x08\"&\n\x0c\x41\x63tionStatus\x12\n\n\x06\x41\x43TIVE\x10\x00\x12\n\n\x06\x43LOSED\x10\x01\x42\t\n\x07version\"\x8b\x05\n\x1dGetGroupActionSignersResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0H\x00\x1a\xf6\x03\n\x1fGetGroupActionSignersResponseV0\x12\x8b\x01\n\x14group_action_signers\x18\x01 \x01(\x0b\x32k.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0.GroupActionSignersH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x35\n\x11GroupActionSigner\x12\x11\n\tsigner_id\x18\x01 \x01(\x0c\x12\r\n\x05power\x18\x02 \x01(\r\x1a\x91\x01\n\x12GroupActionSigners\x12{\n\x07signers\x18\x01 \x03(\x0b\x32j.org.dash.platform.dapi.v0.GetGroupActionSignersResponse.GetGroupActionSignersResponseV0.GroupActionSignerB\x08\n\x06resultB\t\n\x07version\"\xb5\x01\n\x15GetAddressInfoRequest\x12V\n\x02v0\x18\x01 \x01(\x0b\x32H.org.dash.platform.dapi.v0.GetAddressInfoRequest.GetAddressInfoRequestV0H\x00\x1a\x39\n\x17GetAddressInfoRequestV0\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x85\x01\n\x10\x41\x64\x64ressInfoEntry\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12J\n\x11\x62\x61lance_and_nonce\x18\x02 \x01(\x0b\x32*.org.dash.platform.dapi.v0.BalanceAndNonceH\x00\x88\x01\x01\x42\x14\n\x12_balance_and_nonce\"1\n\x0f\x42\x61lanceAndNonce\x12\x0f\n\x07\x62\x61lance\x18\x01 \x01(\x04\x12\r\n\x05nonce\x18\x02 \x01(\r\"_\n\x12\x41\x64\x64ressInfoEntries\x12I\n\x14\x61\x64\x64ress_info_entries\x18\x01 \x03(\x0b\x32+.org.dash.platform.dapi.v0.AddressInfoEntry\"m\n\x14\x41\x64\x64ressBalanceChange\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x19\n\x0bset_balance\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12\x1c\n\x0e\x61\x64\x64_to_balance\x18\x03 \x01(\x04\x42\x02\x30\x01H\x00\x42\x0b\n\toperation\"x\n\x1a\x42lockAddressBalanceChanges\x12\x18\n\x0c\x62lock_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12@\n\x07\x63hanges\x18\x02 \x03(\x0b\x32/.org.dash.platform.dapi.v0.AddressBalanceChange\"k\n\x1b\x41\x64\x64ressBalanceUpdateEntries\x12L\n\rblock_changes\x18\x01 \x03(\x0b\x32\x35.org.dash.platform.dapi.v0.BlockAddressBalanceChanges\"\xe1\x02\n\x16GetAddressInfoResponse\x12X\n\x02v0\x18\x01 \x01(\x0b\x32J.org.dash.platform.dapi.v0.GetAddressInfoResponse.GetAddressInfoResponseV0H\x00\x1a\xe1\x01\n\x18GetAddressInfoResponseV0\x12I\n\x12\x61\x64\x64ress_info_entry\x18\x01 \x01(\x0b\x32+.org.dash.platform.dapi.v0.AddressInfoEntryH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc3\x01\n\x18GetAddressesInfosRequest\x12\\\n\x02v0\x18\x01 \x01(\x0b\x32N.org.dash.platform.dapi.v0.GetAddressesInfosRequest.GetAddressesInfosRequestV0H\x00\x1a>\n\x1aGetAddressesInfosRequestV0\x12\x11\n\taddresses\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf1\x02\n\x19GetAddressesInfosResponse\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetAddressesInfosResponse.GetAddressesInfosResponseV0H\x00\x1a\xe8\x01\n\x1bGetAddressesInfosResponseV0\x12M\n\x14\x61\x64\x64ress_info_entries\x18\x01 \x01(\x0b\x32-.org.dash.platform.dapi.v0.AddressInfoEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xb5\x01\n\x1dGetAddressesTrunkStateRequest\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetAddressesTrunkStateRequest.GetAddressesTrunkStateRequestV0H\x00\x1a!\n\x1fGetAddressesTrunkStateRequestV0B\t\n\x07version\"\xaa\x02\n\x1eGetAddressesTrunkStateResponse\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetAddressesTrunkStateResponse.GetAddressesTrunkStateResponseV0H\x00\x1a\x92\x01\n GetAddressesTrunkStateResponseV0\x12/\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.Proof\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\t\n\x07version\"\xf0\x01\n\x1eGetAddressesBranchStateRequest\x12h\n\x02v0\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetAddressesBranchStateRequest.GetAddressesBranchStateRequestV0H\x00\x1aY\n GetAddressesBranchStateRequestV0\x12\x0b\n\x03key\x18\x01 \x01(\x0c\x12\r\n\x05\x64\x65pth\x18\x02 \x01(\r\x12\x19\n\x11\x63heckpoint_height\x18\x03 \x01(\x04\x42\t\n\x07version\"\xd1\x01\n\x1fGetAddressesBranchStateResponse\x12j\n\x02v0\x18\x01 \x01(\x0b\x32\\.org.dash.platform.dapi.v0.GetAddressesBranchStateResponse.GetAddressesBranchStateResponseV0H\x00\x1a\x37\n!GetAddressesBranchStateResponseV0\x12\x12\n\nmerk_proof\x18\x02 \x01(\x0c\x42\t\n\x07version\"\x9e\x02\n%GetRecentAddressBalanceChangesRequest\x12v\n\x02v0\x18\x01 \x01(\x0b\x32h.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesRequest.GetRecentAddressBalanceChangesRequestV0H\x00\x1ar\n\'GetRecentAddressBalanceChangesRequestV0\x12\x18\n\x0cstart_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x02 \x01(\x08\x12\x1e\n\x16start_height_exclusive\x18\x03 \x01(\x08\x42\t\n\x07version\"\xb8\x03\n&GetRecentAddressBalanceChangesResponse\x12x\n\x02v0\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesResponse.GetRecentAddressBalanceChangesResponseV0H\x00\x1a\x88\x02\n(GetRecentAddressBalanceChangesResponseV0\x12`\n\x1e\x61\x64\x64ress_balance_update_entries\x18\x01 \x01(\x0b\x32\x36.org.dash.platform.dapi.v0.AddressBalanceUpdateEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"G\n\x16\x42lockHeightCreditEntry\x12\x18\n\x0c\x62lock_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x13\n\x07\x63redits\x18\x02 \x01(\x04\x42\x02\x30\x01\"\xb0\x01\n\x1d\x43ompactedAddressBalanceChange\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x19\n\x0bset_credits\x18\x02 \x01(\x04\x42\x02\x30\x01H\x00\x12V\n\x19\x61\x64\x64_to_credits_operations\x18\x03 \x01(\x0b\x32\x31.org.dash.platform.dapi.v0.AddToCreditsOperationsH\x00\x42\x0b\n\toperation\"\\\n\x16\x41\x64\x64ToCreditsOperations\x12\x42\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x31.org.dash.platform.dapi.v0.BlockHeightCreditEntry\"\xae\x01\n#CompactedBlockAddressBalanceChanges\x12\x1e\n\x12start_block_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\x1c\n\x10\x65nd_block_height\x18\x02 \x01(\x04\x42\x02\x30\x01\x12I\n\x07\x63hanges\x18\x03 \x03(\x0b\x32\x38.org.dash.platform.dapi.v0.CompactedAddressBalanceChange\"\x87\x01\n$CompactedAddressBalanceUpdateEntries\x12_\n\x17\x63ompacted_block_changes\x18\x01 \x03(\x0b\x32>.org.dash.platform.dapi.v0.CompactedBlockAddressBalanceChanges\"\xa9\x02\n.GetRecentCompactedAddressBalanceChangesRequest\x12\x88\x01\n\x02v0\x18\x01 \x01(\x0b\x32z.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesRequest.GetRecentCompactedAddressBalanceChangesRequestV0H\x00\x1a\x61\n0GetRecentCompactedAddressBalanceChangesRequestV0\x12\x1e\n\x12start_block_height\x18\x01 \x01(\x04\x42\x02\x30\x01\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\xf0\x03\n/GetRecentCompactedAddressBalanceChangesResponse\x12\x8a\x01\n\x02v0\x18\x01 \x01(\x0b\x32|.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesResponse.GetRecentCompactedAddressBalanceChangesResponseV0H\x00\x1a\xa4\x02\n1GetRecentCompactedAddressBalanceChangesResponseV0\x12s\n(compacted_address_balance_update_entries\x18\x01 \x01(\x0b\x32?.org.dash.platform.dapi.v0.CompactedAddressBalanceUpdateEntriesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xf4\x01\n GetShieldedEncryptedNotesRequest\x12l\n\x02v0\x18\x01 \x01(\x0b\x32^.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesRequest.GetShieldedEncryptedNotesRequestV0H\x00\x1aW\n\"GetShieldedEncryptedNotesRequestV0\x12\x13\n\x0bstart_index\x18\x01 \x01(\x04\x12\r\n\x05\x63ount\x18\x02 \x01(\r\x12\r\n\x05prove\x18\x03 \x01(\x08\x42\t\n\x07version\"\xbc\x05\n!GetShieldedEncryptedNotesResponse\x12n\n\x02v0\x18\x01 \x01(\x0b\x32`.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0H\x00\x1a\x9b\x04\n#GetShieldedEncryptedNotesResponseV0\x12\x8a\x01\n\x0f\x65ncrypted_notes\x18\x01 \x01(\x0b\x32o.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0.EncryptedNotesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1aW\n\rEncryptedNote\x12\x11\n\tnullifier\x18\x01 \x01(\x0c\x12\x0b\n\x03\x63mx\x18\x02 \x01(\x0c\x12\x16\n\x0e\x65ncrypted_note\x18\x03 \x01(\x0c\x12\x0e\n\x06\x63v_net\x18\x04 \x01(\x0c\x1a\x91\x01\n\x0e\x45ncryptedNotes\x12\x7f\n\x07\x65ntries\x18\x01 \x03(\x0b\x32n.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse.GetShieldedEncryptedNotesResponseV0.EncryptedNoteB\x08\n\x06resultB\t\n\x07version\"\xb4\x01\n\x19GetShieldedAnchorsRequest\x12^\n\x02v0\x18\x01 \x01(\x0b\x32P.org.dash.platform.dapi.v0.GetShieldedAnchorsRequest.GetShieldedAnchorsRequestV0H\x00\x1a,\n\x1bGetShieldedAnchorsRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xb1\x03\n\x1aGetShieldedAnchorsResponse\x12`\n\x02v0\x18\x01 \x01(\x0b\x32R.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse.GetShieldedAnchorsResponseV0H\x00\x1a\xa5\x02\n\x1cGetShieldedAnchorsResponseV0\x12m\n\x07\x61nchors\x18\x01 \x01(\x0b\x32Z.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse.GetShieldedAnchorsResponseV0.AnchorsH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x1a\n\x07\x41nchors\x12\x0f\n\x07\x61nchors\x18\x01 \x03(\x0c\x42\x08\n\x06resultB\t\n\x07version\"\xd8\x01\n\"GetMostRecentShieldedAnchorRequest\x12p\n\x02v0\x18\x01 \x01(\x0b\x32\x62.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorRequest.GetMostRecentShieldedAnchorRequestV0H\x00\x1a\x35\n$GetMostRecentShieldedAnchorRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xdc\x02\n#GetMostRecentShieldedAnchorResponse\x12r\n\x02v0\x18\x01 \x01(\x0b\x32\x64.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorResponse.GetMostRecentShieldedAnchorResponseV0H\x00\x1a\xb5\x01\n%GetMostRecentShieldedAnchorResponseV0\x12\x10\n\x06\x61nchor\x18\x01 \x01(\x0cH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xbc\x01\n\x1bGetShieldedPoolStateRequest\x12\x62\n\x02v0\x18\x01 \x01(\x0b\x32T.org.dash.platform.dapi.v0.GetShieldedPoolStateRequest.GetShieldedPoolStateRequestV0H\x00\x1a.\n\x1dGetShieldedPoolStateRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xcb\x02\n\x1cGetShieldedPoolStateResponse\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedPoolStateResponse.GetShieldedPoolStateResponseV0H\x00\x1a\xb9\x01\n\x1eGetShieldedPoolStateResponseV0\x12\x1b\n\rtotal_balance\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xc0\x01\n\x1cGetShieldedNotesCountRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedNotesCountRequest.GetShieldedNotesCountRequestV0H\x00\x1a/\n\x1eGetShieldedNotesCountRequestV0\x12\r\n\x05prove\x18\x01 \x01(\x08\x42\t\n\x07version\"\xd3\x02\n\x1dGetShieldedNotesCountResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetShieldedNotesCountResponse.GetShieldedNotesCountResponseV0H\x00\x1a\xbe\x01\n\x1fGetShieldedNotesCountResponseV0\x12\x1f\n\x11total_notes_count\x18\x01 \x01(\x04\x42\x02\x30\x01H\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadataB\x08\n\x06resultB\t\n\x07version\"\xd4\x01\n\x1cGetShieldedNullifiersRequest\x12\x64\n\x02v0\x18\x01 \x01(\x0b\x32V.org.dash.platform.dapi.v0.GetShieldedNullifiersRequest.GetShieldedNullifiersRequestV0H\x00\x1a\x43\n\x1eGetShieldedNullifiersRequestV0\x12\x12\n\nnullifiers\x18\x01 \x03(\x0c\x12\r\n\x05prove\x18\x02 \x01(\x08\x42\t\n\x07version\"\x86\x05\n\x1dGetShieldedNullifiersResponse\x12\x66\n\x02v0\x18\x01 \x01(\x0b\x32X.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0H\x00\x1a\xf1\x03\n\x1fGetShieldedNullifiersResponseV0\x12\x88\x01\n\x12nullifier_statuses\x18\x01 \x01(\x0b\x32j.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0.NullifierStatusesH\x00\x12\x31\n\x05proof\x18\x02 \x01(\x0b\x32 .org.dash.platform.dapi.v0.ProofH\x00\x12=\n\x08metadata\x18\x03 \x01(\x0b\x32+.org.dash.platform.dapi.v0.ResponseMetadata\x1a\x36\n\x0fNullifierStatus\x12\x11\n\tnullifier\x18\x01 \x01(\x0c\x12\x10\n\x08is_spent\x18\x02 \x01(\x08\x1a\x8e\x01\n\x11NullifierStatuses\x12y\n\x07\x65ntries\x18\x01 \x03(\x0b\x32h.org.dash.platform.dapi.v0.GetShieldedNullifiersResponse.GetShieldedNullifiersResponseV0.NullifierStatusB\x08\n\x06resultB\t\n\x07version*Z\n\nKeyPurpose\x12\x12\n\x0e\x41UTHENTICATION\x10\x00\x12\x0e\n\nENCRYPTION\x10\x01\x12\x0e\n\nDECRYPTION\x10\x02\x12\x0c\n\x08TRANSFER\x10\x03\x12\n\n\x06VOTING\x10\x05*\xb9\x01\n\x16\x43ontractModerationList\x12(\n$CONTRACT_MODERATION_LIST_UNSPECIFIED\x10\x00\x12$\n CONTRACT_MODERATION_LIST_BANLIST\x10\x01\x12(\n$CONTRACT_MODERATION_LIST_SUSPENSIONS\x10\x02\x12%\n!CONTRACT_MODERATION_LIST_WARNINGS\x10\x03\x32\xd0P\n\x08Platform\x12\x93\x01\n\x18\x62roadcastStateTransition\x12:.org.dash.platform.dapi.v0.BroadcastStateTransitionRequest\x1a;.org.dash.platform.dapi.v0.BroadcastStateTransitionResponse\x12l\n\x0bgetIdentity\x12-.org.dash.platform.dapi.v0.GetIdentityRequest\x1a..org.dash.platform.dapi.v0.GetIdentityResponse\x12x\n\x0fgetIdentityKeys\x12\x31.org.dash.platform.dapi.v0.GetIdentityKeysRequest\x1a\x32.org.dash.platform.dapi.v0.GetIdentityKeysResponse\x12\x96\x01\n\x19getIdentitiesContractKeys\x12;.org.dash.platform.dapi.v0.GetIdentitiesContractKeysRequest\x1a<.org.dash.platform.dapi.v0.GetIdentitiesContractKeysResponse\x12{\n\x10getIdentityNonce\x12\x32.org.dash.platform.dapi.v0.GetIdentityNonceRequest\x1a\x33.org.dash.platform.dapi.v0.GetIdentityNonceResponse\x12\x93\x01\n\x18getIdentityContractNonce\x12:.org.dash.platform.dapi.v0.GetIdentityContractNonceRequest\x1a;.org.dash.platform.dapi.v0.GetIdentityContractNonceResponse\x12\xa8\x01\n\x1fgetIdentityKeysRemainingBudgets\x12\x41.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsRequest\x1a\x42.org.dash.platform.dapi.v0.GetIdentityKeysRemainingBudgetsResponse\x12\x81\x01\n\x12getIdentityBalance\x12\x34.org.dash.platform.dapi.v0.GetIdentityBalanceRequest\x1a\x35.org.dash.platform.dapi.v0.GetIdentityBalanceResponse\x12\x8a\x01\n\x15getIdentitiesBalances\x12\x37.org.dash.platform.dapi.v0.GetIdentitiesBalancesRequest\x1a\x38.org.dash.platform.dapi.v0.GetIdentitiesBalancesResponse\x12\xa2\x01\n\x1dgetIdentityBalanceAndRevision\x12?.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionRequest\x1a@.org.dash.platform.dapi.v0.GetIdentityBalanceAndRevisionResponse\x12\xaf\x01\n#getEvonodesProposedEpochBlocksByIds\x12\x45.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByIdsRequest\x1a\x41.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse\x12\xb3\x01\n%getEvonodesProposedEpochBlocksByRange\x12G.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksByRangeRequest\x1a\x41.org.dash.platform.dapi.v0.GetEvonodesProposedEpochBlocksResponse\x12x\n\x0fgetDataContract\x12\x31.org.dash.platform.dapi.v0.GetDataContractRequest\x1a\x32.org.dash.platform.dapi.v0.GetDataContractResponse\x12\x8d\x01\n\x16getDataContractHistory\x12\x38.org.dash.platform.dapi.v0.GetDataContractHistoryRequest\x1a\x39.org.dash.platform.dapi.v0.GetDataContractHistoryResponse\x12\xa5\x01\n\x1egetDataContractsLatestVersions\x12@.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsRequest\x1a\x41.org.dash.platform.dapi.v0.GetDataContractsLatestVersionsResponse\x12{\n\x10getDataContracts\x12\x32.org.dash.platform.dapi.v0.GetDataContractsRequest\x1a\x33.org.dash.platform.dapi.v0.GetDataContractsResponse\x12\x89\x01\n\x17getDataContractsByRange\x12\x39.org.dash.platform.dapi.v0.GetDataContractsByRangeRequest\x1a\x33.org.dash.platform.dapi.v0.GetDataContractsResponse\x12\x87\x01\n\x14getContractGroupInfo\x12\x36.org.dash.platform.dapi.v0.GetContractGroupInfoRequest\x1a\x37.org.dash.platform.dapi.v0.GetContractGroupInfoResponse\x12\x90\x01\n\x17getContractGroupMembers\x12\x39.org.dash.platform.dapi.v0.GetContractGroupMembersRequest\x1a:.org.dash.platform.dapi.v0.GetContractGroupMembersResponse\x12\x9f\x01\n\x1cgetContractGroupsForContract\x12>.org.dash.platform.dapi.v0.GetContractGroupsForContractRequest\x1a?.org.dash.platform.dapi.v0.GetContractGroupsForContractResponse\x12\x9c\x01\n\x1bgetContractModerationStatus\x12=.org.dash.platform.dapi.v0.GetContractModerationStatusRequest\x1a>.org.dash.platform.dapi.v0.GetContractModerationStatusResponse\x12\x9f\x01\n\x1cgetContractModerationEntries\x12>.org.dash.platform.dapi.v0.GetContractModerationEntriesRequest\x1a?.org.dash.platform.dapi.v0.GetContractModerationEntriesResponse\x12\x9c\x01\n\x1bgetContractDocumentRemovals\x12=.org.dash.platform.dapi.v0.GetContractDocumentRemovalsRequest\x1a>.org.dash.platform.dapi.v0.GetContractDocumentRemovalsResponse\x12\x81\x01\n\x12getContractFeePots\x12\x34.org.dash.platform.dapi.v0.GetContractFeePotsRequest\x1a\x35.org.dash.platform.dapi.v0.GetContractFeePotsResponse\x12\x81\x01\n\x12getDocumentHistory\x12\x34.org.dash.platform.dapi.v0.GetDocumentHistoryRequest\x1a\x35.org.dash.platform.dapi.v0.GetDocumentHistoryResponse\x12o\n\x0cgetDocuments\x12..org.dash.platform.dapi.v0.GetDocumentsRequest\x1a/.org.dash.platform.dapi.v0.GetDocumentsResponse\x12\x99\x01\n\x1agetIdentityByPublicKeyHash\x12<.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashRequest\x1a=.org.dash.platform.dapi.v0.GetIdentityByPublicKeyHashResponse\x12\xb4\x01\n#getIdentityByNonUniquePublicKeyHash\x12\x45.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashRequest\x1a\x46.org.dash.platform.dapi.v0.GetIdentityByNonUniquePublicKeyHashResponse\x12\x9f\x01\n\x1cwaitForStateTransitionResult\x12>.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest\x1a?.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse\x12\x81\x01\n\x12getConsensusParams\x12\x34.org.dash.platform.dapi.v0.GetConsensusParamsRequest\x1a\x35.org.dash.platform.dapi.v0.GetConsensusParamsResponse\x12\xa5\x01\n\x1egetProtocolVersionUpgradeState\x12@.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateRequest\x1a\x41.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeStateResponse\x12\xb4\x01\n#getProtocolVersionUpgradeVoteStatus\x12\x45.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusRequest\x1a\x46.org.dash.platform.dapi.v0.GetProtocolVersionUpgradeVoteStatusResponse\x12r\n\rgetEpochsInfo\x12/.org.dash.platform.dapi.v0.GetEpochsInfoRequest\x1a\x30.org.dash.platform.dapi.v0.GetEpochsInfoResponse\x12\x8d\x01\n\x16getFinalizedEpochInfos\x12\x38.org.dash.platform.dapi.v0.GetFinalizedEpochInfosRequest\x1a\x39.org.dash.platform.dapi.v0.GetFinalizedEpochInfosResponse\x12\x8a\x01\n\x15getContestedResources\x12\x37.org.dash.platform.dapi.v0.GetContestedResourcesRequest\x1a\x38.org.dash.platform.dapi.v0.GetContestedResourcesResponse\x12\xa2\x01\n\x1dgetContestedResourceVoteState\x12?.org.dash.platform.dapi.v0.GetContestedResourceVoteStateRequest\x1a@.org.dash.platform.dapi.v0.GetContestedResourceVoteStateResponse\x12\xba\x01\n%getContestedResourceVotersForIdentity\x12G.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityRequest\x1aH.org.dash.platform.dapi.v0.GetContestedResourceVotersForIdentityResponse\x12\xae\x01\n!getContestedResourceIdentityVotes\x12\x43.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesRequest\x1a\x44.org.dash.platform.dapi.v0.GetContestedResourceIdentityVotesResponse\x12\x8a\x01\n\x15getVotePollsByEndDate\x12\x37.org.dash.platform.dapi.v0.GetVotePollsByEndDateRequest\x1a\x38.org.dash.platform.dapi.v0.GetVotePollsByEndDateResponse\x12\xa5\x01\n\x1egetPrefundedSpecializedBalance\x12@.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceRequest\x1a\x41.org.dash.platform.dapi.v0.GetPrefundedSpecializedBalanceResponse\x12\x96\x01\n\x19getTotalCreditsInPlatform\x12;.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformRequest\x1a<.org.dash.platform.dapi.v0.GetTotalCreditsInPlatformResponse\x12x\n\x0fgetPathElements\x12\x31.org.dash.platform.dapi.v0.GetPathElementsRequest\x1a\x32.org.dash.platform.dapi.v0.GetPathElementsResponse\x12\x66\n\tgetStatus\x12+.org.dash.platform.dapi.v0.GetStatusRequest\x1a,.org.dash.platform.dapi.v0.GetStatusResponse\x12\x8a\x01\n\x15getCurrentQuorumsInfo\x12\x37.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoRequest\x1a\x38.org.dash.platform.dapi.v0.GetCurrentQuorumsInfoResponse\x12\x93\x01\n\x18getIdentityTokenBalances\x12:.org.dash.platform.dapi.v0.GetIdentityTokenBalancesRequest\x1a;.org.dash.platform.dapi.v0.GetIdentityTokenBalancesResponse\x12\x99\x01\n\x1agetIdentitiesTokenBalances\x12<.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesRequest\x1a=.org.dash.platform.dapi.v0.GetIdentitiesTokenBalancesResponse\x12\x8a\x01\n\x15getIdentityTokenInfos\x12\x37.org.dash.platform.dapi.v0.GetIdentityTokenInfosRequest\x1a\x38.org.dash.platform.dapi.v0.GetIdentityTokenInfosResponse\x12\x90\x01\n\x17getIdentitiesTokenInfos\x12\x39.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosRequest\x1a:.org.dash.platform.dapi.v0.GetIdentitiesTokenInfosResponse\x12{\n\x10getTokenStatuses\x12\x32.org.dash.platform.dapi.v0.GetTokenStatusesRequest\x1a\x33.org.dash.platform.dapi.v0.GetTokenStatusesResponse\x12\x9f\x01\n\x1cgetTokenDirectPurchasePrices\x12>.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesRequest\x1a?.org.dash.platform.dapi.v0.GetTokenDirectPurchasePricesResponse\x12\x87\x01\n\x14getTokenContractInfo\x12\x36.org.dash.platform.dapi.v0.GetTokenContractInfoRequest\x1a\x37.org.dash.platform.dapi.v0.GetTokenContractInfoResponse\x12\xb1\x01\n\"getTokenPreProgrammedDistributions\x12\x44.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsRequest\x1a\x45.org.dash.platform.dapi.v0.GetTokenPreProgrammedDistributionsResponse\x12\xbd\x01\n&getTokenPerpetualDistributionLastClaim\x12H.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimRequest\x1aI.org.dash.platform.dapi.v0.GetTokenPerpetualDistributionLastClaimResponse\x12\x84\x01\n\x13getTokenTotalSupply\x12\x35.org.dash.platform.dapi.v0.GetTokenTotalSupplyRequest\x1a\x36.org.dash.platform.dapi.v0.GetTokenTotalSupplyResponse\x12o\n\x0cgetGroupInfo\x12..org.dash.platform.dapi.v0.GetGroupInfoRequest\x1a/.org.dash.platform.dapi.v0.GetGroupInfoResponse\x12r\n\rgetGroupInfos\x12/.org.dash.platform.dapi.v0.GetGroupInfosRequest\x1a\x30.org.dash.platform.dapi.v0.GetGroupInfosResponse\x12x\n\x0fgetGroupActions\x12\x31.org.dash.platform.dapi.v0.GetGroupActionsRequest\x1a\x32.org.dash.platform.dapi.v0.GetGroupActionsResponse\x12\x8a\x01\n\x15getGroupActionSigners\x12\x37.org.dash.platform.dapi.v0.GetGroupActionSignersRequest\x1a\x38.org.dash.platform.dapi.v0.GetGroupActionSignersResponse\x12u\n\x0egetAddressInfo\x12\x30.org.dash.platform.dapi.v0.GetAddressInfoRequest\x1a\x31.org.dash.platform.dapi.v0.GetAddressInfoResponse\x12~\n\x11getAddressesInfos\x12\x33.org.dash.platform.dapi.v0.GetAddressesInfosRequest\x1a\x34.org.dash.platform.dapi.v0.GetAddressesInfosResponse\x12\x8d\x01\n\x16getAddressesTrunkState\x12\x38.org.dash.platform.dapi.v0.GetAddressesTrunkStateRequest\x1a\x39.org.dash.platform.dapi.v0.GetAddressesTrunkStateResponse\x12\x90\x01\n\x17getAddressesBranchState\x12\x39.org.dash.platform.dapi.v0.GetAddressesBranchStateRequest\x1a:.org.dash.platform.dapi.v0.GetAddressesBranchStateResponse\x12\xa5\x01\n\x1egetRecentAddressBalanceChanges\x12@.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesRequest\x1a\x41.org.dash.platform.dapi.v0.GetRecentAddressBalanceChangesResponse\x12\xc0\x01\n\'getRecentCompactedAddressBalanceChanges\x12I.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesRequest\x1aJ.org.dash.platform.dapi.v0.GetRecentCompactedAddressBalanceChangesResponse\x12\x96\x01\n\x19getShieldedEncryptedNotes\x12;.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesRequest\x1a<.org.dash.platform.dapi.v0.GetShieldedEncryptedNotesResponse\x12\x81\x01\n\x12getShieldedAnchors\x12\x34.org.dash.platform.dapi.v0.GetShieldedAnchorsRequest\x1a\x35.org.dash.platform.dapi.v0.GetShieldedAnchorsResponse\x12\x9c\x01\n\x1bgetMostRecentShieldedAnchor\x12=.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorRequest\x1a>.org.dash.platform.dapi.v0.GetMostRecentShieldedAnchorResponse\x12\x87\x01\n\x14getShieldedPoolState\x12\x36.org.dash.platform.dapi.v0.GetShieldedPoolStateRequest\x1a\x37.org.dash.platform.dapi.v0.GetShieldedPoolStateResponse\x12\x8a\x01\n\x15getShieldedNotesCount\x12\x37.org.dash.platform.dapi.v0.GetShieldedNotesCountRequest\x1a\x38.org.dash.platform.dapi.v0.GetShieldedNotesCountResponse\x12\x8a\x01\n\x15getShieldedNullifiers\x12\x37.org.dash.platform.dapi.v0.GetShieldedNullifiersRequest\x1a\x38.org.dash.platform.dapi.v0.GetShieldedNullifiersResponseb\x06proto3' , dependencies=[google_dot_protobuf_dot_wrappers__pb2.DESCRIPTOR,google_dot_protobuf_dot_struct__pb2.DESCRIPTOR,google_dot_protobuf_dot_timestamp__pb2.DESCRIPTOR,]) @@ -62,8 +62,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=79303, - serialized_end=79393, + serialized_start=79514, + serialized_end=79604, ) _sym_db.RegisterEnumDescriptor(_KEYPURPOSE) @@ -98,8 +98,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=79396, - serialized_end=79581, + serialized_start=79607, + serialized_end=79792, ) _sym_db.RegisterEnumDescriptor(_CONTRACTMODERATIONLIST) @@ -440,8 +440,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=42013, - serialized_end=42086, + serialized_start=42224, + serialized_end=42297, ) _sym_db.RegisterEnumDescriptor(_GETCONTESTEDRESOURCEVOTESTATEREQUEST_GETCONTESTEDRESOURCEVOTESTATEREQUESTV0_RESULTTYPE) @@ -470,8 +470,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=43008, - serialized_end=43087, + serialized_start=43219, + serialized_end=43298, ) _sym_db.RegisterEnumDescriptor(_GETCONTESTEDRESOURCEVOTESTATERESPONSE_GETCONTESTEDRESOURCEVOTESTATERESPONSEV0_FINISHEDVOTEINFO_FINISHEDVOTEOUTCOME) @@ -500,8 +500,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=46716, - serialized_end=46777, + serialized_start=46927, + serialized_end=46988, ) _sym_db.RegisterEnumDescriptor(_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSEV0_RESOURCEVOTECHOICE_VOTECHOICETYPE) @@ -525,8 +525,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=65341, - serialized_end=65379, + serialized_start=65552, + serialized_end=65590, ) _sym_db.RegisterEnumDescriptor(_GETGROUPACTIONSREQUEST_ACTIONSTATUS) @@ -550,8 +550,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=66626, - serialized_end=66661, + serialized_start=66837, + serialized_end=66872, ) _sym_db.RegisterEnumDescriptor(_GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_EMERGENCYACTIONEVENT_ACTIONTYPE) @@ -575,8 +575,8 @@ ], containing_type=None, serialized_options=None, - serialized_start=65341, - serialized_end=65379, + serialized_start=65552, + serialized_end=65590, ) _sym_db.RegisterEnumDescriptor(_GETGROUPACTIONSIGNERSREQUEST_ACTIONSTATUS) @@ -9155,6 +9155,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='request_user_balance', full_name='org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0.request_user_balance', index=2, + number=3, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), ], extensions=[ ], @@ -9168,7 +9175,7 @@ oneofs=[ ], serialized_start=32928, - serialized_end=33013, + serialized_end=33043, ) _WAITFORSTATETRANSITIONRESULTREQUEST = _descriptor.Descriptor( @@ -9204,10 +9211,41 @@ fields=[]), ], serialized_start=32773, - serialized_end=33024, + serialized_end=33054, ) +_WAITFORSTATETRANSITIONRESULTRESPONSE_SUCCESSWITHOWNERBALANCE = _descriptor.Descriptor( + name='SuccessWithOwnerBalance', + full_name='org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='owner_balance', full_name='org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.owner_balance', index=0, + number=1, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=b'0\001', file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=33215, + serialized_end=33267, +) + _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0 = _descriptor.Descriptor( name='WaitForStateTransitionResultResponseV0', full_name='org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0', @@ -9231,7 +9269,14 @@ is_extension=False, extension_scope=None, serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), _descriptor.FieldDescriptor( - name='metadata', full_name='org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.metadata', index=2, + name='success_with_owner_balance', full_name='org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.success_with_owner_balance', index=2, + number=4, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='metadata', full_name='org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.metadata', index=3, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, @@ -9254,8 +9299,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=33186, - serialized_end=33425, + serialized_start=33270, + serialized_end=33636, ) _WAITFORSTATETRANSITIONRESULTRESPONSE = _descriptor.Descriptor( @@ -9276,7 +9321,7 @@ ], extensions=[ ], - nested_types=[_WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0, ], + nested_types=[_WAITFORSTATETRANSITIONRESULTRESPONSE_SUCCESSWITHOWNERBALANCE, _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0, ], enum_types=[ ], serialized_options=None, @@ -9290,8 +9335,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=33027, - serialized_end=33436, + serialized_start=33057, + serialized_end=33647, ) @@ -9329,8 +9374,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=33564, - serialized_end=33624, + serialized_start=33775, + serialized_end=33835, ) _GETCONSENSUSPARAMSREQUEST = _descriptor.Descriptor( @@ -9365,8 +9410,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=33439, - serialized_end=33635, + serialized_start=33650, + serialized_end=33846, ) @@ -9411,8 +9456,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=33766, - serialized_end=33846, + serialized_start=33977, + serialized_end=34057, ) _GETCONSENSUSPARAMSRESPONSE_CONSENSUSPARAMSEVIDENCE = _descriptor.Descriptor( @@ -9456,8 +9501,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=33848, - serialized_end=33946, + serialized_start=34059, + serialized_end=34157, ) _GETCONSENSUSPARAMSRESPONSE_GETCONSENSUSPARAMSRESPONSEV0 = _descriptor.Descriptor( @@ -9494,8 +9539,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=33949, - serialized_end=34167, + serialized_start=34160, + serialized_end=34378, ) _GETCONSENSUSPARAMSRESPONSE = _descriptor.Descriptor( @@ -9530,8 +9575,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=33638, - serialized_end=34178, + serialized_start=33849, + serialized_end=34389, ) @@ -9562,8 +9607,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=34342, - serialized_end=34398, + serialized_start=34553, + serialized_end=34609, ) _GETPROTOCOLVERSIONUPGRADESTATEREQUEST = _descriptor.Descriptor( @@ -9598,8 +9643,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=34181, - serialized_end=34409, + serialized_start=34392, + serialized_end=34620, ) @@ -9630,8 +9675,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=34874, - serialized_end=35024, + serialized_start=35085, + serialized_end=35235, ) _GETPROTOCOLVERSIONUPGRADESTATERESPONSE_GETPROTOCOLVERSIONUPGRADESTATERESPONSEV0_VERSIONENTRY = _descriptor.Descriptor( @@ -9668,8 +9713,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=35026, - serialized_end=35084, + serialized_start=35237, + serialized_end=35295, ) _GETPROTOCOLVERSIONUPGRADESTATERESPONSE_GETPROTOCOLVERSIONUPGRADESTATERESPONSEV0 = _descriptor.Descriptor( @@ -9718,8 +9763,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=34577, - serialized_end=35094, + serialized_start=34788, + serialized_end=35305, ) _GETPROTOCOLVERSIONUPGRADESTATERESPONSE = _descriptor.Descriptor( @@ -9754,8 +9799,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=34412, - serialized_end=35105, + serialized_start=34623, + serialized_end=35316, ) @@ -9800,8 +9845,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=35285, - serialized_end=35388, + serialized_start=35496, + serialized_end=35599, ) _GETPROTOCOLVERSIONUPGRADEVOTESTATUSREQUEST = _descriptor.Descriptor( @@ -9836,8 +9881,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=35108, - serialized_end=35399, + serialized_start=35319, + serialized_end=35610, ) @@ -9868,8 +9913,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=35902, - serialized_end=36077, + serialized_start=36113, + serialized_end=36288, ) _GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSE_GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSEV0_VERSIONSIGNAL = _descriptor.Descriptor( @@ -9906,8 +9951,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=36079, - serialized_end=36132, + serialized_start=36290, + serialized_end=36343, ) _GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSE_GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSEV0 = _descriptor.Descriptor( @@ -9956,8 +10001,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=35583, - serialized_end=36142, + serialized_start=35794, + serialized_end=36353, ) _GETPROTOCOLVERSIONUPGRADEVOTESTATUSRESPONSE = _descriptor.Descriptor( @@ -9992,8 +10037,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=35402, - serialized_end=36153, + serialized_start=35613, + serialized_end=36364, ) @@ -10045,8 +10090,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=36266, - serialized_end=36390, + serialized_start=36477, + serialized_end=36601, ) _GETEPOCHSINFOREQUEST = _descriptor.Descriptor( @@ -10081,8 +10126,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=36156, - serialized_end=36401, + serialized_start=36367, + serialized_end=36612, ) @@ -10113,8 +10158,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=36762, - serialized_end=36879, + serialized_start=36973, + serialized_end=37090, ) _GETEPOCHSINFORESPONSE_GETEPOCHSINFORESPONSEV0_EPOCHINFO = _descriptor.Descriptor( @@ -10179,8 +10224,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=36882, - serialized_end=37048, + serialized_start=37093, + serialized_end=37259, ) _GETEPOCHSINFORESPONSE_GETEPOCHSINFORESPONSEV0 = _descriptor.Descriptor( @@ -10229,8 +10274,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=36518, - serialized_end=37058, + serialized_start=36729, + serialized_end=37269, ) _GETEPOCHSINFORESPONSE = _descriptor.Descriptor( @@ -10265,8 +10310,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=36404, - serialized_end=37069, + serialized_start=36615, + serialized_end=37280, ) @@ -10325,8 +10370,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=37210, - serialized_end=37380, + serialized_start=37421, + serialized_end=37591, ) _GETFINALIZEDEPOCHINFOSREQUEST = _descriptor.Descriptor( @@ -10361,8 +10406,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=37072, - serialized_end=37391, + serialized_start=37283, + serialized_end=37602, ) @@ -10393,8 +10438,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=37817, - serialized_end=37981, + serialized_start=38028, + serialized_end=38192, ) _GETFINALIZEDEPOCHINFOSRESPONSE_GETFINALIZEDEPOCHINFOSRESPONSEV0_FINALIZEDEPOCHINFO = _descriptor.Descriptor( @@ -10508,8 +10553,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=37984, - serialized_end=38527, + serialized_start=38195, + serialized_end=38738, ) _GETFINALIZEDEPOCHINFOSRESPONSE_GETFINALIZEDEPOCHINFOSRESPONSEV0_BLOCKPROPOSER = _descriptor.Descriptor( @@ -10546,8 +10591,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=38529, - serialized_end=38586, + serialized_start=38740, + serialized_end=38797, ) _GETFINALIZEDEPOCHINFOSRESPONSE_GETFINALIZEDEPOCHINFOSRESPONSEV0 = _descriptor.Descriptor( @@ -10596,8 +10641,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=37535, - serialized_end=38596, + serialized_start=37746, + serialized_end=38807, ) _GETFINALIZEDEPOCHINFOSRESPONSE = _descriptor.Descriptor( @@ -10632,8 +10677,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=37394, - serialized_end=38607, + serialized_start=37605, + serialized_end=38818, ) @@ -10671,8 +10716,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=39102, - serialized_end=39171, + serialized_start=39313, + serialized_end=39382, ) _GETCONTESTEDRESOURCESREQUEST_GETCONTESTEDRESOURCESREQUESTV0 = _descriptor.Descriptor( @@ -10768,8 +10813,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=38745, - serialized_end=39205, + serialized_start=38956, + serialized_end=39416, ) _GETCONTESTEDRESOURCESREQUEST = _descriptor.Descriptor( @@ -10804,8 +10849,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=38610, - serialized_end=39216, + serialized_start=38821, + serialized_end=39427, ) @@ -10836,8 +10881,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=39658, - serialized_end=39718, + serialized_start=39869, + serialized_end=39929, ) _GETCONTESTEDRESOURCESRESPONSE_GETCONTESTEDRESOURCESRESPONSEV0 = _descriptor.Descriptor( @@ -10886,8 +10931,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=39357, - serialized_end=39728, + serialized_start=39568, + serialized_end=39939, ) _GETCONTESTEDRESOURCESRESPONSE = _descriptor.Descriptor( @@ -10922,8 +10967,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=39219, - serialized_end=39739, + serialized_start=39430, + serialized_end=39950, ) @@ -10961,8 +11006,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=40252, - serialized_end=40325, + serialized_start=40463, + serialized_end=40536, ) _GETVOTEPOLLSBYENDDATEREQUEST_GETVOTEPOLLSBYENDDATEREQUESTV0_ENDATTIMEINFO = _descriptor.Descriptor( @@ -10999,8 +11044,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=40327, - serialized_end=40394, + serialized_start=40538, + serialized_end=40605, ) _GETVOTEPOLLSBYENDDATEREQUEST_GETVOTEPOLLSBYENDDATEREQUESTV0 = _descriptor.Descriptor( @@ -11085,8 +11130,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=39877, - serialized_end=40453, + serialized_start=40088, + serialized_end=40664, ) _GETVOTEPOLLSBYENDDATEREQUEST = _descriptor.Descriptor( @@ -11121,8 +11166,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=39742, - serialized_end=40464, + serialized_start=39953, + serialized_end=40675, ) @@ -11160,8 +11205,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=40913, - serialized_end=40999, + serialized_start=41124, + serialized_end=41210, ) _GETVOTEPOLLSBYENDDATERESPONSE_GETVOTEPOLLSBYENDDATERESPONSEV0_SERIALIZEDVOTEPOLLSBYTIMESTAMPS = _descriptor.Descriptor( @@ -11198,8 +11243,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=41002, - serialized_end=41217, + serialized_start=41213, + serialized_end=41428, ) _GETVOTEPOLLSBYENDDATERESPONSE_GETVOTEPOLLSBYENDDATERESPONSEV0 = _descriptor.Descriptor( @@ -11248,8 +11293,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=40605, - serialized_end=41227, + serialized_start=40816, + serialized_end=41438, ) _GETVOTEPOLLSBYENDDATERESPONSE = _descriptor.Descriptor( @@ -11284,8 +11329,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=40467, - serialized_end=41238, + serialized_start=40678, + serialized_end=41449, ) @@ -11323,8 +11368,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=41927, - serialized_end=42011, + serialized_start=42138, + serialized_end=42222, ) _GETCONTESTEDRESOURCEVOTESTATEREQUEST_GETCONTESTEDRESOURCEVOTESTATEREQUESTV0 = _descriptor.Descriptor( @@ -11421,8 +11466,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=41400, - serialized_end=42125, + serialized_start=41611, + serialized_end=42336, ) _GETCONTESTEDRESOURCEVOTESTATEREQUEST = _descriptor.Descriptor( @@ -11457,8 +11502,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=41241, - serialized_end=42136, + serialized_start=41452, + serialized_end=42347, ) @@ -11530,8 +11575,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=42636, - serialized_end=43110, + serialized_start=42847, + serialized_end=43321, ) _GETCONTESTEDRESOURCEVOTESTATERESPONSE_GETCONTESTEDRESOURCEVOTESTATERESPONSEV0_CONTESTEDRESOURCECONTENDERS = _descriptor.Descriptor( @@ -11597,8 +11642,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=43113, - serialized_end=43565, + serialized_start=43324, + serialized_end=43776, ) _GETCONTESTEDRESOURCEVOTESTATERESPONSE_GETCONTESTEDRESOURCEVOTESTATERESPONSEV0_CONTENDER = _descriptor.Descriptor( @@ -11652,8 +11697,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=43567, - serialized_end=43674, + serialized_start=43778, + serialized_end=43885, ) _GETCONTESTEDRESOURCEVOTESTATERESPONSE_GETCONTESTEDRESOURCEVOTESTATERESPONSEV0 = _descriptor.Descriptor( @@ -11702,8 +11747,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=42301, - serialized_end=43684, + serialized_start=42512, + serialized_end=43895, ) _GETCONTESTEDRESOURCEVOTESTATERESPONSE = _descriptor.Descriptor( @@ -11738,8 +11783,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=42139, - serialized_end=43695, + serialized_start=42350, + serialized_end=43906, ) @@ -11777,8 +11822,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=41927, - serialized_end=42011, + serialized_start=42138, + serialized_end=42222, ) _GETCONTESTEDRESOURCEVOTERSFORIDENTITYREQUEST_GETCONTESTEDRESOURCEVOTERSFORIDENTITYREQUESTV0 = _descriptor.Descriptor( @@ -11874,8 +11919,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=43882, - serialized_end=44412, + serialized_start=44093, + serialized_end=44623, ) _GETCONTESTEDRESOURCEVOTERSFORIDENTITYREQUEST = _descriptor.Descriptor( @@ -11910,8 +11955,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=43698, - serialized_end=44423, + serialized_start=43909, + serialized_end=44634, ) @@ -11949,8 +11994,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=44963, - serialized_end=45030, + serialized_start=45174, + serialized_end=45241, ) _GETCONTESTEDRESOURCEVOTERSFORIDENTITYRESPONSE_GETCONTESTEDRESOURCEVOTERSFORIDENTITYRESPONSEV0 = _descriptor.Descriptor( @@ -11999,8 +12044,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=44613, - serialized_end=45040, + serialized_start=44824, + serialized_end=45251, ) _GETCONTESTEDRESOURCEVOTERSFORIDENTITYRESPONSE = _descriptor.Descriptor( @@ -12035,8 +12080,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=44426, - serialized_end=45051, + serialized_start=44637, + serialized_end=45262, ) @@ -12074,8 +12119,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=45600, - serialized_end=45697, + serialized_start=45811, + serialized_end=45908, ) _GETCONTESTEDRESOURCEIDENTITYVOTESREQUEST_GETCONTESTEDRESOURCEIDENTITYVOTESREQUESTV0 = _descriptor.Descriptor( @@ -12145,8 +12190,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=45225, - serialized_end=45728, + serialized_start=45436, + serialized_end=45939, ) _GETCONTESTEDRESOURCEIDENTITYVOTESREQUEST = _descriptor.Descriptor( @@ -12181,8 +12226,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=45054, - serialized_end=45739, + serialized_start=45265, + serialized_end=45950, ) @@ -12220,8 +12265,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=46242, - serialized_end=46489, + serialized_start=46453, + serialized_end=46700, ) _GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSEV0_RESOURCEVOTECHOICE = _descriptor.Descriptor( @@ -12264,8 +12309,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=46492, - serialized_end=46793, + serialized_start=46703, + serialized_end=47004, ) _GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSEV0_CONTESTEDRESOURCEIDENTITYVOTE = _descriptor.Descriptor( @@ -12316,8 +12361,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=46796, - serialized_end=47073, + serialized_start=47007, + serialized_end=47284, ) _GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE_GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSEV0 = _descriptor.Descriptor( @@ -12366,8 +12411,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=45916, - serialized_end=47083, + serialized_start=46127, + serialized_end=47294, ) _GETCONTESTEDRESOURCEIDENTITYVOTESRESPONSE = _descriptor.Descriptor( @@ -12402,8 +12447,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=45742, - serialized_end=47094, + serialized_start=45953, + serialized_end=47305, ) @@ -12441,8 +12486,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=47258, - serialized_end=47326, + serialized_start=47469, + serialized_end=47537, ) _GETPREFUNDEDSPECIALIZEDBALANCEREQUEST = _descriptor.Descriptor( @@ -12477,8 +12522,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47097, - serialized_end=47337, + serialized_start=47308, + serialized_end=47548, ) @@ -12528,8 +12573,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47505, - serialized_end=47694, + serialized_start=47716, + serialized_end=47905, ) _GETPREFUNDEDSPECIALIZEDBALANCERESPONSE = _descriptor.Descriptor( @@ -12564,8 +12609,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47340, - serialized_end=47705, + serialized_start=47551, + serialized_end=47916, ) @@ -12596,8 +12641,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=47854, - serialized_end=47905, + serialized_start=48065, + serialized_end=48116, ) _GETTOTALCREDITSINPLATFORMREQUEST = _descriptor.Descriptor( @@ -12632,8 +12677,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47708, - serialized_end=47916, + serialized_start=47919, + serialized_end=48127, ) @@ -12683,8 +12728,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=48069, - serialized_end=48253, + serialized_start=48280, + serialized_end=48464, ) _GETTOTALCREDITSINPLATFORMRESPONSE = _descriptor.Descriptor( @@ -12719,8 +12764,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=47919, - serialized_end=48264, + serialized_start=48130, + serialized_end=48475, ) @@ -12765,8 +12810,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=48383, - serialized_end=48452, + serialized_start=48594, + serialized_end=48663, ) _GETPATHELEMENTSREQUEST = _descriptor.Descriptor( @@ -12801,8 +12846,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=48267, - serialized_end=48463, + serialized_start=48478, + serialized_end=48674, ) @@ -12833,8 +12878,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=48836, - serialized_end=48864, + serialized_start=49047, + serialized_end=49075, ) _GETPATHELEMENTSRESPONSE_GETPATHELEMENTSRESPONSEV0 = _descriptor.Descriptor( @@ -12883,8 +12928,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=48586, - serialized_end=48874, + serialized_start=48797, + serialized_end=49085, ) _GETPATHELEMENTSRESPONSE = _descriptor.Descriptor( @@ -12919,8 +12964,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=48466, - serialized_end=48885, + serialized_start=48677, + serialized_end=49096, ) @@ -12944,8 +12989,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=48986, - serialized_end=49006, + serialized_start=49197, + serialized_end=49217, ) _GETSTATUSREQUEST = _descriptor.Descriptor( @@ -12980,8 +13025,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=48888, - serialized_end=49017, + serialized_start=49099, + serialized_end=49228, ) @@ -13036,8 +13081,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=49894, - serialized_end=49988, + serialized_start=50105, + serialized_end=50199, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_VERSION_PROTOCOL_TENDERDASH = _descriptor.Descriptor( @@ -13074,8 +13119,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=50221, - serialized_end=50261, + serialized_start=50432, + serialized_end=50472, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_VERSION_PROTOCOL_DRIVE = _descriptor.Descriptor( @@ -13119,8 +13164,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=50263, - serialized_end=50323, + serialized_start=50474, + serialized_end=50534, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_VERSION_PROTOCOL = _descriptor.Descriptor( @@ -13157,8 +13202,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=49991, - serialized_end=50323, + serialized_start=50202, + serialized_end=50534, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_VERSION = _descriptor.Descriptor( @@ -13195,8 +13240,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=49681, - serialized_end=50323, + serialized_start=49892, + serialized_end=50534, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_TIME = _descriptor.Descriptor( @@ -13262,8 +13307,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=50325, - serialized_end=50452, + serialized_start=50536, + serialized_end=50663, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_NODE = _descriptor.Descriptor( @@ -13305,8 +13350,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=50454, - serialized_end=50514, + serialized_start=50665, + serialized_end=50725, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_CHAIN = _descriptor.Descriptor( @@ -13397,8 +13442,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=50517, - serialized_end=50824, + serialized_start=50728, + serialized_end=51035, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_NETWORK = _descriptor.Descriptor( @@ -13442,8 +13487,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=50826, - serialized_end=50893, + serialized_start=51037, + serialized_end=51104, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0_STATESYNC = _descriptor.Descriptor( @@ -13522,8 +13567,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=50896, - serialized_end=51157, + serialized_start=51107, + serialized_end=51368, ) _GETSTATUSRESPONSE_GETSTATUSRESPONSEV0 = _descriptor.Descriptor( @@ -13588,8 +13633,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=49122, - serialized_end=51157, + serialized_start=49333, + serialized_end=51368, ) _GETSTATUSRESPONSE = _descriptor.Descriptor( @@ -13624,8 +13669,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=49020, - serialized_end=51168, + serialized_start=49231, + serialized_end=51379, ) @@ -13649,8 +13694,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=51305, - serialized_end=51337, + serialized_start=51516, + serialized_end=51548, ) _GETCURRENTQUORUMSINFOREQUEST = _descriptor.Descriptor( @@ -13685,8 +13730,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=51171, - serialized_end=51348, + serialized_start=51382, + serialized_end=51559, ) @@ -13731,8 +13776,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=51488, - serialized_end=51558, + serialized_start=51699, + serialized_end=51769, ) _GETCURRENTQUORUMSINFORESPONSE_VALIDATORSETV0 = _descriptor.Descriptor( @@ -13783,8 +13828,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=51561, - serialized_end=51736, + serialized_start=51772, + serialized_end=51947, ) _GETCURRENTQUORUMSINFORESPONSE_GETCURRENTQUORUMSINFORESPONSEV0 = _descriptor.Descriptor( @@ -13842,8 +13887,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=51739, - serialized_end=52013, + serialized_start=51950, + serialized_end=52224, ) _GETCURRENTQUORUMSINFORESPONSE = _descriptor.Descriptor( @@ -13878,8 +13923,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=51351, - serialized_end=52024, + serialized_start=51562, + serialized_end=52235, ) @@ -13924,8 +13969,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=52170, - serialized_end=52260, + serialized_start=52381, + serialized_end=52471, ) _GETIDENTITYTOKENBALANCESREQUEST = _descriptor.Descriptor( @@ -13960,8 +14005,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52027, - serialized_end=52271, + serialized_start=52238, + serialized_end=52482, ) @@ -14004,8 +14049,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52710, - serialized_end=52781, + serialized_start=52921, + serialized_end=52992, ) _GETIDENTITYTOKENBALANCESRESPONSE_GETIDENTITYTOKENBALANCESRESPONSEV0_TOKENBALANCES = _descriptor.Descriptor( @@ -14035,8 +14080,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=52784, - serialized_end=52938, + serialized_start=52995, + serialized_end=53149, ) _GETIDENTITYTOKENBALANCESRESPONSE_GETIDENTITYTOKENBALANCESRESPONSEV0 = _descriptor.Descriptor( @@ -14085,8 +14130,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52421, - serialized_end=52948, + serialized_start=52632, + serialized_end=53159, ) _GETIDENTITYTOKENBALANCESRESPONSE = _descriptor.Descriptor( @@ -14121,8 +14166,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52274, - serialized_end=52959, + serialized_start=52485, + serialized_end=53170, ) @@ -14167,8 +14212,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=53111, - serialized_end=53203, + serialized_start=53322, + serialized_end=53414, ) _GETIDENTITIESTOKENBALANCESREQUEST = _descriptor.Descriptor( @@ -14203,8 +14248,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=52962, - serialized_end=53214, + serialized_start=53173, + serialized_end=53425, ) @@ -14247,8 +14292,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=53682, - serialized_end=53764, + serialized_start=53893, + serialized_end=53975, ) _GETIDENTITIESTOKENBALANCESRESPONSE_GETIDENTITIESTOKENBALANCESRESPONSEV0_IDENTITYTOKENBALANCES = _descriptor.Descriptor( @@ -14278,8 +14323,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=53767, - serialized_end=53950, + serialized_start=53978, + serialized_end=54161, ) _GETIDENTITIESTOKENBALANCESRESPONSE_GETIDENTITIESTOKENBALANCESRESPONSEV0 = _descriptor.Descriptor( @@ -14328,8 +14373,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=53370, - serialized_end=53960, + serialized_start=53581, + serialized_end=54171, ) _GETIDENTITIESTOKENBALANCESRESPONSE = _descriptor.Descriptor( @@ -14364,8 +14409,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=53217, - serialized_end=53971, + serialized_start=53428, + serialized_end=54182, ) @@ -14410,8 +14455,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=54108, - serialized_end=54195, + serialized_start=54319, + serialized_end=54406, ) _GETIDENTITYTOKENINFOSREQUEST = _descriptor.Descriptor( @@ -14446,8 +14491,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=53974, - serialized_end=54206, + serialized_start=54185, + serialized_end=54417, ) @@ -14478,8 +14523,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=54620, - serialized_end=54660, + serialized_start=54831, + serialized_end=54871, ) _GETIDENTITYTOKENINFOSRESPONSE_GETIDENTITYTOKENINFOSRESPONSEV0_TOKENINFOENTRY = _descriptor.Descriptor( @@ -14521,8 +14566,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=54663, - serialized_end=54839, + serialized_start=54874, + serialized_end=55050, ) _GETIDENTITYTOKENINFOSRESPONSE_GETIDENTITYTOKENINFOSRESPONSEV0_TOKENINFOS = _descriptor.Descriptor( @@ -14552,8 +14597,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=54842, - serialized_end=54980, + serialized_start=55053, + serialized_end=55191, ) _GETIDENTITYTOKENINFOSRESPONSE_GETIDENTITYTOKENINFOSRESPONSEV0 = _descriptor.Descriptor( @@ -14602,8 +14647,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=54347, - serialized_end=54990, + serialized_start=54558, + serialized_end=55201, ) _GETIDENTITYTOKENINFOSRESPONSE = _descriptor.Descriptor( @@ -14638,8 +14683,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=54209, - serialized_end=55001, + serialized_start=54420, + serialized_end=55212, ) @@ -14684,8 +14729,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=55144, - serialized_end=55233, + serialized_start=55355, + serialized_end=55444, ) _GETIDENTITIESTOKENINFOSREQUEST = _descriptor.Descriptor( @@ -14720,8 +14765,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=55004, - serialized_end=55244, + serialized_start=55215, + serialized_end=55455, ) @@ -14752,8 +14797,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=54620, - serialized_end=54660, + serialized_start=54831, + serialized_end=54871, ) _GETIDENTITIESTOKENINFOSRESPONSE_GETIDENTITIESTOKENINFOSRESPONSEV0_TOKENINFOENTRY = _descriptor.Descriptor( @@ -14795,8 +14840,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=55731, - serialized_end=55914, + serialized_start=55942, + serialized_end=56125, ) _GETIDENTITIESTOKENINFOSRESPONSE_GETIDENTITIESTOKENINFOSRESPONSEV0_IDENTITYTOKENINFOS = _descriptor.Descriptor( @@ -14826,8 +14871,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=55917, - serialized_end=56068, + serialized_start=56128, + serialized_end=56279, ) _GETIDENTITIESTOKENINFOSRESPONSE_GETIDENTITIESTOKENINFOSRESPONSEV0 = _descriptor.Descriptor( @@ -14876,8 +14921,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=55391, - serialized_end=56078, + serialized_start=55602, + serialized_end=56289, ) _GETIDENTITIESTOKENINFOSRESPONSE = _descriptor.Descriptor( @@ -14912,8 +14957,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=55247, - serialized_end=56089, + serialized_start=55458, + serialized_end=56300, ) @@ -14951,8 +14996,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=56211, - serialized_end=56272, + serialized_start=56422, + serialized_end=56483, ) _GETTOKENSTATUSESREQUEST = _descriptor.Descriptor( @@ -14987,8 +15032,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=56092, - serialized_end=56283, + serialized_start=56303, + serialized_end=56494, ) @@ -15031,8 +15076,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=56673, - serialized_end=56741, + serialized_start=56884, + serialized_end=56952, ) _GETTOKENSTATUSESRESPONSE_GETTOKENSTATUSESRESPONSEV0_TOKENSTATUSES = _descriptor.Descriptor( @@ -15062,8 +15107,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=56744, - serialized_end=56880, + serialized_start=56955, + serialized_end=57091, ) _GETTOKENSTATUSESRESPONSE_GETTOKENSTATUSESRESPONSEV0 = _descriptor.Descriptor( @@ -15112,8 +15157,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=56409, - serialized_end=56890, + serialized_start=56620, + serialized_end=57101, ) _GETTOKENSTATUSESRESPONSE = _descriptor.Descriptor( @@ -15148,8 +15193,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=56286, - serialized_end=56901, + serialized_start=56497, + serialized_end=57112, ) @@ -15187,8 +15232,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57059, - serialized_end=57132, + serialized_start=57270, + serialized_end=57343, ) _GETTOKENDIRECTPURCHASEPRICESREQUEST = _descriptor.Descriptor( @@ -15223,8 +15268,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=56904, - serialized_end=57143, + serialized_start=57115, + serialized_end=57354, ) @@ -15262,8 +15307,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57633, - serialized_end=57684, + serialized_start=57844, + serialized_end=57895, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE_GETTOKENDIRECTPURCHASEPRICESRESPONSEV0_PRICINGSCHEDULE = _descriptor.Descriptor( @@ -15293,8 +15338,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57687, - serialized_end=57854, + serialized_start=57898, + serialized_end=58065, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE_GETTOKENDIRECTPURCHASEPRICESRESPONSEV0_TOKENDIRECTPURCHASEPRICEENTRY = _descriptor.Descriptor( @@ -15343,8 +15388,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=57857, - serialized_end=58085, + serialized_start=58068, + serialized_end=58296, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE_GETTOKENDIRECTPURCHASEPRICESRESPONSEV0_TOKENDIRECTPURCHASEPRICES = _descriptor.Descriptor( @@ -15374,8 +15419,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=58088, - serialized_end=58288, + serialized_start=58299, + serialized_end=58499, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE_GETTOKENDIRECTPURCHASEPRICESRESPONSEV0 = _descriptor.Descriptor( @@ -15424,8 +15469,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=57305, - serialized_end=58298, + serialized_start=57516, + serialized_end=58509, ) _GETTOKENDIRECTPURCHASEPRICESRESPONSE = _descriptor.Descriptor( @@ -15460,8 +15505,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=57146, - serialized_end=58309, + serialized_start=57357, + serialized_end=58520, ) @@ -15499,8 +15544,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=58443, - serialized_end=58507, + serialized_start=58654, + serialized_end=58718, ) _GETTOKENCONTRACTINFOREQUEST = _descriptor.Descriptor( @@ -15535,8 +15580,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=58312, - serialized_end=58518, + serialized_start=58523, + serialized_end=58729, ) @@ -15574,8 +15619,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=58930, - serialized_end=59007, + serialized_start=59141, + serialized_end=59218, ) _GETTOKENCONTRACTINFORESPONSE_GETTOKENCONTRACTINFORESPONSEV0 = _descriptor.Descriptor( @@ -15624,8 +15669,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=58656, - serialized_end=59017, + serialized_start=58867, + serialized_end=59228, ) _GETTOKENCONTRACTINFORESPONSE = _descriptor.Descriptor( @@ -15660,8 +15705,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=58521, - serialized_end=59028, + serialized_start=58732, + serialized_end=59239, ) @@ -15716,8 +15761,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=59461, - serialized_end=59615, + serialized_start=59672, + serialized_end=59826, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSREQUEST_GETTOKENPREPROGRAMMEDDISTRIBUTIONSREQUESTV0 = _descriptor.Descriptor( @@ -15778,8 +15823,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=59205, - serialized_end=59643, + serialized_start=59416, + serialized_end=59854, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSREQUEST = _descriptor.Descriptor( @@ -15814,8 +15859,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=59031, - serialized_end=59654, + serialized_start=59242, + serialized_end=59865, ) @@ -15853,8 +15898,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=60165, - serialized_end=60227, + serialized_start=60376, + serialized_end=60438, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSE_GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSEV0_TOKENTIMEDDISTRIBUTIONENTRY = _descriptor.Descriptor( @@ -15891,8 +15936,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=60230, - serialized_end=60442, + serialized_start=60441, + serialized_end=60653, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSE_GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSEV0_TOKENDISTRIBUTIONS = _descriptor.Descriptor( @@ -15922,8 +15967,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=60445, - serialized_end=60640, + serialized_start=60656, + serialized_end=60851, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSE_GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSEV0 = _descriptor.Descriptor( @@ -15972,8 +16017,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=59835, - serialized_end=60650, + serialized_start=60046, + serialized_end=60861, ) _GETTOKENPREPROGRAMMEDDISTRIBUTIONSRESPONSE = _descriptor.Descriptor( @@ -16008,8 +16053,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=59657, - serialized_end=60661, + serialized_start=59868, + serialized_end=60872, ) @@ -16047,8 +16092,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=60850, - serialized_end=60923, + serialized_start=61061, + serialized_end=61134, ) _GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMREQUEST_GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMREQUESTV0 = _descriptor.Descriptor( @@ -16104,8 +16149,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=60926, - serialized_end=61167, + serialized_start=61137, + serialized_end=61378, ) _GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMREQUEST = _descriptor.Descriptor( @@ -16140,8 +16185,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=60664, - serialized_end=61178, + serialized_start=60875, + serialized_end=61389, ) @@ -16198,8 +16243,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=61699, - serialized_end=61819, + serialized_start=61910, + serialized_end=62030, ) _GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMRESPONSE_GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMRESPONSEV0 = _descriptor.Descriptor( @@ -16248,8 +16293,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=61371, - serialized_end=61829, + serialized_start=61582, + serialized_end=62040, ) _GETTOKENPERPETUALDISTRIBUTIONLASTCLAIMRESPONSE = _descriptor.Descriptor( @@ -16284,8 +16329,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=61181, - serialized_end=61840, + serialized_start=61392, + serialized_end=62051, ) @@ -16323,8 +16368,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=61971, - serialized_end=62034, + serialized_start=62182, + serialized_end=62245, ) _GETTOKENTOTALSUPPLYREQUEST = _descriptor.Descriptor( @@ -16359,8 +16404,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=61843, - serialized_end=62045, + serialized_start=62054, + serialized_end=62256, ) @@ -16405,8 +16450,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=62466, - serialized_end=62586, + serialized_start=62677, + serialized_end=62797, ) _GETTOKENTOTALSUPPLYRESPONSE_GETTOKENTOTALSUPPLYRESPONSEV0 = _descriptor.Descriptor( @@ -16455,8 +16500,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62180, - serialized_end=62596, + serialized_start=62391, + serialized_end=62807, ) _GETTOKENTOTALSUPPLYRESPONSE = _descriptor.Descriptor( @@ -16491,8 +16536,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62048, - serialized_end=62607, + serialized_start=62259, + serialized_end=62818, ) @@ -16537,8 +16582,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=62717, - serialized_end=62809, + serialized_start=62928, + serialized_end=63020, ) _GETGROUPINFOREQUEST = _descriptor.Descriptor( @@ -16573,8 +16618,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62610, - serialized_end=62820, + serialized_start=62821, + serialized_end=63031, ) @@ -16612,8 +16657,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=63178, - serialized_end=63230, + serialized_start=63389, + serialized_end=63441, ) _GETGROUPINFORESPONSE_GETGROUPINFORESPONSEV0_GROUPINFOENTRY = _descriptor.Descriptor( @@ -16650,8 +16695,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=63233, - serialized_end=63385, + serialized_start=63444, + serialized_end=63596, ) _GETGROUPINFORESPONSE_GETGROUPINFORESPONSEV0_GROUPINFO = _descriptor.Descriptor( @@ -16686,8 +16731,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=63388, - serialized_end=63526, + serialized_start=63599, + serialized_end=63737, ) _GETGROUPINFORESPONSE_GETGROUPINFORESPONSEV0 = _descriptor.Descriptor( @@ -16736,8 +16781,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62934, - serialized_end=63536, + serialized_start=63145, + serialized_end=63747, ) _GETGROUPINFORESPONSE = _descriptor.Descriptor( @@ -16772,8 +16817,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=62823, - serialized_end=63547, + serialized_start=63034, + serialized_end=63758, ) @@ -16811,8 +16856,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=63660, - serialized_end=63777, + serialized_start=63871, + serialized_end=63988, ) _GETGROUPINFOSREQUEST_GETGROUPINFOSREQUESTV0 = _descriptor.Descriptor( @@ -16873,8 +16918,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=63780, - serialized_end=64032, + serialized_start=63991, + serialized_end=64243, ) _GETGROUPINFOSREQUEST = _descriptor.Descriptor( @@ -16909,8 +16954,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=63550, - serialized_end=64043, + serialized_start=63761, + serialized_end=64254, ) @@ -16948,8 +16993,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=63178, - serialized_end=63230, + serialized_start=63389, + serialized_end=63441, ) _GETGROUPINFOSRESPONSE_GETGROUPINFOSRESPONSEV0_GROUPPOSITIONINFOENTRY = _descriptor.Descriptor( @@ -16993,8 +17038,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=64464, - serialized_end=64659, + serialized_start=64675, + serialized_end=64870, ) _GETGROUPINFOSRESPONSE_GETGROUPINFOSRESPONSEV0_GROUPINFOS = _descriptor.Descriptor( @@ -17024,8 +17069,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=64662, - serialized_end=64792, + serialized_start=64873, + serialized_end=65003, ) _GETGROUPINFOSRESPONSE_GETGROUPINFOSRESPONSEV0 = _descriptor.Descriptor( @@ -17074,8 +17119,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64160, - serialized_end=64802, + serialized_start=64371, + serialized_end=65013, ) _GETGROUPINFOSRESPONSE = _descriptor.Descriptor( @@ -17110,8 +17155,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64046, - serialized_end=64813, + serialized_start=64257, + serialized_end=65024, ) @@ -17149,8 +17194,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=64932, - serialized_end=65008, + serialized_start=65143, + serialized_end=65219, ) _GETGROUPACTIONSREQUEST_GETGROUPACTIONSREQUESTV0 = _descriptor.Descriptor( @@ -17225,8 +17270,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65011, - serialized_end=65339, + serialized_start=65222, + serialized_end=65550, ) _GETGROUPACTIONSREQUEST = _descriptor.Descriptor( @@ -17262,8 +17307,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=64816, - serialized_end=65390, + serialized_start=65027, + serialized_end=65601, ) @@ -17313,8 +17358,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65772, - serialized_end=65863, + serialized_start=65983, + serialized_end=66074, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_BURNEVENT = _descriptor.Descriptor( @@ -17363,8 +17408,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65865, - serialized_end=65956, + serialized_start=66076, + serialized_end=66167, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_FREEZEEVENT = _descriptor.Descriptor( @@ -17406,8 +17451,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65958, - serialized_end=66032, + serialized_start=66169, + serialized_end=66243, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_UNFREEZEEVENT = _descriptor.Descriptor( @@ -17449,8 +17494,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=66034, - serialized_end=66110, + serialized_start=66245, + serialized_end=66321, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_DESTROYFROZENFUNDSEVENT = _descriptor.Descriptor( @@ -17499,8 +17544,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=66112, - serialized_end=66214, + serialized_start=66323, + serialized_end=66425, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_SHAREDENCRYPTEDNOTE = _descriptor.Descriptor( @@ -17544,8 +17589,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=66216, - serialized_end=66316, + serialized_start=66427, + serialized_end=66527, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_PERSONALENCRYPTEDNOTE = _descriptor.Descriptor( @@ -17589,8 +17634,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=66318, - serialized_end=66441, + serialized_start=66529, + serialized_end=66652, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_EMERGENCYACTIONEVENT = _descriptor.Descriptor( @@ -17633,8 +17678,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=66444, - serialized_end=66677, + serialized_start=66655, + serialized_end=66888, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_TOKENCONFIGUPDATEEVENT = _descriptor.Descriptor( @@ -17676,8 +17721,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=66679, - serialized_end=66779, + serialized_start=66890, + serialized_end=66990, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_UPDATEDIRECTPURCHASEPRICEEVENT_PRICEFORQUANTITY = _descriptor.Descriptor( @@ -17714,8 +17759,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=57633, - serialized_end=57684, + serialized_start=57844, + serialized_end=57895, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_UPDATEDIRECTPURCHASEPRICEEVENT_PRICINGSCHEDULE = _descriptor.Descriptor( @@ -17745,8 +17790,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=67071, - serialized_end=67243, + serialized_start=67282, + serialized_end=67454, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_UPDATEDIRECTPURCHASEPRICEEVENT = _descriptor.Descriptor( @@ -17800,8 +17845,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=66782, - serialized_end=67268, + serialized_start=66993, + serialized_end=67479, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_GROUPACTIONEVENT = _descriptor.Descriptor( @@ -17850,8 +17895,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=67271, - serialized_end=67651, + serialized_start=67482, + serialized_end=67862, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_DOCUMENTEVENT = _descriptor.Descriptor( @@ -17886,8 +17931,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=67654, - serialized_end=67793, + serialized_start=67865, + serialized_end=68004, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_DOCUMENTCREATEEVENT = _descriptor.Descriptor( @@ -17917,8 +17962,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=67795, - serialized_end=67842, + serialized_start=68006, + serialized_end=68053, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_CONTRACTUPDATEEVENT = _descriptor.Descriptor( @@ -17948,8 +17993,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=67844, - serialized_end=67891, + serialized_start=68055, + serialized_end=68102, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_CONTRACTEVENT = _descriptor.Descriptor( @@ -17984,8 +18029,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=67894, - serialized_end=68033, + serialized_start=68105, + serialized_end=68244, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_TOKENEVENT = _descriptor.Descriptor( @@ -18069,8 +18114,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=68036, - serialized_end=69013, + serialized_start=68247, + serialized_end=69224, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_GROUPACTIONENTRY = _descriptor.Descriptor( @@ -18107,8 +18152,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=69016, - serialized_end=69163, + serialized_start=69227, + serialized_end=69374, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0_GROUPACTIONS = _descriptor.Descriptor( @@ -18138,8 +18183,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=69166, - serialized_end=69298, + serialized_start=69377, + serialized_end=69509, ) _GETGROUPACTIONSRESPONSE_GETGROUPACTIONSRESPONSEV0 = _descriptor.Descriptor( @@ -18188,8 +18233,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65513, - serialized_end=69308, + serialized_start=65724, + serialized_end=69519, ) _GETGROUPACTIONSRESPONSE = _descriptor.Descriptor( @@ -18224,8 +18269,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=65393, - serialized_end=69319, + serialized_start=65604, + serialized_end=69530, ) @@ -18284,8 +18329,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=69457, - serialized_end=69663, + serialized_start=69668, + serialized_end=69874, ) _GETGROUPACTIONSIGNERSREQUEST = _descriptor.Descriptor( @@ -18321,8 +18366,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=69322, - serialized_end=69714, + serialized_start=69533, + serialized_end=69925, ) @@ -18360,8 +18405,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=70146, - serialized_end=70199, + serialized_start=70357, + serialized_end=70410, ) _GETGROUPACTIONSIGNERSRESPONSE_GETGROUPACTIONSIGNERSRESPONSEV0_GROUPACTIONSIGNERS = _descriptor.Descriptor( @@ -18391,8 +18436,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=70202, - serialized_end=70347, + serialized_start=70413, + serialized_end=70558, ) _GETGROUPACTIONSIGNERSRESPONSE_GETGROUPACTIONSIGNERSRESPONSEV0 = _descriptor.Descriptor( @@ -18441,8 +18486,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=69855, - serialized_end=70357, + serialized_start=70066, + serialized_end=70568, ) _GETGROUPACTIONSIGNERSRESPONSE = _descriptor.Descriptor( @@ -18477,8 +18522,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=69717, - serialized_end=70368, + serialized_start=69928, + serialized_end=70579, ) @@ -18516,8 +18561,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=70484, - serialized_end=70541, + serialized_start=70695, + serialized_end=70752, ) _GETADDRESSINFOREQUEST = _descriptor.Descriptor( @@ -18552,8 +18597,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=70371, - serialized_end=70552, + serialized_start=70582, + serialized_end=70763, ) @@ -18596,8 +18641,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=70555, - serialized_end=70688, + serialized_start=70766, + serialized_end=70899, ) @@ -18635,8 +18680,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=70690, - serialized_end=70739, + serialized_start=70901, + serialized_end=70950, ) @@ -18667,8 +18712,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=70741, - serialized_end=70836, + serialized_start=70952, + serialized_end=71047, ) @@ -18718,8 +18763,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=70838, - serialized_end=70947, + serialized_start=71049, + serialized_end=71158, ) @@ -18757,8 +18802,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=70949, - serialized_end=71069, + serialized_start=71160, + serialized_end=71280, ) @@ -18789,8 +18834,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=71071, - serialized_end=71178, + serialized_start=71282, + serialized_end=71389, ) @@ -18840,8 +18885,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=71298, - serialized_end=71523, + serialized_start=71509, + serialized_end=71734, ) _GETADDRESSINFORESPONSE = _descriptor.Descriptor( @@ -18876,8 +18921,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=71181, - serialized_end=71534, + serialized_start=71392, + serialized_end=71745, ) @@ -18915,8 +18960,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=71659, - serialized_end=71721, + serialized_start=71870, + serialized_end=71932, ) _GETADDRESSESINFOSREQUEST = _descriptor.Descriptor( @@ -18951,8 +18996,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=71537, - serialized_end=71732, + serialized_start=71748, + serialized_end=71943, ) @@ -19002,8 +19047,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=71861, - serialized_end=72093, + serialized_start=72072, + serialized_end=72304, ) _GETADDRESSESINFOSRESPONSE = _descriptor.Descriptor( @@ -19038,8 +19083,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=71735, - serialized_end=72104, + serialized_start=71946, + serialized_end=72315, ) @@ -19063,8 +19108,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=72244, - serialized_end=72277, + serialized_start=72455, + serialized_end=72488, ) _GETADDRESSESTRUNKSTATEREQUEST = _descriptor.Descriptor( @@ -19099,8 +19144,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=72107, - serialized_end=72288, + serialized_start=72318, + serialized_end=72499, ) @@ -19138,8 +19183,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=72432, - serialized_end=72578, + serialized_start=72643, + serialized_end=72789, ) _GETADDRESSESTRUNKSTATERESPONSE = _descriptor.Descriptor( @@ -19174,8 +19219,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=72291, - serialized_end=72589, + serialized_start=72502, + serialized_end=72800, ) @@ -19220,8 +19265,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=72732, - serialized_end=72821, + serialized_start=72943, + serialized_end=73032, ) _GETADDRESSESBRANCHSTATEREQUEST = _descriptor.Descriptor( @@ -19256,8 +19301,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=72592, - serialized_end=72832, + serialized_start=72803, + serialized_end=73043, ) @@ -19288,8 +19333,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=72978, - serialized_end=73033, + serialized_start=73189, + serialized_end=73244, ) _GETADDRESSESBRANCHSTATERESPONSE = _descriptor.Descriptor( @@ -19324,8 +19369,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=72835, - serialized_end=73044, + serialized_start=73046, + serialized_end=73255, ) @@ -19370,8 +19415,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=73208, - serialized_end=73322, + serialized_start=73419, + serialized_end=73533, ) _GETRECENTADDRESSBALANCECHANGESREQUEST = _descriptor.Descriptor( @@ -19406,8 +19451,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=73047, - serialized_end=73333, + serialized_start=73258, + serialized_end=73544, ) @@ -19457,8 +19502,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=73501, - serialized_end=73765, + serialized_start=73712, + serialized_end=73976, ) _GETRECENTADDRESSBALANCECHANGESRESPONSE = _descriptor.Descriptor( @@ -19493,8 +19538,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=73336, - serialized_end=73776, + serialized_start=73547, + serialized_end=73987, ) @@ -19532,8 +19577,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=73778, - serialized_end=73849, + serialized_start=73989, + serialized_end=74060, ) @@ -19583,8 +19628,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=73852, - serialized_end=74028, + serialized_start=74063, + serialized_end=74239, ) @@ -19615,8 +19660,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=74030, - serialized_end=74122, + serialized_start=74241, + serialized_end=74333, ) @@ -19661,8 +19706,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=74125, - serialized_end=74299, + serialized_start=74336, + serialized_end=74510, ) @@ -19693,8 +19738,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=74302, - serialized_end=74437, + serialized_start=74513, + serialized_end=74648, ) @@ -19732,8 +19777,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=74629, - serialized_end=74726, + serialized_start=74840, + serialized_end=74937, ) _GETRECENTCOMPACTEDADDRESSBALANCECHANGESREQUEST = _descriptor.Descriptor( @@ -19768,8 +19813,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=74440, - serialized_end=74737, + serialized_start=74651, + serialized_end=74948, ) @@ -19819,8 +19864,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=74933, - serialized_end=75225, + serialized_start=75144, + serialized_end=75436, ) _GETRECENTCOMPACTEDADDRESSBALANCECHANGESRESPONSE = _descriptor.Descriptor( @@ -19855,8 +19900,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=74740, - serialized_end=75236, + serialized_start=74951, + serialized_end=75447, ) @@ -19901,8 +19946,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=75385, - serialized_end=75472, + serialized_start=75596, + serialized_end=75683, ) _GETSHIELDEDENCRYPTEDNOTESREQUEST = _descriptor.Descriptor( @@ -19937,8 +19982,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=75239, - serialized_end=75483, + serialized_start=75450, + serialized_end=75694, ) @@ -19990,8 +20035,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=75930, - serialized_end=76017, + serialized_start=76141, + serialized_end=76228, ) _GETSHIELDEDENCRYPTEDNOTESRESPONSE_GETSHIELDEDENCRYPTEDNOTESRESPONSEV0_ENCRYPTEDNOTES = _descriptor.Descriptor( @@ -20021,8 +20066,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=76020, - serialized_end=76165, + serialized_start=76231, + serialized_end=76376, ) _GETSHIELDEDENCRYPTEDNOTESRESPONSE_GETSHIELDEDENCRYPTEDNOTESRESPONSEV0 = _descriptor.Descriptor( @@ -20071,8 +20116,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=75636, - serialized_end=76175, + serialized_start=75847, + serialized_end=76386, ) _GETSHIELDEDENCRYPTEDNOTESRESPONSE = _descriptor.Descriptor( @@ -20107,8 +20152,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=75486, - serialized_end=76186, + serialized_start=75697, + serialized_end=76397, ) @@ -20139,8 +20184,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=76314, - serialized_end=76358, + serialized_start=76525, + serialized_end=76569, ) _GETSHIELDEDANCHORSREQUEST = _descriptor.Descriptor( @@ -20175,8 +20220,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=76189, - serialized_end=76369, + serialized_start=76400, + serialized_end=76580, ) @@ -20207,8 +20252,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=76758, - serialized_end=76784, + serialized_start=76969, + serialized_end=76995, ) _GETSHIELDEDANCHORSRESPONSE_GETSHIELDEDANCHORSRESPONSEV0 = _descriptor.Descriptor( @@ -20257,8 +20302,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=76501, - serialized_end=76794, + serialized_start=76712, + serialized_end=77005, ) _GETSHIELDEDANCHORSRESPONSE = _descriptor.Descriptor( @@ -20293,8 +20338,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=76372, - serialized_end=76805, + serialized_start=76583, + serialized_end=77016, ) @@ -20325,8 +20370,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=76960, - serialized_end=77013, + serialized_start=77171, + serialized_end=77224, ) _GETMOSTRECENTSHIELDEDANCHORREQUEST = _descriptor.Descriptor( @@ -20361,8 +20406,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=76808, - serialized_end=77024, + serialized_start=77019, + serialized_end=77235, ) @@ -20412,8 +20457,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=77183, - serialized_end=77364, + serialized_start=77394, + serialized_end=77575, ) _GETMOSTRECENTSHIELDEDANCHORRESPONSE = _descriptor.Descriptor( @@ -20448,8 +20493,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=77027, - serialized_end=77375, + serialized_start=77238, + serialized_end=77586, ) @@ -20480,8 +20525,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=77509, - serialized_end=77555, + serialized_start=77720, + serialized_end=77766, ) _GETSHIELDEDPOOLSTATEREQUEST = _descriptor.Descriptor( @@ -20516,8 +20561,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=77378, - serialized_end=77566, + serialized_start=77589, + serialized_end=77777, ) @@ -20567,8 +20612,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=77704, - serialized_end=77889, + serialized_start=77915, + serialized_end=78100, ) _GETSHIELDEDPOOLSTATERESPONSE = _descriptor.Descriptor( @@ -20603,8 +20648,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=77569, - serialized_end=77900, + serialized_start=77780, + serialized_end=78111, ) @@ -20635,8 +20680,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=78037, - serialized_end=78084, + serialized_start=78248, + serialized_end=78295, ) _GETSHIELDEDNOTESCOUNTREQUEST = _descriptor.Descriptor( @@ -20671,8 +20716,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=77903, - serialized_end=78095, + serialized_start=78114, + serialized_end=78306, ) @@ -20722,8 +20767,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=78236, - serialized_end=78426, + serialized_start=78447, + serialized_end=78637, ) _GETSHIELDEDNOTESCOUNTRESPONSE = _descriptor.Descriptor( @@ -20758,8 +20803,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=78098, - serialized_end=78437, + serialized_start=78309, + serialized_end=78648, ) @@ -20797,8 +20842,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=78574, - serialized_end=78641, + serialized_start=78785, + serialized_end=78852, ) _GETSHIELDEDNULLIFIERSREQUEST = _descriptor.Descriptor( @@ -20833,8 +20878,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=78440, - serialized_end=78652, + serialized_start=78651, + serialized_end=78863, ) @@ -20872,8 +20917,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=79081, - serialized_end=79135, + serialized_start=79292, + serialized_end=79346, ) _GETSHIELDEDNULLIFIERSRESPONSE_GETSHIELDEDNULLIFIERSRESPONSEV0_NULLIFIERSTATUSES = _descriptor.Descriptor( @@ -20903,8 +20948,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=79138, - serialized_end=79280, + serialized_start=79349, + serialized_end=79491, ) _GETSHIELDEDNULLIFIERSRESPONSE_GETSHIELDEDNULLIFIERSRESPONSEV0 = _descriptor.Descriptor( @@ -20953,8 +20998,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=78793, - serialized_end=79290, + serialized_start=79004, + serialized_end=79501, ) _GETSHIELDEDNULLIFIERSRESPONSE = _descriptor.Descriptor( @@ -20989,8 +21034,8 @@ create_key=_descriptor._internal_create_key, fields=[]), ], - serialized_start=78655, - serialized_end=79301, + serialized_start=78866, + serialized_end=79512, ) _GETIDENTITYREQUEST_GETIDENTITYREQUESTV0.containing_type = _GETIDENTITYREQUEST @@ -21927,8 +21972,10 @@ _WAITFORSTATETRANSITIONRESULTREQUEST.oneofs_by_name['version'].fields.append( _WAITFORSTATETRANSITIONRESULTREQUEST.fields_by_name['v0']) _WAITFORSTATETRANSITIONRESULTREQUEST.fields_by_name['v0'].containing_oneof = _WAITFORSTATETRANSITIONRESULTREQUEST.oneofs_by_name['version'] +_WAITFORSTATETRANSITIONRESULTRESPONSE_SUCCESSWITHOWNERBALANCE.containing_type = _WAITFORSTATETRANSITIONRESULTRESPONSE _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.fields_by_name['error'].message_type = _STATETRANSITIONBROADCASTERROR _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.fields_by_name['proof'].message_type = _PROOF +_WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.fields_by_name['success_with_owner_balance'].message_type = _WAITFORSTATETRANSITIONRESULTRESPONSE_SUCCESSWITHOWNERBALANCE _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.fields_by_name['metadata'].message_type = _RESPONSEMETADATA _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.containing_type = _WAITFORSTATETRANSITIONRESULTRESPONSE _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.oneofs_by_name['result'].fields.append( @@ -21937,6 +21984,9 @@ _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.oneofs_by_name['result'].fields.append( _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.fields_by_name['proof']) _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.fields_by_name['proof'].containing_oneof = _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.oneofs_by_name['result'] +_WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.oneofs_by_name['result'].fields.append( + _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.fields_by_name['success_with_owner_balance']) +_WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.fields_by_name['success_with_owner_balance'].containing_oneof = _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0.oneofs_by_name['result'] _WAITFORSTATETRANSITIONRESULTRESPONSE.fields_by_name['v0'].message_type = _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0 _WAITFORSTATETRANSITIONRESULTRESPONSE.oneofs_by_name['version'].fields.append( _WAITFORSTATETRANSITIONRESULTRESPONSE.fields_by_name['v0']) @@ -24763,6 +24813,13 @@ WaitForStateTransitionResultResponse = _reflection.GeneratedProtocolMessageType('WaitForStateTransitionResultResponse', (_message.Message,), { + 'SuccessWithOwnerBalance' : _reflection.GeneratedProtocolMessageType('SuccessWithOwnerBalance', (_message.Message,), { + 'DESCRIPTOR' : _WAITFORSTATETRANSITIONRESULTRESPONSE_SUCCESSWITHOWNERBALANCE, + '__module__' : 'platform_pb2' + # @@protoc_insertion_point(class_scope:org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance) + }) + , + 'WaitForStateTransitionResultResponseV0' : _reflection.GeneratedProtocolMessageType('WaitForStateTransitionResultResponseV0', (_message.Message,), { 'DESCRIPTOR' : _WAITFORSTATETRANSITIONRESULTRESPONSE_WAITFORSTATETRANSITIONRESULTRESPONSEV0, '__module__' : 'platform_pb2' @@ -24774,6 +24831,7 @@ # @@protoc_insertion_point(class_scope:org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse) }) _sym_db.RegisterMessage(WaitForStateTransitionResultResponse) +_sym_db.RegisterMessage(WaitForStateTransitionResultResponse.SuccessWithOwnerBalance) _sym_db.RegisterMessage(WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0) GetConsensusParamsRequest = _reflection.GeneratedProtocolMessageType('GetConsensusParamsRequest', (_message.Message,), { @@ -26911,6 +26969,7 @@ _GETDOCUMENTSRESPONSE_GETDOCUMENTSRESPONSEV1_RANKEDENTRIES.fields_by_name['skipped']._options = None _GETDOCUMENTHISTORYREQUEST_GETDOCUMENTHISTORYREQUESTV0.fields_by_name['start_at_ms']._options = None _GETDOCUMENTHISTORYRESPONSE_GETDOCUMENTHISTORYRESPONSEV0_DOCUMENTHISTORYENTRY.fields_by_name['date']._options = None +_WAITFORSTATETRANSITIONRESULTRESPONSE_SUCCESSWITHOWNERBALANCE.fields_by_name['owner_balance']._options = None _GETEPOCHSINFORESPONSE_GETEPOCHSINFORESPONSEV0_EPOCHINFO.fields_by_name['first_block_height']._options = None _GETEPOCHSINFORESPONSE_GETEPOCHSINFORESPONSEV0_EPOCHINFO.fields_by_name['start_time']._options = None _GETFINALIZEDEPOCHINFOSRESPONSE_GETFINALIZEDEPOCHINFOSRESPONSEV0_FINALIZEDEPOCHINFO.fields_by_name['first_block_height']._options = None @@ -26962,8 +27021,8 @@ index=0, serialized_options=None, create_key=_descriptor._internal_create_key, - serialized_start=79584, - serialized_end=89904, + serialized_start=79795, + serialized_end=90115, methods=[ _descriptor.MethodDescriptor( name='broadcastStateTransition', diff --git a/packages/dapi-grpc/clients/platform/v0/web/platform_pb.d.ts b/packages/dapi-grpc/clients/platform/v0/web/platform_pb.d.ts index f0dfa62cdb8..6e6804d2716 100644 --- a/packages/dapi-grpc/clients/platform/v0/web/platform_pb.d.ts +++ b/packages/dapi-grpc/clients/platform/v0/web/platform_pb.d.ts @@ -6388,6 +6388,9 @@ export namespace WaitForStateTransitionResultRequest { getProve(): boolean; setProve(value: boolean): void; + getRequestUserBalance(): boolean; + setRequestUserBalance(value: boolean): void; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): WaitForStateTransitionResultRequestV0.AsObject; static toObject(includeInstance: boolean, msg: WaitForStateTransitionResultRequestV0): WaitForStateTransitionResultRequestV0.AsObject; @@ -6402,6 +6405,7 @@ export namespace WaitForStateTransitionResultRequest { export type AsObject = { stateTransitionHash: Uint8Array | string, prove: boolean, + requestUserBalance: boolean, } } @@ -6433,6 +6437,26 @@ export namespace WaitForStateTransitionResultResponse { v0?: WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.AsObject, } + export class SuccessWithOwnerBalance extends jspb.Message { + getOwnerBalance(): string; + setOwnerBalance(value: string): void; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): SuccessWithOwnerBalance.AsObject; + static toObject(includeInstance: boolean, msg: SuccessWithOwnerBalance): SuccessWithOwnerBalance.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: SuccessWithOwnerBalance, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): SuccessWithOwnerBalance; + static deserializeBinaryFromReader(message: SuccessWithOwnerBalance, reader: jspb.BinaryReader): SuccessWithOwnerBalance; + } + + export namespace SuccessWithOwnerBalance { + export type AsObject = { + ownerBalance: string, + } + } + export class WaitForStateTransitionResultResponseV0 extends jspb.Message { hasError(): boolean; clearError(): void; @@ -6444,6 +6468,11 @@ export namespace WaitForStateTransitionResultResponse { getProof(): Proof | undefined; setProof(value?: Proof): void; + hasSuccessWithOwnerBalance(): boolean; + clearSuccessWithOwnerBalance(): void; + getSuccessWithOwnerBalance(): WaitForStateTransitionResultResponse.SuccessWithOwnerBalance | undefined; + setSuccessWithOwnerBalance(value?: WaitForStateTransitionResultResponse.SuccessWithOwnerBalance): void; + hasMetadata(): boolean; clearMetadata(): void; getMetadata(): ResponseMetadata | undefined; @@ -6464,6 +6493,7 @@ export namespace WaitForStateTransitionResultResponse { export type AsObject = { error?: StateTransitionBroadcastError.AsObject, proof?: Proof.AsObject, + successWithOwnerBalance?: WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.AsObject, metadata?: ResponseMetadata.AsObject, } @@ -6471,6 +6501,7 @@ export namespace WaitForStateTransitionResultResponse { RESULT_NOT_SET = 0, ERROR = 1, PROOF = 2, + SUCCESS_WITH_OWNER_BALANCE = 4, } } diff --git a/packages/dapi-grpc/clients/platform/v0/web/platform_pb.js b/packages/dapi-grpc/clients/platform/v0/web/platform_pb.js index 8fe2b482632..54dc934ca3e 100644 --- a/packages/dapi-grpc/clients/platform/v0/web/platform_pb.js +++ b/packages/dapi-grpc/clients/platform/v0/web/platform_pb.js @@ -728,6 +728,7 @@ goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultR goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.VersionCase', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse', null, { proto }); +goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.VersionCase', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0', null, { proto }); goog.exportSymbol('proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.ResultCase', null, { proto }); @@ -4847,6 +4848,27 @@ if (goog.DEBUG && !COMPILED) { */ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.displayName = 'proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.displayName = 'proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -54661,7 +54683,8 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForState proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0.toObject = function(includeInstance, msg) { var f, obj = { stateTransitionHash: msg.getStateTransitionHash_asB64(), - prove: jspb.Message.getBooleanFieldWithDefault(msg, 2, false) + prove: jspb.Message.getBooleanFieldWithDefault(msg, 2, false), + requestUserBalance: jspb.Message.getBooleanFieldWithDefault(msg, 3, false) }; if (includeInstance) { @@ -54706,6 +54729,10 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForState var value = /** @type {boolean} */ (reader.readBool()); msg.setProve(value); break; + case 3: + var value = /** @type {boolean} */ (reader.readBool()); + msg.setRequestUserBalance(value); + break; default: reader.skipField(); break; @@ -54749,6 +54776,13 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForState f ); } + f = message.getRequestUserBalance(); + if (f) { + writer.writeBool( + 3, + f + ); + } }; @@ -54812,6 +54846,24 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForState }; +/** + * optional bool request_user_balance = 3; + * @return {boolean} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0.prototype.getRequestUserBalance = function() { + return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 3, false)); +}; + + +/** + * @param {boolean} value + * @return {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0} returns this + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0.prototype.setRequestUserBalance = function(value) { + return jspb.Message.setProto3BooleanField(this, 3, value); +}; + + /** * optional WaitForStateTransitionResultRequestV0 v0 = 1; * @return {?proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultRequest.WaitForStateTransitionResultRequestV0} @@ -54989,6 +55041,136 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.serializeBi + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.prototype.toObject = function(opt_includeInstance) { + return proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.toObject = function(includeInstance, msg) { + var f, obj = { + ownerBalance: jspb.Message.getFieldWithDefault(msg, 1, "0") + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance; + return proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readUint64String()); + msg.setOwnerBalance(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getOwnerBalance(); + if (parseInt(f, 10) !== 0) { + writer.writeUint64String( + 1, + f + ); + } +}; + + +/** + * optional uint64 owner_balance = 1; + * @return {string} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.prototype.getOwnerBalance = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "0")); +}; + + +/** + * @param {string} value + * @return {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} returns this + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.prototype.setOwnerBalance = function(value) { + return jspb.Message.setProto3StringIntField(this, 1, value); +}; + + + /** * Oneof group definitions for this message. Each group defines the field * numbers belonging to that group. When of these fields' value is set, all @@ -54997,7 +55179,7 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.serializeBi * @private {!Array>} * @const */ -proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.oneofGroups_ = [[1,2]]; +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.oneofGroups_ = [[1,2,4]]; /** * @enum {number} @@ -55005,7 +55187,8 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStat proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.ResultCase = { RESULT_NOT_SET: 0, ERROR: 1, - PROOF: 2 + PROOF: 2, + SUCCESS_WITH_OWNER_BALANCE: 4 }; /** @@ -55048,6 +55231,7 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStat var f, obj = { error: (f = msg.getError()) && proto.org.dash.platform.dapi.v0.StateTransitionBroadcastError.toObject(includeInstance, f), proof: (f = msg.getProof()) && proto.org.dash.platform.dapi.v0.Proof.toObject(includeInstance, f), + successWithOwnerBalance: (f = msg.getSuccessWithOwnerBalance()) && proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.toObject(includeInstance, f), metadata: (f = msg.getMetadata()) && proto.org.dash.platform.dapi.v0.ResponseMetadata.toObject(includeInstance, f) }; @@ -55095,6 +55279,11 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStat reader.readMessage(value,proto.org.dash.platform.dapi.v0.Proof.deserializeBinaryFromReader); msg.setProof(value); break; + case 4: + var value = new proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance; + reader.readMessage(value,proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.deserializeBinaryFromReader); + msg.setSuccessWithOwnerBalance(value); + break; case 3: var value = new proto.org.dash.platform.dapi.v0.ResponseMetadata; reader.readMessage(value,proto.org.dash.platform.dapi.v0.ResponseMetadata.deserializeBinaryFromReader); @@ -55145,6 +55334,14 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStat proto.org.dash.platform.dapi.v0.Proof.serializeBinaryToWriter ); } + f = message.getSuccessWithOwnerBalance(); + if (f != null) { + writer.writeMessage( + 4, + f, + proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance.serializeBinaryToWriter + ); + } f = message.getMetadata(); if (f != null) { writer.writeMessage( @@ -55230,6 +55427,43 @@ proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStat }; +/** + * optional SuccessWithOwnerBalance success_with_owner_balance = 4; + * @return {?proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.prototype.getSuccessWithOwnerBalance = function() { + return /** @type{?proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance} */ ( + jspb.Message.getWrapperField(this, proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance, 4)); +}; + + +/** + * @param {?proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.SuccessWithOwnerBalance|undefined} value + * @return {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0} returns this +*/ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.prototype.setSuccessWithOwnerBalance = function(value) { + return jspb.Message.setOneofWrapperField(this, 4, proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.oneofGroups_[0], value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0} returns this + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.prototype.clearSuccessWithOwnerBalance = function() { + return this.setSuccessWithOwnerBalance(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.org.dash.platform.dapi.v0.WaitForStateTransitionResultResponse.WaitForStateTransitionResultResponseV0.prototype.hasSuccessWithOwnerBalance = function() { + return jspb.Message.getField(this, 4) != null; +}; + + /** * optional ResponseMetadata metadata = 3; * @return {?proto.org.dash.platform.dapi.v0.ResponseMetadata} diff --git a/packages/dapi-grpc/protos/platform/v0/platform.proto b/packages/dapi-grpc/protos/platform/v0/platform.proto index 07c3e8dfc04..8ea042956c1 100644 --- a/packages/dapi-grpc/protos/platform/v0/platform.proto +++ b/packages/dapi-grpc/protos/platform/v0/platform.proto @@ -2343,17 +2343,33 @@ message WaitForStateTransitionResultRequest { bytes state_transition_hash = 1; // The hash of the state transition to wait for bool prove = 2; // Flag to request a proof as the response + // Flag to request, without a proof, the credit balance of the identity + // that owns the transition after it executed. Ignored when a proof is + // requested: from protocol version 14 the proof of an owned, fee-paying + // transition (document and token batches, contract creates and updates, + // identity updates and key limit updates, contract moderation) carries + // the owner's balance itself. + bool request_user_balance = 3; } oneof version { WaitForStateTransitionResultRequestV0 v0 = 1; } } message WaitForStateTransitionResultResponse { + // The result of a successful wait that asked for the owner's balance and for no proof + message SuccessWithOwnerBalance { + // The credit balance of the identity that owns the transition after it + // executed, read from a Drive state at or past the block that executed it + uint64 owner_balance = 1 [ jstype = JS_STRING ]; + } message WaitForStateTransitionResultResponseV0 { oneof result { StateTransitionBroadcastError error = 1; // Any error that occurred during the state transition broadcast Proof proof = 2; // Cryptographic proof for the state transition, if requested + // Success with the owner's balance, unproved, when the request asked + // for it and for no proof + SuccessWithOwnerBalance success_with_owner_balance = 4; } ResponseMetadata metadata = 3; // Metadata about the blockchain state } diff --git a/packages/js-dapi-client/lib/methods/platform/waitForStateTransitionResult/WaitForStateTransitionResultResponse.js b/packages/js-dapi-client/lib/methods/platform/waitForStateTransitionResult/WaitForStateTransitionResultResponse.js index 836987a2376..ee6f82eedcc 100644 --- a/packages/js-dapi-client/lib/methods/platform/waitForStateTransitionResult/WaitForStateTransitionResultResponse.js +++ b/packages/js-dapi-client/lib/methods/platform/waitForStateTransitionResult/WaitForStateTransitionResultResponse.js @@ -8,11 +8,13 @@ class WaitForStateTransitionResultResponse extends AbstractResponse { * @param {Metadata} metadata * @param {Proof} [proof] * @param {ErrorResult} [error] + * @param {bigint} [ownerBalance] */ - constructor(metadata, proof = undefined, error = undefined) { + constructor(metadata, proof = undefined, error = undefined, ownerBalance = undefined) { super(metadata, proof); this.error = error; + this.ownerBalance = ownerBalance; } /** @@ -22,6 +24,18 @@ class WaitForStateTransitionResultResponse extends AbstractResponse { return this.error; } + /** + * The credit balance of the transition's owner after it executed, as DAPI + * read it without a proof. Set when the request asked for the user's + * balance and for no proof; a proved response of an owned, fee-paying + * transition carries the balance inside the proof. + * + * @returns {bigint|undefined} + */ + getOwnerBalance() { + return this.ownerBalance; + } + /** * @param proto * @returns {WaitForStateTransitionResultResponse} @@ -51,10 +65,17 @@ class WaitForStateTransitionResultResponse extends AbstractResponse { const metadata = proto.getV0().getMetadata() ? new Metadata(proto.getV0().getMetadata().toObject()) : null; + let ownerBalance; + const successWithOwnerBalance = proto.getV0().getSuccessWithOwnerBalance(); + if (successWithOwnerBalance) { + ownerBalance = BigInt(successWithOwnerBalance.getOwnerBalance()); + } + return new WaitForStateTransitionResultResponse( metadata, proof, error, + ownerBalance, ); } } diff --git a/packages/js-dapi-client/lib/methods/platform/waitForStateTransitionResult/waitForStateTransitionResultFactory.js b/packages/js-dapi-client/lib/methods/platform/waitForStateTransitionResult/waitForStateTransitionResultFactory.js index 66e5002b74a..1a04fc9c77b 100644 --- a/packages/js-dapi-client/lib/methods/platform/waitForStateTransitionResult/waitForStateTransitionResultFactory.js +++ b/packages/js-dapi-client/lib/methods/platform/waitForStateTransitionResult/waitForStateTransitionResultFactory.js @@ -26,6 +26,7 @@ function waitForStateTransitionResultFactory(grpcTransport) { // Set default timeout timeout: 120000, prove: false, + requestUserBalance: false, retry: 0, throwDeadlineExceeded: true, ...options, @@ -36,7 +37,8 @@ function waitForStateTransitionResultFactory(grpcTransport) { waitForStateTransitionResultRequest.setV0( new WaitForStateTransitionResultRequestV0() .setStateTransitionHash(stateTransitionHash) - .setProve(options.prove), + .setProve(options.prove) + .setRequestUserBalance(options.requestUserBalance), ); let lastError; @@ -44,7 +46,6 @@ function waitForStateTransitionResultFactory(grpcTransport) { // TODO: simple retry before the dapi versioning is properly implemented for (let i = 0; i < 3; i += 1) { try { - // eslint-disable-next-line no-await-in-loop const waitForStateTransitionResultResponse = await grpcTransport.request( PlatformPromiseClient, 'waitForStateTransitionResult', diff --git a/packages/js-dapi-client/test/unit/methods/platform/waitForStateTransitionResult/waitForStateTransitionResultFactory.spec.js b/packages/js-dapi-client/test/unit/methods/platform/waitForStateTransitionResult/waitForStateTransitionResultFactory.spec.js index 70ecacdf4c7..93094e76d1d 100644 --- a/packages/js-dapi-client/test/unit/methods/platform/waitForStateTransitionResult/waitForStateTransitionResultFactory.spec.js +++ b/packages/js-dapi-client/test/unit/methods/platform/waitForStateTransitionResult/waitForStateTransitionResultFactory.spec.js @@ -45,6 +45,7 @@ describe('waitForStateTransitionResultFactory', () => { timeout: 1000, throwDeadlineExceeded: true, retry: 0, + requestUserBalance: false, }; waitForStateTransitionResult = waitForStateTransitionResultFactory(grpcTransportMock); @@ -66,13 +67,15 @@ describe('waitForStateTransitionResultFactory', () => { expect(result.getError()).to.equal(undefined); expect(result.getProof()).to.equal(undefined); + expect(result.getOwnerBalance()).to.equal(undefined); const { WaitForStateTransitionResultRequestV0 } = WaitForStateTransitionResultRequest; const request = new WaitForStateTransitionResultRequest(); request.setV0( new WaitForStateTransitionResultRequestV0() .setStateTransitionHash(hash) - .setProve(false), + .setProve(false) + .setRequestUserBalance(false), ); expect(grpcTransportMock.request).to.be.calledOnceWithExactly( @@ -83,6 +86,21 @@ describe('waitForStateTransitionResultFactory', () => { ); }); + it('should return the owner balance a wait without a proof reports', async () => { + options.prove = false; + options.requestUserBalance = true; + response.getV0().setSuccessWithOwnerBalance( + new WaitForStateTransitionResultResponse.SuccessWithOwnerBalance() + .setOwnerBalance('123456789012345678'), + ); + + const result = await waitForStateTransitionResult(hash, options); + + expect(result.getError()).to.equal(undefined); + expect(result.getProof()).to.equal(undefined); + expect(result.getOwnerBalance()).to.equal(BigInt('123456789012345678')); + }); + it('should return response with proof', async () => { const proof = new Proof(); @@ -123,7 +141,8 @@ describe('waitForStateTransitionResultFactory', () => { request.setV0( new WaitForStateTransitionResultRequestV0() .setStateTransitionHash(hash) - .setProve(true), + .setProve(true) + .setRequestUserBalance(false), ); expect(grpcTransportMock.request).to.be.calledOnceWithExactly( @@ -178,7 +197,8 @@ describe('waitForStateTransitionResultFactory', () => { request.setV0( new WaitForStateTransitionResultRequestV0() .setStateTransitionHash(hash) - .setProve(true), + .setProve(true) + .setRequestUserBalance(false), ); expect(grpcTransportMock.request).to.be.calledOnceWithExactly( diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index d8a36204c73..4d9d3a4040c 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -207,6 +207,8 @@ for (const ref of contract.documentTypeReferences('note')) { contract.documentReferences; ``` +A typed array of identifiers may declare `refersTo` on its `items`, which every element then carries. Such a declaration is listed at the list path of its elements, `path: 'reasons[]'`, which is not a property path: read the list at `reasons` and treat each element as a reference. The same declaration is on the typed array's item, `contract.documentTypeTypedArrays('charter')[0].items.refersTo`. Consensus checks every element when the document is written, and a rejection names the failing element by its index, as in `reasons[2]` for the third. + A document reference comes in two strengths. `permanentDocument` requires the referenced document type to declare `canBeDeleted: false`, so a reference that was accepted keeps resolving. `deletableDocument` takes the same declaration (`contractId`, `documentType`, `propertyAgreement`) and is its disjoint counterpart: the referenced type must allow deletion (`ReferencedDocumentTypeNotDeletable`, 40131, otherwise). The referenced document must exist, and the agreement must hold, when the referring document is written, but it may be deleted afterwards. Nothing blocks that deletion and nothing cleans up after it, so a reader must expect such a reference to resolve to nothing. It can never start resolving to different content: a document id commits to the nonce of its create transition, so a deleted id can not be created again. A writer may not leave it that way: every replace of the referring document re-validates the reference, touched or not, so once the target is gone the replace has to repoint it at a document that exists or clear it (`ReferencedEntityNotFound` otherwise). A writer gate is then checked against the new target, never against a missing one. On an `immutable` property clearing is the only move, and the immutable check lets that one change through. The referring document can always be deleted. A property cannot switch between the two on a contract update, and `preallocated` indexes are only available through `permanentDocument`. Declarations are only parsed from protocol version 14 onward; a contract deserialized against an earlier version reports none even when its raw schema carries the keyword. @@ -241,6 +243,42 @@ const stateTransition = batch.toStateTransition(); From protocol version 14 the id of a new document commits to the identity contract nonce of its create transition. `new DocumentCreateTransition(...)` derives that id from the document's entropy and `identityContractNonce`, puts it on the transition and writes it back onto `document`, so `document.id` is final once the transition exists and equals `transition.base.id`. Before that the `Document` carries a placeholder. To know the id earlier, `document.setIdForCreation(nonce)` or `Document.generateId(type, owner, contract, entropy, nonce)`, or pass `identityContractNonce` to the `Document` constructor. Pass `platformVersion` (defaults to latest) to any of them for a network on an earlier protocol version. No app needs to reimplement the hash. +## Encrypted properties (`encryptedFor`) + +From protocol version 14 a byte array property can declare how its ciphertext was produced, so a wallet reads the recipe from the contract instead of a side channel: the recipient (an identifier property of the same document type, or `$ownerId` for a message the writer encrypts to themself), the integer properties carrying the recipient's and the sender's key ids, and the scheme. The one scheme today, `ecdh-secp256k1-aes256-cbc`, is the dashpay contact request's: a random 16-byte IV followed by AES-256-CBC with PKCS7 padding under the libsecp256k1 ECDH shared key of the two identities' keys. A fetched contract can be asked what it declares: + +```ts +const contract = await sdk.contracts.fetch(contractId); + +contract.documentTypeEncryptedProperties('joinRequest'); +// [{ +// path: 'encryptedMessage', +// recipient: 'recipientId', +// recipientKey: 'recipientKeyId', +// senderKey: 'senderKeyId', +// scheme: 'ecdh-secp256k1-aes256-cbc', +// }] + +// Every document type that declares at least one encrypted property. +contract.documentEncryptedProperties; +``` + +The keyword is only parsed from protocol version 14 onward; a contract deserialized against an earlier version reports none even when its raw schema carries it. Consensus checks only the shape of the bytes on every create and replace (at least 32 bytes and a multiple of 16 for AES-CBC) and nothing about who can decrypt them. A value of the wrong shape is rejected, and the code reaches JS as `error.code`: + +```ts +import { DocumentEncryptionErrorCode } from '@dashevo/evo-sdk'; + +try { + await sdk.documents.create({ document, identityKey, signer }); +} catch (e) { + if (e.code === DocumentEncryptionErrorCode.InvalidEncryptedPropertyShape) { + // the bytes are not a ciphertext of the declared scheme (code 10420) + } +} +``` + +Encrypt and decrypt helpers keyed off the declaration are not part of the SDK yet; the Rust `platform-encryption` crate has the primitives. + ## Immutable properties (`immutable`) From protocol version 14 a mutable document type can freeze some of its top-level properties at creation with the doctype-level `immutable` list, while the rest of the document stays replaceable. A second list, `immutableAllowSetting`, names the frozen properties a replace may still set while the stored document has no value for them; once present they are frozen too. Both are consensus-enforced on every replace, and a fetched contract can be asked what it declares: diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/CreateDocumentScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/CreateDocumentScreen.kt index ea9879eedec..137a699e8c7 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/CreateDocumentScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/CreateDocumentScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -20,6 +21,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,6 +32,7 @@ import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.input.KeyboardType @@ -76,6 +79,10 @@ import org.dashfoundation.example.util.truncateMiddle * supplies the wallet handle, owner id, and the wallet's shared signer * handle (like `DocumentWithPriceScreen`). * + * A typed array (protocol version 14: an array declared by an `items` + * schema) gets one row per element, sent as a JSON array of the element's + * own kind; see [TypedArrayEditor]. + * * Byte-array fields are entered as hex and identifier fields as base58; * both ride to Rust as strings, where the schema-driven sanitize step * decodes them to native bytes. The confirmed canonical JSON the FFI @@ -117,6 +124,8 @@ fun CreateDocumentScreen( val textValues = remember { mutableStateMapOf() } val boolValues = remember { mutableStateMapOf() } val touchedBools = remember { mutableStateSetOf() } + // Typed array rows keyed by property name, one form string per element. + val listValues = remember { mutableStateMapOf>() } var isSubmitting by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } @@ -232,6 +241,7 @@ fun CreateDocumentScreen( textValues = textValues, boolValues = boolValues, touchedBools = touchedBools, + listValues = listValues, ) } if (required.isNotEmpty()) { @@ -255,7 +265,7 @@ fun CreateDocumentScreen( val ownerId = owner ?: return@SubmitButton val mgr = manager ?: return@SubmitButton val propertiesJson = try { - buildPropertiesJson(properties, required, textValues, boolValues, touchedBools) + buildPropertiesJson(properties, required, textValues, boolValues, touchedBools, listValues) } catch (e: Exception) { error = "Could not encode document fields: ${e.message}" return@SubmitButton @@ -311,9 +321,11 @@ internal fun DocumentPropertyField( textValues: MutableMap, boolValues: MutableMap, touchedBools: MutableSet, + listValues: MutableMap>, tagPrefix: String = "createDocument.field", ) { val type = prop.stringField("type") + val typedArray = remember(name, prop) { documentTypedArray(name, prop) } val isByteArray = prop.boolField("byteArray") == true val isIdentifier = prop.stringField("contentMediaType")?.contains("identifier") == true val tag = "$tagPrefix.$name" @@ -326,6 +338,14 @@ internal fun DocumentPropertyField( } } when { + typedArray != null -> TypedArrayEditor( + typedArray = typedArray, + rows = listValues[name].orEmpty(), + onRowsChange = { listValues[name] = it }, + enabled = enabled, + tag = tag, + ) + type == "boolean" -> Switch( checked = boolValues[name] ?: false, onCheckedChange = { @@ -386,6 +406,141 @@ internal fun DocumentPropertyField( } } +/** + * The editor for one typed array: a row per element with an input suited to + * the element kind (a picker when the items declare an `enum`, a switch for + * booleans, a number field for integers and numbers, base58 text for + * identifiers, hex text for byte arrays, plain text for strings), a remove + * button per row, and an add button that stops at `maxItems`. Each row shows + * why its text would be refused, using [typedArrayElement]. Rows carry the + * testTags `.` and `..remove`; the add button is + * `.add`. + */ +@Composable +private fun TypedArrayEditor( + typedArray: DocumentTypedArray, + rows: List, + onRowsChange: (List) -> Unit, + enabled: Boolean, + tag: String, +) { + val item = typedArray.items + val options = typedArrayOptions(item) + Column(modifier = Modifier.fillMaxWidth().testTag(tag)) { + rows.forEachIndexed { index, raw -> + val update = { value: String -> + onRowsChange(rows.toMutableList().also { it[index] = value }) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + when { + options != null -> AccessiblePicker( + label = "Item ${index + 1}", + options = options, + selected = raw.takeIf { it in options } ?: options.first(), + optionLabel = { it }, + testTag = "$tag.$index", + enabled = enabled, + onSelected = update, + ) + + item is TypedArrayItem.BooleanItem -> Switch( + checked = raw == "true", + onCheckedChange = { update(it.toString()) }, + enabled = enabled, + modifier = Modifier.testTag("$tag.$index"), + ) + + else -> OutlinedTextField( + value = raw, + onValueChange = update, + label = { Text(typedArrayRowHint(item, index)) }, + singleLine = true, + enabled = enabled, + keyboardOptions = when (item) { + is TypedArrayItem.IntegerItem, is TypedArrayItem.NumberItem -> + KeyboardOptions(keyboardType = KeyboardType.Number) + else -> KeyboardOptions.Default + }, + modifier = Modifier.fillMaxWidth().testTag("$tag.$index"), + ) + } + val problem = typedArrayElement(item, raw) as? TypedArrayElement.Invalid + if (problem != null) { + Text( + problem.reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + IconButton( + onClick = { onRowsChange(rows.toMutableList().also { it.removeAt(index) }) }, + enabled = enabled, + modifier = Modifier.testTag("$tag.$index.remove"), + ) { + Icon(Icons.Filled.Close, contentDescription = "Remove item ${index + 1}") + } + } + } + val maxItems = typedArray.maxItems + TextButton( + onClick = { onRowsChange(rows + typedArrayNewRow(item)) }, + enabled = enabled && (maxItems == null || rows.size < maxItems), + modifier = Modifier.testTag("$tag.add"), + ) { + Text("Add item") + } + Text( + "${typedArray.summary} · ${rows.size} entered", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** The `enum` of an element kind as form strings, or null when none is declared. */ +internal fun typedArrayOptions(item: TypedArrayItem): List? = when (item) { + is TypedArrayItem.IntegerItem -> item.allowedValues?.map { it.toString() } + is TypedArrayItem.NumberItem -> item.allowedValues?.map { it.toString() } + is TypedArrayItem.BooleanItem -> item.allowedValues?.map { it.toString() } + is TypedArrayItem.StringItem -> item.allowedValues + is TypedArrayItem.ByteArrayItem, TypedArrayItem.IdentifierItem -> null +}?.takeIf { it.isNotEmpty() } + +/** The starting text of a newly added row: the first enum value, or `false`, or blank. */ +internal fun typedArrayNewRow(item: TypedArrayItem): String = + typedArrayOptions(item)?.first() + ?: if (item is TypedArrayItem.BooleanItem) "false" else "" + +private fun typedArrayRowHint(item: TypedArrayItem, index: Int): String { + val what = when (item) { + is TypedArrayItem.IntegerItem -> rangeHint("Integer", item.minimum?.toString(), item.maximum?.toString()) + is TypedArrayItem.NumberItem -> rangeHint("Number", item.minimum?.toString(), item.maximum?.toString()) + is TypedArrayItem.StringItem -> when { + item.minLength != null && item.maxLength != null -> "Text (${item.minLength} to ${item.maxLength} chars)" + item.maxLength != null -> "Text (max ${item.maxLength} chars)" + item.minLength != null -> "Text (min ${item.minLength} chars)" + else -> "Text" + } + is TypedArrayItem.ByteArrayItem -> when { + item.minSize != null && item.minSize == item.maxSize -> "Hex bytes (${item.minSize} bytes)" + item.maxSize != null -> "Hex bytes (max ${item.maxSize} bytes)" + else -> "Hex bytes" + } + TypedArrayItem.IdentifierItem -> "Base58 identifier" + is TypedArrayItem.BooleanItem -> "Boolean" + } + return "Item ${index + 1}: $what" +} + +private fun rangeHint(kind: String, min: String?, max: String?): String = when { + min != null && max != null -> "$kind ($min to $max)" + max != null -> "$kind (max $max)" + min != null -> "$kind (min $min)" + else -> kind +} + internal fun stringHint(prop: JsonObject): String { val min = prop.intField("minLength") val max = prop.intField("maxLength") @@ -415,7 +570,9 @@ internal fun numericHint(prop: JsonObject): String { * `false` for some schemas). Byte-array (hex) and identifier (base58) * fields pass through as strings — the schema-driven sanitize decodes * them Rust-side. `object` fields are parsed so they serialize as nested - * objects rather than a JSON string. Throws on invalid `object` JSON. + * objects rather than a JSON string. A typed array is built from + * [listValues] by [typedArrayJson]. Throws on invalid `object` JSON and on a + * typed array element, count or repeat consensus would refuse. */ internal fun buildPropertiesJson( properties: JsonObject, @@ -423,6 +580,7 @@ internal fun buildPropertiesJson( textValues: Map, boolValues: Map, touchedBools: Set, + listValues: Map> = emptyMap(), ): String = buildJsonObject { for ((name, propEl) in properties) { val prop = propEl as? JsonObject ?: continue @@ -446,8 +604,18 @@ internal fun buildPropertiesJson( } "array" -> { + val typedArray = documentTypedArray(name, prop) val raw = textValues[name]?.trim().orEmpty() - if (raw.isNotEmpty()) { + if (typedArray != null) { + // Typed array: one JSON value per row, of the element's + // own kind. An optional one with no rows is omitted; a + // required one is sent even when empty, so minItems is + // judged here rather than by a paid rejection. + val rows = listValues[name].orEmpty() + if (rows.isNotEmpty() || name in required) { + put(name, typedArrayJson(name, typedArray, rows)) + } + } else if (raw.isNotEmpty()) { if (prop.boolField("byteArray") == true) { // Hex (byte array) or base58 (identifier) — string. put(name, raw) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentActionsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentActionsScreen.kt index 1629dd366a5..89b30ee1db1 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentActionsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentActionsScreen.kt @@ -112,6 +112,10 @@ fun DocumentActionsScreen( // non-blank and is blank at submit is an explicit REMOVE; a field that // was never seeded and stays blank is ABSENT (preserved by the merge). val seededTexts = remember { mutableStateMapOf() } + // Typed array rows, and what the prefill seeded for each: a list seeded + // with elements and emptied at submit is an explicit REMOVE, like a text. + val listValues = remember { mutableStateMapOf>() } + val seededLists = remember { mutableStateMapOf>() } var seededForId by remember { mutableStateOf(null) } var recipient by remember { mutableStateOf(null) } @@ -195,9 +199,12 @@ fun DocumentActionsScreen( textValues.clear() boolValues.clear() touchedBools.clear() - seedReplaceFields(doc.fields, properties, textValues, boolValues, touchedBools) + listValues.clear() + seedReplaceFields(doc.fields, properties, textValues, boolValues, touchedBools, listValues) seededTexts.clear() seededTexts.putAll(textValues) + seededLists.clear() + seededLists.putAll(listValues) seededForId = trimmed } @@ -334,6 +341,7 @@ fun DocumentActionsScreen( textValues = textValues, boolValues = boolValues, touchedBools = touchedBools, + listValues = listValues, tagPrefix = "replaceDocument.field", ) when (lock) { @@ -384,7 +392,8 @@ fun DocumentActionsScreen( val propertiesJson = try { // Rust-side replace is a FULL overwrite // (`set_properties`), and the form can neither - // render composite (object/array) values nor + // render composite values (objects, and arrays + // other than typed arrays) nor // distinguish "left blank" from "clear": overlay // the form's values on the document's current // fields so everything not re-entered keeps its @@ -394,7 +403,14 @@ fun DocumentActionsScreen( val clearedKeys = seededTexts.keys.filter { key -> seededTexts[key].orEmpty().isNotBlank() && textValues[key].orEmpty().isBlank() - }.toSet() + }.toSet() + seededLists.keys.filter { key -> + // A required typed array is sent even when + // emptied (minItems is judged on the form), + // so only an optional one counts as removed. + key !in required && + seededLists[key].orEmpty().isNotEmpty() && + listValues[key].orEmpty().isEmpty() + } // Drive validates required properties AFTER // broadcast and treats their absence as a // paid-invalid transition — reject here so the @@ -409,6 +425,7 @@ fun DocumentActionsScreen( probed?.fields, buildPropertiesJson( properties, required, textValues, boolValues, touchedBools, + listValues, ), clearedKeys = clearedKeys, ) @@ -644,9 +661,11 @@ private fun mergeReplaceProperties( /** * Prefill the replace form's field state from the document's current scalar - * values, so a replace starts from the existing content. Only JSON-primitive - * values are seeded (objects / arrays are left blank for the user to re-enter, - * since their canonical encoding may not round-trip through the string form). + * values, so a replace starts from the existing content. JSON-primitive + * values are seeded, and typed arrays get one row per stored element (see + * [typedArraySeedRows]). Objects and other arrays are left blank for the user + * to re-enter, since their canonical encoding may not round-trip through the + * string form. */ private fun seedReplaceFields( fields: JsonObject, @@ -654,9 +673,15 @@ private fun seedReplaceFields( textValues: MutableMap, boolValues: MutableMap, touchedBools: MutableSet, + listValues: MutableMap>, ) { for ((name, propEl) in properties) { val prop = propEl as? JsonObject ?: continue + val typedArray = documentTypedArray(name, prop) + if (typedArray != null) { + typedArraySeedRows(typedArray.items, fields[name])?.let { listValues[name] = it } + continue + } val value = fields[name] as? JsonPrimitive ?: continue if (prop.stringField("type") == "boolean") { value.content.toBooleanStrictOrNull()?.let { diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt index fa347f08927..77dde84388f 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt @@ -344,6 +344,7 @@ private fun PropertyRow(name: String, property: JsonObject, isRequired: Boolean) property.intField("maxLength")?.let { add("Max: $it") } if (property.stringField("pattern") != null) add("Pattern") if (property.boolField("byteArray") == true) add("Byte Array") + documentTypedArray(name, property)?.let { add(it.summary) } property.stringField("contentMediaType") ?.substringAfterLast('.') ?.let { add(it) } @@ -365,7 +366,10 @@ private fun PropertyRow(name: String, property: JsonObject, isRequired: Boolean) } property.objectField("properties")?.let { subProperties -> subProperties.keys.sorted().forEach { subName -> - val subType = (subProperties[subName] as? JsonObject)?.stringField("type") + val subProperty = subProperties[subName] as? JsonObject + // A nested typed array reads as its element kind and bounds. + val subType = subProperty?.let { documentTypedArray(subName, it)?.summary } + ?: subProperty?.stringField("type") Text( "→ $subName${subType?.let { " ($it)" } ?: ""}", style = MaterialTheme.typography.bodySmall, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/TypedArrays.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/TypedArrays.kt new file mode 100644 index 00000000000..b5aa5c8060c --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/TypedArrays.kt @@ -0,0 +1,311 @@ +package org.dashfoundation.example.ui.contracts + +import java.math.BigInteger +import java.util.Base64 +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.toHex + +/** + * What every element of a typed array is (protocol version 14). Mirrors the + * `DocumentTypedArrayItem` union wasm-dpp2 exposes, read off the property's + * `items` schema. + */ +internal sealed interface TypedArrayItem { + /** Short name for captions: the schema keyword, or byteArray / identifier. */ + val label: String + + data class IntegerItem( + val minimum: BigInteger? = null, + val maximum: BigInteger? = null, + val allowedValues: List? = null, + ) : TypedArrayItem { + override val label: String get() = "integer" + } + + data class NumberItem( + val minimum: Double? = null, + val maximum: Double? = null, + val allowedValues: List? = null, + ) : TypedArrayItem { + override val label: String get() = "number" + } + + data class BooleanItem(val allowedValues: List? = null) : TypedArrayItem { + override val label: String get() = "boolean" + } + + /** + * A string element. `pattern` is kept for display only: the form does not + * check it, since JSON Schema and Java regular expressions differ, and + * consensus judges it anyway. + */ + data class StringItem( + val minLength: Int? = null, + val maxLength: Int? = null, + val pattern: String? = null, + val allowedValues: List? = null, + ) : TypedArrayItem { + override val label: String get() = "string" + } + + data class ByteArrayItem(val minSize: Int? = null, val maxSize: Int? = null) : TypedArrayItem { + override val label: String get() = "byteArray" + } + + data object IdentifierItem : TypedArrayItem { + override val label: String get() = "identifier" + } +} + +/** + * One typed array property of a document type: a `type: "array"` property + * declared by an `items` schema rather than `byteArray: true`. [path] is the + * dotted path within the document type, for example `reasons`, or + * `team.leads` for one nested in an object property. + */ +internal data class DocumentTypedArray( + val path: String, + val items: TypedArrayItem, + val minItems: Int?, + val maxItems: Int?, + val uniqueItems: Boolean, +) { + /** One-line summary for captions and the type details screen. */ + val summary: String + get() = buildList { + add("List of ${items.label}") + when { + minItems != null && maxItems != null -> add("$minItems to $maxItems items") + maxItems != null -> add("up to $maxItems items") + minItems != null -> add("at least $minItems items") + } + if (uniqueItems) add("unique") + }.joinToString(" · ") +} + +/** + * Read [property] as a typed array at [path], or null when it is not one: no + * `items` schema, `byteArray: true` on the property itself (a plain byte + * array), or an element schema this form does not recognise. + */ +internal fun documentTypedArray(path: String, property: JsonObject): DocumentTypedArray? { + if (property.stringField("type") != "array") return null + if (property.boolField("byteArray") == true) return null + val items = property.objectField("items") ?: return null + val item = typedArrayItem(items) ?: return null + return DocumentTypedArray( + path = path, + items = item, + minItems = property.intField("minItems"), + maxItems = property.intField("maxItems"), + uniqueItems = property.boolField("uniqueItems") == true, + ) +} + +/** + * Every typed array a document type [schema] declares, including those nested + * in object properties (dotted paths), sorted by path. Empty for a pre-v14 + * type and for one declaring none. + */ +internal fun documentTypedArrays(schema: JsonObject?): List { + val found = mutableListOf() + fun walk(properties: JsonObject?, prefix: String) { + properties ?: return + for ((name, element) in properties) { + val property = element as? JsonObject ?: continue + val path = if (prefix.isEmpty()) name else "$prefix.$name" + documentTypedArray(path, property)?.let { found += it } + if (property.stringField("type") == "object") { + walk(property.objectField("properties"), path) + } + } + } + walk(schema?.objectField("properties"), "") + return found.sortedBy { it.path } +} + +private fun typedArrayItem(items: JsonObject): TypedArrayItem? = when (items.stringField("type")) { + "integer" -> TypedArrayItem.IntegerItem( + minimum = items.bigIntegerField("minimum"), + maximum = items.bigIntegerField("maximum"), + allowedValues = items.arrayField("enum") + ?.mapNotNull { (it as? JsonPrimitive)?.takeUnless { p -> p.isString }?.content?.toBigIntegerOrNull() }, + ) + "number" -> TypedArrayItem.NumberItem( + minimum = (items["minimum"] as? JsonPrimitive)?.doubleOrNull, + maximum = (items["maximum"] as? JsonPrimitive)?.doubleOrNull, + allowedValues = items.arrayField("enum") + ?.mapNotNull { (it as? JsonPrimitive)?.takeUnless { p -> p.isString }?.doubleOrNull }, + ) + "boolean" -> TypedArrayItem.BooleanItem( + allowedValues = items.arrayField("enum") + ?.mapNotNull { (it as? JsonPrimitive)?.takeUnless { p -> p.isString }?.booleanOrNull }, + ) + "string" -> TypedArrayItem.StringItem( + minLength = items.intField("minLength"), + maxLength = items.intField("maxLength"), + pattern = items.stringField("pattern"), + allowedValues = items.arrayField("enum") + ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }, + ) + "array" -> when { + items.boolField("byteArray") != true -> null + items.stringField("contentMediaType")?.contains("identifier") == true -> + TypedArrayItem.IdentifierItem + else -> TypedArrayItem.ByteArrayItem( + minSize = items.intField("minItems"), + maxSize = items.intField("maxItems"), + ) + } + else -> null +} + +private fun JsonObject.bigIntegerField(key: String): BigInteger? = + (this[key] as? JsonPrimitive)?.takeUnless { it.isString }?.content?.toBigIntegerOrNull() + +/** The outcome of converting one element's form text into its JSON value. */ +internal sealed interface TypedArrayElement { + /** The JSON value to send for the element. */ + data class Valid(val json: JsonPrimitive) : TypedArrayElement + + /** Why the text is not an acceptable element, for the row's error line. */ + data class Invalid(val reason: String) : TypedArrayElement +} + +/** + * Convert the form text [raw] for one element of kind [item] into the JSON + * value the Rust sanitize step accepts: a JSON number for integer and number + * elements, a JSON boolean for boolean ones (sanitize does not turn the + * string "5" into an integer), the string for string elements, the base58 (or + * 64-character hex) string for an identifier, and a lowercase hex string for + * a byte array. The declared bounds and `enum` are checked as a courtesy, so + * the user does not pay for a transition consensus is sure to refuse; + * consensus stays the authority. + */ +internal fun typedArrayElement(item: TypedArrayItem, raw: String): TypedArrayElement { + val text = raw.trim() + return when (item) { + is TypedArrayItem.IntegerItem -> { + val value = text.toBigIntegerOrNull() + ?: return TypedArrayElement.Invalid("not a whole number") + item.minimum?.let { if (value < it) return TypedArrayElement.Invalid("below the minimum $it") } + item.maximum?.let { if (value > it) return TypedArrayElement.Invalid("above the maximum $it") } + if (item.allowedValues != null && value !in item.allowedValues) { + return TypedArrayElement.Invalid("not one of ${item.allowedValues.joinToString(", ")}") + } + TypedArrayElement.Valid(JsonPrimitive(value)) + } + + is TypedArrayItem.NumberItem -> { + val value = text.toDoubleOrNull()?.takeIf { it.isFinite() } + ?: return TypedArrayElement.Invalid("not a number") + item.minimum?.let { if (value < it) return TypedArrayElement.Invalid("below the minimum $it") } + item.maximum?.let { if (value > it) return TypedArrayElement.Invalid("above the maximum $it") } + if (item.allowedValues != null && value !in item.allowedValues) { + return TypedArrayElement.Invalid("not one of ${item.allowedValues.joinToString(", ")}") + } + TypedArrayElement.Valid(JsonPrimitive(value)) + } + + is TypedArrayItem.BooleanItem -> { + val value = text.toBooleanStrictOrNull() + ?: return TypedArrayElement.Invalid("not true or false") + if (item.allowedValues != null && value !in item.allowedValues) { + return TypedArrayElement.Invalid("not one of ${item.allowedValues.joinToString(", ")}") + } + TypedArrayElement.Valid(JsonPrimitive(value)) + } + + is TypedArrayItem.StringItem -> { + // Strings keep their spaces: only the other kinds are trimmed. + val length = raw.codePointCount(0, raw.length) + item.minLength?.let { + if (length < it) return TypedArrayElement.Invalid("shorter than $it characters") + } + item.maxLength?.let { + if (length > it) return TypedArrayElement.Invalid("longer than $it characters") + } + if (item.allowedValues != null && raw !in item.allowedValues) { + return TypedArrayElement.Invalid("not one of ${item.allowedValues.joinToString(", ")}") + } + TypedArrayElement.Valid(JsonPrimitive(raw)) + } + + TypedArrayItem.IdentifierItem -> { + if (Base58.decodeIdentifier(text) == null) { + TypedArrayElement.Invalid("not a base58 identifier") + } else { + TypedArrayElement.Valid(JsonPrimitive(text)) + } + } + + is TypedArrayItem.ByteArrayItem -> { + val hex = text.removePrefix("0x").lowercase() + if (hex.length % 2 != 0 || hex.any { it !in '0'..'9' && it !in 'a'..'f' }) { + return TypedArrayElement.Invalid("not hex bytes") + } + val size = hex.length / 2 + item.minSize?.let { if (size < it) return TypedArrayElement.Invalid("fewer than $it bytes") } + item.maxSize?.let { if (size > it) return TypedArrayElement.Invalid("more than $it bytes") } + TypedArrayElement.Valid(JsonPrimitive(hex)) + } + } +} + +/** + * Convert every row of a typed array into its JSON array, in row order. + * Throws [IllegalArgumentException] naming the first bad row (`name[index]`), + * or a count outside `minItems` / `maxItems`, or a repeated element when + * `uniqueItems` is set. The form surfaces the message instead of broadcasting + * a transition consensus would refuse, which would still be paid for. + */ +internal fun typedArrayJson(name: String, typedArray: DocumentTypedArray, rows: List): JsonArray { + typedArray.minItems?.let { + require(rows.size >= it) { "$name needs at least $it items, has ${rows.size}" } + } + typedArray.maxItems?.let { + require(rows.size <= it) { "$name allows at most $it items, has ${rows.size}" } + } + val elements = rows.mapIndexed { index, raw -> + when (val element = typedArrayElement(typedArray.items, raw)) { + is TypedArrayElement.Valid -> element.json + is TypedArrayElement.Invalid -> throw IllegalArgumentException("$name[$index]: ${element.reason}") + } + } + if (typedArray.uniqueItems) { + val repeated = elements.groupBy { it.content }.filterValues { it.size > 1 }.keys + require(repeated.isEmpty()) { "$name must not repeat an element: ${repeated.joinToString(", ")}" } + } + return JsonArray(elements) +} + +/** + * The form rows for a stored typed array [value] taken from a document's + * canonical JSON, or null when the value is not an array of scalars. Byte + * array elements arrive as base64 and are shown as hex: the Rust sanitize + * step tries hex before base64, so sending back base64 made only of hex + * digits would decode to different bytes. + */ +internal fun typedArraySeedRows(item: TypedArrayItem, value: JsonElement?): List? { + val array = value as? JsonArray ?: return null + return array.map { element -> + val primitive = element as? JsonPrimitive ?: return null + if (item is TypedArrayItem.ByteArrayItem && primitive.isString) { + base64ToHex(primitive.content) ?: primitive.content + } else { + primitive.content + } + } +} + +private fun base64ToHex(text: String): String? = try { + Base64.getDecoder().decode(text).toHex() +} catch (_: IllegalArgumentException) { + null +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/TypedArraysTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/TypedArraysTest.kt new file mode 100644 index 00000000000..23c4b66140f --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/TypedArraysTest.kt @@ -0,0 +1,307 @@ +package org.dashfoundation.example.ui.contracts + +import java.math.BigInteger +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.LenientJson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * Pins the protocol-v14 typed array helpers behind the create and replace + * forms ([documentTypedArray], [typedArrayElement], [typedArrayJson], + * [typedArraySeedRows], [buildPropertiesJson]). The Rust sanitize step turns + * base58 and hex strings into identifiers and bytes inside typed array + * elements, but it does not turn the string "5" into an integer, so the form + * must send each element as a JSON value of its own kind. + */ +class TypedArraysTest { + + private val identifierItems = buildJsonObject { + put("type", "array") + put("byteArray", true) + put("minItems", 32) + put("maxItems", 32) + put("contentMediaType", "application/x.dash.dpp.identifier") + } + + /** A charter-like document type with one typed array of every element kind. */ + private val schema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("members") { + put("type", "array") + put("items", identifierItems) + put("maxItems", 16) + put("uniqueItems", true) + put("position", 0) + } + putJsonObject("scores") { + put("type", "array") + putJsonObject("items") { + put("type", "integer") + put("minimum", 0) + put("maximum", 100) + } + put("minItems", 1) + put("maxItems", 8) + put("position", 1) + } + putJsonObject("ratios") { + put("type", "array") + putJsonObject("items") { + put("type", "number") + put("minimum", 0) + put("maximum", 1) + } + put("maxItems", 4) + put("position", 2) + } + putJsonObject("flags") { + put("type", "array") + putJsonObject("items") { put("type", "boolean") } + put("maxItems", 4) + put("position", 3) + } + putJsonObject("tags") { + put("type", "array") + putJsonObject("items") { + put("type", "string") + putJsonArray("enum") { + add("spam") + add("abuse") + } + } + put("maxItems", 2) + put("position", 4) + } + putJsonObject("digests") { + put("type", "array") + putJsonObject("items") { + put("type", "array") + put("byteArray", true) + put("minItems", 4) + put("maxItems", 4) + } + put("maxItems", 4) + put("position", 5) + } + putJsonObject("avatar") { + put("type", "array") + put("byteArray", true) + put("maxItems", 64) + put("position", 6) + } + putJsonObject("team") { + put("type", "object") + putJsonObject("properties") { + putJsonObject("leads") { + put("type", "array") + put("items", identifierItems) + put("maxItems", 4) + put("position", 0) + } + } + put("position", 7) + } + } + } + + private val properties = schema["properties"]!!.jsonObject + + private fun typedArray(name: String): DocumentTypedArray = + documentTypedArray(name, properties[name]!!.jsonObject)!! + + private val identifierA = Base58.encode(ByteArray(32) { 1 }) + private val identifierB = Base58.encode(ByteArray(32) { 2 }) + + @Test + fun shouldReadEveryElementKindWithItsBounds() { + assertEquals( + DocumentTypedArray("members", TypedArrayItem.IdentifierItem, null, 16, true), + typedArray("members"), + ) + assertEquals( + DocumentTypedArray( + "scores", + TypedArrayItem.IntegerItem(BigInteger.ZERO, BigInteger.valueOf(100)), + 1, + 8, + false, + ), + typedArray("scores"), + ) + assertEquals(TypedArrayItem.NumberItem(0.0, 1.0), typedArray("ratios").items) + assertEquals(TypedArrayItem.BooleanItem(), typedArray("flags").items) + assertEquals( + TypedArrayItem.StringItem(allowedValues = listOf("spam", "abuse")), + typedArray("tags").items, + ) + assertEquals(TypedArrayItem.ByteArrayItem(4, 4), typedArray("digests").items) + } + + @Test + fun shouldNotReadAByteArrayPropertyOrAPlainArrayAsATypedArray() { + assertNull(documentTypedArray("avatar", properties["avatar"]!!.jsonObject)) + assertNull(documentTypedArray("plain", buildJsonObject { put("type", "array") })) + assertNull(documentTypedArray("text", buildJsonObject { put("type", "string") })) + } + + @Test + fun shouldListEveryTypedArrayIncludingNestedOnesByDottedPath() { + assertEquals( + listOf("digests", "flags", "members", "ratios", "scores", "tags", "team.leads"), + documentTypedArrays(schema).map { it.path }, + ) + assertTrue(documentTypedArrays(null).isEmpty()) + } + + @Test + fun shouldConvertEachElementKindToAJsonValueOfItsOwnKind() { + fun valid(item: TypedArrayItem, raw: String): JsonPrimitive = + (typedArrayElement(item, raw) as TypedArrayElement.Valid).json + + assertEquals(JsonPrimitive(BigInteger.valueOf(42)), valid(typedArray("scores").items, " 42 ")) + assertEquals(JsonPrimitive(0.5), valid(typedArray("ratios").items, "0.5")) + assertEquals(JsonPrimitive(true), valid(typedArray("flags").items, "true")) + assertEquals(JsonPrimitive("spam"), valid(typedArray("tags").items, "spam")) + assertEquals(JsonPrimitive(identifierA), valid(TypedArrayItem.IdentifierItem, identifierA)) + assertEquals(JsonPrimitive("deadbeef"), valid(typedArray("digests").items, "0xDEADBEEF")) + // Numbers and booleans go out unquoted: sanitize will not parse strings. + assertTrue(!valid(typedArray("scores").items, "7").isString) + assertTrue(!valid(typedArray("flags").items, "false").isString) + } + + @Test + fun shouldRefuseElementTextConsensusWouldRefuse() { + fun reason(item: TypedArrayItem, raw: String): String = + (typedArrayElement(item, raw) as TypedArrayElement.Invalid).reason + + val scores = typedArray("scores").items + assertEquals("not a whole number", reason(scores, "4.5")) + assertEquals("above the maximum 100", reason(scores, "101")) + assertEquals("below the minimum 0", reason(scores, "-1")) + assertEquals("above the maximum 1.0", reason(typedArray("ratios").items, "1.5")) + assertEquals("not true or false", reason(typedArray("flags").items, "yes")) + assertEquals("not one of spam, abuse", reason(typedArray("tags").items, "other")) + assertEquals("not a base58 identifier", reason(TypedArrayItem.IdentifierItem, "0OIl")) + assertEquals("not hex bytes", reason(typedArray("digests").items, "xyz")) + assertEquals("fewer than 4 bytes", reason(typedArray("digests").items, "dead")) + assertEquals( + "longer than 3 characters", + reason(TypedArrayItem.StringItem(maxLength = 3), "four"), + ) + } + + @Test + fun shouldBuildTheArrayAndRefuseCountsRepeatsAndBadRows() { + assertEquals( + buildJsonArray { + add(identifierA) + add(identifierB) + }, + typedArrayJson("members", typedArray("members"), listOf(identifierA, identifierB)), + ) + assertRefused("scores needs at least 1 items, has 0") { + typedArrayJson("scores", typedArray("scores"), emptyList()) + } + assertRefused("tags allows at most 2 items, has 3") { + typedArrayJson("tags", typedArray("tags"), listOf("spam", "spam", "abuse")) + } + assertRefused("members must not repeat an element: $identifierA") { + typedArrayJson("members", typedArray("members"), listOf(identifierA, identifierA)) + } + assertRefused("scores[1]: not a whole number") { + typedArrayJson("scores", typedArray("scores"), listOf("1", "x")) + } + } + + @Test + fun shouldSendTypedArraysFromTheListRowsAndOmitAnEmptyOptionalOne() { + val json = buildPropertiesJson( + properties = properties, + required = setOf("scores"), + textValues = emptyMap(), + boolValues = emptyMap(), + touchedBools = emptySet(), + listValues = mapOf( + "scores" to listOf("3", "99"), + "flags" to listOf("true", "false"), + "tags" to emptyList(), + ), + ) + val sent = LenientJson.parseToJsonElement(json).jsonObject + assertEquals( + buildJsonArray { + add(3) + add(99) + }, + sent["scores"], + ) + assertEquals( + buildJsonArray { + add(true) + add(false) + }, + sent["flags"], + ) + assertEquals(setOf("scores", "flags"), sent.keys) + } + + @Test + fun shouldSeedReplaceRowsFromTheStoredDocumentWithBytesAsHex() { + val stored = buildJsonArray { + add("3q2+7w==") + add("AAECAw==") + } + assertEquals( + listOf("deadbeef", "00010203"), + typedArraySeedRows(typedArray("digests").items, stored), + ) + assertEquals( + listOf("3", "99"), + typedArraySeedRows( + typedArray("scores").items, + buildJsonArray { + add(3) + add(99) + }, + ), + ) + assertNull(typedArraySeedRows(typedArray("scores").items, JsonPrimitive(3))) + assertNull( + typedArraySeedRows( + typedArray("scores").items, + JsonArray(listOf(buildJsonObject { put("a", 1) })), + ), + ) + } + + @Test + fun shouldStartNewRowsOnTheFirstEnumValueOrFalse() { + assertEquals("spam", typedArrayNewRow(typedArray("tags").items)) + assertEquals("false", typedArrayNewRow(typedArray("flags").items)) + assertEquals("", typedArrayNewRow(typedArray("scores").items)) + } + + private fun assertRefused(message: String, block: () -> Unit) { + try { + block() + fail("expected a refusal: $message") + } catch (e: IllegalArgumentException) { + assertEquals(message, e.message) + } + } +} diff --git a/packages/rs-dapi/src/services/platform_service/wait_for_state_transition_result.rs b/packages/rs-dapi/src/services/platform_service/wait_for_state_transition_result.rs index deb15fea4e8..58436f62523 100644 --- a/packages/rs-dapi/src/services/platform_service/wait_for_state_transition_result.rs +++ b/packages/rs-dapi/src/services/platform_service/wait_for_state_transition_result.rs @@ -3,13 +3,19 @@ use crate::services::platform_service::error_mapping::decode_consensus_error; use crate::services::platform_service::{PlatformServiceImpl, TenderdashStatus}; use crate::services::streaming_service::FilterType; use base64::Engine; +use dapi_grpc::platform::v0::get_identity_balance_request::GetIdentityBalanceRequestV0; +use dapi_grpc::platform::v0::wait_for_state_transition_result_response::SuccessWithOwnerBalance; use dapi_grpc::platform::v0::wait_for_state_transition_result_response::wait_for_state_transition_result_response_v0; use dapi_grpc::platform::v0::{ - Proof, ResponseMetadata, WaitForStateTransitionResultRequest, - WaitForStateTransitionResultResponse, wait_for_state_transition_result_request, + GetIdentityBalanceRequest, Proof, ResponseMetadata, WaitForStateTransitionResultRequest, + WaitForStateTransitionResultResponse, get_identity_balance_request, + get_identity_balance_response, wait_for_state_transition_result_request, wait_for_state_transition_result_response, }; use dapi_grpc::tonic::{Request, Response}; +use dpp::prelude::Identifier; +use dpp::serialization::PlatformDeserializableUntrusted; +use dpp::state_transition::StateTransition; use std::time::Duration; use tokio::time::timeout; use tracing::{Instrument, debug, trace}; @@ -77,7 +83,9 @@ impl PlatformServiceImpl { match self.tenderdash_client.tx(hash_base64).await { Ok(tx) => { debug!(tx = hash_hex, "Transaction already exists, returning it"); - return self.build_response_from_existing_tx(tx, v0.prove).await; + return self + .build_response_from_existing_tx(tx, v0.prove, v0.request_user_balance) + .await; } Err(error) => { debug!(?error, "Transaction not found, will wait for future events"); @@ -100,7 +108,9 @@ impl PlatformServiceImpl { match result { Some(crate::services::streaming_service::StreamingEvent::PlatformTx { event }) => { debug!(tx = hash_hex, "Received matching transaction event"); - return self.build_response_from_event(event, v0.prove).await; + return self + .build_response_from_event(event, v0.prove, v0.request_user_balance) + .await; } Some(message) => { // Ignore other message types @@ -138,6 +148,7 @@ impl PlatformServiceImpl { &self, tx_response: crate::clients::tenderdash_client::TxResponse, prove: bool, + request_user_balance: bool, ) -> Result, DapiError> { let mut response_v0 = wait_for_state_transition_result_response::WaitForStateTransitionResultResponseV0 { @@ -168,24 +179,19 @@ impl PlatformServiceImpl { return Ok(error.into()); } - // No error; generate proof if requested - if prove - && !tx_response.tx.is_empty() + // No error; generate proof if requested, else report what can be read without one + if !tx_response.tx.is_empty() && let Ok(tx_data) = base64::prelude::Engine::decode(&base64::prelude::BASE64_STANDARD, &tx_response.tx) { - match self.fetch_proof_for_state_transition(tx_data).await { - Ok((proof, metadata)) => { - response_v0.result = Some( - wait_for_state_transition_result_response_v0::Result::Proof(proof), - ); - response_v0.metadata = Some(metadata); - } - Err(e) => { - debug!("Failed to fetch proof: {}", e); - // Continue without proof - } - } + self.fill_success_result( + &mut response_v0, + tx_data, + prove, + request_user_balance, + u64::try_from(tx_response.height).unwrap_or(0), + ) + .await; } let body = WaitForStateTransitionResultResponse { @@ -202,6 +208,7 @@ impl PlatformServiceImpl { &self, transaction_event: crate::clients::TransactionEvent, prove: bool, + request_user_balance: bool, ) -> Result, DapiError> { // Check transaction result match transaction_event.result { @@ -211,20 +218,17 @@ impl PlatformServiceImpl { result: None, metadata: None, }; - // Success case - generate proof if requested - if prove && let Some(tx_bytes) = transaction_event.tx { - match self.fetch_proof_for_state_transition(tx_bytes).await { - Ok((proof, metadata)) => { - response_v0.result = Some( - wait_for_state_transition_result_response::wait_for_state_transition_result_response_v0::Result::Proof(proof), - ); - response_v0.metadata = Some(metadata); - } - Err(e) => { - debug!("Failed to fetch proof: {}", e); - // Continue without proof - } - } + // Success case - generate proof if requested, else report what + // can be read without one + if let Some(tx_bytes) = transaction_event.tx { + self.fill_success_result( + &mut response_v0, + tx_bytes, + prove, + request_user_balance, + transaction_event.height, + ) + .await; } let body = WaitForStateTransitionResultResponse { @@ -256,6 +260,106 @@ impl PlatformServiceImpl { } } + /// Complete a successful wait: with `prove`, the proof of the transition's + /// execution (which carries the owner's balance for an owned, fee-paying + /// transition from protocol version 14); else with `request_user_balance`, + /// the balance of the identity that owns the transition read from Drive + /// unverified, and only from a state at or past `executed_at_height`, the + /// block that executed the transition (a read behind it answers with its + /// metadata and no balance). Either read failing leaves the response as it + /// was, which still tells the caller the transition succeeded. + async fn fill_success_result( + &self, + response_v0: &mut wait_for_state_transition_result_response::WaitForStateTransitionResultResponseV0, + tx_bytes: Vec, + prove: bool, + request_user_balance: bool, + executed_at_height: u64, + ) { + if prove { + match self.fetch_proof_for_state_transition(tx_bytes).await { + Ok((proof, metadata)) => { + response_v0.result = Some( + wait_for_state_transition_result_response_v0::Result::Proof(proof), + ); + response_v0.metadata = Some(metadata); + } + Err(e) => { + debug!("Failed to fetch proof: {}", e); + // Continue without proof + } + } + } else if request_user_balance + && let Some(owner_id) = transition_owner_id(&tx_bytes) + && let Some((owner_balance, metadata)) = + self.fetch_identity_balance_unproved(owner_id).await + { + if metadata.height < executed_at_height { + // The read predates the executing block: answer with that + // state's metadata and no balance, so the caller can tell a + // lagging node (metadata height below its transaction's + // block) from a node that was not asked. + tracing::warn!( + balance_height = metadata.height, + executed_at_height, + "Drive's state predates the block that executed the transition; not reporting the owner's balance" + ); + response_v0.metadata = Some(metadata); + return; + } + response_v0.result = Some( + wait_for_state_transition_result_response_v0::Result::SuccessWithOwnerBalance( + SuccessWithOwnerBalance { owner_balance }, + ), + ); + response_v0.metadata = Some(metadata); + } + } + + /// An identity's credit balance as Drive holds it now, without a proof, + /// with the metadata of the state it was read from. `None` when Drive + /// cannot answer, which is logged and not an error of the wait. + async fn fetch_identity_balance_unproved( + &self, + identity_id: Identifier, + ) -> Option<(u64, ResponseMetadata)> { + let request = GetIdentityBalanceRequest { + version: Some(get_identity_balance_request::Version::V0( + GetIdentityBalanceRequestV0 { + id: identity_id.to_vec(), + prove: false, + }, + )), + }; + let mut client = self.drive_client.get_client(); + let response = match client.get_identity_balance(request).await { + Ok(response) => response.into_inner(), + Err(e) => { + debug!( + "Failed to fetch the transition owner's balance from Drive: {}", + e + ); + return None; + } + }; + let Some(get_identity_balance_response::Version::V0(v0)) = response.version else { + debug!("Drive returned an identity balance response without a version"); + return None; + }; + match (v0.result, v0.metadata) { + ( + Some(get_identity_balance_response::get_identity_balance_response_v0::Result::Balance( + balance, + )), + Some(metadata), + ) => Some((balance, metadata)), + _ => { + debug!("Drive returned no balance for the transition owner"); + None + } + } + } + /// Fetch Drive proofs for the provided state transition bytes, returning proof and metadata. async fn fetch_proof_for_state_transition( &self, @@ -293,6 +397,15 @@ impl PlatformServiceImpl { } } +/// The identity that owns a state transition: the one whose credit balance a +/// wait that asks for the user's balance reports back. `None` for a transition +/// without an owner and for bytes that do not decode. +fn transition_owner_id(state_transition_bytes: &[u8]) -> Option { + StateTransition::deserialize_from_bytes_untrusted(state_transition_bytes) + .ok()? + .owner_id() +} + fn validate_state_transition_hash(hash: &[u8]) -> Result<(), DapiError> { if hash.len() != STATE_TRANSITION_HASH_SIZE { return Err(DapiError::InvalidArgument(format!( @@ -334,6 +447,55 @@ pub(super) fn build_wait_for_state_transition_error_response( mod tests { use super::*; use dapi_grpc::platform::v0::StateTransitionBroadcastError; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::batch_transition::BatchTransition; + use dpp::state_transition::batch_transition::BatchTransitionV0; + use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; + use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; + use dpp::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; + use dpp::state_transition::batch_transition::document_delete_transition::DocumentDeleteTransition; + use dpp::state_transition::batch_transition::document_delete_transition::DocumentDeleteTransitionV0; + use dpp::state_transition::identity_credit_transfer_transition::IdentityCreditTransferTransition; + use dpp::state_transition::identity_credit_transfer_transition::v0::IdentityCreditTransferTransitionV0; + + fn document_batch_bytes(owner_id: Identifier) -> Vec { + let base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::new([3u8; 32]), + identity_contract_nonce: 1, + document_type_name: "note".to_string(), + data_contract_id: Identifier::new([4u8; 32]), + }); + StateTransition::Batch(BatchTransition::V0(BatchTransitionV0 { + owner_id, + transitions: vec![DocumentTransition::Delete(DocumentDeleteTransition::V0( + DocumentDeleteTransitionV0 { base }, + ))], + ..Default::default() + })) + .serialize_to_bytes() + .expect("a document batch serializes") + } + + #[test] + fn transition_owner_is_the_identity_whose_balance_is_reported() { + let owner_id = Identifier::new([7u8; 32]); + assert_eq!( + transition_owner_id(&document_batch_bytes(owner_id)), + Some(owner_id) + ); + let transfer = StateTransition::IdentityCreditTransfer( + IdentityCreditTransferTransition::V0(IdentityCreditTransferTransitionV0::default()), + ) + .serialize_to_bytes() + .expect("a transfer serializes"); + assert_eq!(transition_owner_id(&transfer), Some(Identifier::default())); + } + + #[test] + fn undecodable_bytes_report_no_owner() { + assert_eq!(transition_owner_id(&[0xff; 8]), None); + assert_eq!(transition_owner_id(&[]), None); + } #[test] fn state_transition_hash_must_be_exact_width() { diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index fefdea82fee..adc1000459d 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,13 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties, the requiredSince property keyword (the contract version a property is required from), and the timeRange index transform, and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), the requiredSince property keyword (the contract version a property is required from), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "documentProperties": { "type": "object", "patternProperties": { - "^[a-zA-Z0-9-_]{1,64}$": { + "^[a-zA-Z0-9_]{1,64}$": { "type": "object", "allOf": [ { @@ -18,7 +18,7 @@ } }, "propertyNames": { - "pattern": "^[a-zA-Z0-9-_]{1,64}$" + "pattern": "^[a-zA-Z0-9_]{1,64}$" }, "minProperties": 1, "maxProperties": 100 @@ -91,6 +91,43 @@ "uniqueItems": { "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/uniqueItems" }, + "items": { + "description": "typed arrays only: the schema every element of the array has", + "$ref": "#/$defs/documentArrayItem" + }, + "encryptedFor": { + "description": "byte array properties that are not identifiers only: how the property's ciphertext was produced, so wallets and SDKs read the recipe from the contract. recipient names an identifier property of the same document type whose value is the recipient identity's id, or is \"$ownerId\" for a message the writer encrypts to themself; recipientKey and senderKey name integer properties of the same document type (minimum at least 0, maximum at most 4294967295) carrying the recipient's and the sender's identity key ids; scheme names how the bytes were made. Under ecdh-secp256k1-aes256-cbc, the scheme of the dashpay contact request, the shared key is the libsecp256k1 ECDH of the sender's private key and the recipient's public key (SHA256 of the product point's parity byte and x coordinate) and the value is a random 16-byte IV followed by the plaintext under AES-256-CBC with PKCS7 padding and that IV, so at least 32 bytes and a multiple of 16. All four keys are required and the three named properties must exist with those types. Consensus checks only that shape on every create and replace (InvalidEncryptedPropertyShapeError, 10420): who can decrypt the bytes, and whether they decrypt at all, is not verifiable on chain", + "type": "object", + "properties": { + "recipient": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "recipientKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "senderKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "scheme": { + "enum": [ + "ecdh-secp256k1-aes256-cbc" + ] + } + }, + "required": [ + "recipient", + "recipientKey", + "senderKey", + "scheme" + ], + "additionalProperties": false + }, "refersTo": { "type": "object", "properties": { @@ -130,7 +167,7 @@ "type": "string", "minLength": 1, "maxLength": 64, - "pattern": "^[a-zA-Z0-9-_]{1,64}$" + "pattern": "^[a-zA-Z0-9_]{1,64}$" }, "contractRequirements": { "description": "contract references only: what the referenced contract must declare beyond existing, checked when the referring document is written against the contract already fetched for the existence check and the block time, so a requirement costs no further read. Each key names an aspect of the referenced contract and its value the requirement: moderation \"elected\" requires the contract to declare an elected moderation team and \"electionOpen\" one whose own electionDelay, counted from the contract's creation, has passed at the block time of the write (or which declares none); minimumAgeSeconds requires the contract's recorded creation time to be at least that many seconds before the block time of the write, and minimumSecondsSinceUpdate the later of its recorded creation and last update times (a contract without a recorded creation time never meets either); owner \"self\" requires the contract to be owned by the writer of the referring document (its $ownerId), \"other\" by anyone else; readonly true requires the contract's config to be readonly (one that can never be updated again) and keepsHistory true its config to keep history, only true being declarable for either; ownerProtected requires the contract's elected moderation declaration to protect (true) or not protect (false) the contract owner from the team, a contract without elected moderation meeting neither. An unmet requirement refuses the write (ReferencedContractRequirementNotMetError, 40135)", @@ -172,11 +209,52 @@ "additionalProperties": false }, "keyIdProperty": { - "description": "The property of the same document type whose value carries the referenced key id; the reference property's value carries the identity id", + "description": "identityPublicKey references on an identifier property: the property of the same document type whose value carries the referenced key id; the reference property's value carries the identity id. Takes the place of identityProperty", "type": "string", "minLength": 1, "maxLength": 256, - "pattern": "^[a-zA-Z0-9-_]{1,64}(\\.[a-zA-Z0-9-_]{1,64})*$" + "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" + }, + "identityProperty": { + "description": "identityPublicKey references on the key id property itself, the inverse form: the declaring property is an integer from 0 to 4294967295 (a key id) whose value must be the id of a key of the named identity that exists and is not disabled when the document is written (ReferencedIdentityKeyNotFoundError, 40123; ReferencedIdentityKeyDisabledError, 40124). \"$ownerId\" names the writer, the document's owner, and \"$creatorId\" its creator (only on a document type that records creator ids, checked at contract registration), whose existence is already proven, so only the key is fetched; any other value is the path of an identifier property of the same document type (it must exist, be an identifier and not carry an identityPublicKey reference of its own, checked at registration), whose value is the identity, and a key id set while it is not is refused (ReferencedKeyIdPropertyInvalidError, 40125). Takes the place of keyIdProperty", + "anyOf": [ + { + "enum": [ + "$ownerId", + "$creatorId" + ] + }, + { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" + } + ] + }, + "keyRequirements": { + "description": "identityPublicKey references only: what the referenced key must be beyond existing and not being disabled, checked when the referring document is written against the key already fetched for the existence check, so a requirement costs no further read. Each key names an aspect of the referenced key and its value the requirement: purpose requires the key's purpose to be the named one (any but system); boundTo names a document type of the declaring contract and requires the key's contract bounds to be exactly the declaring contract and that document type (a whole-contract or contract group bound never meets it). At registration boundTo must name a document type the contract has, and one a key of the required purpose can be bound to: only authentication, encryption and decryption keys carry a document type bound, and an encryption or decryption key only where the type declares requiresIdentityEncryptionBoundedKey or requiresIdentityDecryptionBoundedKey. An unmet requirement refuses the write (ReferencedIdentityKeyRequirementNotMetError, 40136)", + "type": "object", + "properties": { + "purpose": { + "enum": [ + "authentication", + "encryption", + "decryption", + "transfer", + "voting", + "owner" + ] + }, + "boundTo": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-zA-Z0-9_]{1,64}$" + } + }, + "minProperties": 1, + "additionalProperties": false }, "propertyAgreement": { "description": "permanentDocument and deletableDocument references only: each { referring property: referenced property } pair must hold as an equality between the referring document's value and the referenced document's value, enforced by consensus at document write time. The referring side is a schema property of the declaring document type or its own $ownerId, the writer, which turns the pair into a write gate: only an identity whose id equals the referenced side may create or replace the document. The referenced side is a schema property of the referenced document type, or one of its $ownerId and $creatorId system identifiers, in which case the referring property must be an identifier; $creatorId additionally needs a referenced document type that records creator ids (transferable or tradeable types of a format-1 contract). Both sides must exist and share one value kind, validated at contract registration. $ownerId follows the referenced document through transfers while $creatorId never changes; either is checked when the referring document is written, not when the referenced document later moves", @@ -186,7 +264,7 @@ "propertyNames": { "anyOf": [ { - "pattern": "^[a-zA-Z0-9-_]{1,64}(\\.[a-zA-Z0-9-_]{1,64})*$" + "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" }, { "const": "$ownerId" @@ -199,7 +277,7 @@ { "minLength": 1, "maxLength": 256, - "pattern": "^[a-zA-Z0-9-_]{1,64}(\\.[a-zA-Z0-9-_]{1,64})*$" + "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" }, { "enum": [ @@ -209,6 +287,47 @@ } ] } + }, + "lookup": { + "description": "permanentDocument references only (a key into a deletable type could find a new document once the one it found is deleted, where an id is produced at most once): the property's value is not the referenced document's id. The referenced document is the one the named unique index of the referenced document type finds for a key assembled from the referring document, and the reference holds if that document exists. index names an index of the referenced document type that is unique, carries no timeRange and does not belong to an indexOnly type; its key must stay with the document it found (every schema property of the index fixed by an immutable document type or the immutable list, $ownerId only on a type whose documents can be neither transferred nor traded), so the reference never dangles; keys maps every property of that index (by its name on the referenced side, system ones such as $ownerId included), in any order, to where its value comes from: a property path of the referring document type, \"$ownerId\" for the referring document's owner, or \".\" for the value of the property carrying the reference (on an array element, the element), which must appear exactly once. A property a key reads must be required (and so must every object around it), not transient, and hold the same kind of value as its index property, so a lookup never runs with a missing key part; all of this is validated at contract registration. When the referring document is created or replaced, consensus queries the index for the assembled key, billed as a document fetch, and refuses the write with ReferencedEntityNotFoundError (40120) if it finds no document; propertyAgreement pairs are checked against the document found. A replace re-validates the reference when a property a key reads changed, and on every replace when a key reads $ownerId", + "type": "object", + "properties": { + "index": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "keys": { + "type": "object", + "minProperties": 1, + "maxProperties": 10, + "propertyNames": { + "minLength": 1, + "maxLength": 256 + }, + "additionalProperties": { + "type": "string", + "anyOf": [ + { + "const": "." + }, + { + "const": "$ownerId" + }, + { + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" + } + ] + } + } + }, + "required": [ + "index", + "keys" + ], + "additionalProperties": false } }, "required": [ @@ -239,11 +358,27 @@ "required": ["type"] }, "then": { - "required": ["type", "keyIdProperty"] + "oneOf": [ + { + "required": ["type", "keyIdProperty"], + "properties": { + "identityProperty": false + } + }, + { + "required": ["type", "identityProperty"], + "properties": { + "keyIdProperty": false, + "propertyAgreement": false + } + } + ] }, "else": { "properties": { - "keyIdProperty": false + "keyIdProperty": false, + "identityProperty": false, + "keyRequirements": false } } }, @@ -258,6 +393,18 @@ "contractRequirements": false } } + }, + { + "if": { + "properties": { "type": { "const": "permanentDocument" } }, + "required": ["type"] + }, + "then": {}, + "else": { + "properties": { + "lookup": false + } + } } ] }, @@ -311,6 +458,12 @@ "type": "integer", "minimum": 0 }, + "distinctFrom": { + "description": "Only on identifier properties: the value must differ from the value of the named property of the same document (a dotted path for a nested one) or, as \"$ownerId\", from the document's owner. Checked on document create and replace against the transition alone; an equal pair is refused (DocumentPropertyNotDistinctError, 10419). When the named property is absent from the document there is nothing to differ from, so the rule passes. A transfer to, or a purchase by, the identity a $ownerId declaration names is refused the same way, judged against the stored document. The named property must exist on the document type, be an identifier, and not be the declaring property itself. On a typed array of identifiers the keyword goes on the items and applies to every element. Available from protocol version 14.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, "requiredSince": { "type": "integer", "minimum": 1, @@ -327,6 +480,18 @@ } } }, + "items": { + "description": "should be used only with array type", + "properties": { + "type": { + "type": "string", + "const": "array" + } + }, + "required": [ + "type" + ] + }, "contentMediaType": { "if": { "properties": { @@ -336,6 +501,7 @@ } }, "then": { + "$comment": "an identifier is one value, not a list of bytes: uniqueItems would refuse every identifier that repeats a byte, most of them", "properties": { "byteArray": { "const": true @@ -345,7 +511,8 @@ }, "maxItems": { "const": 32 - } + }, + "uniqueItems": false }, "required": [ "byteArray", @@ -367,8 +534,8 @@ "maxLength" ] }, - "refersTo": { - "description": "refersTo is only allowed on identifier properties", + "distinctFrom": { + "description": "distinctFrom is only allowed on identifier properties", "properties": { "type": { "const": "array" @@ -394,6 +561,76 @@ "maxItems" ] }, + "refersTo": { + "description": "refersTo is only allowed on identifier properties, except an identityPublicKey reference with identityProperty, which sits on the key id property: an integer with minimum 0 and maximum 4294967295, the range of a key id", + "if": { + "properties": { + "refersTo": { + "required": ["identityProperty"] + } + } + }, + "then": { + "properties": { + "type": { + "const": "integer" + }, + "minimum": { + "const": 0 + }, + "maximum": { + "const": 4294967295 + } + }, + "required": [ + "type", + "minimum", + "maximum" + ] + }, + "else": { + "properties": { + "type": { + "const": "array" + }, + "byteArray": { + "const": true + }, + "contentMediaType": { + "const": "application/x.dash.dpp.identifier" + }, + "minItems": { + "const": 32 + }, + "maxItems": { + "const": 32 + } + }, + "required": [ + "type", + "byteArray", + "contentMediaType", + "minItems", + "maxItems" + ] + } + }, + "encryptedFor": { + "description": "encryptedFor is only allowed on byte array properties that are not identifiers", + "properties": { + "type": { + "const": "array" + }, + "byteArray": { + "const": true + }, + "contentMediaType": false + }, + "required": [ + "type", + "byteArray" + ] + }, "format": { "description": "prevent slow format validation of large strings", "properties": { @@ -439,7 +676,7 @@ } }, { - "$comment": "allow only byte arrays", + "$comment": "an array is a byte array or a typed array. A byte array declares byteArray: true, its minItems and maxItems count bytes, and it takes no items (an identifier byte array takes no uniqueItems either, see contentMediaType). A typed array declares items, the schema of every element, instead; its minItems and maxItems count elements, and maxItems is required", "if": { "properties": { "type": { @@ -451,12 +688,25 @@ ] }, "then": { - "properties": { - "byteArray": true + "if": { + "required": [ + "byteArray" + ] }, - "required": [ - "byteArray" - ] + "then": { + "properties": { + "items": false + } + }, + "else": { + "properties": { + "contentMediaType": false + }, + "required": [ + "items", + "maxItems" + ] + } } }, { @@ -493,6 +743,331 @@ } ] }, + "documentArrayItem": { + "$comment": "The element schema of a typed array: one scalar, an integer, a number, a string, a boolean, or a byte array (byteArray: true), which with the identifier contentMediaType is an identifier. Objects and arrays of arrays are refused. An element carries no position, requiredSince, uniqueItems, const or examples of its own (a one-value enum does what const would, and an update can widen it; examples annotate nothing an element needs). An identifier element may carry a refersTo, which every element then declares. An enum's members are values of the element type (a byte array or identifier element takes none), and an integer element's minimum and maximum are integers; the parser reads them so random documents stay inside them", + "type": "object", + "properties": { + "$comment": { + "$ref": "https://json-schema.org/draft/2020-12/meta/core#/properties/$comment" + }, + "description": { + "$ref": "https://json-schema.org/draft/2020-12/meta/meta-data#/properties/description" + }, + "type": { + "enum": [ + "integer", + "number", + "string", + "boolean", + "array" + ] + }, + "multipleOf": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/multipleOf" + }, + "maximum": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/maximum" + }, + "exclusiveMaximum": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/exclusiveMaximum" + }, + "minimum": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/minimum" + }, + "exclusiveMinimum": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/exclusiveMinimum" + }, + "maxLength": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/maxLength" + }, + "minLength": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/minLength" + }, + "pattern": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/pattern" + }, + "maxItems": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/maxItems" + }, + "minItems": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/minItems" + }, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": true + }, + "format": { + "$ref": "https://json-schema.org/draft/2020-12/meta/format-annotation#/properties/format" + }, + "contentMediaType": { + "$ref": "https://json-schema.org/draft/2020-12/meta/content#/properties/contentMediaType" + }, + "byteArray": { + "type": "boolean", + "const": true + }, + "distinctFrom": { + "description": "Only on identifier elements: every element must differ from the value of the named property of the same document (a dotted path for a nested one) or, as \"$ownerId\", from the document's owner, exactly as distinctFrom on an identifier property. The declaration belongs on the items, not on the array. Available from protocol version 14.", + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "refersTo": { + "description": "Only on identifier elements: what every element refers to, the refersTo declaration of an identifier property with the same keys and the same checks, except that identityPublicKey is refused: its keyIdProperty names one sibling key id, which cannot pair with many elements. When a document is created or replaced each element is checked as a single reference is, and the first one that fails refuses the write, its error naming the element by its list path (reasons[2] for the third). A propertyAgreement's referring side is still a property of the referring document or its $ownerId, the same for every element, and its referenced side a property of that element's referenced document. The declaration belongs on the items, not on the array. Available from protocol version 14.", + "$ref": "#/$defs/documentSchema/properties/refersTo", + "properties": { + "type": { + "not": { + "const": "identityPublicKey" + } + } + } + } + }, + "required": [ + "type" + ], + "additionalProperties": false, + "dependentSchemas": { + "refersTo": { + "description": "refersTo is only allowed on identifier elements", + "properties": { + "type": { + "const": "array" + }, + "byteArray": { + "const": true + }, + "contentMediaType": { + "const": "application/x.dash.dpp.identifier" + }, + "minItems": { + "const": 32 + }, + "maxItems": { + "const": 32 + } + }, + "required": [ + "type", + "byteArray", + "contentMediaType", + "minItems", + "maxItems" + ] + }, + "distinctFrom": { + "description": "distinctFrom is only allowed on identifier elements", + "properties": { + "type": { + "const": "array" + }, + "byteArray": { + "const": true + }, + "contentMediaType": { + "const": "application/x.dash.dpp.identifier" + }, + "minItems": { + "const": 32 + }, + "maxItems": { + "const": 32 + } + }, + "required": [ + "type", + "byteArray", + "contentMediaType", + "minItems", + "maxItems" + ] + }, + "byteArray": { + "description": "should be used only with array type", + "properties": { + "type": { + "const": "array" + } + } + }, + "contentMediaType": { + "if": { + "properties": { + "contentMediaType": { + "const": "application/x.dash.dpp.identifier" + } + } + }, + "then": { + "properties": { + "byteArray": { + "const": true + }, + "minItems": { + "const": 32 + }, + "maxItems": { + "const": 32 + } + }, + "required": [ + "byteArray", + "minItems", + "maxItems" + ] + } + }, + "pattern": { + "description": "prevent slow pattern matching of large strings", + "properties": { + "maxLength": { + "type": "integer", + "minimum": 0, + "maximum": 50000 + } + }, + "required": [ + "maxLength" + ] + }, + "format": { + "description": "prevent slow format validation of large strings", + "properties": { + "maxLength": { + "type": "integer", + "minimum": 0, + "maximum": 50000 + } + }, + "required": [ + "maxLength" + ] + } + }, + "allOf": [ + { + "$comment": "an array element is a byte array: a typed array cannot hold another typed array. A byte array or identifier element takes no enum", + "if": { + "properties": { + "type": { + "const": "array" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "enum": false + }, + "required": [ + "byteArray" + ] + } + }, + { + "$comment": "a string element's enum members are strings", + "if": { + "properties": { + "type": { + "const": "string" + } + }, + "required": [ + "type", + "enum" + ] + }, + "then": { + "properties": { + "enum": { + "items": { + "type": "string" + } + } + } + } + }, + { + "$comment": "an integer element's enum members, minimum and maximum are integers", + "if": { + "properties": { + "type": { + "const": "integer" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "enum": { + "items": { + "type": "integer" + } + }, + "minimum": { + "type": "integer" + }, + "maximum": { + "type": "integer" + } + } + } + }, + { + "$comment": "a number element's enum members are numbers", + "if": { + "properties": { + "type": { + "const": "number" + } + }, + "required": [ + "type", + "enum" + ] + }, + "then": { + "properties": { + "enum": { + "items": { + "type": "number" + } + } + } + } + }, + { + "$comment": "a boolean element's enum members are booleans", + "if": { + "properties": { + "type": { + "const": "boolean" + } + }, + "required": [ + "type", + "enum" + ] + }, + "then": { + "properties": { + "enum": { + "items": { + "type": "boolean" + } + } + } + } + } + ] + }, "documentActionTokenCost": { "type": "object", "properties": { @@ -1067,7 +1642,7 @@ "oneOf": [ { "type": "string", - "pattern": "^[a-zA-Z0-9-_]{1,64}$" + "pattern": "^[a-zA-Z0-9_]{1,64}$" }, { "type": "string", diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs index 589b295f98c..978fc4af0dd 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs @@ -1013,6 +1013,14 @@ impl DocumentTypeV2Getters for DocumentType { } } + fn distinct_from_fields(&self) -> &[String] { + match self { + DocumentType::V0(_) => &[], + DocumentType::V1(_) => &[], + DocumentType::V2(v2) => v2.distinct_from_fields(), + } + } + fn immutable_fields(&self) -> &BTreeSet { match self { DocumentType::V0(_) => &NO_IMMUTABLE_FIELDS, @@ -1138,6 +1146,14 @@ impl DocumentTypeV2Getters for DocumentTypeRef<'_> { } } + fn distinct_from_fields(&self) -> &[String] { + match self { + DocumentTypeRef::V0(_) => &[], + DocumentTypeRef::V1(_) => &[], + DocumentTypeRef::V2(v2) => v2.distinct_from_fields(), + } + } + fn immutable_fields(&self) -> &BTreeSet { match self { DocumentTypeRef::V0(_) => &NO_IMMUTABLE_FIELDS, @@ -1229,6 +1245,14 @@ impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> { } } + fn distinct_from_fields(&self) -> &[String] { + match self { + DocumentTypeMutRef::V0(_) => &[], + DocumentTypeMutRef::V1(_) => &[], + DocumentTypeMutRef::V2(v2) => v2.distinct_from_fields(), + } + } + fn immutable_fields(&self) -> &BTreeSet { match self { DocumentTypeMutRef::V0(_) => &NO_IMMUTABLE_FIELDS, diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v0/mod.rs index 7c8b9aca0a9..ee3b2c48c4c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v0/mod.rs @@ -1,6 +1,6 @@ use crate::data_contract::document_type::index::Index; use crate::data_contract::document_type::index_level::IndexLevel; -use crate::data_contract::document_type::property::DocumentProperty; +use crate::data_contract::document_type::property::{DocumentProperty, EncryptedFor}; use platform_value::{Identifier, Value}; @@ -42,6 +42,23 @@ pub trait DocumentTypeV0Getters { /// Returns the properties of the document type. fn properties(&self) -> &IndexMap; + /// The properties declared `encryptedFor`, each by its dotted path in + /// schema order, with the declaration that says for whom, under which + /// keys and under which scheme their bytes were encrypted. Empty on a + /// document type declaring none, and on every contract parsed before + /// protocol version 14, which ignores the keyword. + fn encrypted_properties(&self) -> Vec<(&String, &EncryptedFor)> { + self.flattened_properties() + .iter() + .filter_map(|(path, property)| { + property + .encrypted_for + .as_ref() + .map(|encrypted_for| (path, encrypted_for)) + }) + .collect() + } + /// Returns the identifier paths of the document type. fn identifier_paths(&self) -> &BTreeSet; diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs index 063ffe3fd54..37308e76a8d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs @@ -59,6 +59,11 @@ pub trait DocumentTypeV2Getters { /// mutable, where every property is already immutable. fn immutable_fields(&self) -> &BTreeSet; + /// The dotted paths of the properties that declare `distinctFrom` + /// (protocol version 14), in schema order. Empty on generations that + /// predate the keyword. + fn distinct_from_fields(&self) -> &[String]; + /// The subset of [`Self::immutable_fields`] a replace may still set while /// the stored document has no value for them (the /// `immutableAllowSetting` keyword, protocol version 14). Once present diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/mod.rs index 2ccd4283987..388cff3f589 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/mod.rs @@ -68,7 +68,9 @@ impl DocumentType { validation_operations, platform_version, ), - // in v1 we add the ability to have contracts without documents and just tokens + // in v1 we add the ability to have contracts without documents and just tokens; + // from protocol version 14 it also checks an identity key reference's + // keyRequirements.boundTo, inert before (see v1) 1 => DocumentType::create_document_types_from_document_schemas_v1( data_contract_id, data_contract_system_version, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs index 6529bd47f95..26a9ca3799a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs @@ -1,8 +1,16 @@ use crate::consensus::basic::data_contract::DocumentTypesAreMissingError; use crate::data_contract::config::DataContractConfig; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; use crate::data_contract::document_type::class_methods::consensus_or_protocol_data_contract_error; -use crate::data_contract::document_type::DocumentType; +use crate::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, + DocumentType, +}; +use crate::data_contract::errors::DataContractError; use crate::data_contract::{DocumentName, TokenConfiguration, TokenContractPosition}; +use crate::identity::Purpose; use crate::validation::operations::ProtocolValidationOperation; use crate::version::PlatformVersion; use crate::ProtocolError; @@ -64,6 +72,492 @@ impl DocumentType { contract_document_types.insert(name.to_string(), document_type); } + + // Protocol version 14 and later: an `identityPublicKey` reference's + // `keyRequirements.boundTo` must name a document type of this contract, and one a + // key of the required purpose can be bound to. A document type's parse sees only its + // own schema, so the check runs here, once every document type is parsed, and only + // under full validation (registration), like the meta-schema: a contract read back + // from state passed it when it was written. It holds the reference's promise that + // the write-time check never needs a second contract fetch: the bound the key must + // carry names the declaring contract and one of its own document types. + // + // Inert for every protocol version before 14, which also select this generation: + // `key_requirements` exists on a parsed reference only where the tables carry + // `apply_property_reference: Some(_)`, which no version before 14 does (their + // meta-schemas refuse `refersTo` and their parser ignores it), so the loop below + // finds no requirement to check there and the output is unchanged. + if !full_validation { + return Ok(contract_document_types); + } + + for (name, document_type) in &contract_document_types { + for (path, property) in document_type.as_ref().flattened_properties() { + // Both forms of the key reference carry the same requirements. + // Only a scalar property can hold either: the typed array + // parser refuses identityPublicKey on an element + let key_requirements = match &property.property_type { + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_requirements, .. + }, + ) => key_requirements, + DocumentPropertyType::KeyIdWithReference(reference) => { + &reference.key_requirements + } + _ => continue, + }; + let Some(bound_to) = &key_requirements.bound_to else { + continue; + }; + let Some(bound_document_type) = contract_document_types.get(bound_to) else { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "{name}.{path} refersTo keyRequirements boundTo {bound_to:?} names no document type of this contract" + )), + )); + }; + // Only these purposes carry a document type bound, and the two encryption + // purposes only where the bound type declares that it takes such keys; + // any other pairing is a requirement no key could ever meet + let bound_type_takes_the_key = match key_requirements.purpose { + None | Some(Purpose::AUTHENTICATION) => true, + Some(Purpose::ENCRYPTION) => bound_document_type + .as_ref() + .requires_identity_encryption_bounded_key() + .is_some(), + Some(Purpose::DECRYPTION) => bound_document_type + .as_ref() + .requires_identity_decryption_bounded_key() + .is_some(), + Some(_) => false, + }; + if !bound_type_takes_the_key { + let purpose = key_requirements + .purpose + .map(|purpose| purpose.wire_name()) + .unwrap_or_default(); + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "{name}.{path} refersTo keyRequirements requires a {purpose} key bound to document type {bound_to:?}, which no key can be: only authentication, encryption and decryption keys carry a document type bound, and an encryption or decryption key only where the type declares requiresIdentityEncryptionBoundedKey or requiresIdentityDecryptionBoundedKey" + )), + )); + } + } + } + + // Protocol version 14 and later: a `refersTo` lookup into a document type of this + // contract must resolve in it: the named index exists and is unique, the keys cover + // its properties exactly, every source holds the kind of value its index property + // does, and the key cannot move off the document it found (see + // `DocumentReferenceLookup::referenced_side_error`). Like the check above it + // needs every document type of the contract, and runs under full validation only. A + // lookup into another contract is checked against that contract's state at + // registration, and a reference naming a document type this contract does not have + // is left to that validation too, which reports it. + // + // Inert for every protocol version before 14 for the same reason as the check above: + // a parsed reference carries a `lookup` only where the tables carry + // `apply_property_reference: Some(_)`, so the loop below finds none there. + for (name, document_type) in &contract_document_types { + for (path, property) in document_type.as_ref().flattened_properties() { + // On an identifier property or on the elements of a typed array + let Some(target) = property + .property_type + .reference() + .and_then(|reference| reference.target()) + else { + continue; + }; + let Some(DocumentReferenceDeclaration { + contract_id, + document_type_name, + lookup: Some(lookup), + .. + }) = target.as_any_document_reference() + else { + continue; + }; + if contract_id.is_some_and(|contract_id| contract_id != data_contract_id) { + continue; + } + let Some(referenced_document_type) = + contract_document_types.get(document_type_name) + else { + continue; + }; + // A lookup is only declared on a permanentDocument reference, and a + // deletable target fails that reference whatever its indexes say: + // registration reports it (ReferencedDocumentTypeDeletableError), so the + // lookup is not judged against a type it could never reference + let referenced = referenced_document_type.as_ref(); + if referenced.documents_can_be_deleted() + || referenced.documents_can_be_deleted_by_moderators() + { + continue; + } + if let Some(reason) = + lookup.referenced_side_error(document_type.as_ref(), referenced) + { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{name}\" property \"{path}\" refersTo lookup: {reason}" + )), + )); + } + } + } + Ok(contract_document_types) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; + use crate::data_contract::document_type::{ + IdentityKeyReferenceRequirements, KeyIdReference, KeyReferenceIdentityProperty, + }; + use crate::data_contract::DataContract; + use crate::identity::Purpose; + use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, + }; + use platform_value::platform_value; + use platform_value::string_encoding::Encoding; + use std::ops::Deref; + + /// A contract with a `joinRequest` type whose `recipientId` references an identity key + /// with `refers_to`, and a `submittedCharter` type for a bound to name. Both types declare + /// that they take bound encryption and decryption keys. + fn contract_value(refers_to: Value) -> Value { + contract_value_taking_bound_keys(refers_to, true) + } + + /// [`contract_value`], with or without the two bound key keywords on both types. + fn contract_value_taking_bound_keys(refers_to: Value, takes_bound_keys: bool) -> Value { + let mut value = platform_value!({ + "$formatVersion": "1", + "id": Identifier::from_string("4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", Encoding::Base58).expect("a valid id"), + "ownerId": Identifier::from_string("2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", Encoding::Base58).expect("a valid id"), + "version": 1, + "documentSchemas": { + "joinRequest": { + "type": "object", + "properties": { + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": refers_to + }, + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "position": 1 + } + }, + "required": [], + "additionalProperties": false + }, + "submittedCharter": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 64, + "position": 0 + } + }, + "required": [], + "additionalProperties": false + } + } + }); + if takes_bound_keys { + for document_type_name in ["joinRequest", "submittedCharter"] { + let path = format!("documentSchemas.{document_type_name}"); + let document_schema = value + .get_mut_value_at_path(&path) + .expect("the document schema"); + for keyword in [ + "requiresIdentityEncryptionBoundedKey", + "requiresIdentityDecryptionBoundedKey", + ] { + document_schema + .insert(keyword.to_string(), Value::U8(2)) + .expect("the keyword inserts"); + } + } + } + value + } + + fn recipient_id_target(contract: &DataContract) -> DocumentPropertyType { + contract + .document_type_for_name("joinRequest") + .expect("the joinRequest document type") + .flattened_properties() + .get("recipientId") + .map(|p| p.property_type.clone()) + .expect("the recipientId property") + } + + #[test] + fn should_accept_bound_to_naming_a_document_type_of_the_contract() { + let platform_version = PlatformVersion::latest(); + + for bound_to in ["submittedCharter", "joinRequest"] { + let contract = DataContract::from_value( + contract_value(platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption", "boundTo": bound_to } + })), + true, + platform_version, + ) + .expect("the contract should parse"); + + assert_eq!( + recipient_id_target(&contract), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyId".to_string(), + key_requirements: IdentityKeyReferenceRequirements { + purpose: Some(Purpose::DECRYPTION), + bound_to: Some(bound_to.to_string()), + }, + } + ) + ); + } + } + + /// The invalid contract structure message a contract value is refused with under full + /// validation; without full validation (a contract read back from state) it parses. + fn refusal_message(value: Value) -> String { + let platform_version = PlatformVersion::latest(); + + DataContract::from_value(value.clone(), false, platform_version) + .expect("a contract read back from state is not re-checked"); + + let error = DataContract::from_value(value, true, platform_version) + .expect_err("the contract should be refused"); + let ProtocolError::ConsensusError(consensus_error) = &error else { + panic!("expected a consensus error, got {error}"); + }; + let ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message), + )) = consensus_error.deref() + else { + panic!("expected an invalid contract structure error, got {consensus_error}"); + }; + message.clone() + } + + #[test] + fn should_reject_bound_to_naming_a_document_type_the_contract_does_not_have() { + assert_eq!( + refusal_message(contract_value(platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption", "boundTo": "electedCharter" } + }))), + "joinRequest.recipientId refersTo keyRequirements boundTo \"electedCharter\" names no document type of this contract" + ); + } + + /// [`contract_value`] with the reference moved onto the key id property: `recipientKeyId` + /// carries `refers_to` (an `identityProperty` form) and `recipientId` is a plain identifier. + fn contract_value_with_key_id_reference(refers_to: Value) -> Value { + let mut value = contract_value(platform_value!({ "type": "identity" })); + let recipient_id = value + .get_mut_value_at_path("documentSchemas.joinRequest.properties.recipientId") + .expect("the recipientId schema"); + recipient_id + .remove("refersTo") + .expect("the identity reference removes"); + let recipient_key_id = value + .get_mut_value_at_path("documentSchemas.joinRequest.properties.recipientKeyId") + .expect("the recipientKeyId schema"); + recipient_key_id + .insert("maximum".to_string(), Value::U64(u64::from(u32::MAX))) + .expect("the maximum inserts"); + recipient_key_id + .insert("refersTo".to_string(), refers_to) + .expect("the reference inserts"); + value + } + + /// The bound check reads the key id form's requirements too. + #[test] + fn should_check_bound_to_on_the_key_id_form() { + let platform_version = PlatformVersion::latest(); + + let contract = DataContract::from_value( + contract_value_with_key_id_reference(platform_value!({ + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } + })), + true, + platform_version, + ) + .expect("the contract should parse"); + assert_eq!( + contract + .document_type_for_name("joinRequest") + .expect("the joinRequest document type") + .flattened_properties() + .get("recipientKeyId") + .map(|p| p.property_type.clone()), + Some(DocumentPropertyType::KeyIdWithReference(KeyIdReference { + identity_property: KeyReferenceIdentityProperty::OwnerId, + key_requirements: IdentityKeyReferenceRequirements { + purpose: Some(Purpose::DECRYPTION), + bound_to: Some("submittedCharter".to_string()), + }, + })) + ); + + assert_eq!( + refusal_message(contract_value_with_key_id_reference(platform_value!({ + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyRequirements": { "purpose": "decryption", "boundTo": "electedCharter" } + }))), + "joinRequest.recipientKeyId refersTo keyRequirements boundTo \"electedCharter\" names no document type of this contract" + ); + } + + #[test] + fn should_reject_a_bound_to_no_key_could_ever_carry() { + // Only authentication, encryption and decryption keys carry a document type bound + for purpose in ["transfer", "voting", "owner"] { + let message = refusal_message(contract_value(platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": purpose, "boundTo": "submittedCharter" } + }))); + assert!( + message.starts_with(&format!( + "joinRequest.recipientId refersTo keyRequirements requires a {purpose} key bound to document type \"submittedCharter\", which no key can be" + )), + "{purpose}: {message}" + ); + } + + // An encryption or decryption key is bound to a document type only where the type + // declares that it takes such keys + for purpose in ["encryption", "decryption"] { + let message = refusal_message(contract_value_taking_bound_keys( + platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": purpose, "boundTo": "submittedCharter" } + }), + false, + )); + assert!( + message.contains("which no key can be"), + "{purpose}: {message}" + ); + } + + // Whereas an authentication key, or a key of any purpose, can be bound to a type that + // declares nothing + for requirements in [ + platform_value!({ "purpose": "authentication", "boundTo": "submittedCharter" }), + platform_value!({ "boundTo": "submittedCharter" }), + ] { + DataContract::from_value( + contract_value_taking_bound_keys( + platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": requirements + }), + false, + ), + true, + PlatformVersion::latest(), + ) + .expect("an authentication key can carry any document type bound"); + } + } + + #[test] + fn should_refuse_key_requirements_before_protocol_version_14_and_accept_them_at_it() { + let value = contract_value(platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } + })); + + // The meta-schema of protocol version 13 knows no `refersTo` at all + let platform_version_13 = PlatformVersion::get(13).expect("platform version 13 exists"); + DataContract::from_value(value.clone(), true, platform_version_13) + .expect_err("a contract with keyRequirements should be refused before version 14"); + + let contract = DataContract::from_value(value, true, PlatformVersion::latest()) + .expect("a contract with keyRequirements should parse at version 14"); + assert!(matches!( + recipient_id_target(&contract), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { key_requirements, .. } + ) if key_requirements.purpose == Some(Purpose::DECRYPTION) + && key_requirements.bound_to.as_deref() == Some("submittedCharter") + )); + } + + /// The requirements ride on the schema, which is what the contract serializes, so a + /// contract with them and one without both come back as parsed, and the one without + /// serializes exactly as it did before the keyword existed. + #[test] + fn should_round_trip_key_requirements_through_platform_serialization() { + let platform_version = PlatformVersion::latest(); + + for refers_to in [ + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId" }), + platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } + }), + ] { + let contract = + DataContract::from_value(contract_value(refers_to), true, platform_version) + .expect("the contract should parse"); + + let bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract should serialize"); + let deserialized = DataContract::versioned_deserialize_untrusted( + bytes.as_slice(), + false, + platform_version, + ) + .expect("the contract should deserialize"); + + assert_eq!( + recipient_id_target(&deserialized), + recipient_id_target(&contract) + ); + assert_eq!( + deserialized + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract should serialize again"), + bytes + ); + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs index 0fa5e3e27a7..b617c405a7c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs @@ -8,6 +8,7 @@ use crate::ProtocolError; pub(crate) mod apply_required_since; mod create_document_types_from_document_schemas; +mod parse_typed_array; mod should_use_creator_id; mod system_properties; mod try_from_schema; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs new file mode 100644 index 00000000000..89eac48eb62 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs @@ -0,0 +1,88 @@ +use std::collections::BTreeMap; + +use platform_value::Value; +use platform_version::version::PlatformVersion; + +use crate::data_contract::document_type::{ + DocumentPropertyType, DocumentPropertyTypeParsingOptions, +}; +use crate::data_contract::errors::DataContractError; + +mod v0; + +/// Parses a typed array property: `type: "array"` with an `items` element +/// schema in place of `byteArray`, into [`DocumentPropertyType::TypedArray`]. +/// +/// Returns `None` for every other property, a byte array included, which the +/// caller leaves to `DocumentPropertyType::try_from_value_map`. The element +/// schema is parsed by that same scalar parser with the property's `options`, +/// so an element has the type a scalar property of its schema would have, +/// and a `refersTo` on identifier elements is folded in by the same +/// `apply_property_reference` a scalar identifier goes through. +/// +/// Versioned on `parse_typed_array` in the platform version's document type +/// schema versions. `None` selects the behavior of the versions that predate +/// typed arrays: nothing is parsed here, so `try_from_value_map` refuses an +/// array that is not a byte array, exactly as those versions always did. +pub(crate) fn parse_typed_array( + inner_properties: &BTreeMap, + options: &DocumentPropertyTypeParsingOptions, + platform_version: &PlatformVersion, +) -> Result, DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .parse_typed_array + { + None => Ok(None), + Some(0) => v0::parse_typed_array_v0(inner_properties, options, platform_version), + Some(version) => Err(DataContractError::Unsupported(format!( + "parse_typed_array version {version} is not supported" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_contract::document_type::array::TypedArrayProperty; + use crate::data_contract::document_type::StringPropertySizes; + use platform_value::platform_value; + + #[test] + fn should_parse_a_typed_array_from_protocol_version_14_and_leave_it_alone_before() { + let schema = platform_value!({ + "type": "array", + "minItems": 1, + "maxItems": 8, + "uniqueItems": true, + "items": { "type": "string", "maxLength": 16 } + }); + let map = schema + .to_btree_ref_string_map() + .expect("the schema is a map"); + let options = DocumentPropertyTypeParsingOptions::default(); + + assert_eq!( + parse_typed_array(&map, &options, PlatformVersion::latest()).expect("parses"), + Some(DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: Box::new(DocumentPropertyType::String(StringPropertySizes { + min_length: None, + max_length: Some(16), + })), + item_constraints: Default::default(), + min_items: Some(1), + max_items: 8, + unique_items: true, + })) + ); + // Protocol version 13 leaves the property to the scalar parser + let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13 exists"); + assert_eq!( + parse_typed_array(&map, &options, platform_version_13).expect("parses"), + None + ); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs new file mode 100644 index 00000000000..4fdbe30dff0 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs @@ -0,0 +1,483 @@ +use std::collections::BTreeMap; + +use platform_value::btreemap_extensions::BTreeValueMapHelper; +use platform_value::Value; +use platform_version::version::PlatformVersion; + +use crate::data_contract::document_type::array::{ArrayItemConstraints, TypedArrayProperty}; +use crate::data_contract::document_type::class_methods::try_from_schema::apply_element_reference; +use crate::data_contract::document_type::{ + property_names, DocumentPropertyType, DocumentPropertyTypeParsingOptions, +}; +use crate::data_contract::errors::DataContractError; + +/// Generation 0 parse rules: an array property that does not declare +/// `byteArray` is a typed array, whose `items` must be one scalar element +/// schema. `minItems` and `maxItems` count elements and fit a u16; `maxItems` +/// is required and `minItems` may not exceed it; `contentMediaType` belongs on +/// the items; `uniqueItems` defaults to false. These are the shape of the +/// declaration, so they hold on every parse; the cap on `maxItems` is a +/// registration limit, checked by the generation 3 driver under full +/// validation. +pub(super) fn parse_typed_array_v0( + inner_properties: &BTreeMap, + options: &DocumentPropertyTypeParsingOptions, + platform_version: &PlatformVersion, +) -> Result, DataContractError> { + let is_array = inner_properties + .get(property_names::TYPE) + .and_then(|type_value| type_value.as_text()) + == Some("array"); + + // A byte array keeps its scalar parse, and so does `byteArray: false`, + // which that parse refuses + if !is_array || inner_properties.contains_key(property_names::BYTE_ARRAY) { + return Ok(None); + } + + let Some(items) = inner_properties.get(property_names::ITEMS) else { + return Err(DataContractError::InvalidContractStructure( + "an array property must declare either byteArray: true or the items schema of its \ + elements" + .to_string(), + )); + }; + + if inner_properties.contains_key(property_names::CONTENT_MEDIA_TYPE) { + return Err(DataContractError::InvalidContractStructure( + "contentMediaType belongs on the items of a typed array, not on the array".to_string(), + )); + } + + let item_type = parse_element_type(items, options, platform_version)?; + let item_constraints = parse_item_constraints(items, &item_type)?; + + // Fee estimation sizes the inline list by its bound + let Some(max_items) = inner_properties.get_optional_integer(property_names::MAX_ITEMS)? else { + return Err(DataContractError::InvalidContractStructure( + "a typed array must declare maxItems: its inline encoding is sized by it".to_string(), + )); + }; + let min_items: Option = + inner_properties.get_optional_integer(property_names::MIN_ITEMS)?; + if min_items.is_some_and(|min_items| min_items > max_items) { + return Err(DataContractError::InvalidContractStructure(format!( + "a typed array's minItems may not exceed its maxItems of {max_items}: no document \ + could hold the list" + ))); + } + + Ok(Some(DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: Box::new(item_type), + item_constraints, + min_items, + max_items, + unique_items: inner_properties + .get_optional_bool(property_names::UNIQUE_ITEMS)? + .unwrap_or_default(), + }))) +} + +/// The element type of a typed array: its `items` schema parsed exactly as a +/// scalar property schema is, so an integer element takes the width its +/// bounds give it and a byte array element with the identifier media type is +/// an identifier. Objects and arrays of arrays are refused. +/// +/// A `refersTo` on identifier elements is folded into the element type by +/// `apply_element_reference`, versioned with and calling the rules of +/// `apply_property_reference` that fold one into a scalar identifier, so an +/// element reference has the scalar's target types, keys and checks: the +/// element becomes `IdentifierWithReference(target)`. The one target refused +/// is `identityPublicKey`, in either form: its `keyIdProperty` names a single +/// sibling key id, and an `identityProperty` declaration sits on the key id +/// itself, neither of which can pair with many elements. The contract-level +/// checks of the declaration (the referenced document type, the +/// `propertyAgreement` sides and value kinds) need other contracts and run at +/// registration in drive-abci, which visits element references too. +fn parse_element_type( + items: &Value, + options: &DocumentPropertyTypeParsingOptions, + platform_version: &PlatformVersion, +) -> Result { + // The tuple form (`items: [..]`) and boolean schemas are not one element + // schema + let items_map = items.to_btree_ref_string_map().map_err(|_| { + DataContractError::InvalidContractStructure( + "the items of a typed array must be one element schema (an object)".to_string(), + ) + })?; + if items_map.contains_key(property_names::REF) { + return Err(DataContractError::InvalidContractStructure( + "the items of a typed array must be an inline element schema, not a $ref".to_string(), + )); + } + match items_map + .get(property_names::TYPE) + .and_then(|type_value| type_value.as_text()) + { + Some("object") => { + return Err(DataContractError::InvalidContractStructure( + "arrays of objects are not supported: the elements of a typed array must be \ + scalars (integer, number, string, boolean, byte array or identifier)" + .to_string(), + )) + } + Some("array") if !items_map.contains_key(property_names::BYTE_ARRAY) => { + return Err(DataContractError::InvalidContractStructure( + "arrays of arrays are not supported: an element of a typed array may be a byte \ + array (byteArray: true) or an identifier, but not another array" + .to_string(), + )) + } + _ => {} + } + + let element_type = DocumentPropertyType::try_from_value_map(&items_map, options)?; + let element_type = apply_element_reference(&items_map, element_type, platform_version)?; + match element_type { + DocumentPropertyType::U128 + | DocumentPropertyType::I128 + | DocumentPropertyType::U64 + | DocumentPropertyType::I64 + | DocumentPropertyType::U32 + | DocumentPropertyType::I32 + | DocumentPropertyType::U16 + | DocumentPropertyType::I16 + | DocumentPropertyType::U8 + | DocumentPropertyType::I8 + | DocumentPropertyType::F64 + | DocumentPropertyType::String(_) + | DocumentPropertyType::ByteArray(_) + | DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) + | DocumentPropertyType::Boolean => Ok(element_type), + other => Err(DataContractError::InvalidContractStructure(format!( + "unsupported typed array element type: {}", + other.name() + ))), + } +} + +/// Whether an `enum` member, a `minimum` or a `maximum` is a value of the +/// element type: a string, an integer, a number (an integer counts) or a +/// boolean. +fn is_value_of(element_type: &DocumentPropertyType, value: &Value) -> bool { + match element_type { + DocumentPropertyType::String(_) => value.is_text(), + DocumentPropertyType::U128 + | DocumentPropertyType::I128 + | DocumentPropertyType::U64 + | DocumentPropertyType::I64 + | DocumentPropertyType::U32 + | DocumentPropertyType::I32 + | DocumentPropertyType::U16 + | DocumentPropertyType::I16 + | DocumentPropertyType::U8 + | DocumentPropertyType::I8 => value.to_integer::().is_ok(), + DocumentPropertyType::F64 => value.to_float().is_ok(), + DocumentPropertyType::Boolean => value.as_bool().is_some(), + _ => false, + } +} + +/// The `enum`, `minimum` and `maximum` of the `items` schema. These are the +/// shape of the declaration, so they hold on every parse: an `enum` has at +/// least one member, every member is of the element type and a byte array or +/// identifier element takes none; `minimum` and `maximum` belong to integer +/// and number elements, are read as that type, and `minimum` never exceeds +/// `maximum`. The meta-schema states the same rules for the validating path. +fn parse_item_constraints( + items: &Value, + element_type: &DocumentPropertyType, +) -> Result { + let items_map = items.to_btree_ref_string_map()?; + let mut constraints = ArrayItemConstraints::default(); + let is_number = element_type.is_integer() + || matches!( + element_type, + DocumentPropertyType::U128 | DocumentPropertyType::I128 | DocumentPropertyType::F64 + ); + + if let Some(members) = items_map.get(property_names::ENUM) { + let Some(members) = members.as_array() else { + return Err(DataContractError::InvalidContractStructure( + "the enum of a typed array's elements must be a list of values".to_string(), + )); + }; + if members.is_empty() { + return Err(DataContractError::InvalidContractStructure( + "the enum of a typed array's elements must hold at least one value".to_string(), + )); + } + if matches!( + element_type, + DocumentPropertyType::ByteArray(_) + | DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(DataContractError::InvalidContractStructure( + "enum is not supported on byte array or identifier elements of a typed array" + .to_string(), + )); + } + if let Some(member) = members + .iter() + .find(|member| !is_value_of(element_type, member)) + { + return Err(DataContractError::InvalidContractStructure(format!( + "every enum member of a typed array's elements must be a {} value, found {}", + element_type.name(), + member + ))); + } + constraints.allowed_values = Some(members.clone()); + } + + if is_number { + let read_bound = |keyword: &str| -> Result, DataContractError> { + let Some(bound) = items_map.get(keyword) else { + return Ok(None); + }; + if !is_value_of(element_type, bound) { + return Err(DataContractError::InvalidContractStructure(format!( + "the {keyword} of a typed array's elements must be a {} value, found {}", + element_type.name(), + bound + ))); + } + Ok(Some((*bound).clone())) + }; + constraints.minimum = read_bound(property_names::MINIMUM)?; + constraints.maximum = read_bound(property_names::MAXIMUM)?; + if let (Some(minimum), Some(maximum)) = (&constraints.minimum, &constraints.maximum) { + if minimum.to_float().ok() > maximum.to_float().ok() { + return Err(DataContractError::InvalidContractStructure( + "the minimum of a typed array's elements may not exceed their maximum: no \ + document could hold the list" + .to_string(), + )); + } + } + } + + Ok(constraints) +} + +#[cfg(test)] +mod tests { + use super::*; + use platform_value::platform_value; + + fn parse(schema: Value) -> Result, DataContractError> { + let map = schema + .to_btree_ref_string_map() + .expect("the schema is a map"); + parse_typed_array_v0( + &map, + &DocumentPropertyTypeParsingOptions::default(), + PlatformVersion::latest(), + ) + } + + #[test] + fn should_leave_byte_arrays_and_scalars_to_the_scalar_parser() { + for schema in [ + platform_value!({ "type": "array", "byteArray": true, "maxItems": 32 }), + // The scalar parse refuses it, as it always did + platform_value!({ "type": "array", "byteArray": false }), + platform_value!({ "type": "string", "maxLength": 3 }), + ] { + assert_eq!(parse(schema.clone()).expect("parses"), None, "{schema:?}"); + } + } + + #[test] + fn should_parse_a_typed_array_with_its_bounds() { + assert_eq!( + parse(platform_value!({ + "type": "array", + "minItems": 1, + "maxItems": 4, + "items": { "type": "integer" } + })) + .expect("parses"), + Some(DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: Box::new(DocumentPropertyType::I64), + item_constraints: ArrayItemConstraints::default(), + min_items: Some(1), + max_items: 4, + unique_items: false, + })) + ); + } + + #[test] + fn should_parse_the_enum_minimum_and_maximum_of_the_elements() { + let parsed = parse(platform_value!({ + "type": "array", + "maxItems": 4, + "items": { "type": "integer", "minimum": 1, "maximum": 10, "enum": [1, 5, 10] } + })) + .expect("parses"); + let Some(DocumentPropertyType::TypedArray(typed_array)) = parsed else { + panic!("expected a typed array, got {parsed:?}"); + }; + // Sized by its bounds, as a scalar integer is + assert_eq!(*typed_array.item_type, DocumentPropertyType::U8); + // The bounds keep the schema's own value kinds; compare as integers + let as_integer = |value: &Value| value.to_integer::().expect("an integer"); + let constraints = &typed_array.item_constraints; + assert_eq!( + constraints + .allowed_values + .as_ref() + .map(|members| members.iter().map(as_integer).collect::>()), + Some(vec![1, 5, 10]) + ); + assert_eq!(constraints.minimum.as_ref().map(as_integer), Some(1)); + assert_eq!(constraints.maximum.as_ref().map(as_integer), Some(10)); + } + + #[test] + fn should_refuse_element_constraints_that_no_element_could_satisfy() { + for (items, fragment) in [ + ( + platform_value!({ "type": "string", "enum": [] }), + "at least one value", + ), + ( + platform_value!({ "type": "string", "enum": ["a", 1] }), + "must be a string value", + ), + ( + platform_value!({ "type": "integer", "enum": [1, "b"] }), + "must be a", + ), + ( + platform_value!({ "type": "boolean", "enum": [true, 0] }), + "must be a boolean value", + ), + ( + platform_value!({ "type": "array", "byteArray": true, "enum": [[1, 2]] }), + "not supported on byte array", + ), + // A number element's bound must be a number; an integer element's + // bound is already read by the scalar parse that sizes it + ( + platform_value!({ "type": "number", "minimum": "low" }), + "minimum of a typed array's elements must be a", + ), + ( + platform_value!({ "type": "number", "minimum": 2.5, "maximum": 1 }), + "may not exceed their maximum", + ), + ] { + let error = parse(platform_value!({ + "type": "array", + "maxItems": 4, + "items": items.clone() + })) + .expect_err("should be refused") + .to_string(); + assert!( + error.contains(fragment), + "{items:?}: expected {fragment:?}, got {error}" + ); + } + } + + /// An element is parsed by the scalar parser, so it takes the type a + /// scalar property of the same schema takes: an integer sized by its + /// bounds (when the contract sizes integers), an identifier from the + /// identifier media type. + #[test] + fn should_type_an_element_as_a_scalar_property_of_its_schema() { + for (items, sized_integer_types, expected) in [ + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 100 }), + true, + DocumentPropertyType::U8, + ), + ( + platform_value!({ "type": "integer", "minimum": -1000, "maximum": 1000 }), + true, + DocumentPropertyType::I16, + ), + // A contract that does not size integers keeps them at 64 bits + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 100 }), + false, + DocumentPropertyType::I64, + ), + ( + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }), + true, + DocumentPropertyType::Identifier, + ), + ] { + let schema = + platform_value!({ "type": "array", "maxItems": 4, "items": items.clone() }); + let map = schema + .to_btree_ref_string_map() + .expect("the schema is a map"); + let parsed = parse_typed_array_v0( + &map, + &DocumentPropertyTypeParsingOptions { + sized_integer_types, + }, + PlatformVersion::latest(), + ) + .expect("parses"); + let Some(DocumentPropertyType::TypedArray(typed_array)) = parsed else { + panic!("{items:?} should parse to a typed array"); + }; + assert_eq!(*typed_array.item_type, expected, "{items:?}"); + } + } + + #[test] + fn should_refuse_a_typed_array_missing_items_or_max_items_or_with_a_misplaced_bound() { + for (schema, fragment) in [ + ( + platform_value!({ "type": "array", "maxItems": 2 }), + "items schema", + ), + ( + platform_value!({ "type": "array", "items": { "type": "integer" } }), + "must declare maxItems", + ), + ( + platform_value!({ + "type": "array", + "maxItems": 2, + "contentMediaType": "application/x.dash.dpp.identifier", + "items": { "type": "integer" } + }), + "contentMediaType belongs on the items", + ), + ( + platform_value!({ + "type": "array", + "minItems": 3, + "maxItems": 2, + "items": { "type": "integer" } + }), + "minItems may not exceed its maxItems", + ), + ] { + let error = parse(schema.clone()) + .expect_err("should be refused") + .to_string(); + assert!( + error.contains(fragment), + "{schema:?}: expected {fragment:?}, got {error}" + ); + } + } +} 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 597adf4a2e2..9b4be2ec681 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -803,6 +803,16 @@ fn parse_document_properties( .map_err(consensus_or_protocol_data_contract_error)?; } + // Every property is in the flattened map now, so a `distinctFrom` target + // can be resolved against its siblings. Gated on the same version that + // parsed the declarations, so the two halves of the rule move together. + super::validate_distinct_from_targets( + &flattened_document_properties, + ctx.name, + ctx.platform_version, + ) + .map_err(consensus_or_protocol_data_contract_error)?; + Ok(ParsedProperties { flattened_document_properties, document_properties, @@ -1319,20 +1329,21 @@ fn check_indexable_property_shape( property_type: &DocumentPropertyType, ) -> Result<(), ProtocolError> { match property_type { - // Array and objects aren't supported for indexing yet + // Array and objects aren't supported for indexing yet. A typed array + // is stored inline in the document, with no index entry per element + // and no query operator (see Drive's `allowed_ops_for_type`). DocumentPropertyType::Array(_) | DocumentPropertyType::Object(_) - | DocumentPropertyType::VariableTypeArray(_) => { - Err(ProtocolError::ConsensusError(Box::new( - InvalidIndexPropertyTypeError::new( - document_type_name.to_owned(), - index_name.to_owned(), - property_name.to_owned(), - property_type.name(), - ) - .into(), - ))) - } + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => Err(ProtocolError::ConsensusError(Box::new( + InvalidIndexPropertyTypeError::new( + document_type_name.to_owned(), + index_name.to_owned(), + property_name.to_owned(), + property_type.name(), + ) + .into(), + ))), // Indexed byte array size must be limited DocumentPropertyType::ByteArray(sizes) if sizes @@ -2540,6 +2551,7 @@ pub(super) fn apply_index_only( DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) ) { return Err(structure_error(format!( "entryPayload property \"{}\" of indexOnly document type \"{}\" must be a \ diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index da27d502beb..74a0f58d4f5 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -1,15 +1,25 @@ use crate::data_contract::config::DataContractConfig; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::class_methods::apply_required_since::apply_required_since; +use crate::data_contract::document_type::class_methods::parse_typed_array::parse_typed_array; +use crate::data_contract::document_type::reference_lookup::{ + MAX_LOOKUP_INDEX_NAME_LENGTH, MAX_LOOKUP_KEYS, MAX_LOOKUP_PATH_LENGTH, +}; use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; +use crate::data_contract::document_type::v2::DocumentTypeV2; use crate::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, property_names, ContractReferenceModeration, ContractReferenceOwner, - ContractReferenceRequirements, DocumentProperty, DocumentPropertyReferenceTarget, - DocumentPropertyType, DocumentType, + ContractReferenceRequirements, DistinctFrom, DocumentProperty, DocumentPropertyReferenceTarget, + DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentReferenceLookup, + DocumentType, DocumentTypeRef, EncryptedFor, EncryptedForRecipient, EncryptionScheme, + IdentityKeyReferenceRequirements, KeyIdReference, KeyReferenceIdentityProperty, + LookupKeySource, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; +use crate::identity::Purpose; use crate::util::json_schema::resolve_uri; use crate::validation::operations::ProtocolValidationOperation; use crate::ProtocolError; @@ -27,6 +37,11 @@ mod v3; const NOT_ALLOWED_SYSTEM_PROPERTIES: [&str; 1] = ["$id"]; +/// The longest property path a keyword may name: `keyIdProperty`, the +/// `propertyAgreement` pairs and the `encryptedFor` paths share it, and the +/// meta-schema states the same bound as `maxLength`. +const MAX_PROPERTY_PATH_LENGTH: usize = 256; + const MAX_INDEXED_STRING_PROPERTY_LENGTH: u16 = 63; const MAX_INDEXED_BYTE_ARRAY_PROPERTY_LENGTH: u16 = 255; const MAX_INDEXED_ARRAY_ITEMS: usize = 1024; @@ -154,7 +169,14 @@ fn insert_values( platform_version, )?; - match DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())? { + let options: DocumentPropertyTypeParsingOptions = config.into(); + let property_type = match parse_typed_array(&inner_properties, &options, platform_version)? + { + Some(typed_array) => typed_array, + None => DocumentPropertyType::try_from_value_map(&inner_properties, &options)?, + }; + + match property_type { DocumentPropertyType::Object(_) => { if let Some(properties_as_value) = inner_properties.get(property_names::PROPERTIES) { @@ -183,6 +205,10 @@ fn insert_values( property_type => { let property_type = apply_property_reference(&inner_properties, property_type, platform_version)?; + let distinct_from = + apply_distinct_from(&inner_properties, &property_type, platform_version)?; + let encrypted_for = + apply_encrypted_for(&inner_properties, &property_type, platform_version)?; document_properties.insert( prefixed_property_key, DocumentProperty { @@ -190,6 +216,8 @@ fn insert_values( required: is_required, transient: is_transient, required_since, + distinct_from, + encrypted_for, }, ); } @@ -231,82 +259,88 @@ fn insert_values_nested( platform_version, )?; - let property_type = - match DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())? { - DocumentPropertyType::Object(_) => { - let mut nested_properties = IndexMap::new(); - if let Some(properties_as_value) = inner_properties.get(property_names::PROPERTIES) - { - let properties = - properties_as_value - .as_map() - .ok_or(DataContractError::ValueWrongType( - "properties must be a map".to_string(), - ))?; - - // Nested properties are emitted below in source-map order (the - // `properties.iter()` loop), and that `IndexMap` insertion order is - // consensus-observable: historical contracts were committed in source-map - // order, so re-sorting nested properties by `position` would soft-fork any - // contract whose source order differs from its position order. A previous - // `position`-based `sort_by` here was dead code (its sorted result was never - // read) and read `position` with `.expect()`, which could panic on adversarial - // schema input during block execution. Removed (ordering unchanged). Do NOT - // reintroduce a nested-property sort — even a correct one — nor a panicking - // `position` read here. - - // Create a new set with the prefix removed from the keys - let stripped_required: BTreeSet = known_required - .iter() - .filter_map(|key| { - if key.starts_with(&property_key) && key.len() > property_key.len() { - Some(key[property_key.len() + 1..].to_string()) - } else { - None - } - }) - .collect(); - - let stripped_transient: BTreeSet = known_transient - .iter() - .filter_map(|key| { - if key.starts_with(&property_key) && key.len() > property_key.len() { - Some(key[property_key.len() + 1..].to_string()) - } else { - None - } - }) - .collect(); - - for (object_property_key, object_property_value) in properties.iter() { - let object_property_string = object_property_key - .as_text() - .ok_or(DataContractError::KeyWrongType( - "property key must be a string".to_string(), - ))? - .to_string(); + let options: DocumentPropertyTypeParsingOptions = config.into(); + let property_type = match parse_typed_array(&inner_properties, &options, platform_version)? { + Some(typed_array) => typed_array, + None => DocumentPropertyType::try_from_value_map(&inner_properties, &options)?, + }; - insert_values_nested( - &mut nested_properties, - &stripped_required, - &stripped_transient, - false, - object_property_string, - object_property_value, - root_schema, - config, - platform_version, - )?; - } + let property_type = match property_type { + DocumentPropertyType::Object(_) => { + let mut nested_properties = IndexMap::new(); + if let Some(properties_as_value) = inner_properties.get(property_names::PROPERTIES) { + let properties = + properties_as_value + .as_map() + .ok_or(DataContractError::ValueWrongType( + "properties must be a map".to_string(), + ))?; + + // Nested properties are emitted below in source-map order (the + // `properties.iter()` loop), and that `IndexMap` insertion order is + // consensus-observable: historical contracts were committed in source-map + // order, so re-sorting nested properties by `position` would soft-fork any + // contract whose source order differs from its position order. A previous + // `position`-based `sort_by` here was dead code (its sorted result was never + // read) and read `position` with `.expect()`, which could panic on adversarial + // schema input during block execution. Removed (ordering unchanged). Do NOT + // reintroduce a nested-property sort — even a correct one — nor a panicking + // `position` read here. + + // Create a new set with the prefix removed from the keys + let stripped_required: BTreeSet = known_required + .iter() + .filter_map(|key| { + if key.starts_with(&property_key) && key.len() > property_key.len() { + Some(key[property_key.len() + 1..].to_string()) + } else { + None + } + }) + .collect(); + + let stripped_transient: BTreeSet = known_transient + .iter() + .filter_map(|key| { + if key.starts_with(&property_key) && key.len() > property_key.len() { + Some(key[property_key.len() + 1..].to_string()) + } else { + None + } + }) + .collect(); + + for (object_property_key, object_property_value) in properties.iter() { + let object_property_string = object_property_key + .as_text() + .ok_or(DataContractError::KeyWrongType( + "property key must be a string".to_string(), + ))? + .to_string(); + + insert_values_nested( + &mut nested_properties, + &stripped_required, + &stripped_transient, + false, + object_property_string, + object_property_value, + root_schema, + config, + platform_version, + )?; } - - DocumentPropertyType::Object(nested_properties) } - property_type => property_type, - }; + + DocumentPropertyType::Object(nested_properties) + } + property_type => property_type, + }; let property_type = apply_property_reference(&inner_properties, property_type, platform_version)?; + let distinct_from = apply_distinct_from(&inner_properties, &property_type, platform_version)?; + let encrypted_for = apply_encrypted_for(&inner_properties, &property_type, platform_version)?; document_properties.insert( property_key, @@ -315,21 +349,202 @@ fn insert_values_nested( required: is_required, transient: is_transient, required_since, + distinct_from, + encrypted_for, }, ); Ok(()) } +/// Reads a `distinctFrom` declaration off an identifier property, or off the +/// `items` of a typed array of identifiers: what its value (every element's +/// value) must differ from, the document's `$ownerId` or another property of +/// the same document type. Non-identifier properties cannot carry it. +/// +/// Versioned on `apply_distinct_from` in the platform version's document type +/// schema versions. `None` selects the behavior of the versions that predate +/// the keyword: it is ignored entirely, so their parses stay byte-for-byte +/// identical to what they always produced. +/// +/// The named property is checked against the rest of the document type once +/// every property is parsed, by [`validate_distinct_from_targets`]. +fn apply_distinct_from( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Result, DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_distinct_from + { + None => Ok(None), + Some(0) => apply_distinct_from_v0(inner_properties, property_type), + Some(version) => Err(DataContractError::Unsupported(format!( + "apply_distinct_from version {version} is not supported" + ))), + } +} + +fn apply_distinct_from_v0( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, +) -> Result, DataContractError> { + // A typed array carries the declaration on its `items`: every element must + // differ from the named value, so the elements must be identifiers + if let DocumentPropertyType::TypedArray(typed_array) = property_type { + if inner_properties.contains_key(property_names::DISTINCT_FROM) { + return Err(DataContractError::InvalidContractStructure( + "distinctFrom on a typed array belongs on its items, where it applies to every \ + element" + .to_string(), + )); + } + let items_map = match inner_properties.get(property_names::ITEMS) { + Some(items) => items.to_btree_ref_string_map()?, + None => return Ok(None), + }; + let Some(distinct_from_value) = items_map.get(property_names::DISTINCT_FROM) else { + return Ok(None); + }; + if !matches!( + *typed_array.item_type, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(DataContractError::InvalidContractStructure( + "distinctFrom is only allowed on identifier elements of a typed array".to_string(), + )); + } + let name = distinct_from_value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "distinctFrom must be a string naming $ownerId or a property of the same \ + document type" + .to_string(), + ) + })?; + return DistinctFrom::from_wire_name(name).map(Some); + } + + let Some(distinct_from_value) = inner_properties.get(property_names::DISTINCT_FROM) else { + return Ok(None); + }; + + if !matches!( + property_type, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(DataContractError::InvalidContractStructure( + "distinctFrom is only allowed on identifier properties".to_string(), + )); + } + + let name = distinct_from_value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "distinctFrom must be a string naming $ownerId or a property of the same document \ + type" + .to_string(), + ) + })?; + + DistinctFrom::from_wire_name(name).map(Some) +} + +/// Checks every `distinctFrom` declaration of a document type against the +/// rest of its properties, once they are all parsed: a named property must +/// exist, must be an identifier (the only kind the value can be compared +/// with), and must not be the declaring property itself. `$ownerId` needs no +/// check, every document has one. +/// +/// Runs on every parse, validating or not: the rule is a property of the +/// document type, and the write-time check reads the target through the +/// same flattened map, so a target that does not resolve here could never +/// be judged there. +/// +/// Versioned on `apply_distinct_from`, the version that parsed the +/// declarations: `None` predates the keyword, so there is nothing to check. +fn validate_distinct_from_targets( + flattened_properties: &IndexMap, + document_type_name: &str, + platform_version: &PlatformVersion, +) -> Result<(), DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_distinct_from + { + None => Ok(()), + Some(0) => validate_distinct_from_targets_v0(flattened_properties, document_type_name), + Some(version) => Err(DataContractError::Unsupported(format!( + "validate_distinct_from_targets version {version} is not supported" + ))), + } +} + +fn validate_distinct_from_targets_v0( + flattened_properties: &IndexMap, + document_type_name: &str, +) -> Result<(), DataContractError> { + for (path, property) in flattened_properties { + let Some(DistinctFrom::Property(target)) = &property.distinct_from else { + continue; + }; + if target == path { + return Err(DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" declares distinctFrom \ + itself: name $ownerId or another identifier property of the document type" + ))); + } + let Some(target_property) = flattened_properties.get(target) else { + // Objects are not in the flattened map, only their members are + let names_an_object = flattened_properties.keys().any(|key| { + key.len() > target.len() + && key.starts_with(target) + && key.as_bytes()[target.len()] == b'.' + }); + if names_an_object { + return Err(DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" declares distinctFrom \ + \"{target}\", which is an object, not an identifier property: name one of \ + its identifier members" + ))); + } + return Err(DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" declares distinctFrom \ + \"{target}\", but the document type has no property at that path" + ))); + }; + if !matches!( + target_property.property_type, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" declares distinctFrom \ + \"{target}\", which is not an identifier property" + ))); + } + } + Ok(()) +} + /// Folds a `refersTo` declaration into the property type: an identifier property -/// with `refersTo` becomes `IdentifierWithReference(target)`. Non-identifier -/// properties cannot carry `refersTo`. +/// with `refersTo` becomes `IdentifierWithReference(target)`, and a `u32` key id +/// property with an `identityPublicKey` declaration naming `identityProperty` +/// becomes `KeyIdWithReference(identity property)`. No other property can carry +/// `refersTo`. +/// The typed array parser calls it on an identifier element's `items` +/// schema, so an element reference is read by exactly this code; on the +/// array itself the declaration is refused, it belongs on the items. /// /// Versioned on `apply_property_reference` in the platform version's document /// type schema versions. `None` selects the behavior of the versions that /// predate the keyword: it is ignored entirely, so their parses stay /// byte-for-byte identical to what they always produced. -fn apply_property_reference( +pub(in crate::data_contract::document_type::class_methods) fn apply_property_reference( inner_properties: &BTreeMap, property_type: DocumentPropertyType, platform_version: &PlatformVersion, @@ -357,12 +572,17 @@ fn apply_property_reference_v0( return Ok(property_type); }; - if !matches!( - property_type, - DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) - ) { + // A typed array only exists from protocol version 14, where its element + // reference is read off the items by the typed array parser + if matches!(property_type, DocumentPropertyType::TypedArray(_)) { + let message = if is_identity_public_key_reference(refers_to_value)? { + "identityPublicKey refersTo is not allowed on a typed array or on its elements: it \ + pairs one key id with the reference, which cannot pair with many elements" + } else { + "refersTo on a typed array belongs on its items, where it applies to every element" + }; return Err(DataContractError::InvalidContractStructure( - "refersTo is only allowed on identifier properties".to_string(), + message.to_string(), )); } @@ -382,6 +602,63 @@ fn apply_property_reference_v0( ))); } + // Requirements on the referenced key belong to identity key references alone + if reference_type != "identityPublicKey" + && refers_to_map.contains_key(property_names::KEY_REQUIREMENTS) + { + return Err(DataContractError::InvalidContractStructure(format!( + "{} refersTo does not take keyRequirements", + reference_type + ))); + } + + // `propertyAgreement` compares against a referenced DOCUMENT's values; + // no other target kind has a document body to agree with + if refers_to_map.contains_key(property_names::PROPERTY_AGREEMENT) + && !matches!(reference_type, "permanentDocument" | "deletableDocument") + { + return Err(DataContractError::InvalidContractStructure( + "propertyAgreement is only allowed on permanentDocument and deletableDocument \ + references" + .to_string(), + )); + } + + // `lookup` finds a referenced DOCUMENT through an index of its type, and + // only a permanent one: a key into a deletable type could find a new + // document once the one it found is deleted, where an id is produced at + // most once. The other targets are found by the value itself + if refers_to_map.contains_key(property_names::LOOKUP) && reference_type != "permanentDocument" { + return Err(DataContractError::InvalidContractStructure(format!( + "{reference_type} refersTo does not take lookup: it is only allowed on \ + permanentDocument references" + ))); + } + + // A key reference declared on the key id property itself names whose key + // it is through `identityProperty`; it is the one form that sits on a + // non-identifier property + if let Some(identity_property_value) = refers_to_map.get(property_names::IDENTITY_PROPERTY) { + return apply_key_id_reference_v0( + inner_properties, + &refers_to_map, + reference_type, + identity_property_value, + property_type, + ); + } + + if !matches!( + property_type, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(DataContractError::InvalidContractStructure( + "refersTo is only allowed on identifier properties, except an identityPublicKey \ + reference with identityProperty, which sits on the key id property" + .to_string(), + )); + } + let target = match reference_type { "identity" => DocumentPropertyReferenceTarget::Identity, "contract" => DocumentPropertyReferenceTarget::Contract { @@ -435,11 +712,12 @@ fn apply_property_reference_v0( ) })?; for path in [referring_property.as_str(), referenced_property] { - if path.is_empty() || path.len() > 256 { + if path.is_empty() || path.len() > MAX_PROPERTY_PATH_LENGTH { return Err(DataContractError::InvalidContractStructure( - "propertyAgreement property paths must be between 1 \ - and 256 characters" - .to_string(), + format!( + "propertyAgreement property paths must be between 1 \ + and {MAX_PROPERTY_PATH_LENGTH} characters" + ), )); } } @@ -475,10 +753,22 @@ fn apply_property_reference_v0( let document_type_name = document_type_name.to_string(); if document_target == "permanentDocument" { - DocumentPropertyReferenceTarget::PermanentDocument { - contract_id, - document_type_name, - property_agreement, + // A lookup is its own variant, so an id reference keeps its + // shape (and its encoding in the reference errors) + match refers_to_map.get(property_names::LOOKUP) { + Some(lookup_value) => { + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id, + document_type_name, + property_agreement, + lookup: parse_document_reference_lookup(lookup_value)?, + } + } + None => DocumentPropertyReferenceTarget::PermanentDocument { + contract_id, + document_type_name, + property_agreement, + }, } } else { DocumentPropertyReferenceTarget::DeletableDocument { @@ -493,15 +783,16 @@ fn apply_property_reference_v0( .get_str(property_names::KEY_ID_PROPERTY) .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; - if key_id_property.is_empty() || key_id_property.len() > 256 { - return Err(DataContractError::InvalidContractStructure( - "identityPublicKey refersTo keyIdProperty must be between 1 and 256 characters" - .to_string(), - )); + if key_id_property.is_empty() || key_id_property.len() > MAX_PROPERTY_PATH_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "identityPublicKey refersTo keyIdProperty must be between 1 and \ + {MAX_PROPERTY_PATH_LENGTH} characters" + ))); } DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property: key_id_property.to_string(), + key_requirements: parse_identity_key_reference_requirements(&refers_to_map)?, } } other => { @@ -511,62 +802,567 @@ fn apply_property_reference_v0( } }; - // `propertyAgreement` compares against a referenced DOCUMENT's values — - // no other target kind has a document body to agree with. - if refers_to_map.contains_key(property_names::PROPERTY_AGREEMENT) - && target.as_document_reference().is_none() - { + Ok(DocumentPropertyType::IdentifierWithReference(target)) +} + +/// An `identityPublicKey` declaration on the KEY ID property: `identityProperty` names +/// whose key the value is (`$ownerId`, `$creatorId` or an identifier property of the +/// same document type), so the declaration takes no `keyIdProperty` and sits on an +/// integer property declaring exactly the range of a key id (`minimum` 0, `maximum` +/// 4294967295), the bounds the meta-schema pins, read from the schema rather than the +/// inferred type so that the rule does not depend on the contract's sized integer +/// types setting. Whether a named property or `$creatorId` fits the document type is +/// checked at contract registration. +fn apply_key_id_reference_v0( + inner_properties: &BTreeMap, + refers_to_map: &BTreeMap, + reference_type: &str, + identity_property_value: &Value, + property_type: DocumentPropertyType, +) -> Result { + if reference_type != "identityPublicKey" { + return Err(DataContractError::InvalidContractStructure(format!( + "{reference_type} refersTo does not take identityProperty" + ))); + } + if refers_to_map.contains_key(property_names::KEY_ID_PROPERTY) { return Err(DataContractError::InvalidContractStructure( - "propertyAgreement is only allowed on permanentDocument and deletableDocument \ - references" + "identityPublicKey refersTo takes either keyIdProperty, on the identity \ + property, or identityProperty, on the key id property, not both" + .to_string(), + )); + } + let identity_property_name = identity_property_value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "identityPublicKey refersTo identityProperty must be a string".to_string(), + ) + })?; + let identity_property = KeyReferenceIdentityProperty::from_wire_name(identity_property_name) + .ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "identityPublicKey refersTo identityProperty {identity_property_name:?} is \ + invalid, expected one of {:?} or a property path of 1 to 256 characters", + KeyReferenceIdentityProperty::SYSTEM_WIRE_NAMES + )) + })?; + let minimum = inner_properties.get_optional_integer::(property_names::MINIMUM)?; + let maximum = inner_properties.get_optional_integer::(property_names::MAXIMUM)?; + let is_key_id_range = minimum == Some(0) && maximum == Some(i64::from(u32::MAX)); + if !property_type.is_integer() || !is_key_id_range { + return Err(DataContractError::InvalidContractStructure( + "identityPublicKey refersTo with identityProperty is only allowed on a key id \ + property: an integer with minimum 0 and maximum 4294967295" .to_string(), )); } + Ok(DocumentPropertyType::KeyIdWithReference(KeyIdReference { + identity_property, + key_requirements: parse_identity_key_reference_requirements(refers_to_map)?, + })) +} - Ok(DocumentPropertyType::IdentifierWithReference(target)) +/// Whether a `refersTo` declaration names the `identityPublicKey` target. +fn is_identity_public_key_reference(refers_to: &Value) -> Result { + Ok(refers_to + .to_btree_ref_string_map()? + .get(property_names::TYPE) + .and_then(|reference_type| reference_type.as_text()) + == Some("identityPublicKey")) } -/// The `contractRequirements` of a `contract` reference: each key an aspect of the referenced -/// contract with a closed set of values (`moderation`, `owner`, the config flags), or a bound -/// on it (`minimumAgeSeconds`), at least one when the object is given at all. -fn parse_contract_reference_requirements( - refers_to_map: &BTreeMap, -) -> Result { - let Some(fields_value) = refers_to_map.get(property_names::CONTRACT_REQUIREMENTS) else { - return Ok(ContractReferenceRequirements::default()); +/// Folds a `refersTo` declared on the `items` of a typed array into the +/// element type, as [`apply_property_reference`] folds one into a scalar +/// identifier, which the version 0 rules call for the declaration itself: +/// only an identifier element may carry one, and never an +/// `identityPublicKey` one, in either form, since that pairs one key id with +/// the reference (a sibling `keyIdProperty`, or the key id itself through +/// `identityProperty`), which cannot pair with many elements. +/// +/// Versioned on `apply_property_reference`, the gate of the declarations it +/// reads: `None` ignores the keyword on an element exactly as it does on a +/// property. +pub(in crate::data_contract::document_type::class_methods) fn apply_element_reference( + items: &BTreeMap, + element_type: DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Result { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_property_reference + { + None => Ok(element_type), + Some(0) => apply_element_reference_v0(items, element_type), + Some(version) => Err(DataContractError::Unsupported(format!( + "apply_element_reference version {version} is not supported" + ))), + } +} + +fn apply_element_reference_v0( + items: &BTreeMap, + element_type: DocumentPropertyType, +) -> Result { + let Some(refers_to) = items.get(property_names::REFERS_TO) else { + return Ok(element_type); }; - let fields_map = fields_value.to_btree_ref_string_map()?; - if fields_map.is_empty() { + if !matches!(element_type, DocumentPropertyType::Identifier) { return Err(DataContractError::InvalidContractStructure( - "contract refersTo contractRequirements must declare at least one requirement" + "refersTo is only allowed on identifier elements of a typed array".to_string(), + )); + } + if is_identity_public_key_reference(refers_to)? { + return Err(DataContractError::InvalidContractStructure( + "identityPublicKey refersTo is not allowed on the elements of a typed array: it pairs \ + one key id with the reference, which cannot pair with many elements" .to_string(), )); } - let mut fields = ContractReferenceRequirements::default(); - for (field, value) in fields_map { - match field.as_str() { - property_names::MODERATION => { - let name = value.as_text().ok_or_else(|| { - DataContractError::InvalidContractStructure( - "contract refersTo contractRequirements moderation must be a string" - .to_string(), - ) - })?; - fields.moderation = Some(ContractReferenceModeration::from_wire_name(name).ok_or_else(|| { - DataContractError::InvalidContractStructure(format!( - "contract refersTo contractRequirements moderation {name:?} is unknown, expected one of {:?}", - ContractReferenceModeration::WIRE_NAMES - )) - })?); - } - property_names::MINIMUM_AGE_SECONDS => { - fields.minimum_age_seconds = Some(parse_contract_reference_seconds(&field, value)?); - } - property_names::MINIMUM_SECONDS_SINCE_UPDATE => { - fields.minimum_seconds_since_update = - Some(parse_contract_reference_seconds(&field, value)?); + apply_property_reference_v0(items, element_type) +} + +/// The `lookup` of a `permanentDocument` reference: `index`, the name of an index of the +/// referenced document type, and `keys`, every property of that index mapped to +/// its referring-side source (`"."`, `"$ownerId"` or a property path), with `"."` +/// exactly once. What the names resolve to is checked once the document types +/// are parsed: the sources against the declaring type +/// ([`validate_reference_lookup_sources`]), the index against the referenced +/// one (at contract level for a type of the same contract, at registration for +/// one of another contract). +fn parse_document_reference_lookup( + lookup_value: &Value, +) -> Result { + let lookup_map = lookup_value.to_btree_ref_string_map()?; + if let Some(unknown) = lookup_map.keys().find(|key| { + !matches!( + key.as_str(), + property_names::LOOKUP_INDEX | property_names::LOOKUP_KEYS + ) + }) { + return Err(DataContractError::InvalidContractStructure(format!( + "permanentDocument refersTo lookup {unknown:?} is unknown: a lookup takes index and \ + keys" + ))); + } + + let index = lookup_map + .get_str(property_names::LOOKUP_INDEX) + .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; + if index.is_empty() || index.len() > MAX_LOOKUP_INDEX_NAME_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "permanentDocument refersTo lookup index must be between 1 and \ + {MAX_LOOKUP_INDEX_NAME_LENGTH} characters" + ))); + } + + let keys_map = lookup_map + .get(property_names::LOOKUP_KEYS) + .ok_or_else(|| { + DataContractError::InvalidContractStructure( + "permanentDocument refersTo lookup must declare keys".to_string(), + ) + })? + .to_btree_ref_string_map()?; + if keys_map.is_empty() || keys_map.len() > MAX_LOOKUP_KEYS { + return Err(DataContractError::InvalidContractStructure(format!( + "permanentDocument refersTo lookup keys must map between 1 and {MAX_LOOKUP_KEYS} \ + index properties" + ))); + } + + let keys = keys_map + .into_iter() + .map(|(index_property, source_value)| { + if index_property.is_empty() || index_property.len() > MAX_LOOKUP_PATH_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "permanentDocument refersTo lookup index property names must be between 1 \ + and {MAX_LOOKUP_PATH_LENGTH} characters" + ))); } - property_names::OWNER => { + let source = source_value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "permanentDocument refersTo lookup keys must map each index property to a \ + string: \".\", \"$ownerId\" or a property path" + .to_string(), + ) + })?; + Ok((index_property, LookupKeySource::from_wire_name(source)?)) + }) + .collect::, DataContractError>>()?; + + // Without the reference's own value in the key, every document would + // resolve to the same referenced document whatever the property holds + let reference_value_uses = keys + .values() + .filter(|source| matches!(source, LookupKeySource::ReferenceValue)) + .count(); + if reference_value_uses != 1 { + return Err(DataContractError::InvalidContractStructure(format!( + "permanentDocument refersTo lookup keys must fill exactly one index property from \ + \".\", the reference's own value, found {reference_value_uses}" + ))); + } + + Ok(DocumentReferenceLookup { + index: index.to_string(), + keys, + }) +} + +/// Checks the referring side of every `refersTo` lookup of a document type, +/// once all its properties are parsed: each property a key reads must exist, +/// be a stored, required, single value (with every object around it +/// required), and not be the reference property itself. See +/// [`DocumentReferenceLookup::referring_side_error`]. +/// +/// Runs on every parse, validating or not, like the `encryptedFor` check: the +/// rule is a property of the document type, and the write-time lookup reads +/// the sources through the same flattened map. Generation 3 is the only +/// parser admitting `refersTo` at all. +pub(super) fn validate_reference_lookup_sources( + document_type: DocumentTypeRef, + document_type_name: &str, +) -> Result<(), DataContractError> { + for (path, property) in document_type.flattened_properties() { + // On an identifier property or on the elements of a typed array: the + // key's other parts are the same for every element + let Some(lookup) = property + .property_type + .reference() + .and_then(|reference| reference.target()) + .and_then(|target| target.as_any_document_reference()) + .and_then(|declaration| declaration.lookup) + else { + continue; + }; + if let Some(reason) = lookup.referring_side_error(document_type, path) { + return Err(DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" refersTo lookup: \ + {reason}" + ))); + } + } + Ok(()) +} + +/// Reads a property's `encryptedFor` declaration: how the bytes of a byte +/// array property were encrypted. Non-byte-array properties, identifiers +/// among them, cannot carry it. +/// +/// Versioned on `apply_encrypted_for` in the platform version's document type +/// schema versions. `None` selects the behavior of the versions that predate +/// the keyword: it is ignored entirely, so their parses stay byte-for-byte +/// identical to what they always produced. +fn apply_encrypted_for( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Result, DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_encrypted_for + { + None => Ok(None), + Some(0) => apply_encrypted_for_v0(inner_properties, property_type), + Some(version) => Err(DataContractError::Unsupported(format!( + "apply_encrypted_for version {version} is not supported" + ))), + } +} + +fn apply_encrypted_for_v0( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, +) -> Result, DataContractError> { + let Some(encrypted_for_value) = inner_properties.get(property_names::ENCRYPTED_FOR) else { + return Ok(None); + }; + + match property_type { + DocumentPropertyType::ByteArray(_) => {} + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { + return Err(DataContractError::InvalidContractStructure( + "encryptedFor is not allowed on identifier properties, only on byte arrays" + .to_string(), + )); + } + _ => { + return Err(DataContractError::InvalidContractStructure( + "encryptedFor is only allowed on byte array properties".to_string(), + )); + } + } + + let encrypted_for_map = encrypted_for_value.to_btree_ref_string_map()?; + + for key in encrypted_for_map.keys() { + if !matches!( + key.as_str(), + property_names::RECIPIENT + | property_names::RECIPIENT_KEY + | property_names::SENDER_KEY + | property_names::SCHEME + ) { + return Err(DataContractError::InvalidContractStructure(format!( + "encryptedFor {key:?} is unknown, expected recipient, recipientKey, senderKey \ + and scheme" + ))); + } + } + + let path = |key: &'static str| -> Result<&str, DataContractError> { + let Some(value) = encrypted_for_map.get(key) else { + return Err(DataContractError::InvalidContractStructure(format!( + "encryptedFor must declare {key}" + ))); + }; + let path = value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "encryptedFor {key} must be a property path (a string)" + )) + })?; + if path.is_empty() || path.len() > MAX_PROPERTY_PATH_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "encryptedFor {key} must be between 1 and {MAX_PROPERTY_PATH_LENGTH} characters" + ))); + } + Ok(path) + }; + + let recipient = EncryptedForRecipient::from_path(path(property_names::RECIPIENT)?); + if recipient + .property_path() + .is_some_and(|recipient_path| recipient_path.starts_with('$')) + { + return Err(DataContractError::InvalidContractStructure( + "encryptedFor recipient must name an identifier property of the document type or \ + its $ownerId" + .to_string(), + )); + } + + let mut key_paths = [String::new(), String::new()]; + for (key, slot) in [property_names::RECIPIENT_KEY, property_names::SENDER_KEY] + .into_iter() + .zip(key_paths.iter_mut()) + { + let key_path = path(key)?; + if key_path.starts_with('$') { + return Err(DataContractError::InvalidContractStructure(format!( + "encryptedFor {key} must name an integer property of the document type, not a \ + system property" + ))); + } + *slot = key_path.to_string(); + } + let [recipient_key, sender_key] = key_paths; + + let scheme_value = encrypted_for_map + .get(property_names::SCHEME) + .ok_or_else(|| { + DataContractError::InvalidContractStructure( + "encryptedFor must declare scheme".to_string(), + ) + })?; + let scheme_name = scheme_value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "encryptedFor scheme must be a string".to_string(), + ) + })?; + let scheme = EncryptionScheme::from_wire_name(scheme_name).ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "encryptedFor scheme {scheme_name:?} is unknown, expected one of {}", + EncryptionScheme::ALL + .iter() + .map(|scheme| format!("{:?}", scheme.as_str())) + .collect::>() + .join(", ") + )) + })?; + + Ok(Some(EncryptedFor { + recipient, + recipient_key, + sender_key, + scheme, + })) +} + +/// Checks every `encryptedFor` declaration of a document type against the +/// properties it names, once all of them are parsed: the recipient must be an +/// identifier property (or `$ownerId`), the two key properties integers whose +/// schema declares `minimum` at least 0 and `maximum` at most 4294967295 (read +/// from the schema itself, so the rule holds whatever `sizedIntegerTypes` the +/// contract sets), none of them may be `transient` (a transient property is +/// stripped before storage, which would leave the stored ciphertext without +/// its recipe), and the byte array's own `maxItems` must hold the scheme's +/// shortest ciphertext. Paths are looked up among the flattened properties, +/// so a nested property is named by its dotted path. +/// +/// Owned by parser generation 3: the only generation that admits the keyword. +pub(super) fn validate_encrypted_for_declarations( + document_type: &DocumentTypeV2, + document_type_name: &str, +) -> Result<(), DataContractError> { + let flattened_properties = &document_type.flattened_properties; + for (path, property) in flattened_properties { + let Some(encrypted_for) = &property.encrypted_for else { + continue; + }; + let structure_error = |message: String| { + DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" encryptedFor {message}" + )) + }; + + let shortest = encrypted_for.scheme.minimum_ciphertext_length(); + if let DocumentPropertyType::ByteArray(sizes) = &property.property_type { + if let Some(max_size) = sizes.max_size.filter(|max| usize::from(*max) < shortest) { + return Err(structure_error(format!( + "maxItems {max_size} is below the {shortest} bytes the {} scheme produces \ + at least, so no document could ever carry it", + encrypted_for.scheme + ))); + } + } + + if let Some(recipient_path) = encrypted_for.recipient.property_path() { + match flattened_properties + .get(recipient_path) + .map(|recipient| &recipient.property_type) + { + Some( + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_), + ) => {} + Some(other) => { + return Err(structure_error(format!( + "recipient \"{recipient_path}\" has type {}, not identifier", + other.name() + ))); + } + None => { + return Err(structure_error(format!( + "recipient \"{recipient_path}\" is not a property of the document type" + ))); + } + } + if document_type.transient_fields.contains(recipient_path) { + return Err(structure_error(format!( + "recipient \"{recipient_path}\" is transient: a transient property is never \ + stored, so a reader could not tell whom the bytes are for" + ))); + } + } + + for (key, key_path) in [ + (property_names::RECIPIENT_KEY, &encrypted_for.recipient_key), + (property_names::SENDER_KEY, &encrypted_for.sender_key), + ] { + if !flattened_properties.contains_key(key_path) { + return Err(structure_error(format!( + "{key} \"{key_path}\" is not a property of the document type" + ))); + } + if !is_key_id_schema(&document_type.schema, key_path)? { + return Err(structure_error(format!( + "{key} \"{key_path}\" must be an integer property with minimum at least 0 \ + and maximum at most {}, so that it carries a key id", + u32::MAX + ))); + } + if document_type.transient_fields.contains(key_path) { + return Err(structure_error(format!( + "{key} \"{key_path}\" is transient: a transient property is never stored, \ + so a reader could not tell which key decrypts the bytes" + ))); + } + } + } + Ok(()) +} + +/// Whether the property at the dotted `path` of `schema` is declared as an +/// integer with `minimum` at least 0 and `maximum` at most `u32::MAX`, read +/// from the schema rather than from the parsed type so that the answer does +/// not depend on the contract's `sizedIntegerTypes`. `$ref`s are followed. +fn is_key_id_schema(schema: &Value, path: &str) -> Result { + fn resolve<'a>( + root_schema: &'a Value, + value: &'a Value, + ) -> Result, DataContractError> { + let map = value.to_btree_ref_string_map()?; + match map.get_optional_str(property_names::REF)? { + Some(schema_ref) => { + Ok(resolve_uri(root_schema, schema_ref)?.to_btree_ref_string_map()?) + } + None => Ok(map), + } + } + let mut current = resolve(schema, schema)?; + for segment in path.split('.') { + let Some(properties) = current.get(property_names::PROPERTIES) else { + return Ok(false); + }; + let Some(next) = properties.to_btree_ref_string_map()?.get(segment).copied() else { + return Ok(false); + }; + current = resolve(schema, next)?; + } + let is_integer = current.get_optional_str(property_names::TYPE)? == Some("integer"); + let minimum = current.get_optional_integer::(property_names::MINIMUM)?; + let maximum = current.get_optional_integer::(property_names::MAXIMUM)?; + Ok(is_integer + && minimum.is_some_and(|minimum| minimum >= 0) + && maximum.is_some_and(|maximum| maximum <= i64::from(u32::MAX))) +} + +/// The `contractRequirements` of a `contract` reference: each key an aspect of the referenced +/// contract with a closed set of values (`moderation`, `owner`, the config flags), or a bound +/// on it (`minimumAgeSeconds`), at least one when the object is given at all. +fn parse_contract_reference_requirements( + refers_to_map: &BTreeMap, +) -> Result { + let Some(fields_value) = refers_to_map.get(property_names::CONTRACT_REQUIREMENTS) else { + return Ok(ContractReferenceRequirements::default()); + }; + let fields_map = fields_value.to_btree_ref_string_map()?; + if fields_map.is_empty() { + return Err(DataContractError::InvalidContractStructure( + "contract refersTo contractRequirements must declare at least one requirement" + .to_string(), + )); + } + let mut fields = ContractReferenceRequirements::default(); + for (field, value) in fields_map { + match field.as_str() { + property_names::MODERATION => { + let name = value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "contract refersTo contractRequirements moderation must be a string" + .to_string(), + ) + })?; + fields.moderation = Some(ContractReferenceModeration::from_wire_name(name).ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements moderation {name:?} is unknown, expected one of {:?}", + ContractReferenceModeration::WIRE_NAMES + )) + })?); + } + property_names::MINIMUM_AGE_SECONDS => { + fields.minimum_age_seconds = Some(parse_contract_reference_seconds(&field, value)?); + } + property_names::MINIMUM_SECONDS_SINCE_UPDATE => { + fields.minimum_seconds_since_update = + Some(parse_contract_reference_seconds(&field, value)?); + } + property_names::OWNER => { let name = value.as_text().ok_or_else(|| { DataContractError::InvalidContractStructure( "contract refersTo contractRequirements owner must be a string".to_string(), @@ -597,6 +1393,71 @@ fn parse_contract_reference_requirements( Ok(fields) } +/// The `keyRequirements` of an `identityPublicKey` reference: each key an aspect of the +/// referenced key with a closed set of values (`purpose`) or a document type name of the +/// declaring contract (`boundTo`), at least one when the object is given at all. That `boundTo` +/// names a document type the contract has is checked once every document type is parsed, in +/// `create_document_types_from_document_schemas`. +fn parse_identity_key_reference_requirements( + refers_to_map: &BTreeMap, +) -> Result { + let Some(fields_value) = refers_to_map.get(property_names::KEY_REQUIREMENTS) else { + return Ok(IdentityKeyReferenceRequirements::default()); + }; + let fields_map = fields_value.to_btree_ref_string_map()?; + if fields_map.is_empty() { + return Err(DataContractError::InvalidContractStructure( + "identityPublicKey refersTo keyRequirements must declare at least one requirement" + .to_string(), + )); + } + let mut fields = IdentityKeyReferenceRequirements::default(); + for (field, value) in fields_map { + match field.as_str() { + property_names::PURPOSE => { + let name = value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "identityPublicKey refersTo keyRequirements purpose must be a string" + .to_string(), + ) + })?; + // The purposes a user's key can carry: every one but SYSTEM + let purpose = Purpose::from_wire_name(name) + .filter(|purpose| Purpose::full_range().contains(purpose)) + .ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "identityPublicKey refersTo keyRequirements purpose {name:?} is unknown, expected one of {:?}", + Purpose::full_range().map(|purpose| purpose.wire_name()) + )) + })?; + fields.purpose = Some(purpose); + } + property_names::BOUND_TO => { + let document_type_name = value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "identityPublicKey refersTo keyRequirements boundTo must be a string" + .to_string(), + ) + })?; + if document_type_name.is_empty() || document_type_name.len() > 64 { + return Err(DataContractError::InvalidContractStructure( + "identityPublicKey refersTo keyRequirements boundTo must be between 1 \ + and 64 characters" + .to_string(), + )); + } + fields.bound_to = Some(document_type_name.to_string()); + } + other => { + return Err(DataContractError::InvalidContractStructure(format!( + "identityPublicKey refersTo keyRequirements {other:?} is unknown" + ))); + } + } + } + Ok(fields) +} + /// A duration requirement of a `contract` reference (`minimumAgeSeconds`, /// `minimumSecondsSinceUpdate`): a whole number of seconds from 1 to `u32::MAX`. fn parse_contract_reference_seconds(field: &str, value: &Value) -> Result { @@ -638,9 +1499,20 @@ fn parse_contract_reference_true(field: &str, value: &Value) -> Result serde_json::Value { + json!({ "type": "object", "properties": { - "toUserId": { + "recipientId": { "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, "contentMediaType": "application/x.dash.dpp.identifier", "position": 0, - "refersTo": { - "type": "identityPublicKey" - } + "refersTo": refers_to + }, + "recipientKeyId": { + "type": "integer", + "position": 1 } }, "required": [], "additionalProperties": false - })) - .expect_err("should fail"); + }) } - #[test] - fn should_ignore_refers_to_on_platform_versions_predating_it() { - // Platform versions whose tables carry `apply_property_reference: None` - // predate the `refersTo` keyword: even if it appears in a schema they - // parse (only possible without full validation — their meta-schemas - // reject it), they must ignore it and keep producing the plain - // identifier type they always produced. - let platform_version = PlatformVersion::get(13).expect("platform version 13 should exist"); - - let document_type = try_document_type_from_schema_on_version( - json!({ - "type": "object", - "properties": { - "toUserId": { - "type": "array", - "byteArray": true, - "minItems": 32, - "maxItems": 32, - "contentMediaType": "application/x.dash.dpp.identifier", - "position": 0, - "refersTo": { - "type": "identity" - } - } - }, - "required": [], - "additionalProperties": false - }), - platform_version, - ) - .expect("should parse"); - - let property_type = document_type + fn identity_key_reference_target(refers_to: serde_json::Value) -> DocumentPropertyType { + try_document_type_from_schema(identity_key_reference_schema(refers_to)) + .expect("should parse") .as_ref() .flattened_properties() - .get("toUserId") + .get("recipientId") .map(|p| p.property_type.clone()) - .expect("property should be present"); - - assert!(matches!(property_type, DocumentPropertyType::Identifier)); + .expect("property should be present") } #[test] - fn should_not_reject_refers_to_on_non_identifier_property_on_platform_versions_predating_it() { - let platform_version = PlatformVersion::get(13).expect("platform version 13 should exist"); - - try_document_type_from_schema_on_version( - json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "position": 0, - "refersTo": { "type": "identity" } - } - }, - "required": [], - "additionalProperties": false - }), - platform_version, - ) - .expect("a parse predating refersTo should ignore the keyword entirely"); + fn should_parse_identity_public_key_refers_to_with_key_requirements() { + assert_eq!( + identity_key_reference_target(json!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyId".to_string(), + key_requirements: IdentityKeyReferenceRequirements { + purpose: Some(Purpose::DECRYPTION), + bound_to: Some("submittedCharter".to_string()), + }, + } + ) + ); + for (name, purpose) in [ + ("authentication", Purpose::AUTHENTICATION), + ("encryption", Purpose::ENCRYPTION), + ("decryption", Purpose::DECRYPTION), + ("transfer", Purpose::TRANSFER), + ("voting", Purpose::VOTING), + ("owner", Purpose::OWNER), + ] { + assert_eq!( + identity_key_reference_target(json!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": name } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyId".to_string(), + key_requirements: IdentityKeyReferenceRequirements { + purpose: Some(purpose), + bound_to: None, + }, + } + ) + ); + } + assert_eq!( + identity_key_reference_target(json!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "boundTo": "joinRequest" } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyId".to_string(), + key_requirements: IdentityKeyReferenceRequirements { + purpose: None, + bound_to: Some("joinRequest".to_string()), + }, + } + ) + ); + } + + #[test] + fn should_reject_key_requirements_that_are_empty_unknown_or_on_another_type() { + for (refers_to, fragment) in [ + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": {} }), + "at least one requirement", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "signing" } }), + "is unknown", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "system" } }), + "is unknown", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "DECRYPTION" } }), + "is unknown", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": 2 } }), + "must be a string", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "boundTo": "" } }), + "between 1 and 64 characters", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "boundTo": 1 } }), + "must be a string", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "securityLevel": "high" } }), + "is unknown", + ), + ( + json!({ "type": "identity", "keyRequirements": { "purpose": "decryption" } }), + "does not take keyRequirements", + ), + ( + json!({ "type": "contract", "keyRequirements": { "purpose": "decryption" } }), + "does not take keyRequirements", + ), + ] { + let err = + try_document_type_from_schema(identity_key_reference_schema(refers_to.clone())) + .expect_err("should be refused"); + assert!( + err.to_string().contains(fragment), + "{refers_to}: expected {fragment:?}, got {err}" + ); + } + } + + #[test] + fn should_reject_identity_public_key_refers_to_without_key_id_property() { + try_document_type_from_schema(json!({ + "type": "object", + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey" + } + } + }, + "required": [], + "additionalProperties": false + })) + .expect_err("should fail"); + } + + // ================================================================ + // identityPublicKey on the key id property (identityProperty) + // ================================================================ + + /// A `message` document type whose `senderKeyId` carries `refers_to`, with + /// the property's own keywords under `key_id_schema` (the `u32` key id + /// range by default). + fn key_id_reference_schema( + key_id_schema: serde_json::Value, + refers_to: serde_json::Value, + ) -> serde_json::Value { + let mut sender_key_id = key_id_schema; + sender_key_id["position"] = json!(0); + sender_key_id["refersTo"] = refers_to; + json!({ + "type": "object", + "properties": { + "senderKeyId": sender_key_id, + "note": { "type": "string", "maxLength": 64, "position": 1 } + }, + "required": [], + "additionalProperties": false + }) + } + + fn u32_key_id_schema() -> serde_json::Value { + json!({ "type": "integer", "minimum": 0, "maximum": 4294967295u64 }) + } + + fn owner_key_refers_to() -> serde_json::Value { + json!({ "type": "identityPublicKey", "identityProperty": "$ownerId" }) + } + + fn sender_key_id_type(document_type: &DocumentType) -> DocumentPropertyType { + document_type + .as_ref() + .flattened_properties() + .get("senderKeyId") + .map(|p| p.property_type.clone()) + .expect("property should be present") + } + + #[test] + fn should_parse_identity_public_key_refers_to_on_the_key_id_property() { + // With and without the meta-schema: the v3 meta-schema admits the form + for document_type in [ + try_document_type_from_schema(key_id_reference_schema( + u32_key_id_schema(), + owner_key_refers_to(), + )) + .expect("should parse"), + try_document_type_from_schema_full_validation(key_id_reference_schema( + u32_key_id_schema(), + owner_key_refers_to(), + )) + .expect("should parse under the meta-schema"), + ] { + assert_eq!( + sender_key_id_type(&document_type), + DocumentPropertyType::KeyIdWithReference(KeyIdReference::new( + KeyReferenceIdentityProperty::OwnerId + )) + ); + // A key id property is still an integer to everything that asks + assert!(sender_key_id_type(&document_type).is_integer()); + } + } + + /// The form sits on a nested property too, under the meta-schema and in + /// the flattened properties consensus walks, at its dotted path. + #[test] + fn should_parse_identity_public_key_refers_to_on_a_nested_key_id_property() { + let mut sender_key_id = u32_key_id_schema(); + sender_key_id["position"] = json!(0); + sender_key_id["refersTo"] = owner_key_refers_to(); + let schema = json!({ + "type": "object", + "properties": { + "meta": { + "type": "object", + "position": 0, + "properties": { "senderKeyId": sender_key_id }, + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }); + + let document_type = try_document_type_from_schema_full_validation(schema) + .expect("should parse under the meta-schema"); + + assert_eq!( + document_type + .as_ref() + .flattened_properties() + .get("meta.senderKeyId") + .map(|p| p.property_type.clone()), + Some(DocumentPropertyType::KeyIdWithReference( + KeyIdReference::new(KeyReferenceIdentityProperty::OwnerId) + )) + ); + } + + /// `keyRequirements` sit on the key id form exactly as on the identifier + /// form. + #[test] + fn should_parse_key_requirements_on_the_key_id_form() { + let schema = key_id_reference_schema( + u32_key_id_schema(), + json!({ + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyRequirements": { "purpose": "encryption" } + }), + ); + for document_type in [ + try_document_type_from_schema(schema.clone()).expect("should parse"), + try_document_type_from_schema_full_validation(schema) + .expect("should parse under the meta-schema"), + ] { + assert_eq!( + sender_key_id_type(&document_type), + DocumentPropertyType::KeyIdWithReference(KeyIdReference { + identity_property: KeyReferenceIdentityProperty::OwnerId, + key_requirements: IdentityKeyReferenceRequirements { + purpose: Some(Purpose::ENCRYPTION), + bound_to: None, + }, + }) + ); + } + } + + #[test] + fn should_reject_identity_property_on_an_identifier_property() { + let schema = key_id_reference_schema( + json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }), + owner_key_refers_to(), + ); + let err = try_document_type_from_schema(schema.clone()).expect_err("should fail"); + assert!( + err.to_string() + .contains("only allowed on a key id property"), + "unexpected error: {err}" + ); + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse the form on an identifier"); + } + + #[test] + fn should_reject_identity_property_on_an_integer_without_the_u32_range() { + for key_id_schema in [ + // Narrower than a key id + json!({ "type": "integer", "minimum": 0, "maximum": 255 }), + // Wider than a key id + json!({ "type": "integer", "minimum": 0 }), + json!({ "type": "integer", "minimum": -1, "maximum": 4294967295u64 }), + // Narrower at the bottom: the rule is the exact range, as the + // meta-schema pins it, not the inferred u32 + json!({ "type": "integer", "minimum": 1, "maximum": 4294967295u64 }), + // Not an integer at all + json!({ "type": "string", "maxLength": 10 }), + ] { + let schema = key_id_reference_schema(key_id_schema.clone(), owner_key_refers_to()); + let err = try_document_type_from_schema(schema.clone()).expect_err("should fail"); + assert!( + err.to_string() + .contains("only allowed on a key id property"), + "{key_id_schema}: unexpected error: {err}" + ); + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse a property outside the u32 range"); + } + } + + #[test] + fn should_reject_identity_property_together_with_key_id_property() { + let schema = key_id_reference_schema( + u32_key_id_schema(), + json!({ + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyIdProperty": "senderKeyId" + }), + ); + let err = try_document_type_from_schema(schema.clone()).expect_err("should fail"); + assert!( + err.to_string().contains("not both"), + "unexpected error: {err}" + ); + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse both keys on one declaration"); + } + + /// `identityProperty` admits `$creatorId` and a property path beside + /// `$ownerId`; whether the path or the creator fits the document type is + /// a registration check, not a parse + #[test] + fn should_parse_identity_public_key_refers_to_naming_the_creator_or_a_property() { + for (identity_property, expected) in [ + ("$creatorId", KeyReferenceIdentityProperty::CreatorId), + ( + "note", + KeyReferenceIdentityProperty::Property("note".to_string()), + ), + ( + "meta.toUserId", + KeyReferenceIdentityProperty::Property("meta.toUserId".to_string()), + ), + ] { + let schema = key_id_reference_schema( + u32_key_id_schema(), + json!({ "type": "identityPublicKey", "identityProperty": identity_property }), + ); + for document_type in [ + try_document_type_from_schema(schema.clone()).expect("should parse"), + try_document_type_from_schema_full_validation(schema) + .expect("should parse under the meta-schema"), + ] { + assert_eq!( + sender_key_id_type(&document_type), + DocumentPropertyType::KeyIdWithReference(KeyIdReference::new(expected.clone())), + "{identity_property}" + ); + } + } + } + + #[test] + fn should_reject_identity_property_values_that_are_neither_a_system_name_nor_a_path() { + for identity_property in [ + json!("$id"), + json!("$creatorId2"), + json!(""), + json!("a".repeat(257)), + json!("with-dash"), + json!(5), + ] { + let schema = key_id_reference_schema( + u32_key_id_schema(), + json!({ "type": "identityPublicKey", "identityProperty": identity_property }), + ); + // A hyphenated path is the meta-schema's refusal alone: the parser + // reads stored contracts as they are + if identity_property != json!("with-dash") { + let err = try_document_type_from_schema(schema.clone()).expect_err("should fail"); + assert!( + err.to_string().contains("identityProperty"), + "{identity_property}: unexpected error: {err}" + ); + } + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse the value"); + } + } + + #[test] + fn should_reject_identity_property_on_other_reference_types() { + for reference_type in ["identity", "contract", "token", "deletableDocument"] { + let schema = key_id_reference_schema( + u32_key_id_schema(), + json!({ + "type": reference_type, + "documentType": "note", + "identityProperty": "$ownerId" + }), + ); + let err = try_document_type_from_schema(schema.clone()).expect_err("should fail"); + assert!( + err.to_string().contains(&format!( + "{reference_type} refersTo does not take identityProperty" + )), + "{reference_type}: unexpected error: {err}" + ); + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse identityProperty on other types"); + } + } + + #[test] + fn should_reject_property_agreement_on_the_key_id_form() { + let schema = key_id_reference_schema( + u32_key_id_schema(), + json!({ + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "propertyAgreement": { "note": "note" } + }), + ); + let err = try_document_type_from_schema(schema.clone()).expect_err("should fail"); + assert!( + err.to_string() + .contains("propertyAgreement is only allowed"), + "unexpected error: {err}" + ); + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse propertyAgreement here"); + } + + #[test] + fn should_ignore_the_key_id_form_on_platform_versions_predating_it() { + // Protocol version 13 predates `refersTo` entirely: parsed without the + // meta-schema the keyword is ignored and the property stays the plain + // u32 it always was; its meta-schema (v2) refuses the keyword outright + let platform_version = PlatformVersion::get(13).expect("platform version 13 should exist"); + let schema = key_id_reference_schema(u32_key_id_schema(), owner_key_refers_to()); + + let document_type = + try_document_type_from_schema_on_version(schema.clone(), platform_version) + .expect("should parse"); + assert_eq!( + sender_key_id_type(&document_type), + DocumentPropertyType::U32 + ); + + let config = + DataContractConfig::default_for_version(platform_version).expect("config should build"); + DocumentType::try_from_schema( + Identifier::random(), + 0, + config.version(), + "msg", + platform_value::to_value(schema).expect("schema should convert"), + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + ) + .expect_err("the v2 meta-schema should refuse refersTo"); + } + + #[test] + fn should_ignore_refers_to_on_platform_versions_predating_it() { + // Platform versions whose tables carry `apply_property_reference: None` + // predate the `refersTo` keyword: even if it appears in a schema they + // parse (only possible without full validation — their meta-schemas + // reject it), they must ignore it and keep producing the plain + // identifier type they always produced. + let platform_version = PlatformVersion::get(13).expect("platform version 13 should exist"); + + let document_type = try_document_type_from_schema_on_version( + json!({ + "type": "object", + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identity" + } + } + }, + "required": [], + "additionalProperties": false + }), + platform_version, + ) + .expect("should parse"); + + let property_type = document_type + .as_ref() + .flattened_properties() + .get("toUserId") + .map(|p| p.property_type.clone()) + .expect("property should be present"); + + assert!(matches!(property_type, DocumentPropertyType::Identifier)); + } + + #[test] + fn should_not_reject_refers_to_on_non_identifier_property_on_platform_versions_predating_it() { + let platform_version = PlatformVersion::get(13).expect("platform version 13 should exist"); + + try_document_type_from_schema_on_version( + json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "position": 0, + "refersTo": { "type": "identity" } + } + }, + "required": [], + "additionalProperties": false + }), + platform_version, + ) + .expect("a parse predating refersTo should ignore the keyword entirely"); + } + + // ================================================================ + // distinctFrom + // ================================================================ + + /// An identifier property schema, with `distinct_from` as its `distinctFrom` + /// when given. + fn identifier_property(position: u32, distinct_from: Option<&str>) -> serde_json::Value { + let mut property = json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position + }); + if let Some(distinct_from) = distinct_from { + property["distinctFrom"] = json!(distinct_from); + } + property + } + + /// A document type with `delegateId` declaring `distinct_from`, next to the + /// identifier `toUserId`, the string `note` and the nested object `meta` + /// holding the identifier `meta.reviewerId` and the string `meta.tag`. + fn distinct_from_schema(distinct_from: Option<&str>) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "delegateId": identifier_property(0, distinct_from), + "toUserId": identifier_property(1, None), + "note": {"type": "string", "maxLength": 32, "position": 2}, + "meta": { + "type": "object", + "position": 3, + "properties": { + "reviewerId": identifier_property(0, None), + "tag": {"type": "string", "maxLength": 32, "position": 1} + }, + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }) + } + + fn distinct_from_of(document_type: &DocumentType, path: &str) -> Option { + document_type + .as_ref() + .flattened_properties() + .get(path) + .expect("property should be present") + .distinct_from + .clone() + } + + #[test] + fn should_parse_distinct_from_owner_id() { + for full_validation in [true, false] { + let document_type = if full_validation { + try_document_type_from_schema_full_validation(distinct_from_schema(Some( + "$ownerId", + ))) + } else { + try_document_type_from_schema(distinct_from_schema(Some("$ownerId"))) + } + .expect("should parse"); + + assert_eq!( + distinct_from_of(&document_type, "delegateId"), + Some(DistinctFrom::OwnerId), + "full_validation: {full_validation}" + ); + assert_eq!(distinct_from_of(&document_type, "toUserId"), None); + } + } + + #[test] + fn should_parse_distinct_from_property_path_on_top_level_and_nested_properties() { + let document_type = try_document_type_from_schema_full_validation(json!({ + "type": "object", + "properties": { + "delegateId": identifier_property(0, Some("meta.reviewerId")), + "meta": { + "type": "object", + "position": 1, + "properties": { + "reviewerId": identifier_property(0, Some("delegateId")), + "tag": {"type": "string", "maxLength": 32, "position": 1} + }, + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + })) + .expect("should parse"); + + assert_eq!( + distinct_from_of(&document_type, "delegateId"), + Some(DistinctFrom::Property("meta.reviewerId".to_string())) + ); + assert_eq!( + distinct_from_of(&document_type, "meta.reviewerId"), + Some(DistinctFrom::Property("delegateId".to_string())) + ); + } + + #[test] + fn should_reject_distinct_from_on_a_non_identifier_property() { + let schema = json!({ + "type": "object", + "properties": { + "note": {"type": "string", "maxLength": 32, "position": 0, "distinctFrom": "$ownerId"}, + "toUserId": identifier_property(1, None) + }, + "required": [], + "additionalProperties": false + }); + + // Without the meta-schema the parser refuses it itself + let err = try_document_type_from_schema(schema.clone()).expect_err("should be refused"); + assert!( + err.to_string() + .contains("distinctFrom is only allowed on identifier properties"), + "got {err}" + ); + + // With it the meta-schema's dependent schema refuses it first, before + // the parser gets to run, so the parser's message must not be the one + let err = try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse it"); + assert!( + !err.to_string() + .contains("distinctFrom is only allowed on identifier properties"), + "the meta-schema, not the parser, must refuse it: {err}" + ); + } + + #[test] + fn should_reject_distinct_from_naming_a_property_that_does_not_exist() { + for target in ["missing", "meta.missing", "note.deeper"] { + let err = try_document_type_from_schema(distinct_from_schema(Some(target))) + .expect_err("should be refused"); + assert!( + err.to_string() + .contains(&format!("declares distinctFrom \"{target}\", but the document type has no property at that path")), + "{target}: got {err}" + ); + } + } + + #[test] + fn should_reject_distinct_from_naming_a_non_identifier_property() { + for target in ["note", "meta.tag", "meta"] { + let err = try_document_type_from_schema(distinct_from_schema(Some(target))) + .expect_err("should be refused"); + // An object is not in the flattened map at all; its members are + let expected = if target == "meta" { + "which is an object, not an identifier property".to_string() + } else { + format!("declares distinctFrom \"{target}\", which is not an identifier property") + }; + assert!(err.to_string().contains(&expected), "{target}: got {err}"); + } + } + + #[test] + fn should_reject_distinct_from_naming_itself() { + let err = try_document_type_from_schema(distinct_from_schema(Some("delegateId"))) + .expect_err("should be refused"); + assert!( + err.to_string().contains("declares distinctFrom itself"), + "got {err}" + ); + } + + #[test] + fn should_reject_distinct_from_naming_a_system_property_other_than_owner_id() { + for (target, fragment) in [ + ("$id", "not system property \"$id\""), + ("$creatorId", "not system property \"$creatorId\""), + ("", "between 1 and 256 characters"), + ] { + let err = try_document_type_from_schema(distinct_from_schema(Some(target))) + .expect_err("should be refused"); + assert!(err.to_string().contains(fragment), "{target:?}: got {err}"); + } + } + + #[test] + fn should_reject_distinct_from_that_is_not_a_string() { + let mut schema = distinct_from_schema(None); + schema["properties"]["delegateId"]["distinctFrom"] = json!(["$ownerId"]); + let err = try_document_type_from_schema(schema).expect_err("should be refused"); + assert!( + err.to_string().contains("distinctFrom must be a string"), + "got {err}" + ); + } + + #[test] + fn should_refuse_distinct_from_below_protocol_version_14_under_full_validation() { + // Meta-schema v2 (protocol version 13) knows no such keyword, so a + // registering parse refuses it. + let platform_version = PlatformVersion::get(13).expect("platform version 13 should exist"); + let config = + DataContractConfig::default_for_version(platform_version).expect("config should build"); + let value = platform_value::to_value(distinct_from_schema(Some("$ownerId"))) + .expect("schema should convert"); + + DocumentType::try_from_schema( + Identifier::random(), + 0, + config.version(), + "msg", + value, + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + ) + .expect_err("protocol version 13 should refuse the keyword"); + } + + #[test] + fn should_ignore_distinct_from_below_protocol_version_14_without_full_validation() { + // Platform versions whose tables carry `apply_distinct_from: None` + // predate the keyword: without the meta-schema they must ignore it and + // keep producing the plain property they always produced. + let platform_version = PlatformVersion::get(13).expect("platform version 13 should exist"); + + let document_type = try_document_type_from_schema_on_version( + distinct_from_schema(Some("$ownerId")), + platform_version, + ) + .expect("should parse"); + + assert_eq!(distinct_from_of(&document_type, "delegateId"), None); + } + + #[test] + fn should_accept_distinct_from_at_protocol_version_14() { + let platform_version = PlatformVersion::get(14).expect("platform version 14 should exist"); + + let document_type = try_document_type_from_schema_on_version( + distinct_from_schema(Some("toUserId")), + platform_version, + ) + .expect("should parse"); + + assert_eq!( + distinct_from_of(&document_type, "delegateId"), + Some(DistinctFrom::Property("toUserId".to_string())) + ); + } + + #[test] + fn should_parse_distinct_from_next_to_refers_to_and_judge_the_referencing_property() { + let mut schema = distinct_from_schema(Some("toUserId")); + schema["properties"]["delegateId"]["refersTo"] = json!({ "type": "identity" }); + let document_type = + try_document_type_from_schema_full_validation(schema).expect("should parse"); + + let property = document_type + .as_ref() + .flattened_properties() + .get("delegateId") + .expect("property should be present") + .clone(); + assert_eq!( + property.property_type, + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Identity + ) + ); + assert_eq!( + property.distinct_from, + Some(DistinctFrom::Property("toUserId".to_string())) + ); + + let data = BTreeMap::from([ + ("delegateId".to_string(), Value::Identifier([1; 32])), + ("toUserId".to_string(), Value::Identifier([1; 32])), + ]); + let result = document_type + .as_ref() + .validate_distinct_from_properties( + &data, + Identifier::from([2; 32]), + PlatformVersion::latest(), + ) + .expect("the check should run"); + assert!( + !result.is_valid(), + "a referencing property is judged like any other identifier" + ); + } + + #[test] + fn should_not_judge_distinct_from_before_protocol_version_14() { + // A document type parsed at 14 carries the declaration; judged through + // the dispatcher at 13, whose table has no `validate_distinct_from`, + // nothing is checked, as no property parsed there could declare it. + let document_type = try_document_type_from_schema(distinct_from_schema(Some("$ownerId"))) + .expect("should parse"); + let owner_id = Identifier::from([1; 32]); + let data = BTreeMap::from([("delegateId".to_string(), Value::Identifier([1; 32]))]); + + let before = document_type + .as_ref() + .validate_distinct_from_properties( + &data, + owner_id, + PlatformVersion::get(13).expect("platform version 13 should exist"), + ) + .expect("the check should run"); + assert!(before.is_valid(), "{:?}", before.errors); + + let at = document_type + .as_ref() + .validate_distinct_from_properties(&data, owner_id, PlatformVersion::latest()) + .expect("the check should run"); + assert!(!at.is_valid()); + } + + /// A typed array of identifiers whose items declare `distinct_from`. + fn identifier_array_property(position: u32, distinct_from: Option<&str>) -> serde_json::Value { + let mut items = json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }); + if let Some(distinct_from) = distinct_from { + items["distinctFrom"] = json!(distinct_from); + } + json!({ "type": "array", "maxItems": 8, "items": items, "position": position }) + } + + #[test] + fn should_parse_distinct_from_on_the_items_of_an_identifier_array_and_judge_every_element() { + let mut schema = distinct_from_schema(None); + schema["properties"]["members"] = identifier_array_property(4, Some("$ownerId")); + let document_type = + try_document_type_from_schema_full_validation(schema).expect("should parse"); + + assert_eq!( + distinct_from_of(&document_type, "members"), + Some(DistinctFrom::OwnerId) + ); + + let owner_id = Identifier::from([1; 32]); + let judge = |members: Vec| { + let data = BTreeMap::from([("members".to_string(), Value::Array(members))]); + document_type + .as_ref() + .validate_distinct_from_properties(&data, owner_id, PlatformVersion::latest()) + .expect("the check should run") + }; + + assert!(judge(vec![]).is_valid()); + assert!(judge(vec![Value::Identifier([2; 32]), Value::Identifier([3; 32])]).is_valid()); + let refused = judge(vec![Value::Identifier([2; 32]), Value::Identifier([1; 32])]); + assert_matches!( + refused.errors.as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertyNotDistinctError(e))] + if e.property() == "members" && e.distinct_from() == "$ownerId" + ); + } + + #[test] + fn should_reject_distinct_from_on_the_array_itself_or_on_non_identifier_items() { + for (property, fragment) in [ + ( + json!({ + "type": "array", "maxItems": 8, "position": 4, "distinctFrom": "$ownerId", + "items": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + } + }), + "belongs on its items", + ), + ( + json!({ + "type": "array", "maxItems": 8, "position": 4, + "items": { "type": "integer", "distinctFrom": "$ownerId" } + }), + "only allowed on identifier elements", + ), + ] { + let mut schema = distinct_from_schema(None); + schema["properties"]["members"] = property; + let err = try_document_type_from_schema(schema.clone()).expect_err("should be refused"); + assert!(err.to_string().contains(fragment), "got {err}"); + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse it too"); + } + } + + #[test] + fn should_round_trip_a_contract_through_platform_serialization_with_and_without_distinct_from() + { + let platform_version = PlatformVersion::latest(); + + for distinct_from in [None, Some("$ownerId"), Some("toUserId")] { + let contract_value = platform_value::to_value(json!({ + "$formatVersion": "1", + "id": Identifier::from([7u8; 32]).to_string(Encoding::Base58), + "ownerId": Identifier::from([8u8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { + "message": distinct_from_schema(distinct_from) + } + })) + .expect("contract should convert"); + let contract = DataContract::from_value(contract_value, true, platform_version) + .expect("the contract should parse"); + + let bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract should serialize"); + let recovered = + DataContract::versioned_deserialize_untrusted(&bytes, false, platform_version) + .expect("the contract should deserialize"); + + assert_eq!(contract, recovered, "distinctFrom {distinct_from:?}"); + let expected = distinct_from + .map(|name| DistinctFrom::from_wire_name(name).expect("a valid declaration")); + let message_type = recovered + .document_type_for_name("message") + .expect("the message type") + .to_owned_document_type(); + assert_eq!(distinct_from_of(&message_type, "delegateId"), expected); + } + } + + // ================================================================ + // encryptedFor + // ================================================================ + + /// The `encryptedFor` declaration every encryption test starts from. + fn encrypted_for_declaration() -> serde_json::Value { + json!({ + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }) + } + + /// A document type with a recipient identifier, two bounded key ids, an + /// unbounded integer, a string and a nested object next to the + /// `encryptedMessage` byte array carrying `encrypted_for`. + fn encrypted_schema(encrypted_for: serde_json::Value) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "recipientKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 1 }, + "senderKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 2 }, + "unboundedKeyId": { "type": "integer", "minimum": 0, "position": 3 }, + "note": { "type": "string", "maxLength": 63, "position": 4 }, + "maxOnlyKeyId": { "type": "integer", "maximum": 100, "position": 7 }, + "meta": { + "type": "object", + "position": 5, + "properties": { + "authorId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "additionalProperties": false + }, + "encryptedMessage": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 1040, + "position": 6, + "encryptedFor": encrypted_for + } + }, + "required": [], + "additionalProperties": false + }) + } + + fn encrypted_for_of(document_type: &DocumentType, path: &str) -> Option { + document_type + .as_ref() + .flattened_properties() + .get(path) + .expect("property should be present") + .encrypted_for + .clone() + } + + #[test] + fn should_parse_encrypted_for_on_a_byte_array_property() { + let document_type = try_document_type_from_schema_full_validation(encrypted_schema( + encrypted_for_declaration(), + )) + .expect("should parse"); + + let expected = EncryptedFor { + recipient: EncryptedForRecipient::Property("recipientId".to_string()), + recipient_key: "recipientKeyId".to_string(), + sender_key: "senderKeyId".to_string(), + scheme: EncryptionScheme::EcdhSecp256k1Aes256Cbc, + }; + assert_eq!( + encrypted_for_of(&document_type, "encryptedMessage"), + Some(expected.clone()) + ); + assert_eq!( + document_type.as_ref().encrypted_properties(), + vec![(&"encryptedMessage".to_string(), &expected)] + ); + assert_eq!(encrypted_for_of(&document_type, "note"), None); + } + + #[test] + fn should_parse_encrypted_for_with_the_owner_and_a_nested_identifier_as_recipient() { + for (recipient, expected) in [ + ("$ownerId", EncryptedForRecipient::Owner), + ( + "meta.authorId", + EncryptedForRecipient::Property("meta.authorId".to_string()), + ), + ] { + let mut declaration = encrypted_for_declaration(); + declaration["recipient"] = json!(recipient); + let document_type = + try_document_type_from_schema(encrypted_schema(declaration)).expect("should parse"); + assert_eq!( + encrypted_for_of(&document_type, "encryptedMessage") + .expect("should be declared") + .recipient, + expected + ); + } + } + + #[test] + fn should_reject_encrypted_for_on_a_non_byte_array_or_identifier_property() { + let mut on_string = encrypted_schema(encrypted_for_declaration()); + on_string["properties"]["note"]["encryptedFor"] = encrypted_for_declaration(); + on_string["properties"]["encryptedMessage"] + .as_object_mut() + .expect("object") + .remove("encryptedFor"); + let err = try_document_type_from_schema(on_string).expect_err("should be refused"); + assert!( + err.to_string() + .contains("encryptedFor is only allowed on byte array properties"), + "{err}" + ); + + let mut on_identifier = encrypted_schema(encrypted_for_declaration()); + on_identifier["properties"]["recipientId"]["encryptedFor"] = encrypted_for_declaration(); + on_identifier["properties"]["encryptedMessage"] + .as_object_mut() + .expect("object") + .remove("encryptedFor"); + let err = + try_document_type_from_schema(on_identifier.clone()).expect_err("should be refused"); + assert!( + err.to_string() + .contains("encryptedFor is not allowed on identifier properties"), + "{err}" + ); + // The meta-schema refuses it on an identifier too, before the parser sees it + try_document_type_from_schema_full_validation(on_identifier) + .expect_err("the meta-schema should refuse encryptedFor on an identifier"); + } + + #[test] + fn should_reject_encrypted_for_with_a_missing_unknown_or_malformed_key_or_an_unknown_scheme() { + for (mutate, fragment) in [ + ( + Box::new(|d: &mut serde_json::Value| { + d.as_object_mut().expect("object").remove("recipient"); + }) as Box, + "must declare recipient", + ), + ( + Box::new(|d: &mut serde_json::Value| { + d.as_object_mut().expect("object").remove("scheme"); + }), + "must declare scheme", + ), + ( + Box::new(|d: &mut serde_json::Value| d["scheme"] = json!("rsa-oaep")), + "scheme \"rsa-oaep\" is unknown", + ), + ( + Box::new(|d: &mut serde_json::Value| d["iv"] = json!("ivProperty")), + "\"iv\" is unknown", + ), + ( + Box::new(|d: &mut serde_json::Value| d["recipient"] = json!(7)), + "recipient must be a property path", + ), + ( + Box::new(|d: &mut serde_json::Value| d["recipientKey"] = json!("")), + "recipientKey must be between 1 and 256 characters", + ), + ( + Box::new(|d: &mut serde_json::Value| d["recipient"] = json!("$createdAt")), + "recipient must name an identifier property", + ), + ( + Box::new(|d: &mut serde_json::Value| d["senderKey"] = json!("$ownerId")), + "senderKey must name an integer property", + ), + ] { + let mut declaration = encrypted_for_declaration(); + mutate(&mut declaration); + let err = try_document_type_from_schema(encrypted_schema(declaration.clone())) + .expect_err("should be refused"); + assert!( + err.to_string().contains(fragment), + "{declaration}: expected {fragment:?}, got {err}" + ); + } + } + + #[test] + fn should_reject_encrypted_for_naming_a_property_that_is_missing_or_of_the_wrong_type() { + for (key, path, fragment) in [ + ("recipient", "note", "has type string, not identifier"), + ( + "recipient", + "nowhere", + "is not a property of the document type", + ), + ( + "recipientKey", + "note", + "must be an integer property with minimum at least 0", + ), + ( + "recipientKey", + "unboundedKeyId", + "must be an integer property with minimum at least 0", + ), + ( + "recipientKey", + "maxOnlyKeyId", + "must be an integer property with minimum at least 0", + ), + ( + "senderKey", + "recipientId", + "must be an integer property with minimum at least 0", + ), + ( + "senderKey", + "nowhere", + "is not a property of the document type", + ), + ] { + let mut declaration = encrypted_for_declaration(); + declaration[key] = json!(path); + let err = try_document_type_from_schema(encrypted_schema(declaration)) + .expect_err("should be refused"); + assert!( + err.to_string().contains(fragment), + "{key}={path}: expected {fragment:?}, got {err}" + ); + } + } + + #[test] + fn should_refuse_encrypted_for_below_platform_version_14_and_accept_it_at_14() { + let schema = encrypted_schema(encrypted_for_declaration()); + let v13 = PlatformVersion::get(13).expect("platform version 13 should exist"); + + // The v2 meta-schema does not know the keyword: a validating parse refuses it + let config = DataContractConfig::default_for_version(v13).expect("config should build"); + let value = platform_value::to_value(schema.clone()).expect("schema should convert"); + DocumentType::try_from_schema( + Identifier::random(), + 0, + config.version(), + "msg", + value, + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + v13, + ) + .expect_err("platform version 13 should refuse encryptedFor under full validation"); + + // Without validation the tables carry `apply_encrypted_for: None`, so the keyword + // is ignored and the property parses as the plain byte array it always was + let document_type = + try_document_type_from_schema_on_version(schema.clone(), v13).expect("should parse"); + assert_eq!(encrypted_for_of(&document_type, "encryptedMessage"), None); + assert!(document_type.as_ref().encrypted_properties().is_empty()); + + // At 14 both parses carry it + let document_type = try_document_type_from_schema_full_validation(schema.clone()) + .expect("should parse at platform version 14"); + assert!(encrypted_for_of(&document_type, "encryptedMessage").is_some()); + let document_type = try_document_type_from_schema(schema).expect("should parse"); + assert!(encrypted_for_of(&document_type, "encryptedMessage").is_some()); + } + + #[test] + fn should_reject_encrypted_for_naming_a_transient_recipient_or_key() { + for (transient, fragment) in [ + ("recipientId", "recipient \"recipientId\" is transient"), + ("senderKeyId", "senderKey \"senderKeyId\" is transient"), + ] { + let mut schema = encrypted_schema(encrypted_for_declaration()); + schema["transient"] = json!([transient]); + let err = try_document_type_from_schema(schema).expect_err("should be refused"); + assert!(err.to_string().contains(fragment), "{transient}: got {err}"); + } + } + + #[test] + fn should_reject_encrypted_for_on_a_byte_array_too_short_for_the_scheme() { + let mut schema = encrypted_schema(encrypted_for_declaration()); + schema["properties"]["encryptedMessage"]["minItems"] = json!(1); + schema["properties"]["encryptedMessage"]["maxItems"] = json!(24); + let err = try_document_type_from_schema(schema).expect_err("should be refused"); + assert!( + err.to_string() + .contains("maxItems 24 is below the 32 bytes the ecdh-secp256k1-aes256-cbc"), + "{err}" + ); + } + + /// The key-id bounds are read from the schema, so a contract whose + /// integers are not sized (config V0, or `sizedIntegerTypes: false`) + /// can still declare the keyword. + #[test] + fn should_accept_encrypted_for_key_ids_when_sized_integer_types_are_off() { + use crate::data_contract::config::v0::DataContractConfigV0; + + let platform_version = PlatformVersion::latest(); + let config = DataContractConfig::V0(DataContractConfigV0::default()); + let value = platform_value::to_value(encrypted_schema(encrypted_for_declaration())) + .expect("schema should convert"); + + let document_type = DocumentType::try_from_schema( + Identifier::random(), + 0, + config.version(), + "msg", + value, + None, + &BTreeMap::new(), + &config, + false, + &mut vec![], + platform_version, + ) + .expect("should parse with unsized integers"); + + assert!(matches!( + document_type + .as_ref() + .flattened_properties() + .get("recipientKeyId") + .map(|p| &p.property_type), + Some(DocumentPropertyType::I64) + )); + assert!(encrypted_for_of(&document_type, "encryptedMessage").is_some()); } // ================================================================ diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 79ef89772c6..9369456d887 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -25,9 +25,13 @@ use crate::data_contract::document_type::index::Index; use crate::data_contract::document_type::index::IndexGrammarAdmissions; #[cfg(feature = "validation")] use crate::data_contract::document_type::property::DocumentPropertyType; +#[cfg(feature = "validation")] +use crate::data_contract::document_type::property::{ + DocumentPropertyReferenceTarget, PropertyReference, +}; use crate::data_contract::document_type::property_names; use crate::data_contract::document_type::v2::DocumentTypeV2; -use crate::data_contract::document_type::DocumentType; +use crate::data_contract::document_type::{DocumentType, DocumentTypeRef}; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; use crate::validation::operations::ProtocolValidationOperation; @@ -36,10 +40,15 @@ use crate::ProtocolError; use platform_value::{Identifier, Value}; use std::collections::BTreeMap; +#[cfg(feature = "validation")] +use crate::consensus::basic::data_contract::InvalidDocumentTypeNameError; #[cfg(feature = "validation")] use crate::consensus::basic::data_contract::InvalidIndexedPropertyConstraintError; +#[cfg(feature = "validation")] +use crate::consensus::ConsensusError; use super::common; +use super::{validate_encrypted_for_declarations, validate_reference_lookup_sources}; mod ranked_prefix_overlap; use ranked_prefix_overlap::validate_no_ranked_prefix_overlap; @@ -145,6 +154,13 @@ fn validate_ranked_index_property_key_length( return Ok(()); }; + // A typed array is no index key at all, and its byte bound measures the + // whole list: the property-type check right after this one rejects it + // with the error that explains the problem. + if matches!(property_type, DocumentPropertyType::TypedArray(_)) { + return Ok(()); + } + // `None` is only produced by the array and object types, which the // property-type check right after this one rejects outright with the // error that actually explains the problem. @@ -281,6 +297,18 @@ fn try_from_schema_generation_3( validation_operations: &mut impl Extend, platform_version: &PlatformVersion, ) -> Result { + // Generation 3 refuses `-` in a document type name, as meta-schema v3 + // refuses it in a property name: the path syntax was written for word + // characters, and no contract on mainnet or testnet ever used one. A + // registration rule, checked under full validation like the shared + // name rule; earlier generations keep admitting it. + #[cfg(feature = "validation")] + if full_validation && name.contains('-') { + return Err(ProtocolError::ConsensusError(Box::new( + ConsensusError::from(InvalidDocumentTypeNameError::new(name.to_string())), + ))); + } + // Read the doctype-level keywords before the core parser consumes // `schema`. Each is read wherever it appears, and its shape is enforced on // both paths: see "Doctype-level keywords on contracts that predate them" @@ -384,6 +412,15 @@ fn try_from_schema_generation_3( full_validation, )?; + // After the core parse: every property, its transient flag and its schema + // are known, so each `encryptedFor` declaration can be checked against the + // properties it names. Generation 3 is the only one admitting the keyword. + validate_encrypted_for_declarations(&v2, name) + .map_err(consensus_or_protocol_data_contract_error)?; + // The same for the properties a `refersTo` lookup reads to assemble its key. + validate_reference_lookup_sources(DocumentTypeRef::V2(&v2), name) + .map_err(consensus_or_protocol_data_contract_error)?; + // After `apply_index_only`: the flag is refused on an indexOnly type, so it // has to see that one already applied. common::apply_can_be_deleted_by_moderators( @@ -413,9 +450,135 @@ fn try_from_schema_generation_3( )); } + #[cfg(feature = "validation")] + if full_validation { + validate_typed_array_max_items(&v2, name, platform_version)?; + validate_reference_count(&v2, name, platform_version)?; + validate_no_immutable_deletable_element_references(&v2, name)?; + } + Ok(v2) } +/// Every typed array property's `maxItems` (which the parse requires) is at +/// most `SystemLimits::max_typed_array_items`, so its worst-case encoded +/// size stays small. Read off the flattened properties, which reach a typed +/// array nested in an object too. +/// +/// Full validation only, like the other registration limits: a stored +/// contract was checked when it was registered, and a later protocol version +/// lowering the cap must not make it unreadable. +#[cfg(feature = "validation")] +fn validate_typed_array_max_items( + document_type: &DocumentTypeV2, + name: &str, + platform_version: &PlatformVersion, +) -> Result<(), ProtocolError> { + let limit = platform_version.system_limits.max_typed_array_items; + for (path, property) in document_type.flattened_properties() { + let DocumentPropertyType::TypedArray(typed_array) = &property.property_type else { + continue; + }; + if typed_array.max_items > limit { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "typed array property \"{}\" of document type \"{}\" declares maxItems \ + {}, above the maximum of {}", + path, name, typed_array.max_items, limit, + )), + )); + } + } + Ok(()) +} + +/// The references one document of the type can carry, one for each +/// property declaring `refersTo` (an identifier, or a key id with a key +/// reference) and `maxItems` for each typed array whose elements declare it, +/// are at most +/// `SystemLimits::max_references_per_document`. Every reference is a billed +/// state read when the document is created or replaced, so the sum bounds +/// the reads one write can cause; `max_typed_array_items` alone would let a +/// type declare many arrays of that many references each. +/// +/// Full validation only, like the typed array cap: a stored contract was +/// checked when it was registered. +#[cfg(feature = "validation")] +fn validate_reference_count( + document_type: &DocumentTypeV2, + name: &str, + platform_version: &PlatformVersion, +) -> Result<(), ProtocolError> { + let limit = platform_version.system_limits.max_references_per_document; + let references: u32 = document_type + .flattened_properties() + .values() + .filter_map(|property| property.property_type.reference()) + .map(|reference| reference.max_references()) + .sum(); + if references > u32::from(limit) { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{name}\" declares references for up to {references} values per \ + document (one per property with refersTo, maxItems per typed array of \ + referencing elements), above the maximum of {limit}", + )), + )); + } + Ok(()) +} + +/// An `immutable` property may not hold a `deletableDocument` reference the +/// replace state validation could not clear: a typed array of them, at the +/// top level or inside an immutable object, or a single one inside an +/// immutable object. Every replace re-validates such a reference, so once a +/// target is deleted the property would have to change, which an immutable +/// property cannot: the document could never be replaced again. The one +/// such reference that has a way out is a single one held by an immutable +/// top-level property: a replace may remove it once its target is gone, an +/// exception that reads the one identifier the removed top-level property +/// held, which neither a list nor an object gives it. +#[cfg(feature = "validation")] +fn validate_no_immutable_deletable_element_references( + document_type: &DocumentTypeV2, + name: &str, +) -> Result<(), ProtocolError> { + for (path, property) in document_type.flattened_properties() { + let Some(reference) = property.property_type.reference() else { + continue; + }; + if !matches!( + reference.target(), + Some(DocumentPropertyReferenceTarget::DeletableDocument { .. }) + ) { + continue; + } + let top_level = path.split('.').next().unwrap_or(path); + let is_list = matches!(reference, PropertyReference::Elements { .. }); + // A single reference that is itself the immutable property can be + // cleared once its target is gone + if !is_list && top_level == path { + continue; + } + if document_type.immutable_fields.contains(top_level) { + let held_as = if is_list { + "a typed array of deletableDocument references" + } else { + "a deletableDocument reference inside an object" + }; + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{name}\" lists \"{top_level}\" as immutable, but \"{path}\" is \ + {held_as}: every replace re-validates it, so once a target is deleted the \ + property would have to change and the document could never be replaced \ + again. Use permanentDocument references, or leave the property mutable", + )), + )); + } + } + Ok(()) +} + impl DocumentType { /// Dispatches to this module's generation-3 parser and wraps the result. #[allow(clippy::too_many_arguments)] @@ -460,6 +623,16 @@ mod keep_history_tests; mod meta_schema_v0_stray_keyword_tests; #[cfg(test)] mod moderators_delete_tests; +#[cfg(all(test, feature = "validation"))] +mod name_rules_tests; +#[cfg(all(test, feature = "validation"))] +mod reference_lookup_tests; +#[cfg(all(test, feature = "validation"))] +mod typed_array_reference_tests; +#[cfg(all(test, feature = "validation"))] +mod typed_array_test_helpers; +#[cfg(all(test, feature = "validation", feature = "random-documents"))] +mod typed_array_tests; #[cfg(test)] mod tests { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/name_rules_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/name_rules_tests.rs new file mode 100644 index 00000000000..d5bd1fbdb4e --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/name_rules_tests.rs @@ -0,0 +1,130 @@ +//! Names, generation 3 (protocol version 14): a property name and a document +//! type name are word characters only. Every earlier meta-schema and parser +//! generation admitted `-` as well; a census on 2026-09-23 of every data +//! contract create and update transition on mainnet (72) and testnet (4593), +//! decoded from the raw bytes with dpp, found no property or document type +//! name carrying one, so nothing stored is affected and no contract is left +//! unable to update. The path syntax (`a.b`, `list[]`) was never written for +//! `-`, which is how a hyphenated typed array's elements went unconverted. + +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::config::DataContractConfig; +use crate::data_contract::document_type::DocumentType; +use crate::ProtocolError; +use platform_value::{platform_value, Identifier, Value}; +use platform_version::version::PlatformVersion; +use std::collections::BTreeMap; + +fn parse( + name: &str, + schema: Value, + platform_version: &PlatformVersion, + full_validation: bool, +) -> Result { + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + name, + schema, + None, + &BTreeMap::new(), + &config, + full_validation, + &mut vec![], + platform_version, + ) +} + +fn schema_with_property(property_name: &str) -> Value { + platform_value!({ + "type": "object", + "properties": { + property_name: { "type": "string", "maxLength": 8, "position": 0 } + }, + "additionalProperties": false + }) +} + +fn schema_with_nested_property(property_name: &str) -> Value { + platform_value!({ + "type": "object", + "properties": { + "meta": { + "type": "object", + "position": 0, + "properties": { + property_name: { "type": "string", "maxLength": 8, "position": 0 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }) +} + +fn is_json_schema_error(error: &ProtocolError) -> bool { + matches!( + error, + ProtocolError::ConsensusError(boxed) + if matches!(**boxed, ConsensusError::BasicError(BasicError::JsonSchemaError(_))) + ) +} + +#[test] +fn should_refuse_a_hyphen_in_a_property_name_from_protocol_version_14_and_admit_it_before() { + let v13 = PlatformVersion::get(13).expect("protocol version 13 exists"); + let v14 = PlatformVersion::latest(); + + for schema in [ + schema_with_property("member-ids"), + schema_with_nested_property("member-ids"), + ] { + let error = parse("note", schema.clone(), v14, true) + .expect_err("meta-schema v3 refuses a hyphen in a property name"); + assert!( + is_json_schema_error(&error), + "expected the meta-schema refusal, got {error}" + ); + parse("note", schema.clone(), v13, true) + .expect("meta-schema v2 admits a hyphen in a property name, as it always did"); + // The stored path never checked names + parse("note", schema, v14, false).expect("a stored schema is read as it is"); + } + + for schema in [ + schema_with_property("member_ids"), + schema_with_nested_property("memberIds"), + ] { + parse("note", schema, v14, true).expect("word characters stay admitted"); + } +} + +#[test] +fn should_refuse_a_hyphen_in_a_document_type_name_from_protocol_version_14_and_admit_it_before() { + let v13 = PlatformVersion::get(13).expect("protocol version 13 exists"); + let v14 = PlatformVersion::latest(); + let schema = schema_with_property("label"); + + let error = parse("journal-entry", schema.clone(), v14, true) + .expect_err("generation 3 refuses a hyphen in a document type name"); + assert!( + matches!( + error, + ProtocolError::ConsensusError(ref boxed) + if matches!( + **boxed, + ConsensusError::BasicError(BasicError::InvalidDocumentTypeNameError(_)) + ) + ), + "expected InvalidDocumentTypeNameError, got {error}" + ); + parse("journal-entry", schema.clone(), v13, true) + .expect("generation 2 admits a hyphen in a document type name, as it always did"); + parse("journal-entry", schema.clone(), v14, false) + .expect("a stored document type is read under its name as it is"); + parse("journal_entry", schema, v14, true).expect("word characters stay admitted"); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs new file mode 100644 index 00000000000..9245a15e4c7 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs @@ -0,0 +1,652 @@ +//! Document references resolved through a unique index (`refersTo.lookup`, +//! protocol version 14): the parse of the declaration, the checks of its +//! referring side on every parse, the checks of its referenced side at contract +//! level for a document type of the same contract, the protocol version gate and +//! the platform serialization round trip. + +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::config::DataContractConfig; +use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceLookup, DocumentType, + LookupKeySource, PropertyReference, +}; +use crate::data_contract::DataContract; +use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, +}; +use crate::ProtocolError; +use platform_value::string_encoding::Encoding; +use platform_value::{Identifier, Value}; +use platform_version::version::PlatformVersion; +use serde_json::json; +use std::collections::BTreeMap; + +const CONTRACT_ID: [u8; 32] = [7; 32]; + +fn identifier(position: u32) -> serde_json::Value { + json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position + }) +} + +/// The lookup of the moderation charter's `members`: the member is the owner of +/// a `joinRequest` for the same submitted charter. +fn members_lookup() -> serde_json::Value { + json!({ + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": "submittedCharterId", "$ownerId": "." } + }) +} + +/// A contract with a permanent, immutable `joinRequest` type, unique on +/// (`submittedCharterId`, `$ownerId`) and with a non-unique `byMessage` index, +/// and an `electedCharter` type whose `memberId` declares `refers_to`, next to +/// a required `submittedCharterId`, an optional `alternateCharterId` and a +/// required string `title`. +fn charter_contract(refers_to: serde_json::Value) -> serde_json::Value { + let mut member_id = identifier(1); + member_id["refersTo"] = refers_to; + json!({ + "$formatVersion": "1", + "id": Identifier::from(CONTRACT_ID).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { + "joinRequest": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "submittedCharterId": identifier(0), + "message": { "type": "string", "maxLength": 63, "position": 1 } + }, + "indices": [ + { + "name": "bySubmittedCharter", + "properties": [{ "submittedCharterId": "asc" }, { "$ownerId": "asc" }], + "unique": true + }, + { "name": "byMessage", "properties": [{ "message": "asc" }] } + ], + "required": ["submittedCharterId", "message"], + "additionalProperties": false + }, + "electedCharter": { + "type": "object", + "properties": { + "submittedCharterId": identifier(0), + "memberId": member_id, + "alternateCharterId": identifier(2), + "title": { "type": "string", "maxLength": 63, "position": 3 } + }, + "required": ["submittedCharterId", "title"], + "additionalProperties": false + } + } + }) +} + +fn permanent_join_request(lookup: serde_json::Value) -> serde_json::Value { + json!({ "type": "permanentDocument", "documentType": "joinRequest", "lookup": lookup }) +} + +fn contract_on( + contract: serde_json::Value, + full_validation: bool, + platform_version: &PlatformVersion, +) -> Result { + let value = platform_value::to_value(contract).expect("the contract should convert"); + DataContract::from_value(value, full_validation, platform_version) +} + +fn contract(contract: serde_json::Value) -> Result { + contract_on(contract, true, PlatformVersion::latest()) +} + +fn member_id_type(contract: &DataContract) -> DocumentPropertyType { + contract + .document_type_for_name("electedCharter") + .expect("the electedCharter document type") + .flattened_properties() + .get("memberId") + .expect("the memberId property") + .property_type + .clone() +} + +fn expected_lookup(keys: &[(&str, LookupKeySource)]) -> DocumentReferenceLookup { + DocumentReferenceLookup { + index: "bySubmittedCharter".to_string(), + keys: keys + .iter() + .map(|(index_property, source)| (index_property.to_string(), source.clone())) + .collect(), + } +} + +fn assert_refused(result: Result, fragment: &str) { + let error = result.expect_err("the contract should be refused"); + assert!( + error.to_string().contains(fragment), + "expected {fragment:?} in: {error}" + ); +} + +#[test] +fn should_parse_a_lookup_with_a_property_source_an_owner_source_and_the_reference_value() { + let parsed = + contract(charter_contract(permanent_join_request(members_lookup()))).expect("parses"); + assert_eq!( + member_id_type(&parsed), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: BTreeMap::new(), + lookup: expected_lookup(&[ + ( + "submittedCharterId", + LookupKeySource::Property("submittedCharterId".to_string()) + ), + ("$ownerId", LookupKeySource::ReferenceValue), + ]), + } + ) + ); + + // The writer can fill a key part too, and the reference's own value can fill + // a schema property of the index + let parsed = contract(charter_contract(permanent_join_request(json!({ + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": ".", "$ownerId": "$ownerId" } + })))) + .expect("parses"); + let DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. }, + ) = member_id_type(&parsed) + else { + panic!("expected a permanentDocument lookup reference"); + }; + assert_eq!( + lookup, + expected_lookup(&[ + ("submittedCharterId", LookupKeySource::ReferenceValue), + ("$ownerId", LookupKeySource::OwnerId), + ]) + ); +} + +#[test] +fn should_parse_a_lookup_beside_a_property_agreement() { + let parsed = contract(charter_contract(json!({ + "type": "permanentDocument", + "documentType": "joinRequest", + "propertyAgreement": { "title": "message" }, + "lookup": members_lookup() + }))) + .expect("parses"); + let DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + property_agreement, + lookup, + .. + }, + ) = member_id_type(&parsed) + else { + panic!("expected a permanentDocument lookup reference"); + }; + assert_eq!( + property_agreement, + BTreeMap::from([("title".to_string(), "message".to_string())]) + ); + assert_eq!(lookup.index, "bySubmittedCharter"); +} + +#[test] +fn should_refuse_a_lookup_on_any_reference_but_a_permanent_document_one() { + for reference_type in [ + "identity", + "contract", + "token", + "identityPublicKey", + "deletableDocument", + ] { + let mut refers_to = json!({ "type": reference_type, "lookup": members_lookup() }); + if reference_type == "identityPublicKey" { + refers_to["keyIdProperty"] = json!("title"); + } + let mut schema = charter_contract(refers_to); + if reference_type == "deletableDocument" { + // A deletable target, so the reference itself is well formed: only + // the lookup is out of place + refers_to_deletable_join_requests(&mut schema); + } + + // The meta-schema refuses it under full validation, and the parser on + // its own without it + contract(schema.clone()).expect_err("the meta-schema should refuse it"); + assert_refused( + contract_on(schema, false, PlatformVersion::latest()), + &format!("{reference_type} refersTo does not take lookup"), + ); + } +} + +#[test] +fn should_refuse_a_lookup_naming_a_missing_or_non_unique_index() { + for (lookup, fragment) in [ + ( + json!({ "index": "byNothing", "keys": { "submittedCharterId": "." } }), + "has no index named \"byNothing\"", + ), + ( + json!({ "index": "byMessage", "keys": { "message": "." } }), + "is not unique", + ), + ] { + assert_refused( + contract(charter_contract(permanent_join_request(lookup))), + fragment, + ); + } +} + +#[test] +fn should_refuse_lookup_keys_that_miss_or_add_an_index_property() { + for (keys, fragment) in [ + ( + json!({ "$ownerId": "." }), + "does not map \"submittedCharterId\"", + ), + ( + json!({ "submittedCharterId": "submittedCharterId", "$ownerId": ".", "message": "title" }), + "maps \"message\", which is not a property of index \"bySubmittedCharter\"", + ), + ] { + assert_refused( + contract(charter_contract(permanent_join_request( + json!({ "index": "bySubmittedCharter", "keys": keys }), + ))), + fragment, + ); + } +} + +#[test] +fn should_refuse_lookup_keys_that_use_the_reference_value_twice_or_not_at_all() { + for (keys, found) in [ + (json!({ "submittedCharterId": ".", "$ownerId": "." }), 2), + ( + json!({ "submittedCharterId": "submittedCharterId", "$ownerId": "$ownerId" }), + 0, + ), + ] { + let schema = charter_contract(permanent_join_request( + json!({ "index": "bySubmittedCharter", "keys": keys }), + )); + for full_validation in [true, false] { + assert_refused( + contract_on(schema.clone(), full_validation, PlatformVersion::latest()), + &format!("exactly one index property from \".\", the reference's own value, found {found}"), + ); + } + } +} + +#[test] +fn should_refuse_a_lookup_source_of_the_wrong_value_kind() { + assert_refused( + contract(charter_contract(permanent_join_request(json!({ + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": "title", "$ownerId": "." } + })))), + "is filled from \"title\", which holds a different kind of value", + ); +} + +#[test] +fn should_refuse_an_optional_transient_or_missing_lookup_source_property() { + let lookup_reading = |source: &str| { + permanent_join_request(json!({ + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": source, "$ownerId": "." } + })) + }; + + // Refused on every parse, validating or not: the rule belongs to the type + for full_validation in [true, false] { + assert_refused( + contract_on( + charter_contract(lookup_reading("alternateCharterId")), + full_validation, + PlatformVersion::latest(), + ), + "which is not required", + ); + assert_refused( + contract_on( + charter_contract(lookup_reading("ghostId")), + full_validation, + PlatformVersion::latest(), + ), + "which is not a property of the referring document type", + ); + assert_refused( + contract_on( + charter_contract(lookup_reading("memberId")), + full_validation, + PlatformVersion::latest(), + ), + "names the reference property itself", + ); + } + + let mut transient = charter_contract(lookup_reading("submittedCharterId")); + transient["documentSchemas"]["electedCharter"]["transient"] = json!(["submittedCharterId"]); + assert_refused(contract(transient), "which is transient"); + + // A required leaf inside an optional object is not always present + let mut nested = charter_contract(lookup_reading("meta.charterId")); + nested["documentSchemas"]["electedCharter"]["properties"]["meta"] = json!({ + "type": "object", + "position": 4, + "properties": { "charterId": identifier(0) }, + "required": ["charterId"], + "additionalProperties": false + }); + assert_refused(contract(nested.clone()), "which is not required"); + nested["documentSchemas"]["electedCharter"]["required"] = + json!(["submittedCharterId", "title", "meta"]); + contract(nested).expect("a required leaf of a required object is a valid source"); +} + +#[test] +fn should_refuse_a_lookup_into_a_document_type_that_can_move_the_key() { + let mutable = |schema: &mut serde_json::Value| { + schema["documentSchemas"]["joinRequest"]["documentsMutable"] = json!(true); + }; + + let mut moving = charter_contract(permanent_join_request(members_lookup())); + mutable(&mut moving); + assert_refused( + contract(moving.clone()), + "keys documents by \"submittedCharterId\", which a replace can change", + ); + + // Freezing the key's schema property is enough + let mut frozen = moving; + frozen["documentSchemas"]["joinRequest"]["immutable"] = json!(["submittedCharterId"]); + contract(frozen).expect("an immutable key property holds the key"); +} + +#[test] +fn should_refuse_a_lookup_reading_the_writer_on_a_type_that_can_change_owner() { + let reads_the_writer = charter_contract(permanent_join_request(json!({ + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": ".", "$ownerId": "$ownerId" } + }))); + for (keyword, value) in [("transferable", json!(1)), ("tradeMode", json!(1))] { + let mut changes_owner = reads_the_writer.clone(); + changes_owner["documentSchemas"]["electedCharter"][keyword] = value; + assert_refused( + contract(changes_owner), + "key \"$ownerId\" reads \"$ownerId\", which a transfer or a purchase of the \ + referring document changes", + ); + } + + // A transferable type may still refer through a lookup whose key does not + // read the writer: its value and properties change only with a replace + let mut transferable = charter_contract(permanent_join_request(members_lookup())); + transferable["documentSchemas"]["electedCharter"]["transferable"] = json!(1); + contract(transferable).expect("a key that does not read the writer holds"); +} + +/// Makes the fixture's `joinRequest` deletable, the target a +/// `deletableDocument` reference needs. +fn refers_to_deletable_join_requests(schema: &mut serde_json::Value) { + schema["documentSchemas"]["joinRequest"]["canBeDeleted"] = json!(true); +} + +/// The moderation charter's `members` (#4898), verbatim: a typed array whose +/// elements must each be the owner of a join request for the charter. +fn members(refers_to: serde_json::Value) -> serde_json::Value { + let mut items = identifier(0); + items.as_object_mut().expect("an object").remove("position"); + items["distinctFrom"] = json!("$ownerId"); + items["refersTo"] = refers_to; + json!({ + "type": "array", "minItems": 0, "maxItems": 15, "uniqueItems": true, + "items": items, + "position": 4 + }) +} + +fn with_members(refers_to: serde_json::Value) -> serde_json::Value { + let mut schema = charter_contract(json!({ "type": "identity" })); + schema["documentSchemas"]["electedCharter"]["properties"]["members"] = members(refers_to); + schema +} + +#[test] +fn should_parse_a_lookup_on_the_elements_of_a_typed_array_as_the_charter_declares() { + let parsed = contract(with_members(permanent_join_request(members_lookup()))).expect("parses"); + let members = parsed + .document_type_for_name("electedCharter") + .expect("the electedCharter document type") + .flattened_properties() + .get("members") + .expect("the members property") + .property_type + .clone(); + assert_eq!( + members.reference(), + Some(PropertyReference::Elements { + target: &DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: BTreeMap::new(), + lookup: expected_lookup(&[ + ( + "submittedCharterId", + LookupKeySource::Property("submittedCharterId".to_string()) + ), + ("$ownerId", LookupKeySource::ReferenceValue), + ]), + }, + max_items: 15, + }) + ); +} + +#[test] +fn should_check_an_element_lookup_as_a_single_one_is_checked() { + // The referring side, on every parse + assert_refused( + contract_on( + with_members(permanent_join_request(json!({ + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": "alternateCharterId", "$ownerId": "." } + }))), + false, + PlatformVersion::latest(), + ), + "document type \"electedCharter\" property \"members\" refersTo lookup: key \ + \"submittedCharterId\" reads \"alternateCharterId\", which is not required", + ); + // The referenced side, at contract level + assert_refused( + contract(with_members(permanent_join_request( + json!({ "index": "byMessage", "keys": { "message": "." } }), + ))), + "index \"byMessage\" of \"joinRequest\" is not unique", + ); + // And never on a deletableDocument reference + let mut deletable = with_members(json!({ + "type": "deletableDocument", + "documentType": "joinRequest", + "lookup": members_lookup() + })); + refers_to_deletable_join_requests(&mut deletable); + contract(deletable.clone()).expect_err("the meta-schema should refuse it"); + assert_refused( + contract_on(deletable, false, PlatformVersion::latest()), + "deletableDocument refersTo does not take lookup", + ); +} + +#[test] +fn should_refuse_a_lookup_below_protocol_version_14_and_accept_it_at_14() { + let schema = charter_contract(permanent_join_request(members_lookup())); + let platform_version_13 = PlatformVersion::get(13).expect("platform version 13 should exist"); + + // Meta-schema v2 knows no refersTo, so a registering parse refuses it + contract_on(schema.clone(), true, platform_version_13) + .expect_err("protocol version 13 should refuse the declaration"); + // A parse predating refersTo ignores the whole declaration, lookup and all + let ignored = contract_on(schema.clone(), false, platform_version_13) + .expect("protocol version 13 should parse it as a plain identifier"); + assert_eq!(member_id_type(&ignored), DocumentPropertyType::Identifier); + + let accepted = contract_on(schema, true, PlatformVersion::latest()).expect("parses"); + assert!(matches!( + member_id_type(&accepted), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } + ) + )); +} + +#[test] +fn should_leave_a_lookup_into_another_contract_to_registration() { + // The referenced type is not in this contract, so the parse cannot see its + // indexes: registration checks them against the other contract in state + let parsed = contract(charter_contract(json!({ + "type": "permanentDocument", + "contractId": Identifier::from([9; 32]).to_string(Encoding::Base58), + "documentType": "joinRequest", + "lookup": { "index": "byAnything", "keys": { "anything": "." } } + }))) + .expect("parses"); + assert!(matches!( + member_id_type(&parsed), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: Some(_), + .. + } + ) + )); +} + +#[test] +fn should_round_trip_a_contract_through_platform_serialization_with_and_without_a_lookup() { + let platform_version = PlatformVersion::latest(); + + for refers_to in [ + json!({ "type": "permanentDocument", "documentType": "joinRequest" }), + permanent_join_request(members_lookup()), + ] { + let original = contract(charter_contract(refers_to.clone())).expect("parses"); + let bytes = original + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract should serialize"); + let recovered = + DataContract::versioned_deserialize_untrusted(&bytes, false, platform_version) + .expect("the contract should deserialize"); + + assert_eq!(original, recovered, "refersTo {refers_to}"); + assert_eq!(member_id_type(&original), member_id_type(&recovered)); + } + + // Without a lookup, the parsed reference is exactly the id reference it was + let without = contract(charter_contract( + json!({ "type": "permanentDocument", "documentType": "joinRequest" }), + )) + .expect("parses"); + assert_eq!( + member_id_type(&without), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: BTreeMap::new(), + } + ) + ); +} + +#[test] +fn should_refuse_malformed_lookup_declarations_in_the_parser() { + for (lookup, fragment) in [ + ( + json!({ "index": "bySubmittedCharter" }), + "must declare keys", + ), + ( + json!({ "index": "bySubmittedCharter", "keys": {} }), + "between 1 and 10 index properties", + ), + ( + json!({ "index": "", "keys": { "$ownerId": "." } }), + "lookup index must be between 1 and 32 characters", + ), + ( + json!({ "index": "bySubmittedCharter", "keys": { "$ownerId": 1 } }), + "must map each index property to a string", + ), + ( + json!({ "index": "bySubmittedCharter", "keys": { "$ownerId": "." }, "extra": true }), + "lookup \"extra\" is unknown", + ), + ( + json!({ "index": "bySubmittedCharter", "keys": { "submittedCharterId": "$createdAt", "$ownerId": "." } }), + "not system property \"$createdAt\"", + ), + ] { + let schema = charter_contract(permanent_join_request(lookup.clone())); + assert_refused( + contract_on(schema.clone(), false, PlatformVersion::latest()), + fragment, + ); + contract(schema).expect_err("the meta-schema should refuse it too"); + } +} + +/// The document type parse alone, without the rest of the contract, as a +/// client building one document type sees it. +#[test] +fn should_check_only_the_referring_side_when_a_document_type_is_parsed_alone() { + let platform_version = PlatformVersion::latest(); + let config = + DataContractConfig::default_for_version(platform_version).expect("config should build"); + let schema = charter_contract(permanent_join_request(json!({ + "index": "byNothing", + "keys": { "anything": "." } + }))); + let elected_charter: Value = + platform_value::to_value(schema["documentSchemas"]["electedCharter"].clone()) + .expect("the schema should convert"); + DocumentType::try_from_schema( + Identifier::from(CONTRACT_ID), + 1, + config.version(), + "electedCharter", + elected_charter, + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + ) + .expect("the referenced type is out of sight, so only the sources are checked"); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_reference_tests.rs new file mode 100644 index 00000000000..e8321024ed5 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_reference_tests.rs @@ -0,0 +1,617 @@ +//! `refersTo` on the elements of a typed array: an identifier element's +//! `items` schema may carry the reference declaration a single identifier +//! property takes, and the element then parses to +//! `IdentifierWithReference(target)` inside the array's `item_type`. +//! +//! The declaration is read by `apply_property_reference`, the function a +//! scalar identifier goes through, from `parse_typed_array` 0 (protocol +//! version 14). The checks that need other contracts (the referenced +//! document type, the `propertyAgreement` sides and value kinds) run at +//! registration in drive-abci and are tested there. + +use super::typed_array_test_helpers::{ + expect_json_schema_error, expect_structure_error, parse_dispatched, +}; +use super::*; +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::array::TypedArrayProperty; +use crate::data_contract::document_type::{ + ContractReferenceModeration, ContractReferenceRequirements, DocumentPropertyReferenceTarget, + DocumentPropertyType, PropertyReference, +}; +use crate::data_contract::serialized_version::v0::DataContractInSerializationFormatV0; +use crate::data_contract::DataContract; +use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, +}; +use platform_value::platform_value; +use platform_value::string_encoding::Encoding; + +/// An identifier element schema, carrying `refers_to` when given. +fn identifier_items(refers_to: Option) -> Value { + let mut items = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }); + if let Some(refers_to) = refers_to { + items + .set_value("refersTo", refers_to) + .expect("refersTo applies"); + } + items +} + +/// The `reasons` declaration of the moderation charters contract with the +/// given `items`: up to 64 distinct elements. +fn reasons_with_items(items: Value) -> Value { + platform_value!({ + "type": "array", + "minItems": 0, + "maxItems": 64, + "uniqueItems": true, + "items": items, + "position": 0 + }) +} + +/// A document type with `reasons` and a string `topic` a propertyAgreement +/// can name. +fn schema_with_reasons(reasons: Value) -> Value { + platform_value!({ + "type": "object", + "properties": { + "reasons": reasons, + "topic": { "type": "string", "maxLength": 32, "position": 1 } + }, + "additionalProperties": false + }) +} + +fn reasons_type(document_type: &DocumentType) -> DocumentPropertyType { + document_type + .as_ref() + .flattened_properties() + .get("reasons") + .map(|property| property.property_type.clone()) + .expect("the reasons property is parsed") +} + +/// Every target type a single identifier property takes, `identityPublicKey` +/// aside, with the same keys, folded into the element exactly as a scalar +/// reference is folded into its property. +#[test] +fn should_parse_an_element_reference_of_each_target_type() { + let foreign_contract = Identifier::new([9; 32]); + for (refers_to, expected) in [ + ( + platform_value!({ "type": "identity" }), + DocumentPropertyReferenceTarget::Identity, + ), + ( + platform_value!({ + "type": "contract", + "contractRequirements": { "moderation": "elected", "minimumAgeSeconds": 60 } + }), + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: Some(60), + ..Default::default() + }, + }, + ), + ( + platform_value!({ "type": "token" }), + DocumentPropertyReferenceTarget::Token, + ), + ( + platform_value!({ + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { "topic": "topic", "$ownerId": "$ownerId" } + }), + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "reason".to_string(), + property_agreement: BTreeMap::from([ + ("$ownerId".to_string(), "$ownerId".to_string()), + ("topic".to_string(), "topic".to_string()), + ]), + }, + ), + ( + platform_value!({ + "type": "deletableDocument", + "contractId": foreign_contract.to_string(Encoding::Base58), + "documentType": "draft" + }), + DocumentPropertyReferenceTarget::DeletableDocument { + contract_id: Some(foreign_contract), + document_type_name: "draft".to_string(), + property_agreement: BTreeMap::new(), + }, + ), + ] { + let schema = schema_with_reasons(reasons_with_items(identifier_items(Some( + refers_to.clone(), + )))); + let expected_type = DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: Box::new(DocumentPropertyType::IdentifierWithReference( + expected.clone(), + )), + item_constraints: Default::default(), + min_items: Some(0), + max_items: 64, + unique_items: true, + }); + // The meta-schema admits it, and the stored path reads it the same + for full_validation in [true, false] { + let document_type = + parse_dispatched(schema.clone(), PlatformVersion::latest(), full_validation) + .unwrap_or_else(|error| { + panic!("{refers_to:?} (full validation {full_validation}): {error}") + }); + let parsed = reasons_type(&document_type); + assert_eq!(parsed, expected_type, "{refers_to:?}"); + assert_eq!( + parsed.reference(), + Some(PropertyReference::Elements { + target: &expected, + max_items: 64 + }), + "{refers_to:?}" + ); + } + } +} + +/// `keyIdProperty` names one sibling key id, which cannot pair with many +/// elements. +#[test] +fn should_refuse_an_identity_public_key_reference_on_an_element() { + let schema = platform_value!({ + "type": "object", + "properties": { + "reasons": reasons_with_items(identifier_items(Some(platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "keyId" + })))), + "keyId": { "type": "integer", "minimum": 0, "maximum": 4294967295u64, "position": 1 } + }, + "additionalProperties": false + }); + + let error = expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + assert!( + error.instance_path().contains("/reasons/items/refersTo"), + "the meta-schema refuses the element declaration, got {}", + error.instance_path() + ); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "identityPublicKey refersTo is not allowed on the elements of a typed array", + ); + + // Nor the form declared on the key id itself, which names whose key it is + let schema = schema_with_reasons(reasons_with_items(identifier_items(Some( + platform_value!({ "type": "identityPublicKey", "identityProperty": "$ownerId" }), + )))); + expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "identityPublicKey refersTo is not allowed on the elements of a typed array", + ); +} + +#[test] +fn should_refuse_refers_to_on_a_non_identifier_element() { + for items in [ + platform_value!({ "type": "integer", "refersTo": { "type": "identity" } }), + platform_value!({ "type": "string", "maxLength": 8, "refersTo": { "type": "identity" } }), + // A byte array that is not an identifier + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "refersTo": { "type": "identity" } + }), + ] { + let schema = schema_with_reasons(reasons_with_items(items.clone())); + let error = expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + // The identifier-only rule names the element keyword it fails on + assert!( + error.instance_path().contains("/reasons/items"), + "{items:?}: the meta-schema refuses it on the element, got {}", + error.instance_path() + ); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "refersTo is only allowed on identifier elements of a typed array", + ); + } +} + +/// The declaration applies to every element, so it goes on the items. +#[test] +fn should_refuse_refers_to_on_the_typed_array_itself() { + let mut reasons = reasons_with_items(identifier_items(None)); + reasons + .set_value("refersTo", platform_value!({ "type": "identity" })) + .expect("refersTo applies"); + let schema = schema_with_reasons(reasons); + + expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "refersTo on a typed array belongs on its items", + ); + + // An identityPublicKey declaration is refused on the elements as well, + // so the error does not send the author there + let mut reasons = reasons_with_items(identifier_items(None)); + reasons + .set_value( + "refersTo", + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "topic" }), + ) + .expect("refersTo applies"); + expect_structure_error( + parse_dispatched( + schema_with_reasons(reasons), + PlatformVersion::latest(), + false, + ), + "identityPublicKey refersTo is not allowed on a typed array or on its elements", + ); +} + +/// The rules the parse itself holds for a `propertyAgreement`, identical for +/// an element declaration: only `$ownerId` among the referring document's +/// system properties, only `$ownerId` and `$creatorId` among the referenced +/// document's. Whether a named schema property exists on either side is +/// checked at registration against the referenced contract (drive-abci). +#[test] +fn should_refuse_an_element_property_agreement_naming_an_unusable_system_property() { + for (agreement, fragment) in [ + ( + platform_value!({ "$createdAt": "topic" }), + "propertyAgreement keys must name a schema property", + ), + ( + platform_value!({ "topic": "$id" }), + "propertyAgreement values must name a schema property", + ), + ] { + let schema = schema_with_reasons(reasons_with_items(identifier_items(Some( + platform_value!({ + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": agreement.clone() + }), + )))); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + fragment, + ); + } +} + +/// Typed arrays, and so their element references, are protocol version 14 +/// grammar: meta-schema v2 refuses an array that is not a byte array. +#[test] +fn should_refuse_an_element_reference_before_protocol_version_14_and_accept_it_at_14() { + let schema = schema_with_reasons(reasons_with_items(identifier_items(Some( + platform_value!({ "type": "permanentDocument", "documentType": "reason" }), + )))); + + let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13 exists"); + expect_json_schema_error(parse_dispatched(schema.clone(), platform_version_13, true)); + + let document_type = parse_dispatched(schema, PlatformVersion::latest(), true) + .expect("protocol version 14 admits an element reference"); + assert!(matches!( + reasons_type(&document_type).reference(), + Some(PropertyReference::Elements { + target: DocumentPropertyReferenceTarget::PermanentDocument { .. }, + .. + }) + )); +} + +/// A document type whose `reasons` carries `max_items` references, next to +/// `scalar_references` identifier properties with their own `refersTo`. +fn schema_with_references(max_items: u16, scalar_references: u32) -> Value { + let mut properties = BTreeMap::from([( + "reasons".to_string(), + platform_value!({ + "type": "array", + "maxItems": max_items, + "items": identifier_items(Some(platform_value!({ "type": "identity" }))), + "position": 0 + }), + )]); + for position in 1..=scalar_references { + let mut property = identifier_items(Some(platform_value!({ "type": "identity" }))); + property + .set_value("position", Value::U32(position)) + .expect("position applies"); + properties.insert(format!("ref{position}"), property); + } + platform_value!({ + "type": "object", + "properties": Value::from(properties), + "additionalProperties": false + }) +} + +/// Each element is a billed read when a document is written, so the +/// references one document can carry are bounded at registration: a typed +/// array counts its `maxItems`, a single reference one. +#[test] +fn should_bound_the_references_one_document_can_carry() { + let platform_version = PlatformVersion::latest(); + let limit = platform_version.system_limits.max_references_per_document; + assert!( + limit < platform_version.system_limits.max_typed_array_items, + "the bound is below one maximal typed array" + ); + + parse_dispatched(schema_with_references(limit - 2, 2), platform_version, true) + .expect("a type carrying exactly the maximum registers"); + + expect_structure_error( + parse_dispatched(schema_with_references(limit - 1, 2), platform_version, true), + &format!( + "declares references for up to {} values", + u32::from(limit) + 1 + ), + ); + expect_structure_error( + parse_dispatched(schema_with_references(limit + 1, 0), platform_version, true), + &format!("above the maximum of {limit}"), + ); + + // A key reference declared on the key id itself is one read too + let mut schema = schema_with_references(limit, 0); + schema + .set_value_at_full_path( + "properties.senderKeyId", + platform_value!({ + "type": "integer", + "minimum": 0, + "maximum": 4294967295u64, + "position": 1, + "refersTo": { "type": "identityPublicKey", "identityProperty": "$ownerId" } + }), + ) + .expect("the key id property applies"); + expect_structure_error( + parse_dispatched(schema, platform_version, true), + &format!( + "declares references for up to {} values", + u32::from(limit) + 1 + ), + ); + + // A stored contract was checked when it was registered + parse_dispatched( + schema_with_references(limit + 1, 0), + platform_version, + false, + ) + .expect("the stored path does not re-apply a registration limit"); +} + +/// Every replace re-validates a `deletableDocument` element, and an +/// immutable array could not drop a dead one, so the pair is refused. +#[test] +fn should_refuse_an_immutable_typed_array_of_deletable_document_references() { + let schema_with = |refers_to: Value, immutable: Value| { + let mut schema = schema_with_reasons(reasons_with_items(identifier_items(Some(refers_to)))); + schema + .set_value("documentsMutable", Value::Bool(true)) + .expect("documentsMutable applies"); + schema + .set_value("immutable", immutable) + .expect("immutable applies"); + schema + }; + + expect_structure_error( + parse_dispatched( + schema_with( + platform_value!({ "type": "deletableDocument", "documentType": "draft" }), + platform_value!(["reasons"]), + ), + PlatformVersion::latest(), + true, + ), + "is a typed array of deletableDocument references", + ); + + // Permanent targets never die, and a mutable array can drop a dead one + parse_dispatched( + schema_with( + platform_value!({ "type": "permanentDocument", "documentType": "reason" }), + platform_value!(["reasons"]), + ), + PlatformVersion::latest(), + true, + ) + .expect("an immutable array of permanentDocument references registers"); + parse_dispatched( + schema_with( + platform_value!({ "type": "deletableDocument", "documentType": "draft" }), + platform_value!(["topic"]), + ), + PlatformVersion::latest(), + true, + ) + .expect("a mutable array of deletableDocument references registers"); + + // Inside an immutable object, a list and a single reference alike: the + // replace could only clear either by changing the object + let deletable = platform_value!({ "type": "deletableDocument", "documentType": "draft" }); + let mut single_reference = identifier_items(Some(deletable.clone())); + single_reference + .set_value("position", Value::U32(1)) + .expect("position applies"); + let mut drafts = reasons_with_items(identifier_items(Some(deletable.clone()))); + drafts + .set_value("position", Value::U32(0)) + .expect("position applies"); + for (member, member_schema, held_as) in [ + ( + "drafts", + drafts, + "a typed array of deletableDocument references", + ), + ( + "lead", + single_reference, + "a deletableDocument reference inside an object", + ), + ] { + let schema = platform_value!({ + "type": "object", + "documentsMutable": true, + "immutable": ["team"], + "properties": { + "team": { + "type": "object", + "position": 0, + "properties": { member: member_schema }, + "additionalProperties": false + } + }, + "additionalProperties": false + }); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), true), + &format!("\"team.{member}\" is {held_as}"), + ); + } + + // A single reference that is itself the immutable property can be + // cleared once its target is gone, so it registers + let mut single_reference = identifier_items(Some(deletable)); + single_reference + .set_value("position", Value::U32(0)) + .expect("position applies"); + parse_dispatched( + platform_value!({ + "type": "object", + "documentsMutable": true, + "immutable": ["draftId"], + "properties": { "draftId": single_reference }, + "additionalProperties": false + }), + PlatformVersion::latest(), + true, + ) + .expect("an immutable top-level deletableDocument reference registers"); +} + +/// A contract with `reason` (never deleted) and `submittedCharter`, whose +/// `reasons` elements refer to reasons of the same contract. +fn charter_contract(platform_version: &PlatformVersion) -> DataContract { + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + let reason = platform_value!({ + "type": "object", + "canBeDeleted": false, + "properties": { + "topic": { "type": "string", "maxLength": 32, "position": 0 } + }, + "additionalProperties": false + }); + let submitted_charter = schema_with_reasons(reasons_with_items(identifier_items(Some( + platform_value!({ + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { "topic": "topic" } + }), + )))); + + DataContract::try_from_platform_versioned( + DataContractInSerializationFormatV0 { + id: Identifier::new([7; 32]), + config, + version: 1, + owner_id: Identifier::new([8; 32]), + schema_defs: None, + document_schemas: BTreeMap::from([ + ("reason".to_string(), reason), + ("submittedCharter".to_string(), submitted_charter), + ]), + } + .into(), + true, + &mut vec![], + platform_version, + ) + .expect("the charter contract registers") +} + +#[test] +fn should_round_trip_a_contract_with_an_element_reference_through_platform_serialization() { + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + + let bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract serializes"); + for full_validation in [true, false] { + let restored = DataContract::versioned_deserialize_untrusted( + &bytes, + full_validation, + platform_version, + ) + .expect("the contract deserializes"); + + assert_eq!(restored, contract); + let charter = restored + .document_type_for_name("submittedCharter") + .expect("the charter type exists"); + let reasons = charter + .flattened_properties() + .get("reasons") + .expect("reasons is parsed"); + assert_eq!( + reasons.property_type.reference(), + Some(PropertyReference::Elements { + target: &DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "reason".to_string(), + property_agreement: BTreeMap::from([( + "topic".to_string(), + "topic".to_string() + )]), + }, + max_items: 64, + }) + ); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_test_helpers.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_test_helpers.rs new file mode 100644 index 00000000000..5b3bf7a49bf --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_test_helpers.rs @@ -0,0 +1,72 @@ +//! Test helpers the typed array suites share: a parse through the real +//! `try_from_schema` dispatcher and the two error shapes a refused parse +//! comes back as. + +use super::*; +use crate::consensus::basic::json_schema_error::JsonSchemaError; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::errors::DataContractError; + +/// Parse through the real dispatcher, which picks the parser generation out +/// of the platform version's `try_from_schema` table value (generation 2 at +/// PV13, generation 3 at PV14). +pub(super) fn parse_dispatched( + schema: Value, + platform_version: &PlatformVersion, + full_validation: bool, +) -> Result { + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "charter", + schema, + None, + &BTreeMap::new(), + &config, + full_validation, + &mut vec![], + platform_version, + ) +} + +/// The error of a validating parse the meta-schema refused. +pub(super) fn expect_json_schema_error( + result: Result, +) -> JsonSchemaError { + match result { + Err(ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::JsonSchemaError(error)) => error, + other => panic!("expected a JSON schema error, got {other:?}"), + }, + other => panic!("expected a JSON schema error, got {other:?}"), + } +} + +/// The parser's structure errors surface as `InvalidContractStructure` +/// either directly or, with the `validation` feature on, wrapped as the basic +/// `ContractError`. +pub(super) fn expect_structure_error( + result: Result, + needle: &str, +) { + let message = match result { + Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure( + message, + ))) => message, + Err(ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message), + )) => message, + other => panic!("expected InvalidContractStructure, got {other:?}"), + }, + other => panic!("expected InvalidContractStructure, got {other:?}"), + }; + assert!( + message.contains(needle), + "expected {needle:?} in the error, got: {message}" + ); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs new file mode 100644 index 00000000000..b5ee8a75e41 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs @@ -0,0 +1,1107 @@ +//! Typed arrays: a property that is `type: "array"` with an `items` element +//! schema in place of `byteArray`. +//! +//! The grammar is the v3 document meta-schema's (protocol version 14) and the +//! parse is `parse_typed_array` 0, which the tables select from protocol +//! version 14 only; earlier versions keep refusing an array that is not a +//! byte array. The array is stored inline in the document, so these tests +//! also cover the codec, the document validation and the contract +//! serialization of a type that carries one. + +use super::typed_array_test_helpers::{ + expect_json_schema_error, expect_structure_error, parse_dispatched, +}; +use super::*; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::document_type::array::{ArrayItemConstraints, TypedArrayProperty}; +use crate::data_contract::document_type::{ + ByteArrayPropertySizes, DocumentPropertyType, StringPropertySizes, +}; +use crate::data_contract::serialized_version::v0::DataContractInSerializationFormatV0; +use crate::data_contract::DataContract; +use platform_value::platform_value; + +/// A document type with one property, `list`, declared by `list_schema`. +fn schema_with_list(list_schema: Value) -> Value { + platform_value!({ + "type": "object", + "properties": { + "list": list_schema, + }, + "additionalProperties": false + }) +} + +/// The `reasons` declaration of the moderation charters contract: up to 64 +/// distinct identifiers. +fn reasons_list() -> Value { + platform_value!({ + "type": "array", + "minItems": 0, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 0 + }) +} + +fn list_property_type(document_type: &DocumentType) -> DocumentPropertyType { + document_type + .as_ref() + .flattened_properties() + .get("list") + .map(|property| property.property_type.clone()) + .expect("the list property is parsed") +} + +#[test] +fn should_parse_a_typed_identifier_array() { + let document_type = parse_dispatched( + schema_with_list(reasons_list()), + PlatformVersion::latest(), + true, + ) + .expect("a typed identifier array parses"); + + assert_eq!( + list_property_type(&document_type), + DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: Box::new(DocumentPropertyType::Identifier), + item_constraints: Default::default(), + min_items: Some(0), + max_items: 64, + unique_items: true, + }) + ); +} + +#[test] +fn should_parse_a_typed_integer_array_with_bounds() { + let platform_version = PlatformVersion::latest(); + let document_type = parse_dispatched( + schema_with_list(platform_value!({ + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { "type": "integer", "minimum": 0, "maximum": 100 }, + "position": 0 + })), + platform_version, + true, + ) + .expect("a typed integer array parses"); + + let property_type = list_property_type(&document_type); + assert_eq!( + property_type, + DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: Box::new(DocumentPropertyType::U8), + // The bounds keep the value kinds the schema literal gave them + item_constraints: ArrayItemConstraints { + allowed_values: None, + minimum: Some(Value::I32(0)), + maximum: Some(Value::I32(100)), + }, + min_items: Some(1), + max_items: 10, + unique_items: false, + }) + ); + // A one-byte element count, then 1 to 10 elements of one byte each: the + // element takes the width a scalar property bounded 0 to 100 takes + assert_eq!( + property_type + .min_byte_size(platform_version) + .expect("sized"), + Some(2) + ); + assert_eq!( + property_type + .max_byte_size(platform_version) + .expect("sized"), + Some(11) + ); +} + +#[test] +fn should_parse_every_scalar_element_type() { + for (items, expected) in [ + ( + platform_value!({ "type": "number" }), + DocumentPropertyType::F64, + ), + ( + platform_value!({ "type": "boolean" }), + DocumentPropertyType::Boolean, + ), + ( + platform_value!({ "type": "string", "minLength": 1, "maxLength": 20 }), + DocumentPropertyType::String(StringPropertySizes { + min_length: Some(1), + max_length: Some(20), + }), + ), + ( + platform_value!({ "type": "array", "byteArray": true, "minItems": 4, "maxItems": 8 }), + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(4), + max_size: Some(8), + }), + ), + ( + platform_value!({ "type": "integer", "minimum": -5, "maximum": 5 }), + DocumentPropertyType::I8, + ), + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 70000 }), + DocumentPropertyType::U32, + ), + ] { + let document_type = parse_dispatched( + schema_with_list(platform_value!({ + "type": "array", + "maxItems": 4, + "items": items.clone(), + "position": 0 + })), + PlatformVersion::latest(), + true, + ) + .unwrap_or_else(|e| panic!("{items:?} elements should parse: {e}")); + + let DocumentPropertyType::TypedArray(typed_array) = list_property_type(&document_type) + else { + panic!("{items:?} should parse to a typed array"); + }; + assert_eq!(*typed_array.item_type, expected, "{items:?}"); + } +} + +#[test] +fn should_refuse_arrays_of_objects() { + let list = platform_value!({ + "type": "array", + "maxItems": 4, + "items": { + "type": "object", + "properties": { "name": { "type": "string", "maxLength": 10, "position": 0 } }, + "additionalProperties": false + }, + "position": 0 + }); + + let error = expect_json_schema_error(parse_dispatched( + schema_with_list(list.clone()), + PlatformVersion::latest(), + true, + )); + assert!( + error.instance_path().ends_with("/list/items/type") + || error.instance_path().contains("/list/items"), + "the meta-schema should point at the element schema, got {}", + error.instance_path() + ); + + // A parse that skips the meta-schema refuses it with the same rule + expect_structure_error( + parse_dispatched(schema_with_list(list), PlatformVersion::latest(), false), + "arrays of objects are not supported", + ); +} + +#[test] +fn should_refuse_arrays_of_arrays() { + for items in [ + platform_value!({ "type": "array" }), + platform_value!({ "type": "array", "items": { "type": "integer" }, "maxItems": 2 }), + ] { + let list = platform_value!({ + "type": "array", + "maxItems": 4, + "items": items.clone(), + "position": 0 + }); + + expect_json_schema_error(parse_dispatched( + schema_with_list(list.clone()), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema_with_list(list), PlatformVersion::latest(), false), + "arrays of arrays are not supported", + ); + } +} + +#[test] +fn should_refuse_an_index_on_a_typed_array_property() { + let mut schema = schema_with_list(reasons_list()); + schema + .set_value( + "indices", + platform_value!([{ "name": "byList", "properties": [{ "list": "asc" }] }]), + ) + .expect("indices apply"); + + let result = parse_dispatched(schema, PlatformVersion::latest(), true); + + assert!( + matches!( + &result, + Err(ProtocolError::ConsensusError(boxed)) + if matches!( + **boxed, + ConsensusError::BasicError(BasicError::InvalidIndexPropertyTypeError(_)) + ) + ), + "an index on a typed array should be refused as an invalid index property type, got \ + {result:?}" + ); +} + +/// The ranked key-length check runs before the index property-type check and +/// would otherwise size the whole list as a key, so it skips a typed array: +/// the type error is the one reported. +#[test] +fn should_refuse_a_typed_array_in_an_index_with_a_ranked_axis_as_an_invalid_index_type() { + let mut schema = schema_with_list(reasons_list()); + schema + .set_value( + "indices", + platform_value!([{ + "name": "byList", + "properties": [{ "list": "asc" }], + "rangeCountable": true, + "rankedCountable": true + }]), + ) + .expect("indices apply"); + + let result = parse_dispatched(schema, PlatformVersion::latest(), true); + + assert!( + matches!( + &result, + Err(ProtocolError::ConsensusError(boxed)) + if matches!( + **boxed, + ConsensusError::BasicError(BasicError::InvalidIndexPropertyTypeError(_)) + ) + ), + "a ranked index on a typed array should be refused as an invalid index property type, \ + got {result:?}" + ); +} + +#[test] +fn should_refuse_a_typed_array_below_protocol_version_14_and_accept_it_at_14() { + let schema = schema_with_list(reasons_list()); + let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13 should exist"); + + // Meta-schema v2 has no typed arrays + expect_json_schema_error(parse_dispatched(schema.clone(), platform_version_13, true)); + // and without it the shipped parse refuses an array that is not a byte + // array, exactly as it always did + expect_structure_error( + parse_dispatched(schema.clone(), platform_version_13, false), + "only byte arrays are supported now", + ); + + parse_dispatched(schema, PlatformVersion::latest(), true) + .expect("protocol version 14 parses typed arrays"); +} + +#[test] +fn should_require_max_items_on_a_typed_array_within_the_system_limit() { + let platform_version = PlatformVersion::latest(); + let limit = platform_version.system_limits.max_typed_array_items; + + // maxItems is the shape of the declaration: required on every parse + let without_max_items = schema_with_list(platform_value!({ + "type": "array", + "items": { "type": "boolean" }, + "position": 0 + })); + let error = expect_json_schema_error(parse_dispatched( + without_max_items.clone(), + platform_version, + true, + )); + assert_eq!(error.keyword(), "required"); + expect_structure_error( + parse_dispatched(without_max_items, platform_version, false), + "a typed array must declare maxItems", + ); + + // The cap is a registration limit: a stored contract is read as declared + let over_the_limit = schema_with_list(platform_value!({ + "type": "array", + "maxItems": u64::from(limit) + 1, + "items": { "type": "boolean" }, + "position": 0 + })); + expect_structure_error( + parse_dispatched(over_the_limit.clone(), platform_version, true), + &format!("above the maximum of {limit}"), + ); + parse_dispatched(over_the_limit, platform_version, false) + .expect("the non-validating parse reads the declaration as it is"); + + let at_the_limit = schema_with_list(platform_value!({ + "type": "array", + "maxItems": limit, + "items": { "type": "boolean" }, + "position": 0 + })); + parse_dispatched(at_the_limit, platform_version, true).expect("maxItems at the limit parses"); +} + +#[test] +fn should_hold_the_shape_rules_of_a_typed_array_without_the_meta_schema() { + for (list, needle) in [ + ( + platform_value!({ + "type": "array", + "minItems": 5, + "maxItems": 4, + "items": { "type": "integer" }, + "position": 0 + }), + "minItems may not exceed its maxItems", + ), + ( + platform_value!({ + "type": "array", + "maxItems": 4, + "contentMediaType": "application/x.dash.dpp.identifier", + "items": { "type": "integer" }, + "position": 0 + }), + "contentMediaType belongs on the items", + ), + ] { + for full_validation in [true, false] { + expect_structure_error_or_json_schema_error( + parse_dispatched( + schema_with_list(list.clone()), + PlatformVersion::latest(), + full_validation, + ), + full_validation, + needle, + ); + } + } +} + +/// On the validating path the meta-schema may refuse a declaration before the +/// parser sees it; without it the parser has to refuse it itself. +fn expect_structure_error_or_json_schema_error( + result: Result, + full_validation: bool, + needle: &str, +) { + match result { + Err(ProtocolError::ConsensusError(boxed)) + if full_validation + && matches!( + *boxed, + ConsensusError::BasicError(BasicError::JsonSchemaError(_)) + ) => {} + result => expect_structure_error(result, needle), + } +} + +/// The `uniqueItems` refusal on identifiers is safe for every stored +/// contract: a census on 2026-09-23 of every data contract create and update +/// transition on mainnet (72) and testnet (4593), decoded from the raw bytes +/// with dpp (see `reference_mainnet_explorer_transition_audit` for the +/// method), found no `uniqueItems` on any property of any contract, and the +/// only `items` keywords on 22 testnet creates that were refused. Nothing a +/// node ever stored has to drop a keyword to update at protocol version 14. +#[test] +fn should_refuse_items_on_a_byte_array_and_unique_items_on_an_identifier() { + let byte_array = platform_value!({ + "type": "array", + "byteArray": true, + "maxItems": 16, + "position": 0 + }); + let identifier = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }); + + for (mut list, keyword, value) in [ + ( + byte_array.clone(), + "items", + platform_value!({ "type": "integer" }), + ), + // An identifier is one value: "no repeated byte" would refuse most + // identifiers + (identifier.clone(), "uniqueItems", Value::Bool(true)), + ] { + list.set_value(keyword, value).expect("keyword applies"); + + let error = expect_json_schema_error(parse_dispatched( + schema_with_list(list), + PlatformVersion::latest(), + true, + )); + assert!( + error.instance_path().ends_with(&format!("/list/{keyword}")), + "{keyword} should be refused, got {}", + error.instance_path() + ); + } + + // On a plain byte array uniqueItems keeps its meaning, no repeated byte + let mut list = byte_array; + list.set_value("uniqueItems", Value::Bool(true)) + .expect("keyword applies"); + parse_dispatched(schema_with_list(list), PlatformVersion::latest(), true) + .expect("uniqueItems on a plain byte array parses"); +} + +/// An element may be limited to allowed values with `enum`, but takes no +/// `const` (a list of one repeated value carries only its length, and a +/// one-value `enum` does the same while an update can still widen it) and no +/// `examples`. +#[test] +fn should_accept_enum_and_refuse_const_and_examples_on_the_elements_of_a_typed_array() { + let list_with_items = |items: Value| { + schema_with_list(platform_value!({ + "type": "array", + "maxItems": 4, + "items": items, + "position": 0 + })) + }; + + parse_dispatched( + list_with_items(platform_value!({ + "type": "string", + "maxLength": 20, + "enum": ["spam", "abuse", "offTopic"] + })), + PlatformVersion::latest(), + true, + ) + .expect("enum on an element parses"); + + for items in [ + platform_value!({ "type": "integer", "const": 1 }), + platform_value!({ "type": "integer", "examples": [1] }), + ] { + let error = expect_json_schema_error(parse_dispatched( + list_with_items(items.clone()), + PlatformVersion::latest(), + true, + )); + assert!( + error.instance_path().ends_with("/list/items"), + "{items:?} should be refused on an element, got {}", + error.instance_path() + ); + } +} + +/// A contract whose `charter` type carries a typed array of every element +/// type, `reasons` and `counts` required and the rest optional. +fn charter_contract(platform_version: &PlatformVersion) -> DataContract { + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + let charter = platform_value!({ + "type": "object", + "properties": { + "reasons": reasons_list(), + "counts": { + "type": "array", + "minItems": 1, + "maxItems": 5, + "items": { "type": "integer" }, + "position": 1 + }, + "labels": { + "type": "array", + "maxItems": 5, + "items": { "type": "string", "minLength": 1, "maxLength": 20 }, + "position": 2 + }, + "flags": { + "type": "array", + "maxItems": 2, + "uniqueItems": true, + "items": { "type": "boolean" }, + "position": 3 + }, + "digests": { + "type": "array", + "maxItems": 3, + "items": { "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32 }, + "position": 4 + }, + "weights": { + "type": "array", + "maxItems": 3, + "items": { "type": "number" }, + "position": 5 + }, + "tags": { + "type": "array", + "maxItems": 3, + "uniqueItems": true, + "items": { "type": "string", "maxLength": 20, "enum": ["spam", "abuse", "offTopic"] }, + "position": 6 + }, + "scores": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "items": { "type": "integer", "minimum": 0, "maximum": 100 }, + "position": 7 + }, + "ratios": { + "type": "array", + "maxItems": 2, + "items": { "type": "number", "minimum": 0, "maximum": 1 }, + "position": 8 + } + }, + "required": ["reasons", "counts"], + "additionalProperties": false + }); + + DataContract::try_from_platform_versioned( + DataContractInSerializationFormatV0 { + id: Identifier::new([7; 32]), + config, + version: 1, + owner_id: Identifier::new([8; 32]), + schema_defs: None, + document_schemas: BTreeMap::from([("charter".to_string(), charter)]), + } + .into(), + true, + &mut vec![], + platform_version, + ) + .expect("the charter contract registers") +} + +/// A contract whose `note` type nests typed arrays inside an object: a list +/// of identifiers and a list of byte arrays under `team`. +fn nested_lists_contract(platform_version: &PlatformVersion) -> DataContract { + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + let note = platform_value!({ + "type": "object", + "properties": { + "reasons": reasons_list(), + "team": { + "type": "object", + "position": 1, + "properties": { + "leads": { + "type": "array", + "maxItems": 4, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 0 + }, + "digests": { + "type": "array", + "maxItems": 3, + "items": { "type": "array", "byteArray": true, "minItems": 4, "maxItems": 8 }, + "position": 1 + } + }, + "additionalProperties": false + } + }, + "required": ["reasons"], + "additionalProperties": false + }); + + DataContract::try_from_platform_versioned( + DataContractInSerializationFormatV0 { + id: Identifier::new([7; 32]), + config, + version: 1, + owner_id: Identifier::new([8; 32]), + schema_defs: None, + document_schemas: BTreeMap::from([("note".to_string(), note)]), + } + .into(), + true, + &mut vec![], + platform_version, + ) + .expect("the nested lists contract registers") +} + +/// Built by hand: `platform_value!` would store the identifiers as bytes, +/// not as the identifiers the decoder reads back. +fn charter_properties() -> Value { + Value::Map(vec![ + ( + Value::Text("reasons".to_string()), + Value::Array(vec![Value::Identifier([3; 32]), Value::Identifier([4; 32])]), + ), + ( + Value::Text("counts".to_string()), + Value::Array(vec![Value::I64(-5), Value::I64(0), Value::I64(i64::MAX)]), + ), + ( + Value::Text("labels".to_string()), + Value::Array(vec![ + Value::Text("spam".to_string()), + Value::Text("abuse".to_string()), + ]), + ), + ( + Value::Text("flags".to_string()), + Value::Array(vec![Value::Bool(true)]), + ), + ( + Value::Text("digests".to_string()), + Value::Array(vec![Value::Bytes32([9; 32])]), + ), + ( + Value::Text("weights".to_string()), + Value::Array(vec![Value::Float(0.5)]), + ), + ]) +} + +#[test] +fn should_round_trip_a_contract_with_typed_arrays_through_platform_serialization() { + use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, + }; + + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + + let bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract serializes"); + let restored = DataContract::versioned_deserialize_untrusted(&bytes, true, platform_version) + .expect("the contract deserializes with full validation"); + + assert_eq!(restored, contract); + let reasons = restored + .document_type_for_name("charter") + .expect("charter type") + .flattened_properties() + .get("reasons") + .map(|property| property.property_type.clone()); + assert_eq!( + reasons, + Some(DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: Box::new(DocumentPropertyType::Identifier), + item_constraints: Default::default(), + min_items: Some(0), + max_items: 64, + unique_items: true, + })) + ); +} + +#[test] +fn should_round_trip_a_document_with_typed_arrays_through_serialization() { + use crate::document::serialization_traits::DocumentPlatformConversionMethodsV0; + use crate::document::{Document, DocumentV0, DocumentV0Getters}; + + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + let document_type = contract + .document_type_for_name("charter") + .expect("charter type"); + + let full = charter_properties() + .into_btree_string_map() + .expect("properties are a map"); + // The required arrays alone, one of them empty + let required_only = BTreeMap::from([ + ("reasons".to_string(), Value::Array(vec![])), + ("counts".to_string(), Value::Array(vec![Value::I64(7)])), + ]); + + for properties in [full, required_only] { + let document: Document = DocumentV0 { + contract_version: None, + id: Identifier::new([1; 32]), + owner_id: Identifier::new([2; 32]), + properties, + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + } + .into(); + + let bytes = document + .serialize(document_type, &contract, platform_version) + .expect("the document serializes"); + let restored = Document::from_bytes(&bytes, document_type, platform_version) + .expect("the document deserializes"); + + assert_eq!(restored.properties(), document.properties()); + } +} + +/// The identifier and byte array elements are conversion paths (`reasons[]`, +/// `digests[]`), so a document built from data carrying base58 strings and +/// plain bytes holds identifiers and bytes, as it does for scalar properties. +#[test] +fn should_convert_the_elements_of_typed_arrays_when_creating_a_document_from_data() { + use crate::data_contract::document_type::methods::DocumentTypeV0Methods; + use crate::data_contract::methods::validate_document::DataContractDocumentValidationMethodsV0; + use crate::document::DocumentV0Getters; + use platform_value::btreemap_extensions::BTreeValueMapPathHelper; + use platform_value::string_encoding::Encoding; + + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + let document_type = contract + .document_type_for_name("charter") + .expect("charter type"); + + assert!(document_type.identifier_paths().contains("reasons[]")); + assert!(document_type.binary_paths().contains("digests[]")); + + let reason = Identifier::new([5; 32]); + let data = Value::Map(vec![ + ( + Value::Text("reasons".to_string()), + Value::Array(vec![Value::Text(reason.to_string(Encoding::Base58))]), + ), + ( + Value::Text("counts".to_string()), + Value::Array(vec![Value::I64(1)]), + ), + ]); + let document = document_type + .create_document_from_data( + data, + Identifier::new([2; 32]), + 1, + 1, + [3; 32], + platform_version, + ) + .expect("the document is created"); + + assert_eq!( + document.properties().get("reasons"), + Some(&Value::Array(vec![Value::Identifier([5; 32])])) + ); + let result = contract + .validate_document("charter", &document, platform_version) + .expect("validation runs"); + assert!(result.is_valid(), "{result:?}"); + + // A list nested in an object converts through its dotted list path + let contract = nested_lists_contract(platform_version); + let document_type = contract.document_type_for_name("note").expect("note type"); + assert!(document_type.identifier_paths().contains("team.leads[]")); + assert!(document_type.binary_paths().contains("team.digests[]")); + let lead = Identifier::new([9; 32]); + let data = Value::Map(vec![ + ( + Value::Text("reasons".to_string()), + Value::Array(vec![Value::Text(reason.to_string(Encoding::Base58))]), + ), + ( + Value::Text("team".to_string()), + Value::Map(vec![ + ( + Value::Text("leads".to_string()), + Value::Array(vec![Value::Text(lead.to_string(Encoding::Base58))]), + ), + ( + Value::Text("digests".to_string()), + Value::Array(vec![Value::Bytes(vec![1, 2, 3, 4])]), + ), + ]), + ), + ]); + let document = document_type + .create_document_from_data( + data, + Identifier::new([2; 32]), + 1, + 1, + [3; 32], + platform_version, + ) + .expect("the document is created"); + assert_eq!( + document + .properties() + .get_optional_at_path("team.leads") + .expect("a nested list is reachable"), + Some(&Value::Array(vec![Value::Identifier([9; 32])])) + ); + let result = contract + .validate_document("note", &document, platform_version) + .expect("validation runs"); + assert!(result.is_valid(), "{result:?}"); +} + +#[test] +fn should_convert_the_members_of_a_typed_array_set_on_an_extended_document() { + use crate::data_contract::document_type::random_document::CreateRandomDocument; + use crate::document::extended_document::v0::ExtendedDocumentV0; + use crate::document::DocumentV0Getters; + use platform_value::string_encoding::Encoding; + + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + let document = contract + .document_type_for_name("charter") + .expect("charter type") + .random_document(Some(21), platform_version) + .expect("a random document"); + let mut extended = ExtendedDocumentV0::from_document_with_additional_info( + document, + contract, + "charter".to_string(), + None, + ); + + let reason = Identifier::new([7; 32]); + extended + .set_untrusted( + "reasons", + Value::Array(vec![Value::Text(reason.to_string(Encoding::Base58))]), + ) + .expect("a list of base58 identifiers is set"); + assert_eq!( + extended.document.properties().get("reasons"), + Some(&Value::Array(vec![Value::Identifier([7; 32])])) + ); + + extended + .set_untrusted( + "digests", + Value::Array(vec![Value::Text("AQIDBA==".to_string())]), + ) + .expect("a list of base64 byte arrays is set"); + assert_eq!( + extended.document.properties().get("digests"), + Some(&Value::Array(vec![Value::Bytes(vec![1, 2, 3, 4])])) + ); + + assert!(extended + .set_untrusted("reasons", Value::Text("not a list".to_string())) + .is_err()); +} + +#[test] +fn should_refuse_element_constraints_no_element_could_satisfy_on_both_paths() { + for (items, needle) in [ + ( + platform_value!({ "type": "integer", "enum": ["a"] }), + "must be a", + ), + ( + platform_value!({ "type": "string", "enum": [] }), + "at least one value", + ), + ( + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "enum": [[1]] + }), + "not supported on byte array or identifier", + ), + ( + platform_value!({ "type": "integer", "minimum": 5, "maximum": 4 }), + "may not exceed their maximum", + ), + ( + platform_value!({ "type": "number", "minimum": "low" }), + "minimum of a typed array's elements must be a", + ), + ] { + let list = platform_value!({ + "type": "array", + "maxItems": 4, + "items": items, + "position": 0 + }); + for full_validation in [true, false] { + expect_structure_error_or_json_schema_error( + parse_dispatched( + schema_with_list(list.clone()), + PlatformVersion::latest(), + full_validation, + ), + full_validation, + needle, + ); + } + } +} + +#[test] +fn should_refuse_a_document_whose_typed_array_breaks_its_schema() { + use crate::data_contract::methods::validate_document::DataContractDocumentValidationMethodsV0; + + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + + contract + .validate_document_properties("charter", charter_properties(), platform_version) + .map(|result| assert!(result.is_valid(), "the base document is valid: {result:?}")) + .expect("validation runs"); + + let too_many_counts: Vec = (0..6).map(Value::I64).collect(); + for (property, value, keyword) in [ + ("counts", Value::Array(too_many_counts), "maxItems"), + ("counts", Value::Array(vec![]), "minItems"), + ( + "reasons", + Value::Array(vec![Value::Identifier([3; 32]), Value::Identifier([3; 32])]), + "uniqueItems", + ), + ("counts", platform_value!([Value::I64(1), "two"]), "type"), + ( + "reasons", + platform_value!([Value::Bytes(vec![1; 31])]), + "minItems", + ), + // The element schema's own bounds + ( + "labels", + Value::Array(vec![Value::Text("x".repeat(21))]), + "maxLength", + ), + ( + "labels", + Value::Array(vec![Value::Text(String::new())]), + "minLength", + ), + ] { + let mut properties = charter_properties(); + properties + .set_value(property, value.clone()) + .expect("property applies"); + + let result = contract + .validate_document_properties("charter", properties, platform_version) + .expect("validation returns a consensus result, never an error"); + + let Some(ConsensusError::BasicError(BasicError::JsonSchemaError(error))) = + result.first_error() + else { + panic!("{property} = {value:?} should be refused by the JSON schema, got {result:?}"); + }; + assert_eq!( + error.keyword(), + keyword, + "{property} = {value:?} should break {keyword}, got {error:?}" + ); + } +} + +#[test] +fn should_generate_random_documents_that_validate_against_their_own_schema() { + use crate::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, + }; + use crate::data_contract::methods::validate_document::DataContractDocumentValidationMethodsV0; + use crate::document::serialization_traits::DocumentPlatformConversionMethodsV0; + use crate::document::{Document, DocumentV0Getters}; + use platform_value::Bytes32; + use rand::rngs::StdRng; + use rand::SeedableRng; + + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + let document_type = contract + .document_type_for_name("charter") + .expect("charter type"); + let mut rng = StdRng::seed_from_u64(14); + + let mut documents: Vec<(String, Document)> = (0..8) + .map(|seed| { + let document = document_type + .random_document(Some(seed), platform_version) + .expect("a random document"); + ("random_document".to_string(), document) + }) + .collect(); + for fill_size in [ + DocumentFieldFillSize::MinDocumentFillSize, + DocumentFieldFillSize::MaxDocumentFillSize, + DocumentFieldFillSize::AnyDocumentFillSize, + ] { + for _ in 0..8 { + let owner_id = Identifier::random_with_rng(&mut rng); + let entropy = Bytes32::random_with_rng(&mut rng); + let document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + owner_id, + entropy, + DocumentFieldFillType::FillIfNotRequired, + fill_size, + platform_version, + ) + .expect("a random document"); + documents.push((format!("{fill_size:?}"), document)); + } + } + + for (generator, document) in documents { + let result = contract + .validate_document("charter", &document, platform_version) + .expect("validation runs"); + assert!( + result.is_valid(), + "a {generator} random document should satisfy its own schema: {result:?} for {:?}", + document.properties() + ); + + let bytes = document + .serialize(document_type, &contract, platform_version) + .expect("the random document serializes"); + let restored = Document::from_bytes(&bytes, document_type, platform_version) + .expect("the random document deserializes"); + assert_eq!(restored.properties(), document.properties()); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs index dccb6e19d8b..75eb6892bb3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs @@ -106,6 +106,10 @@ impl Index { let Some(property) = flattened_properties.get(&candidate.name) else { continue; }; + // Only a scalar reference can bind: an index property is never a + // typed array, so element references never reach an index. A + // lookup reference (`PermanentDocumentLookup`) never matches + // either: its value is not the referenced document's `$id` let DocumentPropertyType::IdentifierWithReference( DocumentPropertyReferenceTarget::PermanentDocument { contract_id, @@ -163,7 +167,9 @@ impl Index { #[cfg(test)] mod tests { use super::*; - use crate::data_contract::document_type::IndexProperty; + use crate::data_contract::document_type::{ + DocumentReferenceLookup, IndexProperty, LookupKeySource, + }; use std::collections::BTreeMap; fn identifier_reference_property( @@ -186,6 +192,8 @@ mod tests { ), required: true, required_since: None, + distinct_from: None, + encrypted_for: None, transient: false, } } @@ -199,6 +207,8 @@ mod tests { }), required: true, required_since: None, + distinct_from: None, + encrypted_for: None, transient: false, } } @@ -208,6 +218,8 @@ mod tests { property_type: DocumentPropertyType::Identifier, required: true, required_since: None, + distinct_from: None, + encrypted_for: None, transient: false, } } @@ -351,4 +363,28 @@ mod tests { ); assert_eq!(index.preallocation_bindings(&own, own_contract_id).len(), 1); } + #[test] + fn should_not_bind_through_a_lookup_reference() { + let own_contract_id = Identifier::from([1u8; 32]); + let mut property = identifier_reference_property("post", None, &[]); + property.property_type = DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "post".to_string(), + property_agreement: BTreeMap::new(), + lookup: DocumentReferenceLookup { + index: "byAuthor".to_string(), + keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), + }, + }, + ); + let mut properties = IndexMap::new(); + properties.insert("postId".to_string(), property); + + // The value names the post's owner, not the post, so no path follows + // from the post being created + assert!(index_on(&["postId"]) + .preallocation_bindings(&properties, own_contract_id) + .is_empty()); + } } diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index a9cac78317a..3ceb6d8eace 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -9,13 +9,20 @@ use crate::data_contract::document_type::index_level::IndexLevel; use crate::document::Document; use crate::document::INITIAL_REVISION; use crate::prelude::{BlockHeight, CoreBlockHeight, Revision}; +use crate::validation::SimpleConsensusValidationResult; use crate::version::PlatformVersion; use crate::ProtocolError; -use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +#[cfg(feature = "validation")] +use crate::consensus::basic::document::InvalidEncryptedPropertyShapeError; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; use crate::data_contract::document_type::methods::versioned_methods::DocumentTypeV0MethodsVersioned; use crate::fee::Credits; use crate::voting::vote_polls::VotePoll; +#[cfg(feature = "validation")] +use platform_value::btreemap_extensions::BTreeValueMapPathHelper; use platform_value::{Identifier, Value}; pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { @@ -47,6 +54,85 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { || self.trade_mode().seller_sets_price() } + /// Checks the shape of every `encryptedFor` property `properties` supplies + /// against the scheme its declaration names: at least the IV plus one + /// block, and a multiple of the block length. Nothing else about a + /// ciphertext is verifiable on chain. A declared property the document + /// leaves out is not checked; whether it may be left out is the schema's + /// `required` list's business. + /// + /// Meant to run after the JSON schema validation of `properties`, which + /// already established that every supplied value is a byte array. A value + /// that still is not one is reported as a zero-length ciphertext rather + /// than skipped, so the two nodes can never disagree on it. + /// + /// Versioned on `validate_encrypted_property_shapes` in the document type + /// method versions: `None` before protocol version 14 returns an empty + /// result, which keeps the shipped structure validations that call it inert. + #[cfg(feature = "validation")] + fn validate_encrypted_property_shapes( + &self, + properties: &BTreeMap, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .validate_encrypted_property_shapes + { + None => Ok(SimpleConsensusValidationResult::default()), + Some(0) => Ok(self.validate_encrypted_property_shapes_v0(properties)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "validate_encrypted_property_shapes".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + #[cfg(feature = "validation")] + fn validate_encrypted_property_shapes_v0( + &self, + properties: &BTreeMap, + ) -> SimpleConsensusValidationResult { + let declared = self + .flattened_properties() + .iter() + .filter_map(|(path, property)| Some((path, property.encrypted_for.as_ref()?))); + for (path, encrypted_for) in declared { + let Ok(Some(value)) = properties.get_optional_at_path(path) else { + continue; + }; + let length = match value { + Value::Bytes(bytes) => bytes.len(), + Value::Bytes20(_) => 20, + Value::Bytes32(_) | Value::Identifier(_) => 32, + Value::Bytes36(_) => 36, + Value::Array(items) => items.len(), + other => other + .to_binary_bytes() + .map(|bytes| bytes.len()) + .unwrap_or(0), + }; + let scheme = encrypted_for.scheme; + if !scheme.is_valid_ciphertext_length(length) { + return SimpleConsensusValidationResult::new_with_error( + InvalidEncryptedPropertyShapeError::new( + path.clone(), + scheme.as_str().to_string(), + u32::try_from(length).unwrap_or(u32::MAX), + scheme.minimum_ciphertext_length() as u32, + scheme.block_length() as u32, + ) + .into(), + ); + } + } + SimpleConsensusValidationResult::new() + } + fn top_level_indices(&self) -> Vec<&IndexProperty> { self.indexes() .values() @@ -463,6 +549,43 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe } } + /// Judges the `distinctFrom` declarations of the document type against a document's + /// `data` and the id of the identity writing it: an identifier property whose value + /// equals the named property of the same document, or the writer's `$ownerId`, fails + /// with `DocumentPropertyNotDistinctError` (10419). A declaration whose named property + /// is absent from `data` passes. Reads the transition alone, so it runs in the structure + /// stage of document create and replace, and of transfer and purchase against the + /// stored document and its new owner. + /// + /// `None` in the version table (protocol versions before 14) selects the behavior of + /// the versions that predate the keyword: nothing is checked, as no parsed property + /// carries a declaration there. + fn validate_distinct_from_properties( + &self, + data: &BTreeMap, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result + where + Self: DocumentTypeV2Getters, + { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .validate_distinct_from + { + None => Ok(SimpleConsensusValidationResult::default()), + Some(0) => Ok(self.validate_distinct_from_properties_v0(data, owner_id)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "validate_distinct_from_properties".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + fn sanitize_document_properties(&self, properties: &mut BTreeMap) { // Iterate through each property in the document for (field_name, field_value) in properties.iter_mut() { diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs index 8bbcf432ae1..8aebc1c788a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -1723,6 +1723,7 @@ mod tests { mod validate_schema { use super::*; use crate::consensus::basic::BasicError; + use crate::data_contract::document_type::accessors::DocumentTypeV0MutGetters; use std::collections::BTreeMap; #[test] @@ -2060,6 +2061,585 @@ mod tests { ); } } + + #[test] + fn should_return_invalid_result_when_an_identity_key_reference_requirement_changes() { + let platform_version = PlatformVersion::latest(); + + for (old_fields, new_fields, changed_path) in [ + ( + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex" }), + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "purpose": "decryption" } }), + "/properties/toUserId/refersTo/keyRequirements", + ), + ( + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "purpose": "decryption" } }), + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex" }), + "/properties/toUserId/refersTo/keyRequirements", + ), + ( + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "purpose": "decryption" } }), + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "purpose": "encryption" } }), + "/properties/toUserId/refersTo/keyRequirements/purpose", + ), + ( + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "boundTo": "test" } }), + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "boundTo": "other" } }), + "/properties/toUserId/refersTo/keyRequirements/boundTo", + ), + ( + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "purpose": "decryption" } }), + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "purpose": "decryption", "boundTo": "test" } }), + "/properties/toUserId/refersTo/keyRequirements/boundTo", + ), + ] { + let old_document_type = + identifier_document_type(Some(old_fields), platform_version); + let new_document_type = + identifier_document_type(Some(new_fields), platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == changed_path + ); + } + } + + /// `reasons`, a typed array of identifiers, with `refersTo` on its items as given, + /// next to a `topic` an agreement can name. + fn element_reference_document_type( + refers_to: Option, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut items = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }); + if let Some(refers_to) = refers_to { + items + .insert("refersTo".to_string(), refers_to) + .expect("should insert refersTo"); + } + + let schema = platform_value!({ + "type": "object", + "properties": { + "reasons": { + "type": "array", + "maxItems": 64, + "items": items, + "position": 0 + }, + "topic": { "type": "string", "maxLength": 32, "position": 1 } + }, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "submittedCharter", + schema, + None, + &BTreeMap::new(), + &config, + true, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + /// An element reference is frozen like a scalar one: stored documents were + /// checked against the declaration they were written under, so adding, + /// removing or changing it is an incompatible schema change. + #[test] + fn should_refuse_a_contract_update_that_changes_an_element_refers_to() { + let platform_version = PlatformVersion::latest(); + let permanent = + platform_value!({ "type": "permanentDocument", "documentType": "reason" }); + + for (old_refers_to, new_refers_to, changed_path) in [ + ( + None, + Some(permanent.clone()), + "/properties/reasons/items/refersTo", + ), + ( + Some(permanent.clone()), + None, + "/properties/reasons/items/refersTo", + ), + ( + Some(permanent.clone()), + Some( + platform_value!({ "type": "deletableDocument", "documentType": "reason" }), + ), + "/properties/reasons/items/refersTo/type", + ), + ( + Some(permanent.clone()), + Some(platform_value!({ "type": "permanentDocument", "documentType": "rule" })), + "/properties/reasons/items/refersTo/documentType", + ), + ( + Some(permanent.clone()), + Some(platform_value!({ + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { "topic": "topic" } + })), + "/properties/reasons/items/refersTo/propertyAgreement", + ), + ] { + let old_document_type = + element_reference_document_type(old_refers_to.clone(), platform_version); + let new_document_type = + element_reference_document_type(new_refers_to.clone(), platform_version); + + let result = old_document_type + .as_ref() + .validate_update(new_document_type.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == changed_path, + "{old_refers_to:?} -> {new_refers_to:?}: {:?}", + result.errors + ); + } + + // An unchanged declaration is no change + let document_type = element_reference_document_type(Some(permanent), platform_version); + let result = document_type + .as_ref() + .validate_update(document_type.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + assert!(result.is_valid(), "{:?}", result.errors); + } + + #[test] + fn should_return_invalid_result_when_a_document_reference_lookup_changes() { + let platform_version = PlatformVersion::latest(); + let owner_lookup = |index: &str| { + platform_value!({ + "type": "permanentDocument", + "documentType": "note", + "lookup": { "index": index, "keys": { "$ownerId": "." } } + }) + }; + + for (old_refers_to, new_refers_to, changed_path) in [ + ( + platform_value!({ "type": "permanentDocument", "documentType": "note" }), + owner_lookup("byOwner"), + "/properties/toUserId/refersTo/lookup", + ), + ( + owner_lookup("byOwner"), + platform_value!({ "type": "permanentDocument", "documentType": "note" }), + "/properties/toUserId/refersTo/lookup", + ), + ( + owner_lookup("byOwner"), + owner_lookup("byAuthor"), + "/properties/toUserId/refersTo/lookup/index", + ), + ] { + let old_document_type = + identifier_document_type(Some(old_refers_to), platform_version); + let new_document_type = + identifier_document_type(Some(new_refers_to), platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == changed_path + ); + } + } + + /// `toUserId` and `delegateId`, two identifier properties, with `distinctFrom` on + /// `delegateId` as given. + fn distinct_from_document_type( + distinct_from: Option<&str>, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut delegate_id = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1 + }); + if let Some(distinct_from) = distinct_from { + delegate_id + .insert("distinctFrom".to_string(), distinct_from.into()) + .expect("should insert distinctFrom"); + } + + let schema = platform_value!({ + "type": "object", + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "delegateId": delegate_id + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + #[test] + fn should_return_invalid_result_when_distinct_from_is_added_changed_or_removed() { + let platform_version = PlatformVersion::latest(); + + for (old_distinct_from, new_distinct_from) in [ + (None, Some("$ownerId")), + (Some("$ownerId"), Some("toUserId")), + (Some("toUserId"), None), + ] { + let old_document_type = + distinct_from_document_type(old_distinct_from, platform_version); + let new_document_type = + distinct_from_document_type(new_distinct_from, platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == "/properties/delegateId/distinctFrom", + "{old_distinct_from:?} -> {new_distinct_from:?}" + ); + } + } + + #[test] + fn should_return_valid_result_when_distinct_from_is_unchanged() { + let platform_version = PlatformVersion::latest(); + + let old_document_type = distinct_from_document_type(Some("$ownerId"), platform_version); + let new_document_type = distinct_from_document_type(Some("$ownerId"), platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert!(result.is_valid(), "{:?}", result.errors); + } + + /// A `message` type whose `senderKeyId` is a `u32` key id, carrying + /// `refers_to` when given. + fn key_id_document_type( + refers_to: Option, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut sender_key_id = platform_value!({ + "type": "integer", + "minimum": 0, + "maximum": 4294967295u64, + "position": 0 + }); + + if let Some(refers_to) = refers_to { + sender_key_id + .insert("refersTo".to_string(), refers_to) + .expect("should insert refersTo"); + } + + let schema = platform_value!({ + "type": "object", + "properties": { + "senderKeyId": sender_key_id + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "message", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + /// The key id form of `identityPublicKey` is a `refersTo` like any + /// other: adding it, removing it or changing what it names is an + /// incompatible change, so a contract can neither start nor stop + /// checking a stored key id against the owner's keys. + #[test] + fn should_return_invalid_result_when_a_key_id_reference_is_added_removed_or_changed() { + let platform_version = PlatformVersion::latest(); + let owner_key = platform_value!({ + "type": "identityPublicKey", + "identityProperty": "$ownerId" + }); + + for (old_refers_to, new_refers_to, operation, changed_path) in [ + ( + None, + Some(owner_key.clone()), + "add", + "/properties/senderKeyId/refersTo", + ), + ( + Some(owner_key.clone()), + None, + "remove", + "/properties/senderKeyId/refersTo", + ), + // `identityProperty` admits one value today; what a change + // would look like is the reference losing it for the other + // spelling of the same reference type, which is refused + // before the parser is even asked whether it can be parsed + ( + Some(owner_key.clone()), + Some(platform_value!({ + "type": "identityPublicKey", + "identityProperty": "$creatorId" + })), + "replace", + "/properties/senderKeyId/refersTo/identityProperty", + ), + ] { + let old_document_type = key_id_document_type(old_refers_to, platform_version); + let new_document_type = if new_refers_to + .as_ref() + .is_some_and(|value| value.to_string().contains("creatorId")) + { + // Not parseable at all: compare the schemas directly + // through a type built without the reference, then swap + // the schema in + let mut document_type = key_id_document_type(None, platform_version); + document_type + .schema_mut() + .get_mut("properties") + .expect("the schema should be a map") + .expect("properties should exist") + .get_mut("senderKeyId") + .expect("properties should be a map") + .expect("senderKeyId should exist") + .insert( + "refersTo".to_string(), + new_refers_to.clone().expect("a refersTo value"), + ) + .expect("should insert refersTo"); + document_type + } else { + key_id_document_type(new_refers_to, platform_version) + }; + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.operation() == operation && e.property_path() == changed_path, + "{operation} at {changed_path}: {:?}", result.errors + ); + } + } + } + + mod encrypted_for { + use super::*; + use crate::consensus::basic::BasicError; + use crate::data_contract::config::DataContractConfig; + use platform_value::platform_value; + use std::collections::BTreeMap; + + /// A document type with a recipient, two key ids and an + /// `encryptedMessage` carrying `encrypted_for` when given. + fn encrypted_document_type( + encrypted_for: Option, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut encrypted_message = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 1040, + "position": 3 + }); + if let Some(encrypted_for) = encrypted_for { + encrypted_message + .insert("encryptedFor".to_string(), encrypted_for) + .expect("should insert encryptedFor"); + } + + let schema = platform_value!({ + "type": "object", + "properties": { + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "recipientKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 1 }, + "senderKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 2 }, + "encryptedMessage": encrypted_message + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + fn declaration(recipient: &str) -> platform_value::Value { + platform_value!({ + "recipient": recipient, + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }) + } + + /// Documents written under one recipe could not be read under another, + /// so adding, removing or changing the declaration is incompatible. + #[test] + fn should_return_invalid_result_when_encrypted_for_is_added_removed_or_changed() { + let platform_version = PlatformVersion::latest(); + + for (old_declaration, new_declaration, changed_path) in [ + ( + None, + Some(declaration("recipientId")), + "/properties/encryptedMessage/encryptedFor", + ), + ( + Some(declaration("recipientId")), + None, + "/properties/encryptedMessage/encryptedFor", + ), + ( + Some(declaration("recipientId")), + Some(declaration("$ownerId")), + "/properties/encryptedMessage/encryptedFor/recipient", + ), + ] { + let old_document_type = encrypted_document_type(old_declaration, platform_version); + let new_document_type = encrypted_document_type(new_declaration, platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == changed_path + ); + } + } + + #[test] + fn should_return_valid_result_when_encrypted_for_is_unchanged() { + let platform_version = PlatformVersion::latest(); + + let old_document_type = + encrypted_document_type(Some(declaration("recipientId")), platform_version); + let new_document_type = + encrypted_document_type(Some(declaration("recipientId")), platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert!(result.is_valid(), "{:?}", result.errors); + } } mod validate_byte_array_encoding { diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs index 99c0c95ee2d..6e716d86c25 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -27,7 +27,7 @@ use crate::consensus::state::data_contract::document_type_update_error::Document use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; -use crate::data_contract::document_type::DocumentTypeRef; +use crate::data_contract::document_type::{DocumentPropertyType, DocumentTypeRef}; use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; use platform_version::version::PlatformVersion; @@ -80,6 +80,20 @@ impl DocumentTypeRef<'_> { return Ok(result); } + // Validate that no integer property changes its width or signedness + let result = self.validate_integer_encoding_stability(new_document_type); + + if !result.is_valid() { + return Ok(result); + } + + // Validate that no typed array changes how its elements are encoded + let result = self.validate_typed_array_element_encoding_stability(new_document_type); + + if !result.is_valid() { + return Ok(result); + } + // Validate required-field changes (the schema compatibility differ // has the top-level `required` key stripped, so this is the only // place top-level requiredness changes are judged) @@ -109,6 +123,110 @@ impl DocumentTypeRef<'_> { self.validate_schema_with_options(new_document_type, platform_version, &options) } + /// An integer property is stored at the width and signedness of its type, + /// in the document and in every index key on it, and the type comes from + /// the property's `minimum` and `maximum`, or from its `enum` values when + /// it has no bounds (with `sizedIntegerTypes` on; off, every integer is an + /// i64). The schema compatibility rules allow each change that moves it: + /// raising `maximum`, lowering `minimum`, removing either, adding `enum` + /// values, and so does turning `sizedIntegerTypes` on. Documents already + /// stored then no longer decode, or decode to other values, and their + /// index entries sit under keys of the old width. So the type is held + /// here, as `validate_byte_array_encoding_stability` holds a byte array + /// property's; a bound change that keeps the type is still allowed. The + /// signedness is held with the width, even where the old bounds keep every + /// stored value readable both ways (a u8 capped at 100 read as an i8): + /// nothing bounds a u64 that becomes an i64, and one comparison is the + /// whole rule. A change to a non-integer type is the schema compatibility + /// check's to refuse. + fn validate_integer_encoding_stability( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + let new_properties = new_document_type.flattened_properties(); + + for (path, old_property) in self.flattened_properties() { + if !old_property.property_type.is_integer() { + continue; + } + let Some(new_property) = new_properties.get(path) else { + continue; + }; + if !new_property.property_type.is_integer() { + continue; + } + + let old_encoding = old_property.property_type.stored_encoding(); + let new_encoding = new_property.property_type.stored_encoding(); + if old_encoding != new_encoding { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change the integer encoding of property \ + '{}': its values are stored as {} and would be read as {}", + path, old_encoding, new_encoding, + ), + ) + .into(), + ); + } + } + + SimpleConsensusValidationResult::new() + } + + /// A typed array stores each element exactly as a required scalar property + /// of its element type is stored, so an update that changes how an + /// element encodes would misread every element already stored: an + /// integer element whose width changes (its bounds or `enum` choose it), + /// or a byte array element that turns from fixed-size (raw) to variable + /// (length-prefixed) or to another fixed size. The schema compatibility + /// rules allow the changes that do this (raising `maximum`, widening + /// `maxItems`), so the element encoding is held here, as + /// `validate_byte_array_encoding_stability` holds a byte array + /// property's. Every other element change, a longer `maxLength` included, + /// leaves the encoding as it is. + fn validate_typed_array_element_encoding_stability( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + let new_properties = new_document_type.flattened_properties(); + + for (path, old_property) in self.flattened_properties() { + let DocumentPropertyType::TypedArray(old_array) = &old_property.property_type else { + continue; + }; + let Some(new_property) = new_properties.get(path) else { + continue; + }; + let DocumentPropertyType::TypedArray(new_array) = &new_property.property_type else { + continue; + }; + + let old_encoding = old_array.item_type.stored_encoding(); + let new_encoding = new_array.item_type.stored_encoding(); + if old_encoding != new_encoding { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change the element encoding of typed array \ + property '{}': its elements are stored as {} and would be read as \ + {}", + path, old_encoding, new_encoding, + ), + ) + .into(), + ); + } + } + + SimpleConsensusValidationResult::new() + } + /// The action fees of a document type are fixed when it is published: an /// update may not add, change or remove them, nor switch their pricing. /// A document type added by the update is not judged here and may declare @@ -1473,4 +1591,457 @@ mod tests { ); } } + + // ================================================================ + // Typed array element encoding + // ================================================================ + + mod typed_array_element_encoding { + use super::*; + + /// A document type whose one property is a typed array with the + /// given `items` and `maxItems`. + fn doc_type_with_list( + items: Value, + max_items: u16, + platform_version: &PlatformVersion, + ) -> DocumentType { + let schema = platform_value!({ + "type": "object", + "properties": { + "list": { + "type": "array", + "maxItems": max_items, + "items": items, + "position": 0 + }, + }, + "additionalProperties": false, + }); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + true, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + /// The schema compatibility rules allow each of these changes, but + /// each changes how an element is written, so the elements already + /// stored would be misread. + #[test] + fn should_reject_an_update_that_changes_how_typed_array_elements_are_encoded() { + let platform_version = PlatformVersion::latest(); + + for (old_items, new_items, old_encoding, new_encoding) in [ + // Raising the maximum across a width boundary widens the + // element from one byte to two + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 100 }), + platform_value!({ "type": "integer", "minimum": 0, "maximum": 1000 }), + "u8", + "u16", + ), + // So does adding an enum value past what a byte holds + ( + platform_value!({ "type": "integer", "enum": [1, 2, 3] }), + platform_value!({ "type": "integer", "enum": [1, 2, 3, 300] }), + "u8", + "u16", + ), + // A byte array element whose size stops being pinned gains a + // length prefix + ( + platform_value!({ + "type": "array", "byteArray": true, "minItems": 20, "maxItems": 20 + }), + platform_value!({ + "type": "array", "byteArray": true, "minItems": 20, "maxItems": 32 + }), + "a fixed 20-byte array", + "a length-prefixed byte array", + ), + ] { + let old = doc_type_with_list(old_items.clone(), 8, platform_version); + let new = doc_type_with_list(new_items.clone(), 8, platform_version); + + let result = old + .as_ref() + .validate_update(new.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + + let expected = format!( + "document type can not change the element encoding of typed array property \ + 'list': its elements are stored as {old_encoding} and would be read as \ + {new_encoding}" + ); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] + if e.additional_message() == expected, + "{old_items:?} -> {new_items:?}: {:?}", + result.errors + ); + } + } + + /// Longer strings, more elements, and a raised maximum that stays + /// within the element's width leave every stored element readable. + #[test] + fn should_accept_an_update_that_keeps_how_typed_array_elements_are_encoded() { + let platform_version = PlatformVersion::latest(); + + for (old_items, old_max_items, new_items, new_max_items) in [ + ( + platform_value!({ "type": "string", "maxLength": 20 }), + 8, + platform_value!({ "type": "string", "maxLength": 40 }), + 8, + ), + ( + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }), + 8, + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }), + 64, + ), + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 100 }), + 8, + platform_value!({ "type": "integer", "minimum": 0, "maximum": 200 }), + 8, + ), + ] { + let old = doc_type_with_list(old_items.clone(), old_max_items, platform_version); + let new = doc_type_with_list(new_items.clone(), new_max_items, platform_version); + + let result = old + .as_ref() + .validate_update(new.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + + assert!( + result.is_valid(), + "{old_items:?} ({old_max_items}) -> {new_items:?} ({new_max_items}): {:?}", + result.errors + ); + } + } + } + + // ================================================================ + // Integer encoding (width and signedness) + // ================================================================ + + mod integer_encoding_update { + use super::*; + use crate::data_contract::config::v1::DataContractConfigSettersV1; + use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; + use crate::data_contract::document_type::DocumentPropertyType; + use crate::validation::SimpleConsensusValidationResult; + use std::io::BufReader; + + fn doc_type_with( + properties: Value, + sized_integer_types: bool, + platform_version: &PlatformVersion, + ) -> DocumentType { + let schema = platform_value!({ + "type": "object", + "properties": properties, + "additionalProperties": false, + }); + let mut config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + config.set_sized_integer_types_enabled(sized_integer_types); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + /// A document type whose one property, `score`, has the given schema. + fn doc_type_with_score(score: Value, platform_version: &PlatformVersion) -> DocumentType { + doc_type_with(platform_value!({ "score": score }), true, platform_version) + } + + fn validate_update( + old: &DocumentType, + new: &DocumentType, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + old.as_ref() + .validate_update(new.as_ref(), 2, platform_version) + .expect("validate_update should not error") + } + + fn assert_rejected( + result: SimpleConsensusValidationResult, + path: &str, + old: &str, + new: &str, + ) { + let expected = + format!("'{path}': its values are stored as {old} and would be read as {new}"); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] + if e.additional_message().contains(&expected), + "expected the integer encoding change {old} -> {new} of '{path}' to be refused" + ); + } + + #[test] + fn should_not_read_a_stored_u8_back_as_the_u16_a_raised_maximum_gives() { + // Why the type is held: the bounds choose it, and a value stored + // at the old width does not read back at the new one. + let platform_version = PlatformVersion::latest(); + let old = doc_type_with_score( + platform_value!({"type": "integer", "minimum": 0, "maximum": 100, "position": 0}), + platform_version, + ); + let new = doc_type_with_score( + platform_value!({"type": "integer", "minimum": 0, "maximum": 1000, "position": 0}), + platform_version, + ); + let old_type = &old.flattened_properties()["score"].property_type; + let new_type = &new.flattened_properties()["score"].property_type; + assert_eq!(old_type, &DocumentPropertyType::U8); + assert_eq!(new_type, &DocumentPropertyType::U16); + + let stored = old_type + .encode_value_ref_with_size(&Value::U8(7), true) + .expect("should encode a u8"); + assert_eq!(stored, vec![7]); + + let read_back = + new_type.read_optionally_from(&mut BufReader::new(stored.as_slice()), true); + assert!( + read_back.is_err(), + "a stored u8 must not read back as a u16, got {read_back:?}" + ); + } + + #[test] + fn should_reject_raising_maximum_past_the_width_of_the_type() { + let platform_version = PlatformVersion::latest(); + let old = doc_type_with_score( + platform_value!({"type": "integer", "minimum": 0, "maximum": 100, "position": 0}), + platform_version, + ); + let new = doc_type_with_score( + platform_value!({"type": "integer", "minimum": 0, "maximum": 1000, "position": 0}), + platform_version, + ); + + assert_rejected( + validate_update(&old, &new, platform_version), + "score", + "u8", + "u16", + ); + } + + #[test] + fn should_reject_lowering_minimum_below_zero() { + // The same width with the other signedness: a stored u64 above + // i64::MAX would read back negative + let platform_version = PlatformVersion::latest(); + let old = doc_type_with_score( + platform_value!({"type": "integer", "minimum": 0, "position": 0}), + platform_version, + ); + let new = doc_type_with_score( + platform_value!({"type": "integer", "minimum": -1, "position": 0}), + platform_version, + ); + + assert_rejected( + validate_update(&old, &new, platform_version), + "score", + "u64", + "i64", + ); + } + + #[test] + fn should_reject_removing_the_bounds() { + let platform_version = PlatformVersion::latest(); + let old = doc_type_with_score( + platform_value!({"type": "integer", "minimum": 0, "maximum": 100, "position": 0}), + platform_version, + ); + let new = doc_type_with_score( + platform_value!({"type": "integer", "position": 0}), + platform_version, + ); + + assert_rejected( + validate_update(&old, &new, platform_version), + "score", + "u8", + "i64", + ); + } + + #[test] + fn should_reject_adding_an_enum_value_past_the_width_of_the_type() { + let platform_version = PlatformVersion::latest(); + let old = doc_type_with_score( + platform_value!({"type": "integer", "enum": [1, 2, 3], "position": 0}), + platform_version, + ); + let new = doc_type_with_score( + platform_value!({"type": "integer", "enum": [1, 2, 3, 300], "position": 0}), + platform_version, + ); + + assert_rejected( + validate_update(&old, &new, platform_version), + "score", + "u8", + "u16", + ); + } + + #[test] + fn should_reject_a_width_change_of_a_nested_integer_property() { + let platform_version = PlatformVersion::latest(); + let stats = |maximum: u32| { + platform_value!({ + "stats": { + "type": "object", + "position": 0, + "properties": { + "level": {"type": "integer", "minimum": 0, "maximum": maximum, "position": 0}, + }, + "additionalProperties": false, + }, + }) + }; + let old = doc_type_with(stats(100), true, platform_version); + let new = doc_type_with(stats(1000), true, platform_version); + + assert_rejected( + validate_update(&old, &new, platform_version), + "stats.level", + "u8", + "u16", + ); + } + + #[test] + fn should_reject_turning_sized_integer_types_on() { + // Off, every integer is an i64; on, the bounds make this one a u8. + // The contract config check only refuses turning them off. + let platform_version = PlatformVersion::latest(); + let properties = platform_value!({ + "score": {"type": "integer", "minimum": 0, "maximum": 100, "position": 0}, + }); + let old = doc_type_with(properties.clone(), false, platform_version); + let new = doc_type_with(properties, true, platform_version); + + assert_rejected( + validate_update(&old, &new, platform_version), + "score", + "i64", + "u8", + ); + } + + #[test] + fn should_accept_a_bound_change_that_keeps_the_type() { + let platform_version = PlatformVersion::latest(); + let old = doc_type_with_score( + platform_value!({"type": "integer", "minimum": 0, "maximum": 100, "position": 0}), + platform_version, + ); + let new = doc_type_with_score( + platform_value!({"type": "integer", "minimum": 0, "maximum": 200, "position": 0}), + platform_version, + ); + + let result = validate_update(&old, &new, platform_version); + assert!( + result.is_valid(), + "a u8 that stays a u8 must be accepted, got {:?}", + result.errors + ); + } + + #[test] + fn should_accept_raising_maximum_without_sized_integer_types() { + // Without sized integer types every integer is an i64 whatever + // its bounds, so raising one changes nothing stored + let platform_version = PlatformVersion::latest(); + let score = |maximum: u32| { + platform_value!({ + "score": {"type": "integer", "minimum": 0, "maximum": maximum, "position": 0}, + }) + }; + let old = doc_type_with(score(100), false, platform_version); + let new = doc_type_with(score(1000), false, platform_version); + + let result = validate_update(&old, &new, platform_version); + assert!( + result.is_valid(), + "an i64 that stays an i64 must be accepted, got {:?}", + result.errors + ); + } + + #[test] + fn should_still_accept_a_width_change_at_protocol_version_13() { + // validate_update v0 is frozen for replay of protocol versions up + // to 13, which let the width move + let platform_version = + PlatformVersion::get(13).expect("protocol version 13 must exist"); + let old = doc_type_with_score( + platform_value!({"type": "integer", "minimum": 0, "maximum": 100, "position": 0}), + platform_version, + ); + let new = doc_type_with_score( + platform_value!({"type": "integer", "minimum": 0, "maximum": 1000, "position": 0}), + platform_version, + ); + + let result = validate_update(&old, &new, platform_version); + assert!( + result.is_valid(), + "protocol version 13 must keep accepting the width change, got {:?}", + result.errors + ); + } + } } diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs index c1b95d40cdc..bf5194d7b64 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs @@ -1,4 +1,6 @@ -use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; @@ -17,6 +19,7 @@ use crate::document::{Document, DocumentV0, DocumentV0Getters, INITIAL_REVISION} use crate::fee::Credits; use crate::identity::TimestampMillis; use crate::prelude::{BlockHeight, CoreBlockHeight}; +use crate::validation::SimpleConsensusValidationResult; use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; use crate::voting::vote_polls::VotePoll; use crate::ProtocolError; @@ -774,6 +777,50 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa } } } + + /// `validate_distinct_from_properties` version 0: every property of the document type + /// that declares `distinctFrom` and has a value in `data` is compared with what it + /// must differ from, the document's `owner_id` or the named property, and the first + /// equal pair is reported. The declaring properties are read from the list the parser + /// built, so a type without declarations costs nothing. Each value is judged by + /// `DistinctFrom::violation`, so an array item can be judged by the same rule with + /// the item's value. + fn validate_distinct_from_properties_v0( + &self, + data: &BTreeMap, + owner_id: Identifier, + ) -> SimpleConsensusValidationResult + where + Self: DocumentTypeV2Getters, + { + for path in self.distinct_from_fields() { + let Some(property) = self.flattened_properties().get(path) else { + continue; + }; + let Some(distinct_from) = property.distinct_from.as_ref() else { + continue; + }; + // A lookup error (an intermediate that is not an object) is refused by the + // schema validation that precedes this check, so it reads as absent here. + let Ok(Some(value)) = data.get_optional_at_path(path) else { + continue; + }; + // A typed array declares on its items: every element is judged on its own + let values: &[Value] = match (&property.property_type, value) { + (DocumentPropertyType::TypedArray(_), Value::Array(elements)) => elements, + (DocumentPropertyType::TypedArray(_), _) => continue, + _ => std::slice::from_ref(value), + }; + for value in values { + if let Some(error) = + distinct_from.violation(self.name(), path, value, data, owner_id) + { + return SimpleConsensusValidationResult::new_with_error(error.into()); + } + } + } + SimpleConsensusValidationResult::default() + } } impl DocumentTypeV0MethodsVersioned for DocumentTypeV0 {} diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 3e39ec42e9c..ed17c917031 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -96,6 +96,8 @@ pub(crate) mod property_names { pub const MAXIMUM: &str = "maximum"; pub const MIN_ITEMS: &str = "minItems"; pub const MAX_ITEMS: &str = "maxItems"; + pub const ITEMS: &str = "items"; + pub const UNIQUE_ITEMS: &str = "uniqueItems"; pub const MIN_LENGTH: &str = "minLength"; pub const MAX_LENGTH: &str = "maxLength"; pub const BYTE_ARRAY: &str = "byteArray"; @@ -103,10 +105,23 @@ pub(crate) mod property_names { pub const ENCRYPTION_KEY_REQUIREMENTS: &str = "encryptionKeyReqs"; pub const DECRYPTION_KEY_REQUIREMENTS: &str = "decryptionKeyReqs"; pub const REFERS_TO: &str = "refersTo"; + pub const DISTINCT_FROM: &str = "distinctFrom"; pub const CONTRACT_ID: &str = "contractId"; pub const DOCUMENT_TYPE: &str = "documentType"; pub const KEY_ID_PROPERTY: &str = "keyIdProperty"; + /// `identityPublicKey` reference on the key id property itself: whose key + /// the value names (`"$ownerId"`, the writer). Takes the place of + /// [`KEY_ID_PROPERTY`]; a declaration carries one or the other. + pub const IDENTITY_PROPERTY: &str = "identityProperty"; pub const PROPERTY_AGREEMENT: &str = "propertyAgreement"; + /// `refersTo` on a document reference: the unique index of the referenced + /// document type the referenced document is found through, and the key. + /// Meta-schema v3+ (protocol version 14). + pub const LOOKUP: &str = "lookup"; + /// `lookup`: the name of the referenced document type's unique index. + pub const LOOKUP_INDEX: &str = "index"; + /// `lookup`: every index property mapped to its referring-side source. + pub const LOOKUP_KEYS: &str = "keys"; pub const CONTRACT_REQUIREMENTS: &str = "contractRequirements"; pub const MODERATION: &str = "moderation"; pub const MINIMUM_AGE_SECONDS: &str = "minimumAgeSeconds"; @@ -115,6 +130,23 @@ pub(crate) mod property_names { pub const READONLY: &str = "readonly"; pub const KEEPS_HISTORY: &str = "keepsHistory"; pub const OWNER_PROTECTED: &str = "ownerProtected"; + /// Property-level object on a byte array declaring how its ciphertext was + /// produced: the [`RECIPIENT`], the [`RECIPIENT_KEY`] and [`SENDER_KEY`] + /// properties carrying the key ids, and the [`SCHEME`]. Meta-schema v3+ + /// (protocol version 14). See `apply_encrypted_for` in `try_from_schema`. + pub const ENCRYPTED_FOR: &str = "encryptedFor"; + /// `encryptedFor`: the identifier property naming the recipient identity, + /// or `$ownerId` for the writer's own. + pub const RECIPIENT: &str = "recipient"; + /// `encryptedFor`: the integer property carrying the recipient's key id. + pub const RECIPIENT_KEY: &str = "recipientKey"; + /// `encryptedFor`: the integer property carrying the sender's key id. + pub const SENDER_KEY: &str = "senderKey"; + /// `encryptedFor`: the scheme name, one of `EncryptionScheme::ALL`. + pub const SCHEME: &str = "scheme"; + pub const KEY_REQUIREMENTS: &str = "keyRequirements"; + pub const PURPOSE: &str = "purpose"; + pub const BOUND_TO: &str = "boundTo"; pub const DOCUMENTS_COUNTABLE: &str = "documentsCountable"; pub const RANGE_COUNTABLE: &str = "rangeCountable"; /// Doctype-level flag naming the property whose values are summed into diff --git a/packages/rs-dpp/src/data_contract/document_type/property/array.rs b/packages/rs-dpp/src/data_contract/document_type/property/array.rs index ee26594c276..8e40b10d86a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/array.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/array.rs @@ -1,8 +1,14 @@ +use crate::data_contract::document_type::property::DocumentPropertyType; use crate::data_contract::errors::DataContractError; use crate::ProtocolError; -use integer_encoding::VarInt; +use byteorder::{BigEndian, ReadBytesExt}; +use integer_encoding::{VarInt, VarIntReader}; use platform_value::Value; +use platform_version::version::PlatformVersion; +use rand::rngs::StdRng; +use rand::Rng; use serde::{Deserialize, Serialize}; +use std::io::BufReader; #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] #[serde(into = "ArrayItemTypeRepr", from = "ArrayItemTypeRepr")] @@ -16,12 +22,427 @@ pub enum ArrayItemType { Date, } +/// A typed array property: `type: "array"` with an `items` schema naming the +/// type of every element, parsed from protocol version 14 +/// (`parse_typed_array` 0). +/// +/// It is stored inline in the document like any other property: a varint +/// element count followed by the elements, each encoded exactly as a required +/// scalar property of `item_type` is. So an identifier element is 32 raw +/// bytes, a byte array element whose bounds pin one size is raw, an integer +/// element takes the width its schema's bounds give it, and a string or a +/// variable-size byte array element carries a varint length. Nothing is +/// indexed per element, so a typed array cannot be an index property. +#[derive(Debug, PartialEq, Clone, Serialize)] +pub struct TypedArrayProperty { + /// The scalar type of every element, parsed from `items` exactly as a + /// scalar property schema is: an integer, a number, a string, a boolean, + /// a byte array or an identifier, never an object or an array. + pub item_type: Box, + /// What the `items` schema bounds beyond the type: read by random + /// document generation, enforced on every document by the JSON schema + /// validator. + pub item_constraints: ArrayItemConstraints, + /// `minItems`: the fewest elements a document may hold, never above + /// `max_items`. + pub min_items: Option, + /// `maxItems`: the most elements a document may hold. Every parse + /// requires it; full validation also caps it at + /// `SystemLimits::max_typed_array_items`. + pub max_items: u16, + /// `uniqueItems`: whether a document is refused for repeating an element. + pub unique_items: bool, +} + +/// The bounds an `items` schema declares beyond its element type. The JSON +/// schema validator enforces them on every document; they are parsed so +/// random document generation stays inside them (an `exclusiveMinimum`, +/// `exclusiveMaximum`, `multipleOf`, `pattern` or `format` on an element is +/// not read here, exactly as it is not for a scalar property). +#[derive(Debug, PartialEq, Clone, Default, Serialize)] +pub struct ArrayItemConstraints { + /// `enum`: the values every element must be one of, in declared order. + /// Every member is of the element type; a byte array or identifier + /// element takes none. + pub allowed_values: Option>, + /// `minimum` of an integer or number element, inclusive. + pub minimum: Option, + /// `maximum` of an integer or number element, inclusive, never below + /// `minimum`. + pub maximum: Option, +} + +/// Which size a random element is drawn at. +#[derive(Clone, Copy)] +enum RandomFill { + /// Any size the bounds allow. + Any, + /// The smallest value the bounds allow. + Smallest, + /// The largest value the bounds allow. + Largest, +} + +/// The range an integer element type can hold, for drawing a random element +/// when its schema bounds only one side. +fn integer_kind_range(kind: &DocumentPropertyType) -> Option<(i128, i128)> { + Some(match kind { + DocumentPropertyType::U8 => (0, u8::MAX as i128), + DocumentPropertyType::I8 => (i8::MIN as i128, i8::MAX as i128), + DocumentPropertyType::U16 => (0, u16::MAX as i128), + DocumentPropertyType::I16 => (i16::MIN as i128, i16::MAX as i128), + DocumentPropertyType::U32 => (0, u32::MAX as i128), + DocumentPropertyType::I32 => (i32::MIN as i128, i32::MAX as i128), + DocumentPropertyType::U64 => (0, u64::MAX as i128), + DocumentPropertyType::I64 => (i64::MIN as i128, i64::MAX as i128), + DocumentPropertyType::U128 => (0, i128::MAX), + DocumentPropertyType::I128 => (i128::MIN, i128::MAX), + _ => return None, + }) +} + +/// An integer in the value kind an element type reads back as, so a random +/// document round-trips through the codec unchanged. `n` is within the +/// kind's range, which the caller clamps it to. +fn integer_value_of_kind(kind: &DocumentPropertyType, n: i128) -> Value { + match kind { + DocumentPropertyType::U8 => Value::U8(n as u8), + DocumentPropertyType::I8 => Value::I8(n as i8), + DocumentPropertyType::U16 => Value::U16(n as u16), + DocumentPropertyType::I16 => Value::I16(n as i16), + DocumentPropertyType::U32 => Value::U32(n as u32), + DocumentPropertyType::I32 => Value::I32(n as i32), + DocumentPropertyType::U64 => Value::U64(n as u64), + DocumentPropertyType::U128 => Value::U128(n as u128), + DocumentPropertyType::I128 => Value::I128(n), + _ => Value::I64(n as i64), + } +} + +impl TypedArrayProperty { + /// Whether an element's encoding is a varint length followed by the bytes: + /// a string, and a byte array whose bounds do not pin one size. Every + /// other scalar has a fixed width. + fn element_is_length_prefixed(&self) -> bool { + match self.item_type.as_ref() { + DocumentPropertyType::String(_) => true, + DocumentPropertyType::ByteArray(sizes) => { + !(sizes.min_size.is_some() && sizes.min_size == sizes.max_size) + } + _ => false, + } + } + + /// The width of an element written raw from bytes: 32 for an identifier, + /// the size of a byte array whose bounds pin one. The scalar encoder + /// writes those bytes as given, so the list checks their length: a short + /// element would shift every element after it. + fn element_raw_width(&self) -> Option { + match self.item_type.as_ref() { + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { + Some(32) + } + DocumentPropertyType::ByteArray(sizes) + if sizes.min_size.is_some() && sizes.min_size == sizes.max_size => + { + sizes.min_size.map(usize::from) + } + _ => None, + } + } + + /// An element's encoded size for its scalar byte bound: the bound, plus + /// the varint length in front of a length-prefixed element. + fn element_encoded_size(&self, element_bytes: u16) -> u64 { + let prefix = if self.element_is_length_prefixed() { + usize::from(element_bytes).required_space() as u64 + } else { + 0 + }; + u64::from(element_bytes).saturating_add(prefix) + } + + /// The fewest bytes the array encodes to: the varint count of `minItems` + /// elements and that many of the smallest element, sized as its scalar + /// type is sized, saturating at `u16::MAX`. + pub fn min_encoded_size( + &self, + platform_version: &PlatformVersion, + ) -> Result { + let min_items = self.min_items.unwrap_or(0); + let element_bytes = self.item_type.min_byte_size(platform_version)?.unwrap_or(0); + let size = (min_items.required_space() as u64).saturating_add( + u64::from(min_items).saturating_mul(self.element_encoded_size(element_bytes)), + ); + Ok(u16::try_from(size).unwrap_or(u16::MAX)) + } + + /// The most bytes the array encodes to: the varint count of `maxItems` + /// elements and that many of the largest element, sized as its scalar + /// type is sized, saturating at `u16::MAX`, the size an unbounded string + /// or byte array reports. Also `u16::MAX` when the element is unbounded. + pub fn max_encoded_size( + &self, + platform_version: &PlatformVersion, + ) -> Result { + let element_bytes = match self.item_type.max_byte_size(platform_version)? { + Some(element_bytes) if element_bytes < u16::MAX => element_bytes, + _ => return Ok(u16::MAX), + }; + let size = (self.max_items.required_space() as u64).saturating_add( + u64::from(self.max_items).saturating_mul(self.element_encoded_size(element_bytes)), + ); + Ok(u16::try_from(size).unwrap_or(u16::MAX)) + } + + /// Encodes a list: the varint element count, then each element exactly as + /// a required scalar property of `item_type` is encoded. + pub(super) fn encode_value_ref(&self, value: &Value) -> Result, ProtocolError> { + let Value::Array(elements) = value else { + return Err(DataContractError::ValueWrongType(format!( + "a typed array value must be a list, got {value}" + )) + .into()); + }; + let mut bytes = elements.len().encode_var_vec(); + for element in elements { + // A null encodes to no bytes at all, which would drop the element + if element.is_null() { + return Err(DataContractError::ValueWrongType( + "a typed array element can not be null".to_string(), + ) + .into()); + } + let element_bytes = self.item_type.encode_value_ref_with_size(element, true)?; + if let Some(width) = self.element_raw_width() { + if element_bytes.len() != width { + return Err(DataContractError::ValueWrongType(format!( + "a typed array element must be {width} bytes, got {}", + element_bytes.len() + )) + .into()); + } + } + bytes.extend(element_bytes); + } + Ok(bytes) + } + + /// Reads a list, the mirror of [`Self::encode_value_ref`]: a varint + /// element count, then each element as a required scalar property of + /// `item_type` is read. The count comes from the serialized document, so + /// a count above `maxItems` is refused before anything is read: that + /// bounds the loop even for elements of zero width (a byte array pinned + /// to zero bytes). + pub(super) fn read_from(&self, buf: &mut BufReader<&[u8]>) -> Result { + let count: usize = buf.read_varint().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading varint of typed array element count".to_string(), + ) + })?; + if count > usize::from(self.max_items) { + return Err(DataContractError::CorruptedSerialization(format!( + "a serialized typed array claims {count} elements, more than its maxItems of {}", + self.max_items + ))); + } + let mut elements = Vec::new(); + for _ in 0..count { + let (element, _) = self.item_type.read_optionally_from(buf, true)?; + let Some(element) = element else { + return Err(DataContractError::CorruptedSerialization( + "a typed array element read back as absent".to_string(), + )); + }; + elements.push(element); + } + Ok(Value::Array(elements)) + } + + /// How many elements a random value holds: between `minItems` and + /// `maxItems`. + fn random_items_range(&self) -> (usize, usize) { + let max_items = usize::from(self.max_items); + let min_items = usize::from(self.min_items.unwrap_or(0)).min(max_items); + (min_items, max_items) + } + + /// A random value holding between `minItems` and `maxItems` random + /// elements. + pub(super) fn random_value(&self, rng: &mut StdRng) -> Value { + let (min_items, max_items) = self.random_items_range(); + let count = rng.gen_range(min_items..=max_items); + self.random_items(count, rng, RandomFill::Any) + } + + /// A random value holding `minItems` elements, each of its smallest size. + pub(super) fn random_sub_filled_value(&self, rng: &mut StdRng) -> Value { + let (min_items, _) = self.random_items_range(); + self.random_items(min_items, rng, RandomFill::Smallest) + } + + /// A random value holding `maxItems` elements, each of its largest size. + pub(super) fn random_filled_value(&self, rng: &mut StdRng) -> Value { + let (_, max_items) = self.random_items_range(); + self.random_items(max_items, rng, RandomFill::Largest) + } + + /// `count` elements drawn at `fill`, each in the value kind it reads + /// back as. Under `uniqueItems` a repeat is drawn again, a bounded number + /// of times, so an element type with fewer distinct values than `count` + /// (a boolean, a short `enum`) yields fewer elements rather than looping + /// forever. + fn random_items(&self, count: usize, rng: &mut StdRng, fill: RandomFill) -> Value { + let fixed_size_bytes = matches!( + self.item_type.as_ref(), + DocumentPropertyType::ByteArray(sizes) + if sizes.min_size.is_some() && sizes.min_size == sizes.max_size + ); + let mut items: Vec = Vec::with_capacity(count); + let mut draws_left = count.saturating_mul(8).saturating_add(16); + while items.len() < count && draws_left > 0 { + draws_left -= 1; + let item = match self.random_item(rng, fill) { + Value::Bytes(bytes) if fixed_size_bytes => fixed_size_bytes_value(bytes), + item => item, + }; + if self.unique_items && items.contains(&item) { + continue; + } + items.push(item); + } + Value::Array(items) + } + + /// One element within the item constraints: a member of the `enum` when + /// there is one (the shortest, the longest or any), a number within + /// `minimum` / `maximum`, and otherwise the element type's own random + /// value at `fill`. Integer and number members come back in the element + /// type's own value kind, which is what the codec reads back. + fn random_item(&self, rng: &mut StdRng, fill: RandomFill) -> Value { + if let Some(allowed_values) = &self.item_constraints.allowed_values { + let encoded_len = |value: &Value| { + self.item_type + .encode_value_ref_with_size(value, true) + .map(|bytes| bytes.len()) + .unwrap_or(0) + }; + let member = match fill { + RandomFill::Any => { + allowed_values.get(rng.gen_range(0..allowed_values.len().max(1))) + } + RandomFill::Smallest => { + allowed_values.iter().min_by_key(|value| encoded_len(value)) + } + RandomFill::Largest => allowed_values.iter().max_by_key(|value| encoded_len(value)), + }; + if let Some(member) = member { + return self.in_element_kind(member); + } + } + if let Some(bounded) = self.random_bounded_number(rng, fill) { + return bounded; + } + match fill { + RandomFill::Any => self.item_type.random_value(rng), + RandomFill::Smallest => self.item_type.random_sub_filled_value(rng), + RandomFill::Largest => self.item_type.random_filled_value(rng), + } + } + + /// A constraint value in the element type's own value kind. + fn in_element_kind(&self, value: &Value) -> Value { + if let Some((min, max)) = integer_kind_range(&self.item_type) { + if let Ok(n) = value.to_integer::() { + return integer_value_of_kind(&self.item_type, n.clamp(min, max)); + } + } + if matches!(self.item_type.as_ref(), DocumentPropertyType::F64) { + if let Ok(f) = value.to_float() { + return Value::Float(f); + } + } + value.clone() + } + + /// A random integer or number element within the declared `minimum` / + /// `maximum`, or `None` when the element declares neither or is not a + /// number. A bound the parser could not read as the element's type is + /// ignored, so generation never panics on a stored contract. + fn random_bounded_number(&self, rng: &mut StdRng, fill: RandomFill) -> Option { + let constraints = &self.item_constraints; + if constraints.minimum.is_none() && constraints.maximum.is_none() { + return None; + } + if let Some((kind_min, kind_max)) = integer_kind_range(&self.item_type) { + let min = constraints + .minimum + .as_ref() + .and_then(|value| value.to_integer::().ok()) + .unwrap_or(kind_min) + .clamp(kind_min, kind_max); + let max = constraints + .maximum + .as_ref() + .and_then(|value| value.to_integer::().ok()) + .unwrap_or(kind_max) + .clamp(kind_min, kind_max) + .max(min); + let n = match fill { + RandomFill::Any => rng.gen_range(min..=max), + RandomFill::Smallest => min, + RandomFill::Largest => max, + }; + return Some(integer_value_of_kind(&self.item_type, n)); + } + if matches!(self.item_type.as_ref(), DocumentPropertyType::F64) { + let min = constraints + .minimum + .as_ref() + .and_then(|value| value.to_float().ok()) + .filter(|value| value.is_finite()) + .unwrap_or(-1.0e9); + let max = constraints + .maximum + .as_ref() + .and_then(|value| value.to_float().ok()) + .filter(|value| value.is_finite()) + .unwrap_or(1.0e9) + .max(min); + return Some(Value::Float(match fill { + RandomFill::Any => rng.gen_range(min..=max), + RandomFill::Smallest => min, + RandomFill::Largest => max, + })); + } + None + } +} + +/// The value a fixed-size byte array reads back as: 20, 32 or 36 bytes as +/// `Bytes20`, `Bytes32` or `Bytes36`, any other size as `Bytes`. +fn fixed_size_bytes_value(bytes: Vec) -> Value { + let bytes = match <[u8; 20]>::try_from(bytes) { + Ok(bytes) => return Value::Bytes20(bytes), + Err(bytes) => bytes, + }; + let bytes = match <[u8; 32]>::try_from(bytes) { + Ok(bytes) => return Value::Bytes32(bytes), + Err(bytes) => bytes, + }; + match <[u8; 36]>::try_from(bytes) { + Ok(bytes) => Value::Bytes36(bytes), + Err(bytes) => Value::Bytes(bytes), + } +} + // Internal-`$type` serde shape. Mixed unit + 2-tuple variants, so a // struct-variant Repr (serde can't auto-internal-tag tuple variants). Unit // variants -> `{"$type":"integer"}`; the tuple variants get named size bounds // (`#[serde(default)]` so an omitted bound deserializes as `None`). Serde-only -// type (no bincode); its on-wire form is exercised solely by these tests — -// document-schema parsing goes through `TryFrom<&Value>`, not serde. +// type (no bincode); its on-wire form is exercised solely by these tests. No +// document schema parses into an `ArrayItemType`: a typed array's elements are +// `DocumentPropertyType`s. #[derive(Serialize, Deserialize)] #[serde(tag = "$type", rename_all = "camelCase")] enum ArrayItemTypeRepr { @@ -316,6 +737,71 @@ impl ArrayItemType { } } } + + /// Reads one element of the never-produced [`DocumentPropertyType::Array`], + /// mirroring [`Self::encode_value_ref_with_size`]. Every element takes at + /// least one byte, so a reader looping over a claimed element count stops + /// when the serialized document runs out. + pub(super) fn read_from(&self, buf: &mut BufReader<&[u8]>) -> Result { + match self { + ArrayItemType::String(_, _) => { + let bytes = DocumentPropertyType::read_varint_value(buf)?; + String::from_utf8(bytes).map(Value::Text).map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading string array element from serialized document".to_string(), + ) + }) + } + ArrayItemType::Integer => buf.read_i64::().map(Value::I64).map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading integer array element from serialized document".to_string(), + ) + }), + ArrayItemType::Number | ArrayItemType::Date => { + buf.read_f64::().map(Value::Float).map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading number array element from serialized document".to_string(), + ) + }) + } + ArrayItemType::ByteArray(_, _) => { + let bytes = DocumentPropertyType::read_varint_value(buf)?; + Ok(self.byte_array_value(bytes)) + } + ArrayItemType::Identifier => { + let bytes = DocumentPropertyType::read_varint_value(buf)?; + <[u8; 32]>::try_from(bytes) + .map(Value::Identifier) + .map_err(|bytes| { + DataContractError::CorruptedSerialization(format!( + "identifier array element must be 32 bytes, found {}", + bytes.len() + )) + }) + } + ArrayItemType::Boolean => match buf.read_u8() { + Ok(0) => Ok(Value::Bool(false)), + Ok(1) => Ok(Value::Bool(true)), + _ => Err(DataContractError::CorruptedSerialization( + "error reading boolean array element from serialized document".to_string(), + )), + }, + } + } + + /// The value a byte array element reads back as: a fixed-size element + /// of 20, 32 or 36 bytes as `Bytes20`, `Bytes32` or `Bytes36`, the kinds a + /// fixed-size scalar byte array reads back as, and `Bytes` otherwise. + fn byte_array_value(&self, bytes: Vec) -> Value { + match self { + ArrayItemType::ByteArray(min_size, max_size) + if min_size.is_some() && min_size == max_size => + { + fixed_size_bytes_value(bytes) + } + _ => Value::Bytes(bytes), + } + } } fn get_field_type_matching_error() -> ProtocolError { diff --git a/packages/rs-dpp/src/data_contract/document_type/property/encrypted_for.rs b/packages/rs-dpp/src/data_contract/document_type/property/encrypted_for.rs new file mode 100644 index 00000000000..71fbf9cc395 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/encrypted_for.rs @@ -0,0 +1,230 @@ +//! The `encryptedFor` declaration of a byte array property: how its ciphertext +//! was produced, so that a wallet or SDK reads the recipe from the contract +//! instead of a side channel. +//! +//! Consensus can only check the shape of the bytes (see +//! [`EncryptionScheme::is_valid_ciphertext_length`]); who can decrypt them, +//! and whether they decrypt at all, is not verifiable on chain. + +use crate::document::property_names::OWNER_ID; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The `encryptedFor` declaration of a byte array property. +/// +/// Declared as +/// `"encryptedFor": { "recipient": "...", "recipientKey": "...", "senderKey": "...", "scheme": "..." }` +/// on a `byteArray` property that is not an identifier. The three paths name +/// properties of the same document type: the recipient an identifier property +/// (or the writer's own `$ownerId`), the two keys integer properties bounded +/// to `u32` that carry identity key ids. The parser checks all three exist +/// with those types when the contract is registered. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EncryptedFor { + /// Whose keys decrypt the bytes. + pub recipient: EncryptedForRecipient, + /// Path of the integer property carrying the id of the recipient's key. + pub recipient_key: String, + /// Path of the integer property carrying the id of the sender's key. + pub sender_key: String, + /// How the bytes were produced. + pub scheme: EncryptionScheme, +} + +/// The recipient of an [`EncryptedFor`] declaration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EncryptedForRecipient { + /// The document's owner: a message the writer encrypts to themself. + Owner, + /// The identifier property, of the same document type, whose value is the + /// recipient identity's id. A dotted path when the property is nested. + Property(String), +} + +impl EncryptedForRecipient { + /// The recipient a schema path names: `$ownerId` is the owner, anything + /// else a property path. + pub fn from_path(path: &str) -> Self { + if path == OWNER_ID { + EncryptedForRecipient::Owner + } else { + EncryptedForRecipient::Property(path.to_string()) + } + } + + /// The path as the schema spells it: `$ownerId` for the owner. + pub fn as_path(&self) -> &str { + match self { + EncryptedForRecipient::Owner => OWNER_ID, + EncryptedForRecipient::Property(path) => path.as_str(), + } + } + + /// The property path when the recipient is a property, `None` for the owner. + pub fn property_path(&self) -> Option<&str> { + match self { + EncryptedForRecipient::Owner => None, + EncryptedForRecipient::Property(path) => Some(path.as_str()), + } + } +} + +impl Serialize for EncryptedForRecipient { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_path()) + } +} + +impl<'de> Deserialize<'de> for EncryptedForRecipient { + fn deserialize>(deserializer: D) -> Result { + let path = String::deserialize(deserializer)?; + Ok(EncryptedForRecipient::from_path(&path)) + } +} + +impl fmt::Display for EncryptedForRecipient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_path()) + } +} + +/// The scheme under which an [`EncryptedFor`] property's bytes were produced. +/// +/// Consensus knows the shape each scheme produces and nothing more. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EncryptionScheme { + /// The scheme the dashpay contact request uses for `encryptedPublicKey` + /// (DIP-15): the shared key is the libsecp256k1 ECDH of the sender's + /// private key and the recipient's public key, `SHA256((y & 1 | 2) || x)` + /// of the product point; the bytes are a random 16-byte IV followed by + /// the plaintext under AES-256-CBC with PKCS7 padding and that IV. So a + /// ciphertext is at least 32 bytes and always a multiple of 16. + #[serde(rename = "ecdh-secp256k1-aes256-cbc")] + EcdhSecp256k1Aes256Cbc, +} + +impl EncryptionScheme { + /// Every scheme, in wire-name order. + pub const ALL: [EncryptionScheme; 1] = [EncryptionScheme::EcdhSecp256k1Aes256Cbc]; + + /// The wire name, the value of `encryptedFor.scheme`. + pub const fn as_str(&self) -> &'static str { + match self { + EncryptionScheme::EcdhSecp256k1Aes256Cbc => "ecdh-secp256k1-aes256-cbc", + } + } + + /// The scheme a wire name names, `None` for any other name. + pub fn from_wire_name(name: &str) -> Option { + Self::ALL.into_iter().find(|scheme| scheme.as_str() == name) + } + + /// The cipher's block length in bytes; a ciphertext is a multiple of it. + pub const fn block_length(&self) -> usize { + match self { + EncryptionScheme::EcdhSecp256k1Aes256Cbc => 16, + } + } + + /// The shortest ciphertext the scheme can produce: the IV prefix plus one + /// padded block. + pub const fn minimum_ciphertext_length(&self) -> usize { + match self { + EncryptionScheme::EcdhSecp256k1Aes256Cbc => 16 + 16, + } + } + + /// Whether `length` bytes have the shape the scheme produces. This is all + /// consensus can tell about a ciphertext. + pub fn is_valid_ciphertext_length(&self, length: usize) -> bool { + length >= self.minimum_ciphertext_length() && length.is_multiple_of(self.block_length()) + } +} + +impl fmt::Display for EncryptionScheme { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn should_round_trip_a_declaration_through_json_with_the_schema_spelling() { + let declaration = EncryptedFor { + recipient: EncryptedForRecipient::Property("recipientId".to_string()), + recipient_key: "recipientKeyId".to_string(), + sender_key: "senderKeyId".to_string(), + scheme: EncryptionScheme::EcdhSecp256k1Aes256Cbc, + }; + let json = serde_json::to_value(&declaration).expect("should serialize"); + assert_eq!( + json, + json!({ + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }) + ); + let back: EncryptedFor = serde_json::from_value(json).expect("should deserialize"); + assert_eq!(back, declaration); + } + + #[test] + fn should_spell_the_owner_recipient_as_owner_id() { + let owner = EncryptedForRecipient::from_path("$ownerId"); + assert_eq!(owner, EncryptedForRecipient::Owner); + assert_eq!(owner.as_path(), "$ownerId"); + assert_eq!(owner.property_path(), None); + assert_eq!( + serde_json::to_value(&owner).expect("should serialize"), + json!("$ownerId") + ); + let property = EncryptedForRecipient::from_path("recipientId"); + assert_eq!(property.property_path(), Some("recipientId")); + } + + #[test] + fn should_refuse_an_unknown_scheme_and_name_every_known_one() { + assert_eq!(EncryptionScheme::from_wire_name("rsa"), None); + for scheme in EncryptionScheme::ALL { + assert_eq!( + EncryptionScheme::from_wire_name(scheme.as_str()), + Some(scheme) + ); + assert_eq!( + serde_json::to_value(scheme).expect("should serialize"), + json!(scheme.as_str()) + ); + } + } + + #[test] + fn should_accept_only_an_iv_plus_whole_blocks_for_aes_cbc() { + let scheme = EncryptionScheme::EcdhSecp256k1Aes256Cbc; + assert_eq!(scheme.minimum_ciphertext_length(), 32); + assert_eq!(scheme.block_length(), 16); + for (length, valid) in [ + (0, false), + (16, false), + (31, false), + (32, true), + (47, false), + (48, true), + (96, true), + (1040, true), + (1041, false), + ] { + assert_eq!( + scheme.is_valid_ciphertext_length(length), + valid, + "{length} bytes" + ); + } + } +} 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 d71648c21e2..3477d4cfe66 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -10,6 +10,7 @@ use platform_serialization_derive::{ }; use crate::consensus::basic::decode::DecodingError; +use crate::consensus::basic::document::DocumentPropertyNotDistinctError; use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::accessors::v1::DataContractV1Getters; use crate::data_contract::config::v0::DataContractConfigGettersV0; @@ -19,14 +20,17 @@ use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::property_names; use crate::data_contract::DataContract; use crate::document::property_names::{CREATOR_ID, OWNER_ID}; +use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use crate::identity::identity_public_key::contract_bounds::ContractBounds; +use crate::identity::{IdentityPublicKey, Purpose}; use crate::prelude::TimestampMillis; use crate::ProtocolError; -use array::ArrayItemType; +use array::{ArrayItemType, TypedArrayProperty}; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use indexmap::IndexMap; use integer_encoding::{VarInt, VarIntReader}; use itertools::Itertools; -use platform_value::btreemap_extensions::BTreeValueMapHelper; +use platform_value::btreemap_extensions::{BTreeValueMapHelper, BTreeValueMapPathHelper}; use platform_value::{Identifier, Value}; use platform_version::version::PlatformVersion; use rand::distributions::{Alphanumeric, Standard}; @@ -35,6 +39,11 @@ use rand::Rng; use serde::{Deserialize, Serialize}; pub mod array; +pub mod encrypted_for; +pub mod reference_lookup; + +pub use encrypted_for::{EncryptedFor, EncryptedForRecipient, EncryptionScheme}; +pub use reference_lookup::{DocumentReferenceLookup, LookupKeySource}; #[cfg(test)] mod byte_array_encoding_flip_tests; @@ -52,6 +61,119 @@ pub struct DocumentProperty { /// for optional properties. Only ever `Some` when `required` is true. #[serde(skip_serializing_if = "Option::is_none")] pub required_since: Option, + /// What this identifier property's value must differ from (`distinctFrom`): + /// the document's `$ownerId` or another identifier property of the same + /// document type. `None` for every property that declares nothing, which + /// is every property parsed before protocol version 14. + #[serde(skip_serializing_if = "Option::is_none")] + pub distinct_from: Option, + /// How the property's bytes were encrypted (`encryptedFor`): the recipient, + /// the key ids and the scheme. Only ever `Some` on a byte array property, + /// and only on contracts parsed from protocol version 14 on. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted_for: Option, +} + +/// What a `distinctFrom` identifier property must differ from. +/// +/// Declared as `"distinctFrom": "$ownerId"` or `"distinctFrom": ""` +/// on an identifier property, or on the `items` of a typed array of identifiers, where it +/// binds every element (meta-schema v3, protocol version 14). A pure structure rule: +/// consensus compares the property's value with the named one when the document is created +/// or replaced, and refuses an equal pair with `DocumentPropertyNotDistinctError` (10419). +/// When the named property is absent from the document there is nothing to differ from, +/// so the rule passes. A transfer to, or a purchase by, the identity an `$ownerId` +/// declaration names is refused the same way, judged against the stored document. The +/// target is checked at contract registration and update: it must +/// be `$ownerId` or an existing identifier property of the same document type other than +/// the declaring one. +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +#[serde(into = "String")] +pub enum DistinctFrom { + /// The document's `$ownerId`, which the write transition carries. + OwnerId, + /// The dotted path of another identifier property of the same document type. + Property(String), +} + +impl DistinctFrom { + /// The declaration a wire name spells: `$ownerId` or a property path. Any other + /// `$`-prefixed name is refused, since no other system property is an identifier the + /// rule could compare against. + pub fn from_wire_name(name: &str) -> Result { + if name == OWNER_ID { + return Ok(DistinctFrom::OwnerId); + } + if name.starts_with('$') { + return Err(DataContractError::InvalidContractStructure(format!( + "distinctFrom must name \"{OWNER_ID}\" or a property of the same document type, \ + not system property \"{name}\"" + ))); + } + if name.is_empty() || name.len() > 256 { + return Err(DataContractError::InvalidContractStructure( + "distinctFrom property paths must be between 1 and 256 characters".to_string(), + )); + } + Ok(DistinctFrom::Property(name.to_string())) + } + + /// The wire name, as the schema spells it. + pub fn as_str(&self) -> &str { + match self { + DistinctFrom::OwnerId => OWNER_ID, + DistinctFrom::Property(path) => path.as_str(), + } + } + + /// The collision this declaration finds for one value: the error to refuse the write + /// with when `value`, the declaring property's own value, equals what it must differ + /// from, and `None` when the two differ or when the named property is absent from + /// `data` (there is nothing to differ from). `value` is passed on its own rather than + /// read from `data` so that an array item can be judged by the same rule with the + /// item's value; `path` is the declaring property's dotted path, for the error. + /// + /// A value on either side that is not a 32-byte identifier cannot collide: the schema + /// validation that precedes this check refuses such a document on its own. + pub fn violation( + &self, + document_type_name: &str, + path: &str, + value: &Value, + data: &BTreeMap, + owner_id: Identifier, + ) -> Option { + let Ok(value) = value.to_identifier() else { + return None; + }; + let other = match self { + DistinctFrom::OwnerId => owner_id, + DistinctFrom::Property(target) => { + // A lookup error (an intermediate that is not an object) is the same as + // absence here: the schema forbids the shape, so nothing to compare against. + let Ok(Some(other)) = data.get_optional_at_path(target) else { + return None; + }; + let Ok(other) = other.to_identifier() else { + return None; + }; + other + } + }; + (value == other).then(|| { + DocumentPropertyNotDistinctError::new( + document_type_name.to_string(), + path.to_string(), + self.as_str().to_string(), + ) + }) + } +} + +impl From for String { + fn from(distinct_from: DistinctFrom) -> Self { + distinct_from.as_str().to_string() + } } impl DocumentProperty { @@ -420,6 +542,166 @@ impl ContractReferenceRequirements { } } +/// What an `identityPublicKey` reference requires of the key it points at, beyond its existence +/// and its not being disabled. +/// +/// Declared as `refersTo: { "type": "identityPublicKey", "keyIdProperty": ..., "keyRequirements": +/// { ... } }`: each key names an aspect of the referenced key and its value the requirement on it. +/// Consensus checks the requirements when the referring document is written, against the key it +/// has already fetched for the existence check, so a requirement costs no further read. An unmet +/// one refuses the write with `ReferencedIdentityKeyRequirementNotMetError` (40136). Keys are +/// added to this object as new requirements arrive (a security level, say); a requirement is +/// never a new reference type. +#[derive( + Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize, Encode, Decode, DecodeUntrusted, +)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct IdentityKeyReferenceRequirements { + /// The purpose the referenced key must have, spelled by its wire name (`"decryption"`). + /// Any purpose but `SYSTEM`, which no identity key of a user carries. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "purpose_wire_name" + )] + pub purpose: Option, + /// The document type of the declaring contract the referenced key must be bound to: its + /// contract bounds must be `SingleContractDocumentType` naming the declaring contract and + /// exactly this type. A whole-contract bound or a contract group bound never meets it, even + /// where the group holds the type: the check reads nothing beyond the key. Validated when + /// the contract is registered to name a document type of the declaring contract that a key + /// of the required purpose can be bound to, so the check never needs a second contract + /// fetch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bound_to: Option, +} + +/// Serde for [`IdentityKeyReferenceRequirements::purpose`]: the purpose's wire name, not the +/// number `Purpose` serializes to on an identity key, so the value matches the schema keyword. +mod purpose_wire_name { + use crate::identity::Purpose; + use serde::de::Error; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn serialize( + purpose: &Option, + serializer: S, + ) -> Result { + purpose + .as_ref() + .map(Purpose::wire_name) + .serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let name: Option = Option::deserialize(deserializer)?; + name.map(|name| { + // The purposes a user's key can carry, every one but SYSTEM, as the schema + // parser admits them + Purpose::from_wire_name(&name) + .filter(|purpose| Purpose::full_range().contains(purpose)) + .ok_or_else(|| D::Error::custom(format!("unknown key purpose {name:?}"))) + }) + .transpose() + } +} + +/// One requirement of an [`IdentityKeyReferenceRequirements`] declaration, named the way the +/// declaration spells it, for the error that reports it unmet. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum IdentityKeyReferenceRequirement<'a> { + Purpose(Purpose), + BoundTo(&'a str), +} + +impl IdentityKeyReferenceRequirement<'_> { + /// The `keyRequirements` key the requirement was declared under. + pub fn field(&self) -> &'static str { + match self { + IdentityKeyReferenceRequirement::Purpose(_) => property_names::PURPOSE, + IdentityKeyReferenceRequirement::BoundTo(_) => property_names::BOUND_TO, + } + } + + /// The value the declaration requires, as spelled in the schema. + pub fn required(&self) -> String { + match self { + IdentityKeyReferenceRequirement::Purpose(purpose) => purpose.wire_name().to_string(), + IdentityKeyReferenceRequirement::BoundTo(document_type_name) => { + document_type_name.to_string() + } + } + } + + /// Whether `key`, a key of an identity, meets this requirement for a reference declared by + /// the contract `declaring_contract_id`. + pub fn is_met_by(&self, key: &IdentityPublicKey, declaring_contract_id: Identifier) -> bool { + match self { + IdentityKeyReferenceRequirement::Purpose(purpose) => key.purpose() == *purpose, + IdentityKeyReferenceRequirement::BoundTo(document_type_name) => matches!( + key.contract_bounds(), + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name: bound_document_type_name, + }) if *id == declaring_contract_id && bound_document_type_name == document_type_name + ), + } + } + + /// What `key` has where the declaration requires [`Self::required`], for the error that + /// reports the requirement unmet. + pub fn actual_of(&self, key: &IdentityPublicKey) -> String { + match self { + IdentityKeyReferenceRequirement::Purpose(_) => key.purpose().wire_name().to_string(), + IdentityKeyReferenceRequirement::BoundTo(_) => match key.contract_bounds() { + None => "no contract bounds".to_string(), + Some(ContractBounds::SingleContract { id }) => { + format!("whole contract {id}, not a document type") + } + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => format!("contract {id} document type {document_type_name}"), + Some(ContractBounds::ContractGroup { id }) => { + format!("contract group {id}, which never meets a document type bound") + } + }, + } + } +} + +impl IdentityKeyReferenceRequirements { + /// Whether the declaration requires nothing beyond the key's existence. + pub fn is_empty(&self) -> bool { + self.purpose.is_none() && self.bound_to.is_none() + } + + /// The requirements, in declaration order. + pub fn requirements(&self) -> impl Iterator> + '_ { + self.purpose + .into_iter() + .map(IdentityKeyReferenceRequirement::Purpose) + .chain( + self.bound_to + .as_deref() + .map(IdentityKeyReferenceRequirement::BoundTo), + ) + } + + /// The first requirement `key` does not meet for a reference declared by the contract + /// `declaring_contract_id`, `None` when it meets them all. + pub fn first_unmet_by( + &self, + key: &IdentityPublicKey, + declaring_contract_id: Identifier, + ) -> Option> { + self.requirements() + .find(|requirement| !requirement.is_met_by(key, declaring_contract_id)) + } +} + // This enum is embedded in consensus errors, so it is consensus-serialized. // @append_only #[derive( @@ -483,14 +765,22 @@ pub enum DocumentPropertyReferenceTarget { /// identity id and the named sibling property of the same document type /// holds the key id. Identity keys can be disabled but never removed, so /// an existing reference can never dangle; at write time the key must - /// exist and must not be disabled. The referenced key is the (identity - /// id, key id) pair, so a replace that changes either property - /// re-validates the reference. + /// exist, must not be disabled and must meet the declared + /// [`IdentityKeyReferenceRequirements`], if any. The referenced key is + /// the (identity id, key id) pair, so a replace that changes either + /// property re-validates the reference. #[serde(rename = "identityPublicKey")] IdentityPublicKey { /// The property of the same document type whose value carries the /// referenced key id key_id_property: String, + /// What the referenced key must be beyond existing: a purpose, a + /// binding to a document type of the declaring contract + #[serde( + default, + skip_serializing_if = "IdentityKeyReferenceRequirements::is_empty" + )] + key_requirements: IdentityKeyReferenceRequirements, }, /// A document of a document type whose documents CAN be deleted: the /// counterpart of [`Self::PermanentDocument`], disjoint from it, so a @@ -503,7 +793,9 @@ pub enum DocumentPropertyReferenceTarget { /// expect the reference to resolve to nothing. It can not come back /// pointing at something else: a document id commits to the nonce of /// its create transition, so an id is produced at most once and a - /// reference means that one document or nothing. A WRITER may not leave + /// reference means that one document or nothing (which is why there is + /// no lookup form of it: a key could find a new document once the one it + /// found is deleted). A WRITER may not leave /// it that way: every replace of the referring document re-validates /// the reference, so a dead one has to be repointed at a document that /// exists or cleared (on an `immutable` property, clearing is the only @@ -520,6 +812,39 @@ pub enum DocumentPropertyReferenceTarget { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] property_agreement: BTreeMap, }, + /// A `permanentDocument` reference declared with a `lookup`: the + /// property's value (or each element of a typed array) is NOT the + /// referenced document's id, but one part of a key; the referenced + /// document is the one the named unique index of the referenced document + /// type finds for the key the [`DocumentReferenceLookup`] assembles from + /// the referring document. Everything else is as for + /// [`Self::PermanentDocument`]: the referenced type must forbid deletion, + /// the agreement pairs are checked against the document found, and the + /// key must stay with that document (its parts cannot be changed by a + /// replace, a transfer or a purchase), so the reference can not dangle + /// either. There is no deletable form: a key into a deletable type could + /// find a new document once the one it found is deleted, where an id is + /// produced at most once. + /// + /// A variant of its own rather than a field of + /// [`Self::PermanentDocument`], appended as this enum's rule requires: an + /// id reference keeps its consensus encoding (the enum is embedded in + /// reference errors), and code matching `PermanentDocument` as "the value + /// is a document id" can not mistake a lookup for one. It serializes + /// under the same `permanentDocument` tag, with a `lookup` field (the + /// enum is serialize-only, so the shared tag is never read back). + #[serde(rename = "permanentDocument")] + PermanentDocumentLookup { + /// The contract the referenced document type lives in; `None` means + /// the declaring contract itself + contract_id: Option, + document_type_name: String, + /// See [`Self::PermanentDocument`]'s `property_agreement`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + property_agreement: BTreeMap, + /// How the referenced document is found. + lookup: DocumentReferenceLookup, + }, } /// The declaration content the two document reference targets, @@ -537,12 +862,30 @@ pub struct DocumentReferenceDeclaration<'a> { /// Whether the referenced document type must forbid deletion /// (`permanentDocument`) or must allow it (`deletableDocument`) pub permanent: bool, + /// How the referenced document is found when the value is not its id + /// ([`DocumentPropertyReferenceTarget::PermanentDocumentLookup`]); `None` + /// when the value is the referenced document's id. Only + /// [`DocumentPropertyReferenceTarget::as_any_document_reference`] ever + /// returns a declaration carrying one. + pub lookup: Option<&'a DocumentReferenceLookup>, } impl DocumentPropertyReferenceTarget { - /// The declaration of a reference to a DOCUMENT, of either kind; - /// `None` for every other target. + /// The declaration of a reference whose value is a DOCUMENT's id, of + /// either kind; `None` for every other target, a lookup reference + /// included, whose value is not a document id. This is the accessor for + /// code that treats the value as the referenced document's `$id` (by-id + /// joins); code that validates every kind of document reference uses + /// [`Self::as_any_document_reference`]. pub fn as_document_reference(&self) -> Option> { + self.as_any_document_reference() + .filter(|declaration| declaration.lookup.is_none()) + } + + /// The declaration of any reference to a DOCUMENT: of either kind, and + /// found by its id or through a `lookup` (then `lookup` is `Some`, and + /// the value is not the document's id). `None` for every other target. + pub fn as_any_document_reference(&self) -> Option> { match self { DocumentPropertyReferenceTarget::PermanentDocument { contract_id, @@ -553,6 +896,19 @@ impl DocumentPropertyReferenceTarget { document_type_name, property_agreement, permanent: true, + lookup: None, + }), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id, + document_type_name, + property_agreement, + lookup, + } => Some(DocumentReferenceDeclaration { + contract_id: *contract_id, + document_type_name, + property_agreement, + permanent: true, + lookup: Some(lookup), }), DocumentPropertyReferenceTarget::DeletableDocument { contract_id, @@ -563,6 +919,7 @@ impl DocumentPropertyReferenceTarget { document_type_name, property_agreement, permanent: false, + lookup: None, }), DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Contract { .. } @@ -572,6 +929,53 @@ impl DocumentPropertyReferenceTarget { } } +/// A property's `refersTo` declaration and what holds the reference: the +/// property's own value, every element of a typed array of identifiers, or, +/// for a key reference declared on the key id itself, the key id. Returned +/// by [`DocumentPropertyType::reference`], which is how the registration and +/// write-time validators, the per-document reference bound and the client +/// bindings enumerate a document type's references, so no kind can be +/// skipped by a caller matching one property type. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum PropertyReference<'a> { + /// An identifier property: its value is the referenced id. + Value(&'a DocumentPropertyReferenceTarget), + /// A typed array whose elements are identifiers carrying `refersTo` + /// (declared on its `items`): each element is a referenced id, all of + /// them to `target`, at most `max_items` of them. Never an + /// [`DocumentPropertyReferenceTarget::IdentityPublicKey`], which the + /// parser refuses on an element. + Elements { + target: &'a DocumentPropertyReferenceTarget, + max_items: u16, + }, + /// A key id property carrying an `identityPublicKey` declaration that + /// names whose key it is ([`DocumentPropertyType::KeyIdWithReference`]). + KeyId(&'a KeyIdReference), +} + +impl<'a> PropertyReference<'a> { + /// The declaration of an identifier or element reference; `None` for a + /// key reference on the key id, which has no identifier target. + pub fn target(&self) -> Option<&'a DocumentPropertyReferenceTarget> { + match self { + PropertyReference::Value(target) | PropertyReference::Elements { target, .. } => { + Some(target) + } + PropertyReference::KeyId(_) => None, + } + } + + /// How many referenced values one document can carry through this + /// declaration: `max_items` for a typed array, one otherwise. + pub fn max_references(&self) -> u32 { + match self { + PropertyReference::Elements { max_items, .. } => u32::from(*max_items), + PropertyReference::Value(_) | PropertyReference::KeyId(_) => 1, + } + } +} + /// The system properties of a referenced document that the referenced side /// of a `propertyAgreement` pair may name, next to the referenced document /// type's schema properties: `$ownerId`, the current owner (which follows @@ -644,44 +1048,162 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { } DocumentPropertyReferenceTarget::Token => write!(f, "token"), DocumentPropertyReferenceTarget::PermanentDocument { - contract_id: Some(contract_id), + contract_id, document_type_name, .. - } => write!( - f, - "permanent document (contract {contract_id}, document type {document_type_name})" - ), - DocumentPropertyReferenceTarget::PermanentDocument { - contract_id: None, + } => write_document_reference(f, "permanent", *contract_id, document_type_name, None), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id, document_type_name, + lookup, .. - } => write!( + } => write_document_reference( f, - "permanent document (own contract, document type {document_type_name})" - ), - DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { - write!(f, "identity public key (key id property {key_id_property})") - } - DocumentPropertyReferenceTarget::DeletableDocument { - contract_id: Some(contract_id), + "permanent", + *contract_id, document_type_name, - .. - } => write!( - f, - "deletable document (contract {contract_id}, document type {document_type_name})" + Some(lookup), ), + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, + key_requirements, + } => { + write!(f, "identity public key (key id property {key_id_property})")?; + if let Some(purpose) = key_requirements.purpose { + write!(f, " with purpose {}", purpose.wire_name())?; + } + if let Some(document_type_name) = &key_requirements.bound_to { + write!(f, " bound to document type {document_type_name}")?; + } + Ok(()) + } DocumentPropertyReferenceTarget::DeletableDocument { - contract_id: None, + contract_id, document_type_name, .. - } => write!( - f, - "deletable document (own contract, document type {document_type_name})" - ), + } => write_document_reference(f, "deletable", *contract_id, document_type_name, None), + } + } +} + +/// Whose key a `refersTo: identityPublicKey` declaration on a KEY ID property +/// names: its `identityProperty`. The declaring property carries the key id +/// (a `u32`, so an integer property with `minimum` 0 and `maximum` +/// 4294967295) and this names the identity the key belongs to: the document's +/// owner, its creator, or an identifier property of the same document type. +/// It is the inverse of [`DocumentPropertyReferenceTarget::IdentityPublicKey`], +/// where the declaring property carries the identity id and `keyIdProperty` +/// names the sibling carrying the key id; a declaration is one form or the +/// other, never both. +// @append_only +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub enum KeyReferenceIdentityProperty { + /// `"$ownerId"`: the writer's own identity. The document's owner signs + /// the transition, which already proved the identity exists, so the + /// reference costs the key fetch alone. The owner can change through a + /// transfer or a purchase, so every replace re-validates the reference. + #[serde(rename = "$ownerId")] + OwnerId, + /// `"$creatorId"`: the identity that created the document, the writer + /// of its create and the stored creator id after that. Only a document + /// type that records creator ids (a transferable or tradeable type of a + /// format-1 contract) may declare it, checked at contract registration. + /// The creator never changes, so a replace re-validates the reference + /// when the key id changed. + #[serde(rename = "$creatorId")] + CreatorId, + /// An identifier property of the same document type (a dotted path when + /// nested) whose value is the identity; it must exist, be an identifier + /// and not carry an `identityPublicKey` reference of its own, checked at + /// contract registration. The identity is read from the document, so a + /// replace re-validates the reference when the key id or that property + /// changed, and a key id set while the property is not is refused. + Property(String), +} + +impl KeyReferenceIdentityProperty { + /// The system `identityProperty` values the schema admits, as spelled + /// there; every other admitted value is a property path. + pub const SYSTEM_WIRE_NAMES: [&'static str; 2] = [OWNER_ID, CREATOR_ID]; + + /// The value for its schema spelling: a system name, or a property path + /// of 1 to 256 characters that does not start with `$`. `None` for a + /// spelling the schema does not admit. + pub fn from_wire_name(name: &str) -> Option { + match name { + OWNER_ID => Some(KeyReferenceIdentityProperty::OwnerId), + CREATOR_ID => Some(KeyReferenceIdentityProperty::CreatorId), + path if path.starts_with('$') || path.is_empty() || path.len() > 256 => None, + path => Some(KeyReferenceIdentityProperty::Property(path.to_string())), + } + } + + /// The schema spelling. + pub fn as_str(&self) -> &str { + match self { + KeyReferenceIdentityProperty::OwnerId => OWNER_ID, + KeyReferenceIdentityProperty::CreatorId => CREATOR_ID, + KeyReferenceIdentityProperty::Property(path) => path, + } + } +} + +impl std::fmt::Display for KeyReferenceIdentityProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A `refersTo: identityPublicKey` declaration on a KEY ID property: whose +/// key the value is, and what that key must be beyond existing and not +/// being disabled, the same [`IdentityKeyReferenceRequirements`] the +/// identifier form takes, checked the same way. +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct KeyIdReference { + pub identity_property: KeyReferenceIdentityProperty, + #[serde( + default, + skip_serializing_if = "IdentityKeyReferenceRequirements::is_empty" + )] + pub key_requirements: IdentityKeyReferenceRequirements, +} + +impl KeyIdReference { + /// A declaration requiring nothing of the key beyond existing. + pub fn new(identity_property: KeyReferenceIdentityProperty) -> Self { + KeyIdReference { + identity_property, + key_requirements: IdentityKeyReferenceRequirements::default(), } } } +/// How the two document reference targets read: the kind, the contract, the +/// document type and, for a lookup, the unique index the document is found +/// through. +fn write_document_reference( + f: &mut std::fmt::Formatter<'_>, + kind: &str, + contract_id: Option, + document_type_name: &str, + lookup: Option<&DocumentReferenceLookup>, +) -> std::fmt::Result { + match contract_id { + Some(contract_id) => write!( + f, + "{kind} document (contract {contract_id}, document type {document_type_name}" + )?, + None => write!( + f, + "{kind} document (own contract, document type {document_type_name}" + )?, + } + if let Some(lookup) = lookup { + write!(f, ", found through unique index {}", lookup.index)?; + } + write!(f, ")") +} + // @append_only #[derive(Debug, PartialEq, Clone, Serialize)] pub enum DocumentPropertyType { @@ -702,9 +1224,24 @@ pub enum DocumentPropertyType { Boolean, Date, Object(IndexMap), + /// A list of elements of one type with no element count bounds. The + /// schema parser never produces it: a typed array property parses to + /// [`DocumentPropertyType::TypedArray`], which shares its encoding. Array(ArrayItemType), VariableTypeArray(Vec), IdentifierWithReference(DocumentPropertyReferenceTarget), + /// A typed array property (`type: "array"` with an `items` element + /// schema), from protocol version 14: the element type with the + /// `minItems` / `maxItems` element count bounds and `uniqueItems`. Stored + /// inline like [`DocumentPropertyType::Array`], a varint element count + /// followed by the elements. + TypedArray(TypedArrayProperty), + /// A `u32` key id carrying a `refersTo: identityPublicKey` declaration + /// with `identityProperty`: the value is the id of a key of the named + /// identity, which must exist, not be disabled and meet the declared + /// requirements when the document is written. Sized, encoded and queried + /// exactly as [`Self::U32`]. + KeyIdWithReference(KeyIdReference), } impl DocumentPropertyType { @@ -745,13 +1282,32 @@ impl DocumentPropertyType { } } + /// The kind of value this type holds, for the rules that compare a value of + /// one property with a value of another (`propertyAgreement` pairs, + /// `lookup` key parts): two types of the same kind can hold equal values. + /// Sizes and other constraints do not count, and an identifier, or a `u32` + /// key id, is one kind whether or not it carries its own reference. + pub fn value_kind(&self) -> std::mem::Discriminant { + match self { + DocumentPropertyType::IdentifierWithReference(_) => { + std::mem::discriminant(&DocumentPropertyType::Identifier) + } + DocumentPropertyType::KeyIdWithReference(_) => { + std::mem::discriminant(&DocumentPropertyType::U32) + } + other => std::mem::discriminant(other), + } + } + pub fn name(&self) -> String { match self { DocumentPropertyType::U128 => "u128".to_string(), DocumentPropertyType::I128 => "i128".to_string(), DocumentPropertyType::U64 => "u64".to_string(), DocumentPropertyType::I64 => "i64".to_string(), - DocumentPropertyType::U32 => "u32".to_string(), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { + "u32".to_string() + } DocumentPropertyType::I32 => "i32".to_string(), DocumentPropertyType::U16 => "u16".to_string(), DocumentPropertyType::I16 => "i16".to_string(), @@ -766,18 +1322,63 @@ impl DocumentPropertyType { DocumentPropertyType::Boolean => "boolean".to_string(), DocumentPropertyType::Date => "date".to_string(), DocumentPropertyType::Object(_) => "object".to_string(), - DocumentPropertyType::Array(_) => "array".to_string(), + DocumentPropertyType::Array(_) | DocumentPropertyType::TypedArray(_) => { + "array".to_string() + } DocumentPropertyType::VariableTypeArray(_) => "variableTypeArray".to_string(), } } + /// The `refersTo` declaration this property carries, on its own value + /// (an identifier property), on every element (a typed array whose + /// `items` declare it) or on the key id (a key reference naming whose + /// key it is); `None` for a property without one. + pub fn reference(&self) -> Option> { + match self { + DocumentPropertyType::IdentifierWithReference(target) => { + Some(PropertyReference::Value(target)) + } + DocumentPropertyType::TypedArray(typed_array) => match typed_array.item_type.as_ref() { + DocumentPropertyType::IdentifierWithReference(target) => { + Some(PropertyReference::Elements { + target, + max_items: typed_array.max_items, + }) + } + _ => None, + }, + DocumentPropertyType::KeyIdWithReference(reference) => { + Some(PropertyReference::KeyId(reference)) + } + _ => None, + } + } + + /// How a value of this scalar type is laid out in a stored document, in + /// the words a contract update error uses. The layout of two scalar types + /// is the same exactly when they give the same answer. The schema chooses + /// it in two places: an integer is stored at the width and signedness of + /// its type, which its `minimum`, `maximum` or `enum` pick, and a byte + /// array is stored raw when its `minItems` and `maxItems` pin one size and + /// length-prefixed otherwise. Every other scalar is laid out the same + /// whatever its schema says, so it answers with its name. + pub(crate) fn stored_encoding(&self) -> String { + match self { + DocumentPropertyType::ByteArray(sizes) => match (sizes.min_size, sizes.max_size) { + (Some(min), Some(max)) if min == max => format!("a fixed {min}-byte array"), + _ => "a length-prefixed byte array".to_string(), + }, + other => other.name(), + } + } + pub fn min_size(&self) -> Option { match self { DocumentPropertyType::U128 => Some(16), DocumentPropertyType::I128 => Some(16), DocumentPropertyType::U64 => Some(8), DocumentPropertyType::I64 => Some(8), - DocumentPropertyType::U32 => Some(4), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => Some(4), DocumentPropertyType::I32 => Some(4), DocumentPropertyType::U16 => Some(2), DocumentPropertyType::I16 => Some(2), @@ -798,7 +1399,7 @@ impl DocumentPropertyType { .iter() .map(|(_, sub_field)| sub_field.property_type.min_size()) .sum(), - DocumentPropertyType::Array(_) => None, + DocumentPropertyType::Array(_) | DocumentPropertyType::TypedArray(_) => None, DocumentPropertyType::VariableTypeArray(_) => None, DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Some(32) @@ -815,7 +1416,7 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Ok(Some(16)), DocumentPropertyType::U64 => Ok(Some(8)), DocumentPropertyType::I64 => Ok(Some(8)), - DocumentPropertyType::U32 => Ok(Some(4)), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => Ok(Some(4)), DocumentPropertyType::I32 => Ok(Some(4)), DocumentPropertyType::U16 => Ok(Some(2)), DocumentPropertyType::I16 => Ok(Some(2)), @@ -847,6 +1448,9 @@ impl DocumentPropertyType { .sum(), DocumentPropertyType::Array(_) => Ok(None), DocumentPropertyType::VariableTypeArray(_) => Ok(None), + DocumentPropertyType::TypedArray(typed_array) => { + typed_array.min_encoded_size(platform_version).map(Some) + } DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Ok(Some(32)) } @@ -862,7 +1466,7 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Ok(Some(16)), DocumentPropertyType::U64 => Ok(Some(8)), DocumentPropertyType::I64 => Ok(Some(8)), - DocumentPropertyType::U32 => Ok(Some(4)), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => Ok(Some(4)), DocumentPropertyType::I32 => Ok(Some(4)), DocumentPropertyType::U16 => Ok(Some(2)), DocumentPropertyType::I16 => Ok(Some(2)), @@ -894,6 +1498,9 @@ impl DocumentPropertyType { .sum(), DocumentPropertyType::Array(_) => Ok(None), DocumentPropertyType::VariableTypeArray(_) => Ok(None), + DocumentPropertyType::TypedArray(typed_array) => { + typed_array.max_encoded_size(platform_version).map(Some) + } DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Ok(Some(32)) } @@ -906,7 +1513,7 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Some(16), DocumentPropertyType::U64 => Some(8), DocumentPropertyType::I64 => Some(8), - DocumentPropertyType::U32 => Some(4), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => Some(4), DocumentPropertyType::I32 => Some(4), DocumentPropertyType::U16 => Some(2), DocumentPropertyType::I16 => Some(2), @@ -927,7 +1534,7 @@ impl DocumentPropertyType { .iter() .map(|(_, sub_field)| sub_field.property_type.max_size()) .sum(), - DocumentPropertyType::Array(_) => None, + DocumentPropertyType::Array(_) | DocumentPropertyType::TypedArray(_) => None, DocumentPropertyType::VariableTypeArray(_) => None, DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Some(32) @@ -949,7 +1556,9 @@ impl DocumentPropertyType { | DocumentPropertyType::I64 | DocumentPropertyType::F64 | DocumentPropertyType::Date => Some(8), - DocumentPropertyType::U32 | DocumentPropertyType::I32 => Some(4), + DocumentPropertyType::U32 + | DocumentPropertyType::KeyIdWithReference(_) + | DocumentPropertyType::I32 => Some(4), DocumentPropertyType::U16 | DocumentPropertyType::I16 => Some(2), DocumentPropertyType::U8 | DocumentPropertyType::I8 | DocumentPropertyType::Boolean => { Some(1) @@ -964,7 +1573,8 @@ impl DocumentPropertyType { DocumentPropertyType::String(_) | DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) - | DocumentPropertyType::VariableTypeArray(_) => None, + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => None, } } @@ -1040,7 +1650,9 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Value::I128(rng.gen::()), DocumentPropertyType::U64 => Value::U64(rng.gen::()), DocumentPropertyType::I64 => Value::I64(rng.gen::()), - DocumentPropertyType::U32 => Value::U32(rng.gen::()), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { + Value::U32(rng.gen::()) + } DocumentPropertyType::I32 => Value::I32(rng.gen::()), DocumentPropertyType::U16 => Value::U16(rng.gen::()), DocumentPropertyType::I16 => Value::I16(rng.gen::()), @@ -1101,6 +1713,7 @@ impl DocumentPropertyType { DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Value::Identifier(rng.gen()) } + DocumentPropertyType::TypedArray(typed_array) => typed_array.random_value(rng), } } @@ -1110,7 +1723,9 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Value::I128(rng.gen::()), DocumentPropertyType::U64 => Value::U64(rng.gen::()), DocumentPropertyType::I64 => Value::I64(rng.gen::()), - DocumentPropertyType::U32 => Value::U32(rng.gen::()), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { + Value::U32(rng.gen::()) + } DocumentPropertyType::I32 => Value::I32(rng.gen::()), DocumentPropertyType::U16 => Value::U16(rng.gen::()), DocumentPropertyType::I16 => Value::I16(rng.gen::()), @@ -1152,6 +1767,9 @@ impl DocumentPropertyType { DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Value::Identifier(rng.gen()) } + DocumentPropertyType::TypedArray(typed_array) => { + typed_array.random_sub_filled_value(rng) + } } } @@ -1161,7 +1779,9 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Value::I128(rng.gen::()), DocumentPropertyType::U64 => Value::U64(rng.gen::()), DocumentPropertyType::I64 => Value::I64(rng.gen::()), - DocumentPropertyType::U32 => Value::U32(rng.gen::()), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { + Value::U32(rng.gen::()) + } DocumentPropertyType::I32 => Value::I32(rng.gen::()), DocumentPropertyType::U16 => Value::U16(rng.gen::()), DocumentPropertyType::I16 => Value::I16(rng.gen::()), @@ -1203,6 +1823,7 @@ impl DocumentPropertyType { DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Value::Identifier(rng.gen()) } + DocumentPropertyType::TypedArray(typed_array) => typed_array.random_filled_value(rng), } } @@ -1291,7 +1912,7 @@ impl DocumentPropertyType { })?; Ok((Some(Value::I64(value)), false)) } - DocumentPropertyType::U32 => { + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { let value = buf.read_u32::().map_err(|_| { DataContractError::CorruptedSerialization( "error reading u32 from serialized document".to_string(), @@ -1455,9 +2076,26 @@ impl DocumentPropertyType { Ok((Some(Value::Map(values)), false)) } } - DocumentPropertyType::Array(_array_field_type) => Err(DataContractError::Unsupported( - "serialization of arrays not yet supported".to_string(), - )), + DocumentPropertyType::TypedArray(typed_array) => { + Ok((Some(typed_array.read_from(buf)?), false)) + } + DocumentPropertyType::Array(item_type) => { + // Mirrors the encoding: a varint element count, then the + // elements. The count comes from the serialized document, so + // it never sizes an allocation; every element takes at least + // one byte, so a count the document cannot hold fails once + // the input runs out. + let count: usize = buf.read_varint().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading varint of array element count".to_string(), + ) + })?; + let mut items = Vec::new(); + for _ in 0..count { + items.push(item_type.read_from(buf)?); + } + Ok((Some(Value::Array(items)), false)) + } DocumentPropertyType::VariableTypeArray(_) => Err(DataContractError::Unsupported( "serialization of variable type arrays not yet supported".to_string(), )), @@ -1541,7 +2179,7 @@ impl DocumentPropertyType { Ok(r_vec) } } - DocumentPropertyType::U32 => { + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { let value_as_u32: u32 = value.into_integer().map_err(ProtocolError::ValueError)?; let mut value_bytes = value_as_u32.to_be_bytes().to_vec(); if required { @@ -1663,6 +2301,7 @@ impl DocumentPropertyType { Err(get_field_type_matching_error(&value).into()) } } + DocumentPropertyType::TypedArray(typed_array) => typed_array.encode_value_ref(&value), DocumentPropertyType::Array(array_field_type) => { if let Value::Array(array) = value { let mut r_vec = array.len().encode_var_vec(); @@ -1734,7 +2373,7 @@ impl DocumentPropertyType { let value_as_i64: i64 = value.to_integer().map_err(ProtocolError::ValueError)?; Ok(value_as_i64.to_be_bytes().to_vec()) } - DocumentPropertyType::U32 => { + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { let value_as_u32: u32 = value.to_integer().map_err(ProtocolError::ValueError)?; Ok(value_as_u32.to_be_bytes().to_vec()) } @@ -1818,6 +2457,7 @@ impl DocumentPropertyType { len_prepended_vec.append(&mut r_vec); Ok(len_prepended_vec) } + DocumentPropertyType::TypedArray(typed_array) => typed_array.encode_value_ref(value), DocumentPropertyType::Array(array_field_type) => { if let Value::Array(array) = value { let mut r_vec = array.len().encode_var_vec(); @@ -1879,7 +2519,7 @@ impl DocumentPropertyType { let value_as_i64 = value.to_integer().map_err(ProtocolError::ValueError)?; Ok(DocumentPropertyType::encode_i64(value_as_i64)) } - DocumentPropertyType::U32 => { + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { let value_as_u32 = value.to_integer().map_err(ProtocolError::ValueError)?; Ok(DocumentPropertyType::encode_u32(value_as_u32)) } @@ -1929,13 +2569,14 @@ impl DocumentPropertyType { "we should never try encoding an object".to_string(), ), )), - DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) => { - Err(ProtocolError::DataContractError( - DataContractError::EncodingDataStructureNotSupported( - "we should never try encoding an array".to_string(), - ), - )) - } + // Arrays are never index keys: the parser refuses an index on one + DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => Err(ProtocolError::DataContractError( + DataContractError::EncodingDataStructureNotSupported( + "we should never try encoding an array".to_string(), + ), + )), } } @@ -1989,7 +2630,7 @@ impl DocumentPropertyType { )?; Ok(Value::I64(integer)) } - DocumentPropertyType::U32 => { + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { let integer = DocumentPropertyType::decode_u32(value).ok_or( ProtocolError::DecodingError("could not decode u32".to_string()), )?; @@ -2052,13 +2693,13 @@ impl DocumentPropertyType { "we should never try decoding an object".to_string(), ), )), - DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) => { - Err(ProtocolError::DataContractError( - DataContractError::EncodingDataStructureNotSupported( - "we should never try decoding an array".to_string(), - ), - )) - } + DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => Err(ProtocolError::DataContractError( + DataContractError::EncodingDataStructureNotSupported( + "we should never try decoding an array".to_string(), + ), + )), } } @@ -2102,11 +2743,13 @@ impl DocumentPropertyType { "value is not an i64 integer from string".to_string(), ) }), - DocumentPropertyType::U32 => str.parse::().map(Value::U32).map_err(|_| { - DataContractError::ValueWrongType( - "value is not a u32 integer from string".to_string(), - ) - }), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { + str.parse::().map(Value::U32).map_err(|_| { + DataContractError::ValueWrongType( + "value is not a u32 integer from string".to_string(), + ) + }) + } DocumentPropertyType::I32 => str.parse::().map(Value::I32).map_err(|_| { DataContractError::ValueWrongType( "value is not an i32 integer from string".to_string(), @@ -2182,7 +2825,10 @@ impl DocumentPropertyType { "we should never try encoding an object".to_string(), )) } - DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) => { + // A string names one value, never a list of them + DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => { Err(DataContractError::EncodingDataStructureNotSupported( "we should never try encoding an array".to_string(), )) @@ -2734,6 +3380,7 @@ impl DocumentPropertyType { | DocumentPropertyType::U8 | DocumentPropertyType::U16 | DocumentPropertyType::U32 + | DocumentPropertyType::KeyIdWithReference(_) | DocumentPropertyType::U64 ) } @@ -2884,17 +3531,32 @@ impl DocumentPropertyType { *value = Value::U16(n as u16); } - (DocumentPropertyType::U32, Value::U32(_)) => {} // Already correct - (DocumentPropertyType::U32, Value::U8(n)) => { + ( + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_), + Value::U32(_), + ) => {} // Already correct + ( + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_), + Value::U8(n), + ) => { *value = Value::U32(n as u32); } - (DocumentPropertyType::U32, Value::U16(n)) => { + ( + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_), + Value::U16(n), + ) => { *value = Value::U32(n as u32); } - (DocumentPropertyType::U32, Value::U64(n)) if n <= u32::MAX as u64 => { + ( + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_), + Value::U64(n), + ) if n <= u32::MAX as u64 => { *value = Value::U32(n as u32); } - (DocumentPropertyType::U32, Value::U128(n)) if n <= u32::MAX as u128 => { + ( + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_), + Value::U128(n), + ) if n <= u32::MAX as u128 => { *value = Value::U32(n as u32); } @@ -3051,6 +3713,15 @@ impl DocumentPropertyType { } } + // A typed array's elements sanitize as scalars of its element type + (DocumentPropertyType::TypedArray(typed_array), Value::Array(_)) => { + if let Value::Array(items) = value { + for item in items.iter_mut() { + typed_array.item_type.sanitize_value_mut(item); + } + } + } + // Handle VariableTypeArray - each item can have a different type (DocumentPropertyType::VariableTypeArray(item_types), Value::Array(_)) => { if let Value::Array(items) = value { @@ -3230,6 +3901,9 @@ fn find_integer_type_for_min_and_max_values(min: i64, max: i64) -> DocumentPrope #[allow(clippy::approx_constant)] mod tests { use super::*; + use crate::identity::identity_public_key::v0::IdentityPublicKeyV0; + use crate::identity::{KeyType, SecurityLevel}; + use platform_value::BinaryData; use platform_version::version::PlatformVersion; // ----------------------------------------------------------------------- @@ -3443,6 +4117,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); sub_fields.insert( @@ -3452,6 +4128,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -5106,21 +5784,358 @@ mod tests { assert_eq!(value, Some(Value::Bytes(vec![10, 20, 30]))); } - #[test] - fn test_read_optionally_from_array_returns_error() { - use std::io::BufReader; - let prop = DocumentPropertyType::Array(ArrayItemType::Integer); - let data: &[u8] = &[1, 2, 3]; - let mut reader = BufReader::new(data); - let result = prop.read_optionally_from(&mut reader, true); - assert!(result.is_err()); + fn typed_array(item_type: DocumentPropertyType) -> DocumentPropertyType { + DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: Box::new(item_type), + item_constraints: Default::default(), + min_items: None, + max_items: 8, + unique_items: false, + }) } - #[test] - fn test_read_optionally_from_variable_type_array_returns_error() { + fn encode_and_read_back(property_type: &DocumentPropertyType, value: &Value) -> Vec { use std::io::BufReader; - let prop = DocumentPropertyType::VariableTypeArray(vec![]); - let data: &[u8] = &[1, 2, 3]; + // The document serializer writes the presence flag of a property that + // is not required itself + let encoded = property_type + .encode_value_ref_with_size(value, true) + .expect("encodes"); + let mut reader = BufReader::new(encoded.as_slice()); + let (decoded, finished) = property_type + .read_optionally_from(&mut reader, true) + .expect("decodes"); + assert_eq!(decoded.as_ref(), Some(value), "{property_type:?}"); + assert!(!finished); + assert!(reader.buffer().is_empty(), "{property_type:?} left bytes"); + + let mut with_marker = vec![1]; + with_marker.extend(&encoded); + let mut reader = BufReader::new(with_marker.as_slice()); + let (decoded, _) = property_type + .read_optionally_from(&mut reader, false) + .expect("decodes behind a presence flag"); + assert_eq!( + decoded.as_ref(), + Some(value), + "{property_type:?} behind a presence flag" + ); + encoded + } + + #[test] + fn should_round_trip_every_typed_array_element_type_through_encode_and_read_optionally_from() { + for (item_type, items) in [ + ( + DocumentPropertyType::I64, + vec![Value::I64(i64::MIN), Value::I64(-1), Value::I64(i64::MAX)], + ), + ( + DocumentPropertyType::U8, + vec![Value::U8(0), Value::U8(u8::MAX)], + ), + ( + DocumentPropertyType::I16, + vec![Value::I16(i16::MIN), Value::I16(1000)], + ), + (DocumentPropertyType::U32, vec![Value::U32(u32::MAX)]), + (DocumentPropertyType::U128, vec![Value::U128(u128::MAX)]), + ( + DocumentPropertyType::F64, + vec![Value::Float(-0.5), Value::Float(1e300)], + ), + ( + DocumentPropertyType::String(StringPropertySizes { + min_length: None, + max_length: Some(20), + }), + vec![Value::Text("".to_string()), Value::Text("über".to_string())], + ), + ( + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(1), + max_size: Some(40), + }), + vec![Value::Bytes(vec![0xFF]), Value::Bytes(vec![7; 40])], + ), + // Fixed-size elements read back as the fixed-size value kinds + ( + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(32), + max_size: Some(32), + }), + vec![Value::Bytes32([0x80; 32])], + ), + ( + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(3), + max_size: Some(3), + }), + vec![Value::Bytes(vec![1, 2, 3]), Value::Bytes(vec![4, 5, 6])], + ), + ( + DocumentPropertyType::Identifier, + vec![Value::Identifier([1; 32]), Value::Identifier([2; 32])], + ), + ( + DocumentPropertyType::Boolean, + vec![Value::Bool(true), Value::Bool(false)], + ), + ] { + let property_type = typed_array(item_type); + for items in [items.clone(), vec![]] { + encode_and_read_back(&property_type, &Value::Array(items)); + } + } + } + + /// Each element is written exactly as a required scalar property of its + /// type is written: after the varint count, an identifier is its 32 raw + /// bytes, an integer takes its width, a fixed-size byte array is raw, and + /// only strings and variable-size byte arrays carry a length. + #[test] + fn should_encode_each_typed_array_element_as_a_required_scalar_property_of_its_type() { + let identifiers = encode_and_read_back( + &typed_array(DocumentPropertyType::Identifier), + &Value::Array(vec![ + Value::Identifier([0xAA; 32]), + Value::Identifier([0xBB; 32]), + ]), + ); + let mut expected = vec![2]; + expected.extend([0xAA; 32]); + expected.extend([0xBB; 32]); + assert_eq!(identifiers, expected); + + let small_integers = encode_and_read_back( + &typed_array(DocumentPropertyType::U8), + &Value::Array(vec![Value::U8(7), Value::U8(200)]), + ); + assert_eq!(small_integers, vec![2, 7, 200]); + + let wide_integers = encode_and_read_back( + &typed_array(DocumentPropertyType::I64), + &Value::Array(vec![Value::I64(1)]), + ); + assert_eq!(wide_integers, vec![1, 0, 0, 0, 0, 0, 0, 0, 1]); + + let hashes = encode_and_read_back( + &typed_array(DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(3), + max_size: Some(3), + })), + &Value::Array(vec![Value::Bytes(vec![1, 2, 3])]), + ); + assert_eq!(hashes, vec![1, 1, 2, 3]); + + let blobs = encode_and_read_back( + &typed_array(DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: None, + max_size: Some(3), + })), + &Value::Array(vec![Value::Bytes(vec![1, 2])]), + ); + assert_eq!(blobs, vec![1, 2, 1, 2]); + + let strings = encode_and_read_back( + &typed_array(DocumentPropertyType::String(StringPropertySizes { + min_length: None, + max_length: Some(8), + })), + &Value::Array(vec![Value::Text("ab".to_string())]), + ); + assert_eq!(strings, vec![1, 2, b'a', b'b']); + + let flags = encode_and_read_back( + &typed_array(DocumentPropertyType::Boolean), + &Value::Array(vec![Value::Bool(true), Value::Bool(false)]), + ); + assert_eq!(flags, vec![2, 1, 0]); + } + + #[test] + fn should_refuse_to_encode_a_typed_array_value_that_is_not_a_list_of_its_elements() { + let identifiers = typed_array(DocumentPropertyType::Identifier); + for value in [ + Value::Identifier([1; 32]), + Value::Array(vec![Value::Null]), + Value::Array(vec![Value::Text("not an identifier".to_string())]), + Value::Array(vec![Value::Bytes(vec![1; 31])]), + ] { + assert!( + identifiers + .encode_value_ref_with_size(&value, true) + .is_err(), + "{value:?}" + ); + } + // An element out of a fixed size's bounds is refused, not written raw + let hashes = typed_array(DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(3), + max_size: Some(3), + })); + assert!(hashes + .encode_value_ref_with_size(&Value::Array(vec![Value::Bytes(vec![1, 2])]), true) + .is_err()); + } + + #[test] + fn should_refuse_a_typed_array_whose_elements_run_past_the_serialized_document() { + use std::io::BufReader; + // One element claimed, two of its eight bytes present + let data: &[u8] = &[1, 2, 3]; + let mut reader = BufReader::new(data); + let result = typed_array(DocumentPropertyType::I64).read_optionally_from(&mut reader, true); + assert!(matches!( + result, + Err(DataContractError::CorruptedSerialization(_)) + )); + + // One identifier claimed, 31 of its 32 bytes present: refused as a + // scalar identifier cut short is + let mut data = vec![1]; + data.extend([5; 31]); + let mut reader = BufReader::new(data.as_slice()); + assert!(typed_array(DocumentPropertyType::Identifier) + .read_optionally_from(&mut reader, true) + .is_err()); + } + + /// The count comes from the serialized document, so one above `maxItems` + /// is refused before any element is read. Without that, elements of zero + /// width (a byte array pinned to zero bytes) would let a few bytes claim + /// a list of any length. + #[test] + fn should_refuse_a_serialized_typed_array_counting_more_elements_than_its_max_items() { + use std::io::BufReader; + for item_type in [ + DocumentPropertyType::Boolean, + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(0), + max_size: Some(0), + }), + ] { + let property_type = typed_array(item_type); + for count in [9u64, u64::MAX] { + let mut data = count.encode_var_vec(); + data.extend([1; 16]); + let mut reader = BufReader::new(data.as_slice()); + let error = property_type + .read_optionally_from(&mut reader, true) + .expect_err("more elements than maxItems"); + assert!( + matches!(error, DataContractError::CorruptedSerialization(ref message) + if message.contains("more than its maxItems of 8")), + "{error}" + ); + } + } + + // maxItems zero-width elements read back as that many empty byte arrays + let empties = typed_array(DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(0), + max_size: Some(0), + })); + let data: &[u8] = &[8]; + let mut reader = BufReader::new(data); + let (value, _) = empties + .read_optionally_from(&mut reader, true) + .expect("maxItems elements decode"); + assert_eq!(value, Some(Value::Array(vec![Value::Bytes(vec![]); 8]))); + } + + #[test] + fn should_bound_a_typed_array_by_its_item_counts_times_its_element_bounds() { + let pv = PlatformVersion::latest(); + let bounded = |item_type, min_items, max_items| { + DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: Box::new(item_type), + item_constraints: Default::default(), + min_items, + max_items, + unique_items: true, + }) + }; + + // Identifiers are 32 raw bytes each + let identifiers = bounded(DocumentPropertyType::Identifier, Some(2), 64); + assert_eq!(identifiers.min_byte_size(pv).unwrap(), Some(1 + 2 * 32)); + assert_eq!(identifiers.max_byte_size(pv).unwrap(), Some(1 + 64 * 32)); + + // An integer element takes the width its bounds give it + let small_integers = bounded(DocumentPropertyType::U8, Some(1), 10); + assert_eq!(small_integers.min_byte_size(pv).unwrap(), Some(2)); + assert_eq!(small_integers.max_byte_size(pv).unwrap(), Some(11)); + + // A fixed-size byte array carries no length; a variable one does + let hashes = bounded( + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(20), + max_size: Some(20), + }), + None, + 4, + ); + assert_eq!(hashes.max_byte_size(pv).unwrap(), Some(1 + 4 * 20)); + let blobs = bounded( + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: None, + max_size: Some(200), + }), + None, + 4, + ); + assert_eq!(blobs.max_byte_size(pv).unwrap(), Some(1 + 4 * (2 + 200))); + + // A string element is sized as a string property is: four bytes per + // character of its length bounds, plus its varint length + let strings = bounded( + DocumentPropertyType::String(StringPropertySizes { + min_length: Some(3), + max_length: Some(40), + }), + None, + 200, + ); + assert_eq!(strings.min_byte_size(pv).unwrap(), Some(1)); + assert_eq!( + strings.max_byte_size(pv).unwrap(), + Some(2 + 200 * (2 + 160)) + ); + + // Unbounded, or past what a u16 holds, reports u16::MAX + let unbounded_elements = bounded( + DocumentPropertyType::String(StringPropertySizes { + min_length: None, + max_length: None, + }), + None, + 4, + ); + assert_eq!( + unbounded_elements.max_byte_size(pv).unwrap(), + Some(u16::MAX) + ); + let saturated = bounded( + DocumentPropertyType::String(StringPropertySizes { + min_length: Some(1), + max_length: Some(5000), + }), + Some(1024), + 1024, + ); + assert_eq!(saturated.max_byte_size(pv).unwrap(), Some(u16::MAX)); + assert_eq!( + saturated.min_byte_size(pv).unwrap(), + Some(2 + 1024 * (1 + 4)) + ); + } + + #[test] + fn test_read_optionally_from_variable_type_array_returns_error() { + use std::io::BufReader; + let prop = DocumentPropertyType::VariableTypeArray(vec![]); + let data: &[u8] = &[1, 2, 3]; let mut reader = BufReader::new(data); let result = prop.read_optionally_from(&mut reader, true); assert!(result.is_err()); @@ -5806,6 +6821,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); inner_fields.insert( @@ -5815,6 +6832,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -5866,6 +6885,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -5886,6 +6907,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); inner_fields.insert( @@ -5895,6 +6918,8 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6328,6 +7353,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6363,6 +7390,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); sub_fields.insert( @@ -6372,6 +7401,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -6390,6 +7421,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); sub_fields.insert( @@ -6399,6 +7432,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -6691,6 +7726,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); sub_fields.insert( @@ -6700,6 +7737,8 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -6760,6 +7799,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); sub_fields.insert( @@ -6769,6 +7810,8 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -6855,6 +7898,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); sub_fields.insert( @@ -6864,6 +7909,8 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -7130,6 +8177,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7159,6 +8208,8 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); // Second field is required @@ -7169,6 +8220,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7285,6 +8338,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7303,6 +8358,8 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7609,6 +8666,8 @@ mod tests { required: true, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -7872,6 +8931,8 @@ mod tests { required: false, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, }; let value = serde_json::to_value(&property).expect("serialization should succeed"); @@ -7884,6 +8945,131 @@ mod tests { ); } + #[test] + fn should_serialize_key_id_reference_metadata() { + let property = DocumentProperty { + property_type: DocumentPropertyType::KeyIdWithReference(KeyIdReference::new( + KeyReferenceIdentityProperty::OwnerId, + )), + required: false, + transient: false, + required_since: None, + distinct_from: None, + encrypted_for: None, + }; + + let value = serde_json::to_value(&property).expect("serialization should succeed"); + + assert_eq!( + value.get("property_type"), + Some(&serde_json::json!({ + "KeyIdWithReference": { "identity_property": "$ownerId" } + })) + ); + } + + /// A key id with a reference is a `u32` to everything that sizes, encodes, + /// decodes or names a property: the declaration changes what consensus + /// checks, not the bytes. + #[test] + fn should_treat_a_key_id_with_reference_exactly_as_a_u32() { + let platform_version = PlatformVersion::latest(); + let key_id = DocumentPropertyType::KeyIdWithReference(KeyIdReference::new( + KeyReferenceIdentityProperty::OwnerId, + )); + let u32_type = DocumentPropertyType::U32; + + assert_eq!(key_id.name(), u32_type.name()); + assert!(key_id.is_integer()); + assert_eq!(key_id.min_size(), u32_type.min_size()); + assert_eq!(key_id.max_size(), u32_type.max_size()); + assert_eq!( + key_id.middle_size(platform_version), + u32_type.middle_size(platform_version) + ); + assert_eq!( + key_id.min_byte_size(platform_version).unwrap(), + u32_type.min_byte_size(platform_version).unwrap() + ); + assert_eq!( + key_id.max_byte_size(platform_version).unwrap(), + u32_type.max_byte_size(platform_version).unwrap() + ); + + let value = Value::U32(7); + assert_eq!( + key_id.encode_value_for_tree_keys(&value).unwrap(), + u32_type.encode_value_for_tree_keys(&value).unwrap() + ); + assert_eq!( + key_id.encode_value_with_size(value.clone(), true).unwrap(), + u32_type + .encode_value_with_size(value.clone(), true) + .unwrap() + ); + assert_eq!( + key_id.encode_value_ref_with_size(&value, false).unwrap(), + u32_type.encode_value_ref_with_size(&value, false).unwrap() + ); + let encoded = key_id.encode_value_for_tree_keys(&value).unwrap(); + assert_eq!( + key_id.decode_value_for_tree_keys(&encoded).unwrap(), + u32_type.decode_value_for_tree_keys(&encoded).unwrap() + ); + assert_eq!( + key_id.value_from_string("7").unwrap(), + u32_type.value_from_string("7").unwrap() + ); + + let mut widened = Value::U64(7); + key_id.sanitize_value_mut(&mut widened); + assert_eq!(widened, Value::U32(7)); + } + + #[test] + fn should_spell_the_key_reference_identity_property_as_the_schema_does() { + assert_eq!(KeyReferenceIdentityProperty::OwnerId.as_str(), "$ownerId"); + assert_eq!( + KeyReferenceIdentityProperty::OwnerId.to_string(), + "$ownerId" + ); + assert_eq!( + KeyReferenceIdentityProperty::CreatorId.as_str(), + "$creatorId" + ); + assert_eq!( + KeyReferenceIdentityProperty::Property("meta.toUserId".to_string()).as_str(), + "meta.toUserId" + ); + assert_eq!( + KeyReferenceIdentityProperty::from_wire_name("$ownerId"), + Some(KeyReferenceIdentityProperty::OwnerId) + ); + assert_eq!( + KeyReferenceIdentityProperty::from_wire_name("$creatorId"), + Some(KeyReferenceIdentityProperty::CreatorId) + ); + assert_eq!( + KeyReferenceIdentityProperty::from_wire_name("toUserId"), + Some(KeyReferenceIdentityProperty::Property( + "toUserId".to_string() + )) + ); + // Other system names, the empty path and an overlong path are not admitted + assert_eq!(KeyReferenceIdentityProperty::from_wire_name("$id"), None); + assert_eq!(KeyReferenceIdentityProperty::from_wire_name(""), None); + assert_eq!( + KeyReferenceIdentityProperty::from_wire_name(&"a".repeat(257)), + None + ); + for name in KeyReferenceIdentityProperty::SYSTEM_WIRE_NAMES { + assert_eq!( + KeyReferenceIdentityProperty::from_wire_name(name).map(|p| p.as_str().to_string()), + Some(name.to_string()) + ); + } + } + #[test] fn should_meet_a_minimum_age_from_the_recorded_creation_time_at_the_block_time() { let created_at: TimestampMillis = 1_700_000_000_000; @@ -8406,6 +9592,20 @@ mod tests { .to_string(), "deletable document (own contract, document type note)" ); + assert_eq!( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: Default::default(), + lookup: DocumentReferenceLookup { + index: "bySubmittedCharter".to_string(), + keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), + }, + } + .to_string(), + "permanent document (own contract, document type joinRequest, found through unique \ + index bySubmittedCharter)" + ); } #[test] @@ -8437,14 +9637,201 @@ mod tests { .is_none()); } + /// A lookup reference is a document reference, but its value is not a + /// document id: only the accessor for every kind returns it, so code that + /// treats the value as an id can not take it for one. + #[test] + fn should_return_a_lookup_reference_only_from_the_accessor_for_every_kind() { + let lookup = DocumentReferenceLookup { + index: "bySubmittedCharter".to_string(), + keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), + }; + let target = DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: Default::default(), + lookup: lookup.clone(), + }; + + assert_eq!(target.as_document_reference(), None); + let declaration = target + .as_any_document_reference() + .expect("a document reference"); + assert!(declaration.permanent); + assert_eq!(declaration.document_type_name, "joinRequest"); + assert_eq!(declaration.lookup, Some(&lookup)); + + // An id reference is returned by both, without a lookup + let id_reference = DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: Default::default(), + }; + assert_eq!( + id_reference.as_document_reference(), + id_reference.as_any_document_reference() + ); + assert_eq!( + id_reference + .as_document_reference() + .and_then(|declaration| declaration.lookup), + None + ); + } + + fn key_with(purpose: Purpose, contract_bounds: Option) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 2, + purpose, + security_level: SecurityLevel::HIGH, + contract_bounds, + key_type: KeyType::ECDSA_HASH160, + data: BinaryData::new(vec![0x74; 20]), + read_only: false, + disabled_at: None, + }) + } + + #[test] + fn should_report_the_first_unmet_key_requirement_and_what_the_key_has() { + let contract_id = Identifier::new([3; 32]); + let other_contract_id = Identifier::new([4; 32]); + let requirements = IdentityKeyReferenceRequirements { + purpose: Some(Purpose::DECRYPTION), + bound_to: Some("submittedCharter".to_string()), + }; + let bound_to_charter = |id: Identifier| ContractBounds::SingleContractDocumentType { + id, + document_type_name: "submittedCharter".to_string(), + }; + + assert!(requirements + .first_unmet_by( + &key_with(Purpose::DECRYPTION, Some(bound_to_charter(contract_id))), + contract_id, + ) + .is_none()); + + // The purpose is checked first, whatever the bound + let key = key_with(Purpose::ENCRYPTION, None); + let unmet = requirements + .first_unmet_by(&key, contract_id) + .expect("the purpose is unmet"); + assert_eq!( + unmet, + IdentityKeyReferenceRequirement::Purpose(Purpose::DECRYPTION) + ); + assert_eq!(unmet.field(), "purpose"); + assert_eq!(unmet.required(), "decryption"); + assert_eq!(unmet.actual_of(&key), "encryption"); + + for (key, actual) in [ + (key_with(Purpose::DECRYPTION, None), "no contract bounds"), + ( + key_with( + Purpose::DECRYPTION, + Some(ContractBounds::SingleContract { id: contract_id }), + ), + &format!("whole contract {contract_id}, not a document type"), + ), + ( + key_with( + Purpose::DECRYPTION, + Some(ContractBounds::SingleContractDocumentType { + id: contract_id, + document_type_name: "joinRequest".to_string(), + }), + ), + &format!("contract {contract_id} document type joinRequest"), + ), + ( + key_with( + Purpose::DECRYPTION, + Some(bound_to_charter(other_contract_id)), + ), + &format!("contract {other_contract_id} document type submittedCharter"), + ), + ( + key_with( + Purpose::DECRYPTION, + Some(ContractBounds::ContractGroup { id: contract_id }), + ), + &format!("contract group {contract_id}, which never meets a document type bound"), + ), + ] { + let unmet = requirements + .first_unmet_by(&key, contract_id) + .expect("the bound is unmet"); + assert_eq!( + unmet, + IdentityKeyReferenceRequirement::BoundTo("submittedCharter") + ); + assert_eq!(unmet.field(), "boundTo"); + assert_eq!(unmet.required(), "submittedCharter"); + assert_eq!(unmet.actual_of(&key), actual); + } + + assert!(IdentityKeyReferenceRequirements::default().is_empty()); + assert!(IdentityKeyReferenceRequirements::default() + .first_unmet_by(&key_with(Purpose::ENCRYPTION, None), contract_id) + .is_none()); + } + + #[test] + fn should_serialize_key_requirements_by_their_wire_names() { + let requirements = IdentityKeyReferenceRequirements { + purpose: Some(Purpose::DECRYPTION), + bound_to: Some("submittedCharter".to_string()), + }; + let json = serde_json::to_value(&requirements).expect("serializes"); + assert_eq!( + json, + serde_json::json!({ "purpose": "decryption", "boundTo": "submittedCharter" }) + ); + assert_eq!( + serde_json::from_value::(json).expect("parses"), + requirements + ); + assert_eq!( + serde_json::to_value(IdentityKeyReferenceRequirements::default()).expect("serializes"), + serde_json::json!({}) + ); + for name in ["signing", "system", "DECRYPTION"] { + assert!( + serde_json::from_value::( + serde_json::json!({ "purpose": name }) + ) + .is_err(), + "{name} should not deserialize as a key purpose requirement" + ); + } + assert_eq!( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyId".to_string(), + key_requirements: requirements, + } + .to_string(), + "identity public key (key id property recipientKeyId) with purpose decryption bound \ + to document type submittedCharter" + ); + assert_eq!( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyId".to_string(), + key_requirements: Default::default(), + } + .to_string(), + "identity public key (key id property recipientKeyId)" + ); + } + /// A compile-time guard, not a behavioural test. /// /// `DocumentPropertyReferenceTarget` is mirrored outside this crate — /// notably by wasm-dpp2's `DocumentPropertyReference` TypeScript union /// and the conversion that builds it. Those live behind a `match` that /// a new variant would not break, because they can fall back to a - /// catch-all. This exhaustive `match` has no catch-all, so adding a - /// seventh variant fails to compile *here*, in the crate that owns the + /// catch-all. This exhaustive `match` has no catch-all, so adding an + /// eighth variant fails to compile *here*, in the crate that owns the /// enum, where whoever adds it will see it. #[test] fn reference_targets_are_exhaustively_mirrored() { @@ -8461,12 +9848,22 @@ mod tests { }, DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property: "signerKeyId".to_string(), + key_requirements: Default::default(), }, DocumentPropertyReferenceTarget::DeletableDocument { contract_id: None, document_type_name: "note".to_string(), property_agreement: Default::default(), }, + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "note".to_string(), + property_agreement: Default::default(), + lookup: DocumentReferenceLookup { + index: "byOwner".to_string(), + keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), + }, + }, ]; for target in &targets { @@ -8478,6 +9875,9 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocument { .. } => "permanentDocument", DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", DocumentPropertyReferenceTarget::DeletableDocument { .. } => "deletableDocument", + DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } => { + "permanentDocument" + } }; // The tag is the `refersTo` schema keyword's own `type` value, diff --git a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs new file mode 100644 index 00000000000..5813a1b4be6 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs @@ -0,0 +1,826 @@ +//! The `lookup` of a document reference: the referenced document is the one a +//! unique index of the referenced document type finds for a key assembled from +//! the referring document, rather than the document whose id the property holds. +//! +//! Declared inside a `permanentDocument` `refersTo` (meta-schema v3, protocol +//! version 14); a `deletableDocument` reference cannot carry one, since a key +//! into a deletable type could find a new document once the one it found is +//! deleted, where an id is produced at most once: +//! +//! ```json +//! "refersTo": { +//! "type": "permanentDocument", +//! "documentType": "joinRequest", +//! "lookup": { +//! "index": "bySubmittedCharter", +//! "keys": { "submittedCharterId": "submittedCharterId", "$ownerId": "." } +//! } +//! } +//! ``` +//! +//! reads: the value must be the owner of a `joinRequest` whose +//! `submittedCharterId` equals this document's `submittedCharterId`. The rules +//! live here so the two places that check a declaration against its referenced +//! document type (the contract parse for a type of the same contract, the +//! registration state validation for a type of another contract) cannot drift. + +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; +use crate::data_contract::document_type::property::DocumentPropertyType; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::data_contract::errors::DataContractError; +use crate::document::property_names::{ + CREATED_AT, CREATED_AT_BLOCK_HEIGHT, CREATED_AT_CORE_BLOCK_HEIGHT, CREATOR_ID, ID, OWNER_ID, + TRANSFERRED_AT, TRANSFERRED_AT_BLOCK_HEIGHT, TRANSFERRED_AT_CORE_BLOCK_HEIGHT, UPDATED_AT, + UPDATED_AT_BLOCK_HEIGHT, UPDATED_AT_CORE_BLOCK_HEIGHT, +}; +use crate::nft::TradeMode; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::btreemap_extensions::BTreeValueMapPathHelper; +use platform_value::{Identifier, Value}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::mem::Discriminant; + +/// The wire name of [`LookupKeySource::ReferenceValue`]. +pub const LOOKUP_REFERENCE_VALUE: &str = "."; + +/// Where the value of one key part of a [`DocumentReferenceLookup`] comes from, +/// on the referring side. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +#[serde(into = "String")] +pub enum LookupKeySource { + /// `"."`: the value of the property carrying the reference, or, for a + /// reference on the elements of an array, the element. The one part of the + /// key the reference's own value supplies. + ReferenceValue, + /// `"$ownerId"`: the `$ownerId` of the referring document, the writer. + OwnerId, + /// A property path of the referring document type. The property must be + /// required, so the key is never missing a part. + Property(String), +} + +impl LookupKeySource { + /// The source a wire name names: `"."`, `"$ownerId"` or a property path. + /// Any other `$`-prefixed name is refused: the writer is the only system + /// value a referring document carries that a key could need. + pub fn from_wire_name(name: &str) -> Result { + match name { + LOOKUP_REFERENCE_VALUE => Ok(LookupKeySource::ReferenceValue), + OWNER_ID => Ok(LookupKeySource::OwnerId), + _ if name.starts_with('$') => { + Err(DataContractError::InvalidContractStructure(format!( + "refersTo lookup keys take \".\", \"{OWNER_ID}\" or a property of the \ + referring document type, not system property \"{name}\"" + ))) + } + _ if name.is_empty() || name.len() > MAX_LOOKUP_PATH_LENGTH => { + Err(DataContractError::InvalidContractStructure(format!( + "refersTo lookup key sources must be between 1 and {MAX_LOOKUP_PATH_LENGTH} \ + characters" + ))) + } + _ => Ok(LookupKeySource::Property(name.to_string())), + } + } + + /// The wire name, as the schema spells it. + pub fn as_str(&self) -> &str { + match self { + LookupKeySource::ReferenceValue => LOOKUP_REFERENCE_VALUE, + LookupKeySource::OwnerId => OWNER_ID, + LookupKeySource::Property(path) => path.as_str(), + } + } +} + +impl From for String { + fn from(source: LookupKeySource) -> Self { + source.as_str().to_string() + } +} + +/// The longest index property name or source path a lookup may name, the bound +/// meta-schema v3 puts on index property names. +pub const MAX_LOOKUP_PATH_LENGTH: usize = 256; + +/// The most key parts a lookup may map, the most properties an index can have. +pub const MAX_LOOKUP_KEYS: usize = 10; + +/// The longest index name a lookup may name, the bound meta-schema v3 puts on +/// index names. +pub const MAX_LOOKUP_INDEX_NAME_LENGTH: usize = 32; + +/// How a document reference finds the referenced document when its value is not +/// that document's id: through the unique index `index` of the referenced +/// document type, with one key part per index property. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +pub struct DocumentReferenceLookup { + /// The name of a unique index of the referenced document type. + pub index: String, + /// Every property of the index, by its name on the referenced side + /// (`$ownerId` among the system ones), mapped to the referring-side source + /// of its value. [`LookupKeySource::ReferenceValue`] appears exactly once. + pub keys: BTreeMap, +} + +impl DocumentReferenceLookup { + /// The referring document type's property paths the key reads, in key + /// order: a replace that changes one of them re-validates the reference. + pub fn referring_properties(&self) -> impl Iterator { + self.keys.values().filter_map(|source| match source { + LookupKeySource::Property(path) => Some(path.as_str()), + LookupKeySource::ReferenceValue | LookupKeySource::OwnerId => None, + }) + } + + /// Why the referring side of this lookup, declared on the property at + /// `reference_path` of `declaring`, cannot always assemble a key; `None` + /// when it can. Every property source must be a stored, required, single + /// value of the declaring type (with every object around it required too), + /// so the lookup never runs with a missing key part and a reader can + /// assemble the same key from the stored document; it may not be the + /// reference property itself, whose value is `"."`. A `"$ownerId"` source + /// needs a declaring type whose documents cannot be transferred or + /// traded: the reference is validated when the document is written, and + /// a transfer or a purchase would move the writer part of the key + /// without any write, leaving a validated reference that finds nothing. + /// With that, every part of the referring side's key changes only through + /// a replace, which re-validates the reference. + pub fn referring_side_error( + &self, + declaring: DocumentTypeRef, + reference_path: &str, + ) -> Option { + for (index_property, source) in &self.keys { + let path = match source { + LookupKeySource::ReferenceValue => continue, + LookupKeySource::OwnerId => { + if owner_can_change(declaring) { + return Some(format!( + "key \"{index_property}\" reads \"$ownerId\", which a transfer or a \ + purchase of the referring document changes without re-validating \ + the reference: a lookup may read the writer only on a document type \ + that cannot be transferred or traded" + )); + } + continue; + } + LookupKeySource::Property(path) => path, + }; + if path == reference_path { + return Some(format!( + "key \"{index_property}\" names the reference property itself: write \".\" \ + for the reference's own value" + )); + } + let Some(property) = declaring.flattened_properties().get(path) else { + return Some(format!( + "key \"{index_property}\" reads \"{path}\", which is not a property of the \ + referring document type" + )); + }; + if matches!( + property.property_type, + DocumentPropertyType::TypedArray(_) + | DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::Object(_) + ) { + return Some(format!( + "key \"{index_property}\" reads \"{path}\", which is not a single value" + )); + } + if declaring.transient_fields().contains(path) { + return Some(format!( + "key \"{index_property}\" reads \"{path}\", which is transient: the key must \ + be readable from the stored document" + )); + } + // A required leaf inside an optional object is only present when the + // object is, so every ancestor must be required as well + let mut prefix = String::new(); + for segment in path.split('.') { + if !prefix.is_empty() { + prefix.push('.'); + } + prefix.push_str(segment); + if !declaring.required_fields().contains(&prefix) { + return Some(format!( + "key \"{index_property}\" reads \"{path}\", which is not required: a \ + lookup never runs with a missing key part, so every property it reads \ + (and every object around it) must be listed in `required`" + )); + } + } + } + None + } + + /// Why this lookup, declared on a property of `declaring`, cannot resolve + /// in `referenced`, the referenced document type; `None` when it can. The + /// index must exist and be unique, so the key finds at most one document; + /// it may not bucket a timestamp (`timeRange`), since its first key part + /// is then a bucket start no referring value names; the referenced type + /// may not be `indexOnly`; `keys` must map every property of the index + /// exactly once and nothing else; and each source must hold the same kind + /// of value as the index property it fills, or no document could ever + /// match. The key must also stay with the document it found, see + /// [`Self::moving_key_part`]. + pub fn referenced_side_error( + &self, + declaring: DocumentTypeRef, + referenced: DocumentTypeRef, + ) -> Option { + let Some(index) = referenced.indexes().get(&self.index) else { + return Some(format!( + "the referenced document type \"{}\" has no index named \"{}\"", + referenced.name(), + self.index + )); + }; + if !index.unique { + return Some(format!( + "index \"{}\" of \"{}\" is not unique: a lookup must find at most one document", + self.index, + referenced.name() + )); + } + if index.time_range.is_some() { + return Some(format!( + "index \"{}\" of \"{}\" buckets its first property by a timeRange, so no \ + referring value could name a key of it", + self.index, + referenced.name() + )); + } + if referenced.index_only() { + return Some(format!( + "\"{}\" is an indexOnly document type, which a lookup cannot reference", + referenced.name() + )); + } + if let Some(missing) = index + .properties + .iter() + .find(|property| !self.keys.contains_key(&property.name)) + { + return Some(format!( + "keys does not map \"{}\", a property of index \"{}\": every index property \ + needs a source", + missing.name, self.index + )); + } + if let Some(extra) = self.keys.keys().find(|name| { + !index + .properties + .iter() + .any(|property| &property.name == *name) + }) { + return Some(format!( + "keys maps \"{extra}\", which is not a property of index \"{}\"", + self.index + )); + } + for (index_property, source) in &self.keys { + let Some(indexed_kind) = index_property_value_kind(referenced, index_property) else { + return Some(format!( + "index property \"{index_property}\" is not a property of \"{}\"", + referenced.name() + )); + }; + let source_kind = match source { + // Both are identifiers, the reference's own value because + // `refersTo` is only allowed on identifiers + LookupKeySource::ReferenceValue | LookupKeySource::OwnerId => { + std::mem::discriminant(&DocumentPropertyType::Identifier) + } + LookupKeySource::Property(path) => { + let Some(property) = declaring.flattened_properties().get(path) else { + return Some(format!( + "key \"{index_property}\" reads \"{path}\", which is not a property \ + of the referring document type" + )); + }; + property.property_type.value_kind() + } + }; + if source_kind != indexed_kind { + return Some(format!( + "key \"{index_property}\" is filled from \"{}\", which holds a different \ + kind of value: no document could ever match", + source.as_str() + )); + } + } + if let Some((index_property, why)) = self.moving_key_part(referenced) { + return Some(format!( + "index \"{}\" of \"{}\" keys documents by \"{index_property}\", which {why}: a \ + lookup must keep finding the document it found, so every part of its key must \ + be fixed once the document is written (make the type immutable or list the \ + property under `immutable`)", + self.index, + referenced.name() + )); + } + None + } + + /// The first key part a referenced document of `referenced` can change + /// after it was written, with how, `None` when every part is fixed for + /// good. A `permanentDocument` reference, the only kind that takes a + /// lookup, promises it never dangles: its referenced type forbids + /// deletion, and a lookup's key must not move off the document either, or + /// a document validated against it would later find nothing. A schema property is fixed on a type whose documents are + /// immutable or when its top-level property is listed under `immutable` + /// (an `immutableAllowSetting` entry can only be set on a document that + /// has no value for it, which no key could have found); `$ownerId` is + /// fixed unless documents can be transferred or traded; `$id`, + /// `$creatorId` and the creation times never change; the update and + /// transfer times change with the document. Every flag read here is + /// immutable on contract update, and the `immutable` list may only grow, + /// so the answer holds for good. + fn moving_key_part(&self, referenced: DocumentTypeRef) -> Option<(&str, &'static str)> { + let changes_owner = owner_can_change(referenced); + let replaceable = referenced.documents_mutable(); + self.keys.keys().find_map(|index_property| { + let why = match index_property.as_str() { + ID + | CREATOR_ID + | CREATED_AT + | CREATED_AT_BLOCK_HEIGHT + | CREATED_AT_CORE_BLOCK_HEIGHT => None, + OWNER_ID => changes_owner.then_some("a transfer or a purchase changes"), + UPDATED_AT | UPDATED_AT_BLOCK_HEIGHT | UPDATED_AT_CORE_BLOCK_HEIGHT => (replaceable + || changes_owner) + .then_some("a replace, a transfer or a purchase changes"), + TRANSFERRED_AT | TRANSFERRED_AT_BLOCK_HEIGHT | TRANSFERRED_AT_CORE_BLOCK_HEIGHT => { + changes_owner.then_some("a transfer or a purchase changes") + } + property => { + let top_level = property.split('.').next().unwrap_or(property); + (replaceable && !referenced.immutable_fields().contains(top_level)) + .then_some("a replace can change") + } + }; + why.map(|why| (index_property.as_str(), why)) + }) + } + + /// The equality values of the key the lookup assembles for one write, by + /// index property name: `reference_value` for `"."` (the property's value, + /// or one array element's), `owner_id` for `"$ownerId"`, and the value at + /// each source path in `document_data`. `None` when a source path holds no + /// value or does not resolve. Registration admits required sources only, + /// so a value is missing only on a document stamped before the property + /// became required (`requiredSince`) or one the schema validation refuses + /// anyway, and a key with a missing part finds no document. + pub fn key_values( + &self, + reference_value: Identifier, + document_data: &BTreeMap, + owner_id: Identifier, + ) -> Option> { + self.keys + .iter() + .map(|(index_property, source)| { + let value = match source { + LookupKeySource::ReferenceValue => { + Value::Identifier(reference_value.to_buffer()) + } + LookupKeySource::OwnerId => Value::Identifier(owner_id.to_buffer()), + LookupKeySource::Property(path) => { + document_data.get_optional_at_path(path).ok()??.clone() + } + }; + Some((index_property.clone(), value)) + }) + .collect() + } +} + +/// The kind of value an index property of `document_type` holds: a system +/// property's fixed type, or the schema property's. `None` when the name is +/// neither. +/// Whether a document of `document_type` can change owner after it was +/// written, by a transfer or a purchase. Both flags are immutable on contract +/// update, so the answer holds for good. +fn owner_can_change(document_type: DocumentTypeRef) -> bool { + document_type.documents_transferable().is_transferable() + || document_type.trade_mode() != TradeMode::None +} + +fn index_property_value_kind( + document_type: DocumentTypeRef, + name: &str, +) -> Option> { + let system_type = match name { + ID | OWNER_ID | CREATOR_ID => Some(DocumentPropertyType::Identifier), + CREATED_AT | UPDATED_AT | TRANSFERRED_AT => Some(DocumentPropertyType::Date), + CREATED_AT_BLOCK_HEIGHT | UPDATED_AT_BLOCK_HEIGHT | TRANSFERRED_AT_BLOCK_HEIGHT => { + Some(DocumentPropertyType::U64) + } + CREATED_AT_CORE_BLOCK_HEIGHT + | UPDATED_AT_CORE_BLOCK_HEIGHT + | TRANSFERRED_AT_CORE_BLOCK_HEIGHT => Some(DocumentPropertyType::U32), + _ => None, + }; + match system_type { + Some(system_type) => Some(system_type.value_kind()), + None => document_type + .flattened_properties() + .get(name) + .map(|property| property.property_type.value_kind()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_contract::config::DataContractConfig; + use crate::data_contract::document_type::DocumentType; + use platform_value::platform_value; + use platform_version::version::PlatformVersion; + + fn identifier() -> Value { + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32u32, + "maxItems": 32u32, + "contentMediaType": "application/x.dash.dpp.identifier" + }) + } + + fn with_position(mut property: Value, position: u32) -> Value { + property + .insert("position".to_string(), Value::U32(position)) + .expect("the position inserts"); + property + } + + fn document_type(name: &str, schema: Value) -> DocumentType { + let platform_version = PlatformVersion::latest(); + let config = + DataContractConfig::default_for_version(platform_version).expect("config should build"); + DocumentType::try_from_schema( + Identifier::from([1; 32]), + 1, + config.version(), + name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut vec![], + platform_version, + ) + .expect("the document type should parse") + } + + /// A `joinRequest` with a unique (`submittedCharterId`, `$ownerId`) index + /// and a non-unique `byNote` index, immutable and not transferable, with + /// `extra` merged into its schema. + fn join_request_with(extra: Value) -> DocumentType { + let mut schema = platform_value!({ + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "submittedCharterId": with_position(identifier(), 0), + "note": { "type": "string", "maxLength": 32u32, "position": 1u32 } + }, + "indices": [ + { + "name": "bySubmittedCharter", + "properties": [{ "submittedCharterId": "asc" }, { "$ownerId": "asc" }], + "unique": true + }, + { "name": "byNote", "properties": [{ "note": "asc" }] } + ], + "required": ["submittedCharterId"], + "additionalProperties": false + }); + merge_keywords(&mut schema, extra); + document_type("joinRequest", schema) + } + + /// Merges the top-level keywords of `extra` into `schema`. + fn merge_keywords(schema: &mut Value, extra: Value) { + if let Value::Map(entries) = extra { + for (key, value) in entries { + let key = key.to_text().expect("a text key"); + schema.insert(key, value).expect("the keyword inserts"); + } + } + } + + fn join_request() -> DocumentType { + join_request_with(platform_value!({})) + } + + /// An `electedCharter` with a required `submittedCharterId`, an optional + /// `optionalCharterId`, a string `note` and the reference `memberId`, with + /// `extra` merged into its schema. + fn elected_charter_with(extra: Value) -> DocumentType { + let mut schema = platform_value!({ + "type": "object", + "properties": { + "submittedCharterId": with_position(identifier(), 0), + "optionalCharterId": with_position(identifier(), 1), + "note": { "type": "string", "maxLength": 32u32, "position": 2u32 }, + "memberId": with_position(identifier(), 3) + }, + "required": ["submittedCharterId", "note"], + "additionalProperties": false + }); + merge_keywords(&mut schema, extra); + document_type("electedCharter", schema) + } + + fn elected_charter() -> DocumentType { + elected_charter_with(platform_value!({})) + } + + fn lookup(index: &str, keys: &[(&str, &str)]) -> DocumentReferenceLookup { + DocumentReferenceLookup { + index: index.to_string(), + keys: keys + .iter() + .map(|(index_property, source)| { + ( + index_property.to_string(), + LookupKeySource::from_wire_name(source).expect("a valid source"), + ) + }) + .collect(), + } + } + + #[test] + fn should_accept_a_lookup_covering_a_unique_index_with_sources_of_the_right_kind() { + let lookup = lookup( + "bySubmittedCharter", + &[ + ("submittedCharterId", "submittedCharterId"), + ("$ownerId", "."), + ], + ); + let declaring = elected_charter(); + assert_eq!( + lookup.referring_side_error(declaring.as_ref(), "memberId"), + None + ); + assert_eq!( + lookup.referenced_side_error(declaring.as_ref(), join_request().as_ref()), + None + ); + } + + #[test] + fn should_refuse_a_missing_or_non_unique_index() { + let declaring = elected_charter(); + for (index, fragment) in [ + ("byMissing", "has no index named \"byMissing\""), + ("byNote", "is not unique"), + ] { + let lookup = lookup(index, &[("note", "."), ("x", "note")]); + let error = lookup + .referenced_side_error(declaring.as_ref(), join_request().as_ref()) + .expect("the index should be refused"); + assert!(error.contains(fragment), "{index}: {error}"); + } + } + + #[test] + fn should_refuse_keys_that_miss_or_add_an_index_property() { + let declaring = elected_charter(); + for (keys, fragment) in [ + ( + vec![("$ownerId", ".")], + "does not map \"submittedCharterId\"", + ), + ( + vec![ + ("submittedCharterId", "submittedCharterId"), + ("$ownerId", "."), + ("note", "note"), + ], + "maps \"note\", which is not a property of index", + ), + ] { + let error = lookup("bySubmittedCharter", &keys) + .referenced_side_error(declaring.as_ref(), join_request().as_ref()) + .expect("the keys should be refused"); + assert!(error.contains(fragment), "{keys:?}: {error}"); + } + } + + #[test] + fn should_refuse_a_source_of_the_wrong_value_kind() { + let declaring = elected_charter(); + let error = lookup( + "bySubmittedCharter", + &[("submittedCharterId", "note"), ("$ownerId", ".")], + ) + .referenced_side_error(declaring.as_ref(), join_request().as_ref()) + .expect("the string source should be refused"); + assert!(error.contains("holds a different kind of value"), "{error}"); + } + + #[test] + fn should_refuse_an_optional_missing_or_self_referring_source() { + let declaring = elected_charter(); + for (source, fragment) in [ + ("optionalCharterId", "which is not required"), + ( + "ghostId", + "which is not a property of the referring document type", + ), + ("memberId", "names the reference property itself"), + ] { + let error = lookup( + "bySubmittedCharter", + &[("submittedCharterId", source), ("$ownerId", ".")], + ) + .referring_side_error(declaring.as_ref(), "memberId") + .expect("the source should be refused"); + assert!(error.contains(fragment), "{source}: {error}"); + } + } + + #[test] + fn should_refuse_a_lookup_whose_key_the_referenced_document_can_move() { + let lookup = lookup( + "bySubmittedCharter", + &[ + ("submittedCharterId", "submittedCharterId"), + ("$ownerId", "."), + ], + ); + let declaring = elected_charter(); + let refused = |referenced: DocumentType| { + lookup.referenced_side_error(declaring.as_ref(), referenced.as_ref()) + }; + + // A replace could move `submittedCharterId`, a transfer or a purchase `$ownerId` + for (extra, moving) in [ + ( + platform_value!({ "documentsMutable": true }), + "keys documents by \"submittedCharterId\", which a replace can change", + ), + ( + platform_value!({ "transferable": 1u8 }), + "keys documents by \"$ownerId\", which a transfer or a purchase changes", + ), + ( + platform_value!({ "tradeMode": 1u8 }), + "keys documents by \"$ownerId\", which a transfer or a purchase changes", + ), + ] { + let error = + refused(join_request_with(extra.clone())).expect("a moving key should be refused"); + assert!(error.contains(moving), "{extra:?}: {error}"); + } + + // Frozen by the `immutable` list on a mutable type, the key holds + assert_eq!( + refused(join_request_with(platform_value!({ + "documentsMutable": true, + "immutable": ["submittedCharterId"] + }))), + None + ); + } + + /// A key part read from the writer would move with a transfer or a + /// purchase of the referring document, which re-validates nothing, so + /// only a type that can do neither may read it. + #[test] + fn should_refuse_a_writer_key_part_on_a_referring_type_that_can_change_owner() { + let lookup = lookup( + "bySubmittedCharter", + &[("submittedCharterId", "."), ("$ownerId", "$ownerId")], + ); + for extra in [ + platform_value!({ "transferable": 1u8 }), + platform_value!({ "tradeMode": 1u8 }), + ] { + let error = lookup + .referring_side_error(elected_charter_with(extra.clone()).as_ref(), "memberId") + .expect("a writer key part should be refused"); + assert!( + error.contains( + "key \"$ownerId\" reads \"$ownerId\", which a transfer or a purchase of \ + the referring document changes" + ), + "{extra:?}: {error}" + ); + } + + // A type that can neither be transferred nor traded keeps its writer + assert_eq!( + lookup.referring_side_error(elected_charter().as_ref(), "memberId"), + None + ); + } + + #[test] + fn should_accept_a_key_id_reference_as_a_source_for_a_key_id_index_property() { + let key_id = |position: u32| { + platform_value!({ + "type": "integer", + "minimum": 0u32, + "maximum": 4294967295u32, + "position": position + }) + }; + let referenced = document_type( + "keyRequest", + platform_value!({ + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { "keyId": key_id(0) }, + "indices": [{ + "name": "byKey", + "properties": [{ "keyId": "asc" }, { "$ownerId": "asc" }], + "unique": true + }], + "required": ["keyId"], + "additionalProperties": false + }), + ); + let mut signer_key_id = key_id(0); + signer_key_id + .insert( + "refersTo".to_string(), + platform_value!({ "type": "identityPublicKey", "identityProperty": "$ownerId" }), + ) + .expect("refersTo inserts"); + let declaring = document_type( + "keyUse", + platform_value!({ + "type": "object", + "properties": { + "signerKeyId": signer_key_id, + "requesterId": with_position(identifier(), 1) + }, + "required": ["signerKeyId"], + "additionalProperties": false + }), + ); + assert!(matches!( + declaring.as_ref().flattened_properties()["signerKeyId"].property_type, + DocumentPropertyType::KeyIdWithReference(_) + )); + + // A key id reference holds a u32 like the plain key id it is matched with + let lookup = lookup("byKey", &[("keyId", "signerKeyId"), ("$ownerId", ".")]); + assert_eq!( + lookup.referenced_side_error(declaring.as_ref(), referenced.as_ref()), + None + ); + } + + #[test] + fn should_refuse_other_system_sources() { + for source in ["$id", "$createdAt", "$creatorId"] { + let error = + LookupKeySource::from_wire_name(source).expect_err("the source should be refused"); + assert!( + error.to_string().contains("not system property"), + "{source}: {error}" + ); + } + } + + #[test] + fn should_assemble_the_key_from_the_reference_value_the_owner_and_the_document() { + let lookup = lookup( + "bySubmittedCharter", + &[ + ("submittedCharterId", "submittedCharterId"), + ("$ownerId", "."), + ], + ); + let member = Identifier::from([3; 32]); + let owner = Identifier::from([4; 32]); + let data = BTreeMap::from([("submittedCharterId".to_string(), Value::Identifier([5; 32]))]); + + assert_eq!( + lookup.key_values(member, &data, owner), + Some(BTreeMap::from([ + ("$ownerId".to_string(), Value::Identifier([3; 32])), + ("submittedCharterId".to_string(), Value::Identifier([5; 32])), + ])) + ); + assert_eq!(lookup.key_values(member, &BTreeMap::new(), owner), None); + assert_eq!( + lookup.referring_properties().collect::>(), + vec!["submittedCharterId"] + ); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/mod.rs b/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/mod.rs index ccd1e64fedb..7ab44ba9518 100644 --- a/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/mod.rs @@ -7,6 +7,7 @@ use platform_version::version::dpp_versions::dpp_contract_versions::DocumentType use std::collections::BTreeSet; mod v0; +mod v1; impl DocumentType { pub(in crate::data_contract) fn find_identifier_and_binary_paths( @@ -20,9 +21,12 @@ impl DocumentType { 0 => Ok(DocumentTypeV0::find_identifier_and_binary_paths_v0( properties, )), + 1 => Ok(DocumentTypeV0::find_identifier_and_binary_paths_v1( + properties, + )), version => Err(ProtocolError::UnknownVersionMismatch { method: "find_identifier_and_binary_paths".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } diff --git a/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/v1/mod.rs new file mode 100644 index 00000000000..407606ae69c --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/v1/mod.rs @@ -0,0 +1,99 @@ +//! Generation 1: generation 0 plus the typed array (protocol version 14), +//! whose identifier and byte array elements are registered as `path[]` +//! conversion paths, as the never-produced `Array` variant's always were. + +use crate::data_contract::document_type::array::{ArrayItemType, TypedArrayProperty}; +use crate::data_contract::document_type::property::{DocumentProperty, DocumentPropertyType}; +use crate::data_contract::document_type::v0::DocumentTypeV0; + +use indexmap::IndexMap; +use std::collections::BTreeSet; + +impl DocumentTypeV0 { + #[inline(always)] + pub(super) fn find_identifier_and_binary_paths_v1( + properties: &IndexMap, + ) -> (BTreeSet, BTreeSet) { + Self::find_identifier_and_binary_paths_inner_v1(properties, "") + } + + #[inline(always)] + fn find_identifier_and_binary_paths_inner_v1( + properties: &IndexMap, + current_path: &str, + ) -> (BTreeSet, BTreeSet) { + let mut identifier_paths = BTreeSet::new(); + let mut binary_paths = BTreeSet::new(); + + for (key, value) in properties.iter() { + let new_path = if current_path.is_empty() { + key.clone() + } else { + format!("{}.{}", current_path, key) + }; + + match &value.property_type { + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) => { + identifier_paths.insert(new_path); + } + DocumentPropertyType::ByteArray(_) => { + binary_paths.insert(new_path); + } + DocumentPropertyType::Object(inner_properties) => { + let (inner_identifier_paths, inner_binary_paths) = + Self::find_identifier_and_binary_paths_inner_v1( + inner_properties, + &new_path, + ); + + identifier_paths.extend(inner_identifier_paths); + binary_paths.extend(inner_binary_paths); + } + // `path[]` addresses every element of the list + DocumentPropertyType::Array(item_type) => { + let new_path = format!("{}[]", new_path); + match item_type { + ArrayItemType::Identifier => { + identifier_paths.insert(new_path); + } + ArrayItemType::ByteArray(_, _) => { + binary_paths.insert(new_path); + } + _ => {} + } + } + DocumentPropertyType::TypedArray(TypedArrayProperty { item_type, .. }) => { + let new_path = format!("{}[]", new_path); + match item_type.as_ref() { + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) => { + identifier_paths.insert(new_path); + } + DocumentPropertyType::ByteArray(_) => { + binary_paths.insert(new_path); + } + _ => {} + } + } + DocumentPropertyType::VariableTypeArray(item_types) => { + for (i, array_field_type) in item_types.iter().enumerate() { + let new_path = format!("{}[{}]", new_path, i); + match array_field_type { + ArrayItemType::Identifier => { + identifier_paths.insert(new_path.clone()); + } + ArrayItemType::ByteArray(_, _) => { + binary_paths.insert(new_path.clone()); + } + _ => {} + } + } + } + _ => {} + } + } + + (identifier_paths, binary_paths) + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs index 72164db4da9..5143ef5c0e1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs @@ -198,6 +198,8 @@ impl DocumentTypeV0 { required, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, } }; @@ -282,7 +284,7 @@ impl DocumentTypeV0 { } serde_json::Value::Object(schema) }, - DocumentPropertyType::U128 | DocumentPropertyType::U64 | DocumentPropertyType::U32 | DocumentPropertyType::U16 | DocumentPropertyType::U8 | + DocumentPropertyType::U128 | DocumentPropertyType::U64 | DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) | DocumentPropertyType::U16 | DocumentPropertyType::U8 | DocumentPropertyType::I128 | DocumentPropertyType::I64 | DocumentPropertyType::I32 | DocumentPropertyType::I16 | DocumentPropertyType::I8 => { let mut schema = serde_json::Map::new(); schema.insert("type".to_string(), serde_json::Value::String("integer".to_owned())); @@ -368,6 +370,68 @@ impl DocumentTypeV0 { "byteArray": true, }) }, + DocumentPropertyType::TypedArray(typed_array) => { + // Bounds are only written when declared: a `null` bound is no schema + fn with_bounds(mut schema: serde_json::Value, bounds: [(&str, Option); 2]) -> serde_json::Value { + if let serde_json::Value::Object(ref mut map) = schema { + for (keyword, bound) in bounds { + if let Some(bound) = bound { + map.insert(keyword.to_string(), json!(bound)); + } + } + } + schema + } + // The element schema that parses back to the element type + let integer = |minimum: Option, maximum: Option| { + let mut schema = json!({"type": "integer"}); + if let serde_json::Value::Object(ref mut map) = schema { + if let Some(minimum) = minimum { + map.insert("minimum".to_string(), json!(minimum as i64)); + } + if let Some(maximum) = maximum { + map.insert("maximum".to_string(), json!(maximum as i64)); + } + } + schema + }; + let bound = |size: Option| size.map(usize::from); + let items_schema = match typed_array.item_type.as_ref() { + DocumentPropertyType::U8 => integer(Some(0), Some(u8::MAX.into())), + DocumentPropertyType::I8 => integer(Some(i8::MIN.into()), Some(i8::MAX.into())), + DocumentPropertyType::U16 => integer(Some(0), Some(u16::MAX.into())), + DocumentPropertyType::I16 => integer(Some(i16::MIN.into()), Some(i16::MAX.into())), + DocumentPropertyType::U32 => integer(Some(0), Some(u32::MAX.into())), + DocumentPropertyType::I32 => integer(Some(i32::MIN.into()), Some(i32::MAX.into())), + DocumentPropertyType::U64 => integer(Some(0), None), + DocumentPropertyType::I64 | DocumentPropertyType::U128 | DocumentPropertyType::I128 => integer(None, None), + DocumentPropertyType::F64 | DocumentPropertyType::Date => json!({"type": "number"}), + DocumentPropertyType::String(sizes) => with_bounds(json!({"type": "string"}), [("minLength", bound(sizes.min_length)), ("maxLength", bound(sizes.max_length))]), + DocumentPropertyType::ByteArray(sizes) => with_bounds(json!({"type": "array", "byteArray": true}), [("minItems", bound(sizes.min_size)), ("maxItems", bound(sizes.max_size))]), + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + }), + DocumentPropertyType::Boolean => json!({"type": "boolean"}), + // The parser admits no other element type + _ => json!({}), + }; + + with_bounds( + json!({ + "type": "array", + "items": items_schema, + "uniqueItems": typed_array.unique_items, + }), + [ + ("minItems", typed_array.min_items.map(usize::from)), + ("maxItems", Some(usize::from(typed_array.max_items))), + ], + ) + }, DocumentPropertyType::VariableTypeArray(types) => { let types_schema = types.iter().map(|t| { match t { @@ -528,6 +592,8 @@ impl DocumentTypeV0 { required, transient: false, required_since: None, + distinct_from: None, + encrypted_for: None, } }; diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs index 800e9ee59ea..13b2ed0ac4c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs @@ -249,6 +249,10 @@ impl DocumentTypeV2Getters for DocumentTypeV2 { &self.immutable_fields } + fn distinct_from_fields(&self) -> &[String] { + &self.distinct_from_fields + } + fn immutable_fields_allow_setting(&self) -> &BTreeSet { &self.immutable_fields_allow_setting } diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 6c5213eaf44..9421b62dc17 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -55,6 +55,10 @@ pub struct DocumentTypeV2 { /// keyword, protocol version 14). Once present they are frozen like the /// rest of the list. Every entry is also in `immutable_fields`. pub(in crate::data_contract) immutable_fields_allow_setting: BTreeSet, + /// The dotted paths of the properties that declare `distinctFrom` + /// (protocol version 14), in schema order, so a document write finds + /// them without walking every property. Empty on every pre-PV14 contract. + pub(in crate::data_contract) distinct_from_fields: Vec, /// On an indexOnly type, the top-level properties stored in every entry's /// value after the row commitment (the `entryPayload` keyword), in name /// order. Empty on every other type and on every pre-PV14 contract. @@ -176,8 +180,21 @@ impl crate::data_contract::document_type::accessors::DocumentTypeV1Setters for D } } +/// The dotted paths of the properties that declare `distinctFrom`, in the +/// flattened map's (schema) order. +fn distinct_from_fields_of( + flattened_properties: &IndexMap, +) -> Vec { + flattened_properties + .iter() + .filter(|(_, property)| property.distinct_from.is_some()) + .map(|(path, _)| path.clone()) + .collect() +} + impl From for DocumentTypeV2 { fn from(value: DocumentTypeV0) -> Self { + let distinct_from_fields = distinct_from_fields_of(&value.flattened_properties); DocumentTypeV2 { name: value.name, schema: value.schema, @@ -191,6 +208,7 @@ impl From for DocumentTypeV2 { transient_fields: value.transient_fields, immutable_fields: BTreeSet::new(), immutable_fields_allow_setting: BTreeSet::new(), + distinct_from_fields, entry_payload: BTreeSet::new(), documents_keep_history: value.documents_keep_history, documents_keep_transfer_history: value.documents_keep_transfer_history, @@ -224,6 +242,7 @@ impl From for DocumentTypeV2 { impl From for DocumentTypeV2 { fn from(value: DocumentTypeV1) -> Self { + let distinct_from_fields = distinct_from_fields_of(&value.flattened_properties); DocumentTypeV2 { name: value.name, schema: value.schema, @@ -237,6 +256,7 @@ impl From for DocumentTypeV2 { transient_fields: value.transient_fields, immutable_fields: BTreeSet::new(), immutable_fields_allow_setting: BTreeSet::new(), + distinct_from_fields, entry_payload: BTreeSet::new(), documents_keep_history: value.documents_keep_history, documents_keep_transfer_history: value.documents_keep_transfer_history, diff --git a/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs b/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs index e62db2c432a..3c2bbecfd31 100644 --- a/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs +++ b/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs @@ -252,4 +252,116 @@ mod tests { assert_eq!(v1.owner_id(), recovered.owner_id()); assert_eq!(v1.version(), recovered.version()); } + + /// A `secret` document type whose `encryptedMessage` carries `encryptedFor` + /// when `encrypted` is set, and the same type without the keyword otherwise. + #[cfg(feature = "random-identities")] + fn secret_schema(encrypted: bool) -> platform_value::Value { + use platform_value::platform_value; + + let mut encrypted_message = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 1040, + "position": 3 + }); + if encrypted { + encrypted_message + .insert( + "encryptedFor".to_string(), + platform_value!({ + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }), + ) + .expect("expected to insert encryptedFor"); + } + platform_value!({ + "type": "object", + "properties": { + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "recipientKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 1 }, + "senderKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 2 }, + "encryptedMessage": encrypted_message + }, + "required": ["recipientId", "recipientKeyId", "senderKeyId", "encryptedMessage"], + "additionalProperties": false + }) + } + + /// The declaration lives in the document schema the contract serializes, so + /// a contract carrying it round-trips like any other and comes back with + /// the declaration parsed; one without it serializes exactly as before. + #[test] + #[cfg(feature = "random-identities")] + fn should_round_trip_a_contract_with_and_without_encrypted_for_through_platform_serialization() + { + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; + use crate::data_contract::document_type::{ + EncryptedFor, EncryptedForRecipient, EncryptionScheme, + }; + use crate::data_contract::schema::DataContractSchemaMethodsV0; + + let platform_version = PlatformVersion::latest(); + let identity = Identity::random_identity(5, Some(5), platform_version) + .expect("expected a random identity"); + + for encrypted in [true, false] { + let mut contract = get_data_contract_fixture( + Some(identity.id()), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + contract + .set_document_schema( + "secret", + secret_schema(encrypted), + true, + &mut Vec::new(), + platform_version, + ) + .expect("expected to add the secret document type"); + + let bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("expected to serialize"); + let recovered = + DataContract::versioned_deserialize_untrusted(&bytes, true, platform_version) + .expect("expected to deserialize"); + assert_eq!(contract, recovered); + + let secret_type = recovered + .document_type_for_name("secret") + .expect("expected the secret document type"); + let encrypted_properties = secret_type.encrypted_properties(); + if encrypted { + assert_eq!( + encrypted_properties, + vec![( + &"encryptedMessage".to_string(), + &EncryptedFor { + recipient: EncryptedForRecipient::Property("recipientId".to_string()), + recipient_key: "recipientKeyId".to_string(), + sender_key: "senderKeyId".to_string(), + scheme: EncryptionScheme::EcdhSecp256k1Aes256Cbc, + } + )] + ); + } else { + assert!(encrypted_properties.is_empty()); + } + } + } } diff --git a/packages/rs-dpp/src/document/extended_document/v0/mod.rs b/packages/rs-dpp/src/document/extended_document/v0/mod.rs index ba2fd2002b0..c776c44a365 100644 --- a/packages/rs-dpp/src/document/extended_document/v0/mod.rs +++ b/packages/rs-dpp/src/document/extended_document/v0/mod.rs @@ -466,17 +466,59 @@ impl ExtendedDocumentV0 { let identifiers = document_type.identifier_paths(); let binary_paths = document_type.binary_paths(); - if identifiers.contains(path) { - let value = - ReplacementType::Identifier.replace_for_bytes(value.to_identifier_bytes()?)?; - self.set(path, value) + // A typed array of identifiers or byte arrays is registered as + // `path[]`: every member of the list set at `path` is converted + let list_path = format!("{path}[]"); + let value = if identifiers.contains(path) { + Self::untrusted_value(ReplacementType::Identifier, value)? } else if binary_paths.contains(path) { - let value = - ReplacementType::BinaryBytes.replace_for_bytes(value.to_identifier_bytes()?)?; - self.set(path, value) + Self::untrusted_value(ReplacementType::BinaryBytes, value)? + } else if identifiers.contains(&list_path) { + Self::untrusted_list(ReplacementType::Identifier, path, value)? + } else if binary_paths.contains(&list_path) { + Self::untrusted_list(ReplacementType::BinaryBytes, path, value)? } else { - self.set(path, value) - } + value + }; + self.set(path, value) + } + + /// One untrusted value in its stored form: an identifier from base58 + /// text, an identifier or 32 bytes; binary bytes from base64 text or + /// bytes. + fn untrusted_value( + replacement_type: ReplacementType, + value: Value, + ) -> Result { + let bytes = match replacement_type { + ReplacementType::Identifier | ReplacementType::TextBase58 => { + value.into_identifier_bytes()? + } + ReplacementType::BinaryBytes | ReplacementType::TextBase64 => { + value.into_binary_bytes()? + } + }; + Ok(replacement_type.replace_for_bytes(bytes)?) + } + + /// The members of an untrusted list, each in its stored form. + fn untrusted_list( + replacement_type: ReplacementType, + path: &str, + value: Value, + ) -> Result { + let Value::Array(members) = value else { + return Err(ProtocolError::ValueError( + platform_value::Error::StructureError(format!( + "the value set at {path} must be a list, as the property is a typed array" + )), + )); + }; + let members = members + .into_iter() + .map(|member| Self::untrusted_value(replacement_type, member)) + .collect::, ProtocolError>>()?; + Ok(Value::Array(members)) } /// Retrieves field specified by path diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index 3eb229a6ebf..75960594ef7 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -54,13 +54,13 @@ use crate::consensus::basic::decode::{ use crate::consensus::basic::document::{ ContestedDocumentsTemporarilyNotAllowedError, DataContractNotPresentError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, - DocumentTransitionsAreAbsentError, DuplicateDocumentTransitionsWithIdsError, - DuplicateDocumentTransitionsWithIndicesError, InconsistentCompoundIndexDataError, - InvalidDocumentTransitionActionError, InvalidDocumentTransitionIdError, - InvalidDocumentTypeError, MaxDocumentsTransitionsExceededError, - MissingDataContractIdBasicError, MissingDocumentTransitionActionError, - MissingDocumentTransitionTypeError, MissingDocumentTypeError, - MissingPositionsInDocumentTypePropertiesError, NonceOutOfBoundsError, + DocumentPropertyNotDistinctError, DocumentTransitionsAreAbsentError, + DuplicateDocumentTransitionsWithIdsError, DuplicateDocumentTransitionsWithIndicesError, + InconsistentCompoundIndexDataError, InvalidDocumentTransitionActionError, + InvalidDocumentTransitionIdError, InvalidDocumentTypeError, InvalidEncryptedPropertyShapeError, + MaxDocumentsTransitionsExceededError, MissingDataContractIdBasicError, + MissingDocumentTransitionActionError, MissingDocumentTransitionTypeError, + MissingDocumentTypeError, MissingPositionsInDocumentTypePropertiesError, NonceOutOfBoundsError, }; use crate::consensus::basic::identity::ContractGroupBoundKeyNotAllowedInShieldedIdentityCreationError; use crate::consensus::basic::identity::IdentityKeyLimitsUpdateEmptyError; @@ -805,6 +805,13 @@ pub enum BasicError { // Documents cited by a contract moderation reason (protocol version 14). #[error(transparent)] InvalidContractModerationReasonDocumentsError(InvalidContractModerationReasonDocumentsError), + + #[error(transparent)] + DocumentPropertyNotDistinctError(DocumentPropertyNotDistinctError), + + // The shape of an `encryptedFor` property's ciphertext (protocol version 14). + #[error(transparent)] + InvalidEncryptedPropertyShapeError(InvalidEncryptedPropertyShapeError), } impl From for ConsensusError { @@ -882,13 +889,38 @@ mod tests { )), 192 ); - // Documents cited by a contract moderation reason (protocol version 14): the tail of - // the enum. + // Documents cited by a contract moderation reason (protocol version 14). assert_eq!( discriminant_of(BasicError::InvalidContractModerationReasonDocumentsError( InvalidContractModerationReasonDocumentsError::new("x".to_string()) )), 193 ); + // A `distinctFrom` identifier property equal to what it must differ from (protocol + // version 14). + assert_eq!( + discriminant_of(BasicError::DocumentPropertyNotDistinctError( + DocumentPropertyNotDistinctError::new( + "post".to_string(), + "delegateId".to_string(), + "$ownerId".to_string(), + ) + )), + 194 + ); + // The shape of an `encryptedFor` property's ciphertext (protocol version 14): the + // tail of the enum. + assert_eq!( + discriminant_of(BasicError::InvalidEncryptedPropertyShapeError( + InvalidEncryptedPropertyShapeError::new( + "encryptedMessage".to_string(), + "ecdh-secp256k1-aes256-cbc".to_string(), + 47, + 32, + 16 + ) + )), + 195 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/document_property_not_distinct_error.rs b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_not_distinct_error.rs new file mode 100644 index 00000000000..2c3470f1a00 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_not_distinct_error.rs @@ -0,0 +1,73 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A `distinctFrom` identifier property of the written document equals the value it must +/// differ from: the document's `$ownerId`, or the named property of the same document. +/// +/// A pure structure check on document create and replace (protocol version 14): it reads +/// the transition alone, so it is a basic error, not a state one. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Document type \"{document_type_name}\" property \"{property}\" must differ from \ + \"{distinct_from}\", but the two values are equal" +)] +#[platform_serialize(unversioned)] +pub struct DocumentPropertyNotDistinctError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_type_name: String, + /// Dotted path of the declaring property within the document type. + property: String, + /// What the declaration named: `$ownerId` or the dotted path of the property whose + /// value collided. + distinct_from: String, +} + +impl DocumentPropertyNotDistinctError { + pub fn new(document_type_name: String, property: String, distinct_from: String) -> Self { + Self { + document_type_name, + property, + distinct_from, + } + } + + pub fn document_type_name(&self) -> &str { + &self.document_type_name + } + + pub fn property(&self) -> &str { + &self.property + } + + pub fn distinct_from(&self) -> &str { + &self.distinct_from + } +} + +impl From for ConsensusError { + fn from(err: DocumentPropertyNotDistinctError) -> Self { + Self::BasicError(BasicError::DocumentPropertyNotDistinctError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/invalid_encrypted_property_shape_error.rs b/packages/rs-dpp/src/errors/consensus/basic/document/invalid_encrypted_property_shape_error.rs new file mode 100644 index 00000000000..64066468381 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/document/invalid_encrypted_property_shape_error.rs @@ -0,0 +1,93 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A document supplied a value for a property its type declares `encryptedFor` +/// whose length is not one the declared scheme produces: at least the IV plus +/// one block, and a multiple of the block length. The shape is all consensus +/// can check about a ciphertext. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Property {property} is declared encrypted under {scheme}, but its {actual_length} bytes are \ + not a ciphertext of that scheme: at least {minimum_length} bytes and a multiple of \ + {block_length} are required" +)] +#[platform_serialize(unversioned)] +pub struct InvalidEncryptedPropertyShapeError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + property: String, + scheme: String, + actual_length: u32, + minimum_length: u32, + block_length: u32, +} + +impl InvalidEncryptedPropertyShapeError { + pub fn new( + property: String, + scheme: String, + actual_length: u32, + minimum_length: u32, + block_length: u32, + ) -> Self { + Self { + property, + scheme, + actual_length, + minimum_length, + block_length, + } + } + + /// The dotted path of the property, as the document type flattens it. + pub fn property(&self) -> &str { + &self.property + } + + /// The wire name of the scheme the property is declared under. + pub fn scheme(&self) -> &str { + &self.scheme + } + + /// The length the document supplied. + pub fn actual_length(&self) -> u32 { + self.actual_length + } + + /// The shortest ciphertext the scheme produces. + pub fn minimum_length(&self) -> u32 { + self.minimum_length + } + + /// The block length a ciphertext of the scheme is a multiple of. + pub fn block_length(&self) -> u32 { + self.block_length + } +} + +impl From for ConsensusError { + fn from(err: InvalidEncryptedPropertyShapeError) -> Self { + Self::BasicError(BasicError::InvalidEncryptedPropertyShapeError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs index f40fc9c3044..940fd3cbf34 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs @@ -2,6 +2,7 @@ mod contested_documents_temporarily_not_allowed_error; mod data_contract_not_present_error; mod document_creation_not_allowed_error; mod document_field_max_size_exceeded_error; +mod document_property_not_distinct_error; mod document_transitions_are_absent_error; mod duplicate_document_transitions_with_ids_error; mod duplicate_document_transitions_with_indices_error; @@ -10,6 +11,7 @@ mod inconsistent_compound_index_data_error; mod invalid_document_transition_action_error; mod invalid_document_transition_id_error; mod invalid_document_type_error; +mod invalid_encrypted_property_shape_error; mod max_documents_transitions_exceeded_error; mod missing_data_contract_id_basic_error; mod missing_document_transition_action_error; @@ -21,6 +23,7 @@ pub use contested_documents_temporarily_not_allowed_error::*; pub use data_contract_not_present_error::*; pub use document_creation_not_allowed_error::*; pub use document_field_max_size_exceeded_error::*; +pub use document_property_not_distinct_error::*; pub use document_transitions_are_absent_error::*; pub use duplicate_document_transitions_with_ids_error::*; pub use duplicate_document_transitions_with_indices_error::*; @@ -29,6 +32,7 @@ pub use inconsistent_compound_index_data_error::*; pub use invalid_document_transition_action_error::*; pub use invalid_document_transition_id_error::*; pub use invalid_document_type_error::*; +pub use invalid_encrypted_property_shape_error::*; pub use max_documents_transitions_exceeded_error::*; pub use missing_data_contract_id_basic_error::*; pub use missing_document_transition_action_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 8048f541b18..85ab66e2729 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -166,6 +166,8 @@ impl ErrorWithCode for BasicError { Self::DocumentCreationNotAllowedError(_) => 10416, Self::DocumentFieldMaxSizeExceededError(_) => 10417, Self::ContestedDocumentsTemporarilyNotAllowedError(_) => 10418, + Self::DocumentPropertyNotDistinctError(_) => 10419, + Self::InvalidEncryptedPropertyShapeError(_) => 10420, // Token Errors: 10450-10499 Self::InvalidTokenIdError(_) => 10450, @@ -359,6 +361,8 @@ impl ErrorWithCode for StateError { Self::DocumentActionFeeAgreementMismatchError(_) => 40133, Self::DocumentActionFeeMultiplierNotToleratedError(_) => 40134, Self::ReferencedContractRequirementNotMetError(_) => 40135, + Self::ReferencedIdentityKeyRequirementNotMetError(_) => 40136, + Self::ReferencedDocumentLookupInvalidError(_) => 40137, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, @@ -393,6 +397,7 @@ impl ErrorWithCode for StateError { Self::MasternodeIncorrectVotingAddressError(_) => 40305, Self::MasternodeIncorrectVoterIdentityIdError(_) => 40306, Self::VoteChoiceNotAllowedForVotePollError(_) => 40307, + Self::YesNoVotePollNotAvailableForVotingError(_) => 40308, // Prefunded specialized balances Errors: 40400-40499 Self::PrefundedSpecializedBalanceInsufficientError(_) => 40400, diff --git a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs index b22abf538ff..1c873c7cb42 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs @@ -20,6 +20,7 @@ pub mod document_timestamps_mismatch_error; pub mod duplicate_unique_index_error; pub mod invalid_document_revision_error; pub mod referenced_contract_requirement_not_met_error; +pub mod referenced_document_lookup_invalid_error; pub mod referenced_document_property_agreement_invalid_error; pub mod referenced_document_property_mismatch_error; pub mod referenced_document_type_deletable_error; @@ -28,4 +29,5 @@ pub mod referenced_document_type_not_found_error; pub mod referenced_entity_not_found_error; pub mod referenced_identity_key_disabled_error; pub mod referenced_identity_key_not_found_error; +pub mod referenced_identity_key_requirement_not_met_error; pub mod referenced_key_id_property_invalid_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_lookup_invalid_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_lookup_invalid_error.rs new file mode 100644 index 00000000000..82d65fb76c0 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_lookup_invalid_error.rs @@ -0,0 +1,70 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A `refersTo` lookup into a document type of another contract cannot resolve +/// there: the named index is missing or not unique, its keys do not cover the +/// index exactly, or a source holds a different kind of value than its index +/// property. Reported at contract registration and update; a lookup into the +/// declaring contract's own document type is refused by the contract parse +/// instead. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("invalid refersTo lookup through index {index} declared at {path}: {reason}")] +#[platform_serialize(unversioned)] +pub struct ReferencedDocumentLookupInvalidError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + path: String, + index: String, + reason: String, +} + +impl ReferencedDocumentLookupInvalidError { + pub fn new(path: String, index: String, reason: String) -> Self { + Self { + path, + index, + reason, + } + } + + /// The declaring property, as `documentTypeName.propertyPath`. + pub fn path(&self) -> &str { + &self.path + } + + /// The index the lookup names. + pub fn index(&self) -> &str { + &self.index + } + + pub fn reason(&self) -> &str { + &self.reason + } +} + +impl From for ConsensusError { + fn from(err: ReferencedDocumentLookupInvalidError) -> Self { + Self::StateError(StateError::ReferencedDocumentLookupInvalidError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_requirement_not_met_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_requirement_not_met_error.rs new file mode 100644 index 00000000000..645603a1ede --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_requirement_not_met_error.rs @@ -0,0 +1,105 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::identity::KeyID; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "referenced public key {key_id} of identity {identity_id} for {document_type_name}.{path} has {field} {actual}, the reference requires {required}" +)] +#[platform_serialize(unversioned)] +pub struct ReferencedIdentityKeyRequirementNotMetError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_type_name: String, + path: String, + identity_id: Identifier, + key_id: KeyID, + field: String, + required: String, + actual: String, +} + +impl ReferencedIdentityKeyRequirementNotMetError { + pub fn new( + document_type_name: String, + path: String, + identity_id: Identifier, + key_id: KeyID, + field: String, + required: String, + actual: String, + ) -> Self { + Self { + document_type_name, + path, + identity_id, + key_id, + field, + required, + actual, + } + } + + /// The document type declaring the reference + pub fn document_type_name(&self) -> &str { + &self.document_type_name + } + + /// The referring property + pub fn path(&self) -> &str { + &self.path + } + + /// The identity whose key was referenced + pub fn identity_id(&self) -> &Identifier { + &self.identity_id + } + + /// The referenced key, which exists and is enabled but does not meet the requirement + pub fn key_id(&self) -> KeyID { + self.key_id + } + + /// The `keyRequirements` key of the requirement, `purpose` or `boundTo` + pub fn field(&self) -> &str { + &self.field + } + + /// The value the reference requires, `decryption` for one + pub fn required(&self) -> &str { + &self.required + } + + /// What the key has instead, `encryption` for one + pub fn actual(&self) -> &str { + &self.actual + } +} + +impl From for ConsensusError { + fn from(err: ReferencedIdentityKeyRequirementNotMetError) -> Self { + Self::StateError(StateError::ReferencedIdentityKeyRequirementNotMetError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index a2793685e97..2afb518be2d 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -69,9 +69,11 @@ use crate::consensus::state::document::referenced_document_type_deletable_error: use crate::consensus::state::document::referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError; use crate::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; use crate::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; +use crate::consensus::state::document::referenced_document_lookup_invalid_error::ReferencedDocumentLookupInvalidError; use crate::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; use crate::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; use crate::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; +use crate::consensus::state::document::referenced_identity_key_requirement_not_met_error::ReferencedIdentityKeyRequirementNotMetError; use crate::consensus::state::document::referenced_document_property_agreement_invalid_error::ReferencedDocumentPropertyAgreementInvalidError; use crate::consensus::state::document::referenced_document_property_mismatch_error::ReferencedDocumentPropertyMismatchError; use crate::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; @@ -102,6 +104,7 @@ use crate::consensus::state::voting::masternode_voted_too_many_times::Masternode use crate::consensus::state::voting::vote_poll_not_available_for_voting_error::VotePollNotAvailableForVotingError; use crate::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; use crate::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; +use crate::consensus::state::voting::yes_no_vote_poll_not_available_for_voting_error::YesNoVotePollNotAvailableForVotingError; use super::document::document_timestamps_are_equal_error::DocumentTimestampsAreEqualError; @@ -587,6 +590,18 @@ pub enum StateError { // Requirements on a referenced contract (protocol version 14). #[error(transparent)] ReferencedContractRequirementNotMetError(ReferencedContractRequirementNotMetError), + + // Requirements on a referenced identity key (protocol version 14). + #[error(transparent)] + ReferencedIdentityKeyRequirementNotMetError(ReferencedIdentityKeyRequirementNotMetError), + + // Document references resolved through a unique index (protocol version 14). + #[error(transparent)] + ReferencedDocumentLookupInvalidError(ReferencedDocumentLookupInvalidError), + + // Yes/no vote polls (protocol version 14). + #[error(transparent)] + YesNoVotePollNotAvailableForVotingError(YesNoVotePollNotAvailableForVotingError), } impl From for ConsensusError { @@ -598,6 +613,7 @@ impl From for ConsensusError { #[cfg(test)] mod tests { use super::*; + use crate::block::block_info::BlockInfo; use crate::consensus::state::contract_moderation::ContractModerationCounterpartyRole; use crate::consensus::state::identity::identity_public_key_limit_not_set_error::KeyLimit; use crate::data_contract::config::moderation::ContractModerationList; @@ -607,11 +623,17 @@ mod tests { use crate::data_contract::document_type::action_fees::{ ActionFeePricing, ContractFeePot, DocumentActionFee, }; + use crate::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentReferenceLookup, LookupKeySource, + }; use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; use crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use crate::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStatus; use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + use crate::voting::vote_polls::yes_no_vote_poll::{YesNoMinimumVotingPower, YesNoVotePoll}; use crate::voting::vote_polls::VotePoll; - use platform_value::Identifier; + use platform_value::{BinaryData, Identifier}; + use std::collections::BTreeMap; /// `StateError` is encoded by variant position, so inserting a variant /// anywhere but the end silently reassigns the discriminant of every @@ -627,6 +649,61 @@ mod tests { bytes[0] } + /// A reference error for an id reference encodes exactly as it did before + /// lookup references existed: the lookup form is an appended variant of + /// `DocumentPropertyReferenceTarget` (`PermanentDocumentLookup`), not a + /// field of `PermanentDocument`, so a client decoding with an earlier dpp + /// still reads every error an id reference produces. The bytes are pinned; + /// `PermanentDocument` keeps variant 3 of the target, the lookup form + /// takes 6. + #[test] + fn should_keep_the_encoding_of_a_reference_error_for_an_id_reference() { + let id_reference = DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "note".to_string(), + property_agreement: BTreeMap::new(), + }; + let error = StateError::ReferencedEntityNotFoundError(ReferencedEntityNotFoundError::new( + Identifier::from([1; 32]), + id_reference.clone(), + "noteId".to_string(), + )); + let bytes = bincode::encode_to_vec(error, bincode::config::standard()) + .expect("expected to encode the state error"); + // StateError variant 93, the referenced id, target variant 3 + // (PermanentDocument: no contract id, "note", no agreement), the path. + assert_eq!( + hex::encode(bytes), + concat!( + "5d", + "0101010101010101010101010101010101010101010101010101010101010101", + "03", + "00", + "046e6f7465", + "00", + "066e6f74654964", + ) + ); + + let target_variant = |target: &DocumentPropertyReferenceTarget| { + bincode::encode_to_vec(target, bincode::config::standard()) + .expect("expected to encode the target")[0] + }; + assert_eq!(target_variant(&id_reference), 3); + assert_eq!( + target_variant(&DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "note".to_string(), + property_agreement: BTreeMap::new(), + lookup: DocumentReferenceLookup { + index: "byOwner".to_string(), + keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), + }, + }), + 6 + ); + } + #[test] fn state_error_discriminants_are_frozen() { assert_eq!( @@ -1062,7 +1139,7 @@ mod tests { )), 140 ); - // Elected moderation teams (protocol version 14): the tail of the enum. + // Elected moderation teams (protocol version 14). assert_eq!( discriminant_of(StateError::ContractModeratedDocumentTypeNotYetUsableError( ContractModeratedDocumentTypeNotYetUsableError::new(group_id, "post".to_string()) @@ -1086,7 +1163,7 @@ mod tests { )), 142 ); - // Requirements on a referenced contract (protocol version 14): the tail of the enum. + // Requirements on a referenced contract (protocol version 14). assert_eq!( discriminant_of(StateError::ReferencedContractRequirementNotMetError( ReferencedContractRequirementNotMetError::new( @@ -1098,5 +1175,46 @@ mod tests { )), 143 ); + // Requirements on a referenced identity key (protocol version 14). + assert_eq!( + discriminant_of(StateError::ReferencedIdentityKeyRequirementNotMetError( + ReferencedIdentityKeyRequirementNotMetError::new( + "joinRequest".to_string(), + "recipientId".to_string(), + group_id, + 2, + "purpose".to_string(), + "decryption".to_string(), + "encryption".to_string(), + ) + )), + 144 + ); + // Document references resolved through a unique index (protocol version 14). + assert_eq!( + discriminant_of(StateError::ReferencedDocumentLookupInvalidError( + ReferencedDocumentLookupInvalidError::new( + "electedCharter.members".to_string(), + "bySubmittedCharter".to_string(), + "is not unique".to_string(), + ) + )), + 145 + ); + // Yes/no vote polls (protocol version 14): the tail of the enum. + assert_eq!( + discriminant_of(StateError::YesNoVotePollNotAvailableForVotingError( + YesNoVotePollNotAvailableForVotingError::new( + YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![1; 32])], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute(400), + }, + YesNoVotePollStatus::Started(BlockInfo::default()) + ) + )), + 146 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs b/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs index 0414bd7656a..35488d89dc3 100644 --- a/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs @@ -6,3 +6,4 @@ pub mod masternode_voted_too_many_times; pub mod vote_choice_not_allowed_for_vote_poll_error; pub mod vote_poll_not_available_for_voting_error; pub mod vote_poll_not_found_error; +pub mod yes_no_vote_poll_not_available_for_voting_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/voting/yes_no_vote_poll_not_available_for_voting_error.rs b/packages/rs-dpp/src/errors/consensus/state/voting/yes_no_vote_poll_not_available_for_voting_error.rs new file mode 100644 index 00000000000..47e592f2b84 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/voting/yes_no_vote_poll_not_available_for_voting_error.rs @@ -0,0 +1,53 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStatus; +use crate::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A vote on a yes/no poll that exists but is no longer taking votes (protocol version 14). +#[derive( + Error, + Debug, + Clone, + PartialEq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("yes/no vote poll {vote_poll} is not available for voting: {status}")] +#[platform_serialize(unversioned)] +pub struct YesNoVotePollNotAvailableForVotingError { + /* + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + */ + vote_poll: YesNoVotePoll, + status: YesNoVotePollStatus, +} + +impl YesNoVotePollNotAvailableForVotingError { + pub fn new(vote_poll: YesNoVotePoll, status: YesNoVotePollStatus) -> Self { + Self { vote_poll, status } + } + + pub fn vote_poll(&self) -> &YesNoVotePoll { + &self.vote_poll + } + + pub fn status(&self) -> &YesNoVotePollStatus { + &self.status + } +} + +impl From for ConsensusError { + fn from(err: YesNoVotePollNotAvailableForVotingError) -> Self { + Self::StateError(StateError::YesNoVotePollNotAvailableForVotingError(err)) + } +} diff --git a/packages/rs-dpp/src/identity/identity_public_key/purpose.rs b/packages/rs-dpp/src/identity/identity_public_key/purpose.rs index 18f0b6e20c0..3e593a9dff9 100644 --- a/packages/rs-dpp/src/identity/identity_public_key/purpose.rs +++ b/packages/rs-dpp/src/identity/identity_public_key/purpose.rs @@ -129,6 +129,34 @@ impl Purpose { pub fn encryption_decryption() -> [Purpose; 2] { [ENCRYPTION, DECRYPTION] } + + /// The name a schema keyword spells the purpose with: the variant's own name in lower case, + /// `"decryption"` for one. + pub fn wire_name(&self) -> &'static str { + match self { + AUTHENTICATION => "authentication", + ENCRYPTION => "encryption", + DECRYPTION => "decryption", + TRANSFER => "transfer", + SYSTEM => "system", + VOTING => "voting", + OWNER => "owner", + } + } + + /// The purpose a wire name names, `None` for any other name. + pub fn from_wire_name(name: &str) -> Option { + match name { + "authentication" => Some(AUTHENTICATION), + "encryption" => Some(ENCRYPTION), + "decryption" => Some(DECRYPTION), + "transfer" => Some(TRANSFER), + "system" => Some(SYSTEM), + "voting" => Some(VOTING), + "owner" => Some(OWNER), + _ => None, + } + } } #[cfg(test)] @@ -259,6 +287,26 @@ mod tests { } } + // -- wire names -- + #[test] + fn should_round_trip_every_purpose_through_its_wire_name() { + for purpose in [ + AUTHENTICATION, + ENCRYPTION, + DECRYPTION, + TRANSFER, + SYSTEM, + VOTING, + OWNER, + ] { + let name = purpose.wire_name(); + assert_eq!(name, format!("{purpose:?}").to_lowercase()); + assert_eq!(Purpose::from_wire_name(name), Some(purpose)); + } + assert_eq!(Purpose::from_wire_name("AUTHENTICATION"), None); + assert_eq!(Purpose::from_wire_name("signing"), None); + } + // -- ordering -- #[test] fn test_purpose_ordering_matches_discriminant() { diff --git a/packages/rs-dpp/src/serialization/json/safe_fields.rs b/packages/rs-dpp/src/serialization/json/safe_fields.rs index 149ba871c1f..45af8462ba3 100644 --- a/packages/rs-dpp/src/serialization/json/safe_fields.rs +++ b/packages/rs-dpp/src/serialization/json/safe_fields.rs @@ -140,6 +140,11 @@ impl JsonSafeFields { } impl JsonSafeFields for crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice {} +impl JsonSafeFields + for crate::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice +{ +} +impl JsonSafeFields for crate::voting::vote_polls::yes_no_vote_poll::YesNoMinimumVotingPower {} impl JsonSafeFields for crate::group::action_event::GroupActionEvent {} // TokenEvent contains u64 aliases (TokenAmount, Credits) in tuple variants that // `#[json_safe_fields]` can't auto-annotate. Developer takes responsibility for diff --git a/packages/rs-dpp/src/serialization/serialization_traits.rs b/packages/rs-dpp/src/serialization/serialization_traits.rs index 537a7010a0e..1f6d60d468f 100644 --- a/packages/rs-dpp/src/serialization/serialization_traits.rs +++ b/packages/rs-dpp/src/serialization/serialization_traits.rs @@ -67,17 +67,53 @@ pub trait PlatformSerializableWithPlatformVersion { /// length prefix, so it must never see bytes that arrived from a peer, a /// client, a proof or a host caller; those go through /// [`PlatformDeserializableUntrusted`]. +/// +/// The derive implements the two `_with_bytes_len` methods, which report how +/// many bytes the value took; the other methods are defaults over them. pub trait PlatformDeserializableTrusted { + /// Decodes under the type's configured byte budget, returning the value + /// and the number of bytes it took. + fn deserialize_from_bytes_trusted_with_bytes_len( + data: &[u8], + ) -> Result<(Self, usize), ProtocolError> + where + Self: Sized; + + /// [`Self::deserialize_from_bytes_trusted_with_bytes_len`] without the + /// byte budget. + fn deserialize_from_bytes_trusted_no_limit_with_bytes_len( + data: &[u8], + ) -> Result<(Self, usize), ProtocolError> + where + Self: Sized; + + /// Decodes under the type's configured byte budget. Bytes left over after + /// the value are ignored; [`Self::deserialize_from_bytes_trusted_exact`] + /// refuses them. fn deserialize_from_bytes_trusted(data: &[u8]) -> Result where Self: Sized, { - Self::deserialize_from_bytes_trusted_no_limit(data) + Self::deserialize_from_bytes_trusted_with_bytes_len(data).map(|(value, _)| value) } fn deserialize_from_bytes_trusted_no_limit(data: &[u8]) -> Result where - Self: Sized; + Self: Sized, + { + Self::deserialize_from_bytes_trusted_no_limit_with_bytes_len(data).map(|(value, _)| value) + } + + /// [`Self::deserialize_from_bytes_trusted`] that also refuses bytes left + /// over after the value. + fn deserialize_from_bytes_trusted_exact(data: &[u8]) -> Result + where + Self: Sized, + { + let (value, consumed) = Self::deserialize_from_bytes_trusted_with_bytes_len(data)?; + refuse_left_over_bytes::(data.len(), consumed)?; + Ok(value) + } } /// Deserialization of bytes from outside this node: state transitions, query @@ -86,17 +122,69 @@ pub trait PlatformDeserializableTrusted { /// Runs bincode's untrusted decoder, which reserves nothing from a length /// prefix before the elements it announces have actually been read, so a /// short input claiming a huge collection fails instead of allocating. +/// +/// The derive implements the two `_with_bytes_len` methods, which report how +/// many bytes the value took; the other methods are defaults over them. pub trait PlatformDeserializableUntrusted { + /// Decodes under the type's configured byte budget, returning the value + /// and the number of bytes it took. + fn deserialize_from_bytes_untrusted_with_bytes_len( + data: &[u8], + ) -> Result<(Self, usize), ProtocolError> + where + Self: Sized; + + /// [`Self::deserialize_from_bytes_untrusted_with_bytes_len`] without the + /// byte budget. + fn deserialize_from_bytes_untrusted_no_limit_with_bytes_len( + data: &[u8], + ) -> Result<(Self, usize), ProtocolError> + where + Self: Sized; + + /// Decodes under the type's configured byte budget. Bytes left over after + /// the value are ignored; [`Self::deserialize_from_bytes_untrusted_exact`] + /// refuses them. fn deserialize_from_bytes_untrusted(data: &[u8]) -> Result where Self: Sized, { - Self::deserialize_from_bytes_untrusted_no_limit(data) + Self::deserialize_from_bytes_untrusted_with_bytes_len(data).map(|(value, _)| value) } fn deserialize_from_bytes_untrusted_no_limit(data: &[u8]) -> Result where - Self: Sized; + Self: Sized, + { + Self::deserialize_from_bytes_untrusted_no_limit_with_bytes_len(data).map(|(value, _)| value) + } + + /// [`Self::deserialize_from_bytes_untrusted`] that also refuses bytes left + /// over after the value. + /// + /// A value followed by anything decodes loosely as the value alone, so a + /// caller that shows a user what the bytes contain and then signs them + /// must use this one to know the value is all there is. + fn deserialize_from_bytes_untrusted_exact(data: &[u8]) -> Result + where + Self: Sized, + { + let (value, consumed) = Self::deserialize_from_bytes_untrusted_with_bytes_len(data)?; + refuse_left_over_bytes::(data.len(), consumed)?; + Ok(value) + } +} + +/// The error for a decode of `T` that took `consumed` of `len` input bytes. +fn refuse_left_over_bytes(len: usize, consumed: usize) -> Result<(), ProtocolError> { + if consumed == len { + return Ok(()); + } + Err(ProtocolError::PlatformDeserializationError(format!( + "unable to deserialize {}: {} bytes left over after the value", + std::any::type_name::(), + len.saturating_sub(consumed) + ))) } /// We will deserialize a versioned structure into a code structure diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 3c338489596..17f6681bb6b 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -160,6 +160,7 @@ use crate::state_transition::identity_update_transition::accessors::IdentityUpda use crate::state_transition::identity_update_transition::{ IdentityUpdateTransition, IdentityUpdateTransitionSignable, }; +use crate::state_transition::masternode_vote_transition::accessors::MasternodeVoteTransitionAccessorsV0; use crate::state_transition::masternode_vote_transition::MasternodeVoteTransition; use crate::state_transition::masternode_vote_transition::MasternodeVoteTransitionSignable; use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; @@ -181,6 +182,9 @@ use crate::state_transition::state_transitions::document::batch_transition::meth use crate::state_transition::unshield_transition::{ UnshieldTransition, UnshieldTransitionSignable, }; +use crate::voting::vote_polls::VotePoll; +use crate::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; +use crate::voting::votes::Vote; use state_transitions::document::batch_transition::batched_transition::token_transition::TokenTransition; pub use state_transitions::*; @@ -496,6 +500,10 @@ macro_rules! call_errorable_method_identity_signed { }; } +/// Byte budget of a serialized [`StateTransition`], as the `limit` of its `platform_serialize` +/// attribute below declares it; the attribute takes a literal, so the number is repeated here. +pub const STATE_TRANSITION_MAX_ENCODED_BYTES: usize = 100_000; + #[derive( Debug, Clone, @@ -894,6 +902,59 @@ mod json_convertible_tests { "shieldFromIdentity", ); } + + /// Every kind decodes from its untagged bytes (the inner transition serialized on its own) + /// into the same transition, and from its tagged bytes exactly. + #[test] + fn every_kind_decodes_untagged_and_exactly() { + use crate::serialization::{PlatformDeserializableUntrusted, PlatformSerializable}; + + let transitions = [ + StateTransition::DataContractCreate(crate::state_transition::data_contract_create_transition::json_convertible_tests::fixture()), + StateTransition::DataContractUpdate(crate::state_transition::data_contract_update_transition::json_convertible_tests::fixture()), + StateTransition::Batch(crate::state_transition::batch_transition::json_convertible_tests::fixture()), + StateTransition::IdentityCreate(crate::state_transition::identity_create_transition::json_convertible_tests::fixture()), + StateTransition::IdentityTopUp(crate::state_transition::identity_topup_transition::json_convertible_tests::fixture()), + StateTransition::IdentityCreditWithdrawal(crate::state_transition::identity_credit_withdrawal_transition::json_convertible_tests::fixture()), + StateTransition::IdentityUpdate(crate::state_transition::identity_update_transition::json_convertible_tests::fixture()), + StateTransition::IdentityKeyLimitsUpdate(crate::state_transition::identity_key_limits_update_transition::json_convertible_tests::fixture()), + StateTransition::ContractUserModeration(crate::state_transition::contract_user_moderation_transition::json_convertible_tests::fixture()), + StateTransition::ContractFeeClaim(crate::state_transition::contract_fee_claim_transition::json_convertible_tests::fixture()), + StateTransition::IdentityCreditTransfer(crate::state_transition::identity_credit_transfer_transition::json_convertible_tests::fixture()), + StateTransition::MasternodeVote(crate::state_transition::masternode_vote_transition::json_convertible_tests::fixture()), + StateTransition::IdentityCreditTransferToAddresses(crate::state_transition::identity_credit_transfer_to_addresses_transition::json_convertible_tests::fixture()), + StateTransition::IdentityCreateFromAddresses(crate::state_transition::identity_create_from_addresses_transition::json_convertible_tests::fixture()), + StateTransition::IdentityTopUpFromAddresses(crate::state_transition::identity_topup_from_addresses_transition::json_convertible_tests::fixture()), + StateTransition::AddressFundsTransfer(crate::state_transition::address_funds_transfer_transition::json_convertible_tests::fixture()), + StateTransition::AddressFundingFromAssetLock(crate::state_transition::address_funding_from_asset_lock_transition::json_convertible_tests::fixture()), + StateTransition::AddressCreditWithdrawal(crate::state_transition::address_credit_withdrawal_transition::json_convertible_tests::fixture()), + StateTransition::Shield(crate::state_transition::shield_transition::json_convertible_tests::fixture()), + StateTransition::ShieldedTransfer(crate::state_transition::shielded_transfer_transition::json_convertible_tests::fixture()), + StateTransition::Unshield(crate::state_transition::unshield_transition::json_convertible_tests::fixture()), + StateTransition::ShieldFromAssetLock(crate::state_transition::shield_from_asset_lock_transition::json_convertible_tests::fixture()), + StateTransition::ShieldedWithdrawal(crate::state_transition::shielded_withdrawal_transition::json_convertible_tests::fixture()), + StateTransition::IdentityCreateFromShieldedPool(crate::state_transition::identity_create_from_shielded_pool_transition::json_convertible_tests::fixture()), + StateTransition::IdentityTopUpFromShieldedPool(crate::state_transition::identity_top_up_from_shielded_pool_transition::json_convertible_tests::fixture()), + StateTransition::ShieldFromIdentity(crate::state_transition::shield_from_identity_transition::json_convertible_tests::fixture()), + ]; + for transition in transitions { + let tagged = transition.serialize_to_bytes().expect("serializes"); + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted_exact(&tagged).expect("tagged"), + transition + ); + // The variant tags are all below 251, so bincode's varint writes them in one byte. + let untagged = &tagged[1..]; + assert_eq!( + StateTransition::deserialize_untagged_untrusted_exact( + transition.state_transition_type(), + untagged + ) + .unwrap_or_else(|e| panic!("{} untagged: {e}", transition.name())), + transition + ); + } + } } impl OptionallyAssetLockProved for StateTransition { @@ -1046,8 +1107,18 @@ impl StateTransition { } StateTransition::IdentityTopUp(_) | StateTransition::IdentityCreditWithdrawal(_) - | StateTransition::IdentityCreditTransfer(_) - | StateTransition::MasternodeVote(_) => ALL_VERSIONS, + | StateTransition::IdentityCreditTransfer(_) => ALL_VERSIONS, + // A vote that names a yes/no poll, as a yes/no vote or as a resource vote, exists + // from protocol version 14. Binaries from before it cannot decode one, so an earlier + // version rejects it without charging, exactly as they do, and no masternode vote + // validator selected before 14 ever sees a yes/no poll. + StateTransition::MasternodeVote(masternode_vote) => match masternode_vote.vote() { + Vote::ResourceVote(resource_vote) => match resource_vote.vote_poll() { + VotePoll::ContestedDocumentResourceVotePoll(_) => ALL_VERSIONS, + VotePoll::YesNoVotePoll(_) => 14..=LATEST_VERSION, + }, + Vote::YesNoVote(_) => 14..=LATEST_VERSION, + }, StateTransition::IdentityCreditTransferToAddresses(_) | StateTransition::IdentityTopUpFromAddresses(_) | StateTransition::AddressFundsTransfer(_) @@ -2543,6 +2614,69 @@ mod tests { assert_eq!(sample_withdrawal_st().active_version_range(), ALL_VERSIONS); } + /// Both vote kinds can name a yes/no poll: a yes/no vote, and a resource vote (which + /// protocol version 14 refuses as naming no contested poll). Before 14 neither decodes, so + /// the masternode vote validators selected there never meet a yes/no poll. + #[test] + fn test_active_version_range_masternode_vote_on_a_yes_no_poll_starts_at_14() { + use crate::serialization::PlatformSerializable; + use crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use crate::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; + use crate::voting::vote_polls::yes_no_vote_poll::{YesNoMinimumVotingPower, YesNoVotePoll}; + use crate::voting::votes::resource_vote::v0::ResourceVoteV0; + use crate::voting::votes::resource_vote::ResourceVote; + use crate::voting::votes::yes_no_vote::v0::YesNoVoteV0; + use crate::voting::votes::yes_no_vote::YesNoVote; + + let vote_poll = YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![7u8; 32])], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute(400), + }; + let votes = [ + Vote::YesNoVote(YesNoVote::V0(YesNoVoteV0 { + vote_poll: vote_poll.clone(), + vote_choice: YesNoAbstainVoteChoice::Yes, + })), + Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::YesNoVotePoll(vote_poll), + resource_vote_choice: ResourceVoteChoice::Abstain, + })), + ]; + let version_13 = PlatformVersion::get(13).expect("protocol version 13"); + let version_14 = PlatformVersion::get(14).expect("protocol version 14"); + + for vote in votes { + let v0 = MasternodeVoteTransitionV0 { + pro_tx_hash: Identifier::from([3u8; 32]), + voter_identity_id: Identifier::from([4u8; 32]), + vote, + nonce: 2, + signature_public_key_id: 5, + signature: BinaryData::new(vec![9u8; 10]), + }; + let state_transition = + StateTransition::MasternodeVote(MasternodeVoteTransition::V0(v0)); + assert_eq!(state_transition.active_version_range(), 14..=LATEST_VERSION); + + let bytes = state_transition + .serialize_to_bytes() + .expect("expected to serialize the vote"); + assert!(matches!( + StateTransition::deserialize_from_bytes_untrusted_in_version(&bytes, version_13), + Err(ProtocolError::StateTransitionError( + StateTransitionIsNotActiveError { .. } + )) + )); + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted_in_version(&bytes, version_14) + .expect("expected the vote to be active at 14"), + state_transition + ); + } + } + #[test] fn test_unique_identifiers_non_empty() { let ids = sample_transfer_st().unique_identifiers(); diff --git a/packages/rs-dpp/src/state_transition/proof_result.rs b/packages/rs-dpp/src/state_transition/proof_result.rs index 53b2a75f7ec..f2f4b51cec7 100644 --- a/packages/rs-dpp/src/state_transition/proof_result.rs +++ b/packages/rs-dpp/src/state_transition/proof_result.rs @@ -163,49 +163,115 @@ pub enum StateTransitionProofResult { VerifiedContractDocumentRemoval(Identifier, String, Identifier, ContractDocumentRemoval), } -/// A verified state-transition proof result, tagged with the guarantee the -/// proof establishes. -/// -/// Some transition families (balance top-ups, credit transfers and -/// withdrawals, address funds movements, shields, no-history token -/// operations) produce proofs whose values cannot be bound to the execution -/// of one specific transition: the proof only authenticates the affected -/// keys' state at the committed block. The tag makes that distinction part -/// of the type so a snapshot cannot be mistaken for execution evidence. -#[derive(Debug, PartialEq, strum::Display)] +/// The guarantee a verified state-transition proof establishes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display)] #[cfg_attr( feature = "serde-conversion", derive(serde::Serialize, serde::Deserialize) )] -pub enum StateTransitionProofOutcome { +pub enum StateTransitionProofGuarantee { /// The proof binds the execution of this specific state transition: /// the verified values could only exist if the transition was applied. - ExecutionProved(StateTransitionProofResult), + ExecutionProved, /// The proof authenticates a snapshot of the state the transition /// affects — keys derived from the transition, values as of the proof's /// block — but cannot bind them to the execution of this transition. /// Treat as a height-pinned snapshot, not as evidence of execution. - AffectedState(StateTransitionProofResult), + AffectedState, +} + +/// A verified state-transition proof: the result, the guarantee the proof +/// establishes for it, and, when the proof carries it, the credit balance of +/// the identity that owns the transition after it executed. +/// +/// Some transition families (balance top-ups, credit transfers and +/// withdrawals, address funds movements, shields, no-history token +/// operations) produce proofs whose values cannot be bound to the execution +/// of one specific transition: the proof only authenticates the affected +/// keys' state at the committed block. The guarantee makes that distinction +/// part of the type so a snapshot cannot be mistaken for execution evidence. +/// +/// From protocol version 14 the proof of an owned, fee-paying transition +/// (document and token batches, contract creates and updates, identity +/// updates and key limit updates, contract moderation) also carries the +/// owner's credit balance, read from the same state as the result. It is a +/// snapshot at the proof's block whatever the guarantee, and `None` for a +/// proof made at an earlier version or for a transition without an owner. +#[derive(Debug, PartialEq)] +#[cfg_attr( + feature = "serde-conversion", + derive(serde::Serialize, serde::Deserialize) +)] +pub struct StateTransitionProofOutcome { + guarantee: StateTransitionProofGuarantee, + result: StateTransitionProofResult, + #[cfg_attr( + feature = "json-conversion", + serde(with = "crate::serialization::json_safe_option_u64") + )] + owner_balance: Option, } impl StateTransitionProofOutcome { - /// The verified result, regardless of the guarantee tag. - pub fn result(&self) -> &StateTransitionProofResult { - match self { - Self::ExecutionProved(result) | Self::AffectedState(result) => result, + /// An outcome whose proof binds the execution of the transition. + pub fn execution_proved(result: StateTransitionProofResult) -> Self { + Self { + guarantee: StateTransitionProofGuarantee::ExecutionProved, + result, + owner_balance: None, } } - /// Consume the outcome, discarding the guarantee tag. - pub fn into_result(self) -> StateTransitionProofResult { - match self { - Self::ExecutionProved(result) | Self::AffectedState(result) => result, + /// An outcome whose proof only authenticates the affected state. + pub fn affected_state(result: StateTransitionProofResult) -> Self { + Self { + guarantee: StateTransitionProofGuarantee::AffectedState, + result, + owner_balance: None, } } + /// The same outcome carrying the owner's credit balance the proof showed. + pub fn with_owner_balance(mut self, owner_balance: Option) -> Self { + self.owner_balance = owner_balance; + self + } + + /// The guarantee the proof establishes. + pub fn guarantee(&self) -> StateTransitionProofGuarantee { + self.guarantee + } + /// Whether the proof established that this specific transition executed. pub fn is_execution_proved(&self) -> bool { - matches!(self, Self::ExecutionProved(_)) + self.guarantee == StateTransitionProofGuarantee::ExecutionProved + } + + /// The verified result, regardless of the guarantee. + pub fn result(&self) -> &StateTransitionProofResult { + &self.result + } + + /// Consume the outcome, discarding the guarantee and the balance. + pub fn into_result(self) -> StateTransitionProofResult { + self.result + } + + /// The credit balance of the transition's owner after it executed, when + /// the proof carried it: a snapshot at the proof's block. + pub fn owner_balance(&self) -> Option { + self.owner_balance + } + + /// Consume the outcome into its guarantee, result and owner balance. + pub fn into_parts( + self, + ) -> ( + StateTransitionProofGuarantee, + StateTransitionProofResult, + Option, + ) { + (self.guarantee, self.result, self.owner_balance) } } @@ -344,6 +410,28 @@ mod json_convertible_tests { assert_eq!(original, recovered); } + /// The outcome carries the owner's balance next to the result; past + /// `Number.MAX_SAFE_INTEGER` it serializes as a string, and a proof made + /// before protocol version 14 carries none. + #[test] + fn outcome_owner_balance_serializes_as_string_past_safe_integer() { + let outcome = StateTransitionProofOutcome::execution_proved(fixture()) + .with_owner_balance(Some(9_007_199_254_740_993)); + let json = serde_json::to_value(&outcome).expect("to json"); + assert_eq!(json["owner_balance"], json!("9007199254740993")); + assert_eq!(json["guarantee"], json!("ExecutionProved")); + let recovered: StateTransitionProofOutcome = + serde_json::from_value(json).expect("from json"); + assert_eq!(outcome, recovered); + + let without_balance = StateTransitionProofOutcome::affected_state(fixture()); + let json = serde_json::to_value(&without_balance).expect("to json"); + assert_eq!(json["owner_balance"], serde_json::Value::Null); + let recovered: StateTransitionProofOutcome = + serde_json::from_value(json).expect("from json"); + assert_eq!(without_balance, recovered); + } + #[test] fn verified_address_infos_large_credits_serialize_as_string() { use crate::serialization::{JsonConvertible, ValueConvertible}; diff --git a/packages/rs-dpp/src/state_transition/serialization.rs b/packages/rs-dpp/src/state_transition/serialization.rs index c3e12dbb61c..120a78a35d7 100644 --- a/packages/rs-dpp/src/state_transition/serialization.rs +++ b/packages/rs-dpp/src/state_transition/serialization.rs @@ -1,5 +1,5 @@ use crate::serialization::PlatformDeserializableUntrusted; -use crate::state_transition::StateTransition; +use crate::state_transition::*; use crate::ProtocolError; impl StateTransition { @@ -13,6 +13,134 @@ impl StateTransition { }) .collect() } + + /// Decodes one transition of `state_transition_type` serialized on its own, without the + /// `StateTransition` variant tag in front (what `IdentityUpdateTransition::serialize_to_bytes` + /// produces, for example). Bytes left over after the transition are refused, and the + /// `StateTransition` byte budget is applied to the body up front. + pub fn deserialize_untagged_untrusted_exact( + state_transition_type: StateTransitionType, + bytes: &[u8], + ) -> Result { + // The inner transition types declare no budget of their own, so the `StateTransition` + // one is applied here: an untagged body is the tagged transition less its one-byte tag. + if bytes.len() >= STATE_TRANSITION_MAX_ENCODED_BYTES { + return Err(ProtocolError::MaxEncodedBytesReachedError { + max_size_kbytes: STATE_TRANSITION_MAX_ENCODED_BYTES, + size_hit: bytes.len(), + }); + } + let state_transition: Self = match state_transition_type { + StateTransitionType::DataContractCreate => { + DataContractCreateTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::DataContractUpdate => { + DataContractUpdateTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::Batch => { + BatchTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityCreate => { + IdentityCreateTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityTopUp => { + IdentityTopUpTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityCreditWithdrawal => { + IdentityCreditWithdrawalTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::IdentityUpdate => { + IdentityUpdateTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityCreditTransfer => { + IdentityCreditTransferTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::MasternodeVote => { + MasternodeVoteTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityCreditTransferToAddresses => { + IdentityCreditTransferToAddressesTransition::deserialize_from_bytes_untrusted_exact( + bytes, + )? + .into() + } + StateTransitionType::IdentityCreateFromAddresses => { + IdentityCreateFromAddressesTransition::deserialize_from_bytes_untrusted_exact( + bytes, + )? + .into() + } + StateTransitionType::IdentityTopUpFromAddresses => { + IdentityTopUpFromAddressesTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::AddressFundsTransfer => { + AddressFundsTransferTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::AddressFundingFromAssetLock => { + AddressFundingFromAssetLockTransition::deserialize_from_bytes_untrusted_exact( + bytes, + )? + .into() + } + StateTransitionType::AddressCreditWithdrawal => { + AddressCreditWithdrawalTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::Shield => { + ShieldTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::ShieldedTransfer => { + ShieldedTransferTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::Unshield => { + UnshieldTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::ShieldFromAssetLock => { + ShieldFromAssetLockTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::ShieldedWithdrawal => { + ShieldedWithdrawalTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityCreateFromShieldedPool => { + IdentityCreateFromShieldedPoolTransition::deserialize_from_bytes_untrusted_exact( + bytes, + )? + .into() + } + StateTransitionType::ShieldFromIdentity => { + ShieldFromIdentityTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + StateTransitionType::IdentityTopUpFromShieldedPool => { + IdentityTopUpFromShieldedPoolTransition::deserialize_from_bytes_untrusted_exact( + bytes, + )? + .into() + } + StateTransitionType::IdentityKeyLimitsUpdate => { + IdentityKeyLimitsUpdateTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::ContractUserModeration => { + ContractUserModerationTransition::deserialize_from_bytes_untrusted_exact(bytes)? + .into() + } + StateTransitionType::ContractFeeClaim => { + ContractFeeClaimTransition::deserialize_from_bytes_untrusted_exact(bytes)?.into() + } + }; + // Every arm converts into `StateTransition`, so a mismatched arm would still compile. + if state_transition.state_transition_type() != state_transition_type { + return Err(ProtocolError::CorruptedCodeExecution(format!( + "untagged {state_transition_type} bytes decoded as {}", + state_transition.state_transition_type() + ))); + } + Ok(state_transition) + } } #[cfg(test)] @@ -56,6 +184,9 @@ mod tests { use crate::state_transition::identity_credit_withdrawal_transition::v0::IdentityCreditWithdrawalTransitionV0; use crate::state_transition::identity_topup_transition::v0::IdentityTopUpTransitionV0; use crate::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; + use crate::state_transition::identity_update_transition::IdentityUpdateTransition; + use crate::state_transition::StateTransitionType; + use crate::state_transition::STATE_TRANSITION_MAX_ENCODED_BYTES; use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Setters; use crate::state_transition::StateTransition; use crate::tests::fixtures::{ @@ -770,4 +901,161 @@ mod tests { assert_eq!(recovered[1], st2); assert_eq!(recovered[2], st3); } + + #[test] + #[cfg(feature = "random-identities")] + fn exact_decode_refuses_trailing_bytes() { + let platform_version = PlatformVersion::latest(); + let identity = Identity::random_identity(5, Some(5), platform_version) + .expect("expected a random identity"); + let st: StateTransition = IdentityCreditWithdrawalTransitionV0 { + identity_id: identity.id(), + amount: 1000000, + core_fee_per_byte: 34, + pooling: Pooling::Standard, + output_script: CoreScript::from_bytes((0..23).collect::>()), + nonce: 1, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: [1u8; 65].to_vec().into(), + } + .into(); + let bytes = st.serialize_to_bytes().unwrap(); + + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted_exact(&bytes).unwrap(), + st + ); + + let mut padded = bytes.clone(); + padded.push(0); + // The derived decoder ignores the suffix; the exact one refuses it. + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted(&padded).unwrap(), + st + ); + assert!(matches!( + StateTransition::deserialize_from_bytes_untrusted_exact(&padded), + Err(ProtocolError::PlatformDeserializationError(message)) + if message.contains("1 bytes left over") + )); + } + + /// A tagged transition followed by another decodes loosely as the first alone; only the + /// exact decoder reports the suffix. + #[test] + fn exact_decode_refuses_a_transition_followed_by_another() { + let update: StateTransition = IdentityUpdateTransitionV0 { + identity_id: Identifier::from([0x21; 32]), + revision: 1, + nonce: 1, + disable_public_keys: vec![1], + ..Default::default() + } + .into(); + let withdrawal: StateTransition = IdentityCreditWithdrawalTransitionV0 { + identity_id: Identifier::from([0x21; 32]), + amount: 1000000, + core_fee_per_byte: 34, + pooling: Pooling::Standard, + output_script: CoreScript::from_bytes((0..23).collect::>()), + nonce: 2, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + + let mut both = update.serialize_to_bytes().unwrap(); + both.extend_from_slice(&withdrawal.serialize_to_bytes().unwrap()); + + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted(&both).unwrap(), + update + ); + assert!(matches!( + StateTransition::deserialize_from_bytes_untrusted_exact(&both), + Err(ProtocolError::PlatformDeserializationError(message)) if message.contains("left over") + )); + } + + #[test] + #[cfg(feature = "random-identities")] + fn untagged_decode_matches_the_tagged_transition() { + let platform_version = PlatformVersion::latest(); + let identity = Identity::random_identity(5, Some(5), platform_version) + .expect("expected a random identity"); + let update = IdentityUpdateTransitionV0 { + signature: [7u8; 65].to_vec().into(), + signature_public_key_id: 0, + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: identity + .public_keys() + .values() + .map(|public_key| public_key.into()) + .collect(), + disable_public_keys: vec![], + user_fee_increase: 0, + }; + let inner_bytes = IdentityUpdateTransition::from(update.clone()) + .serialize_to_bytes() + .unwrap(); + let st: StateTransition = update.into(); + + let decoded = StateTransition::deserialize_untagged_untrusted_exact( + StateTransitionType::IdentityUpdate, + &inner_bytes, + ) + .expect("untagged identity update decodes"); + assert_eq!(decoded, st); + assert_eq!( + &decoded.serialize_to_bytes().unwrap()[1..], + &inner_bytes[..] + ); + + assert!(StateTransition::deserialize_untagged_untrusted_exact( + StateTransitionType::Batch, + &inner_bytes, + ) + .is_err()); + + let mut padded = inner_bytes.clone(); + padded.push(0); + assert!(StateTransition::deserialize_untagged_untrusted_exact( + StateTransitionType::IdentityUpdate, + &padded, + ) + .is_err()); + } + + /// The untagged decoder refuses a body at the `StateTransition` byte budget, naming it. + #[test] + fn untagged_decode_honours_the_state_transition_budget() { + let st: StateTransition = IdentityCreditWithdrawalTransitionV0 { + identity_id: Identifier::from([0x21; 32]), + amount: 1000000, + core_fee_per_byte: 34, + pooling: Pooling::Standard, + output_script: CoreScript::from_bytes(vec![0; STATE_TRANSITION_MAX_ENCODED_BYTES]), + nonce: 1, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + let tagged = bincode::encode_to_vec(&st, bincode::config::standard().with_big_endian()) + .expect("encodes"); + + match StateTransition::deserialize_untagged_untrusted_exact( + StateTransitionType::IdentityCreditWithdrawal, + &tagged[1..], + ) { + Err(ProtocolError::MaxEncodedBytesReachedError { + max_size_kbytes, .. + }) => assert_eq!(max_size_kbytes, STATE_TRANSITION_MAX_ENCODED_BYTES), + other => panic!("expected the budget error, got {other:?}"), + } + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/mod.rs index 23d13da9b2a..e3bc87f1243 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/mod.rs @@ -133,7 +133,9 @@ mod test { use super::*; use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use crate::identity::{KeyType, Purpose, SecurityLevel}; - use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; + use crate::state_transition::public_key_in_creation::accessors::{ + IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreationV1Getters, + }; use crate::state_transition::public_key_in_creation::methods::IdentityPublicKeyInCreationMethodsV0; use crate::version::LATEST_PLATFORM_VERSION; use platform_value::BinaryData; @@ -208,6 +210,35 @@ mod test { assert_eq!(back.id(), key.id()); } + /// The limits are part of what the identity signs, so a V1 key (one carrying a budget or an + /// expiry) must become a V1 key in creation with the limits intact, and a V0 key must stay V0 + /// so identities that do not use limits keep their historical bytes. + #[test] + fn test_from_identity_public_key_keeps_limits() { + let plain: IdentityPublicKey = make_high_key(5).into(); + let plain_in_creation = IdentityPublicKeyInCreation::from(&plain); + assert!(matches!( + plain_in_creation, + IdentityPublicKeyInCreation::V0(_) + )); + assert!(!plain_in_creation.has_limits()); + + for (total_budget, expires_at) in [ + (Some(10_000_000_000), None), + (None, Some(1_800_000_000_000)), + (Some(10_000_000_000), Some(1_800_000_000_000)), + ] { + let limited = plain.clone().with_limits(total_budget, expires_at); + assert!(matches!(limited, IdentityPublicKey::V1(_))); + + let in_creation = IdentityPublicKeyInCreation::from(&limited); + assert!(matches!(in_creation, IdentityPublicKeyInCreation::V1(_))); + assert_eq!(in_creation.total_budget(), total_budget); + assert_eq!(in_creation.expires_at(), expires_at); + assert_eq!(IdentityPublicKey::from(in_creation), limited); + } + } + #[test] fn test_into_identity_public_key_method() { let key = make_master_key(0); diff --git a/packages/rs-dpp/src/validation/meta_validators/mod.rs b/packages/rs-dpp/src/validation/meta_validators/mod.rs index 097c3b3fe4c..781155bc06d 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -404,6 +404,101 @@ mod tests { } } + #[test] + fn should_accept_key_requirements_on_an_identity_public_key_refers_to_in_v3_document_schema() { + for requirements in [ + json!({ "purpose": "decryption" }), + json!({ "purpose": "authentication" }), + json!({ "purpose": "owner" }), + json!({ "boundTo": "submittedCharter" }), + json!({ "purpose": "decryption", "boundTo": "submittedCharter" }), + ] { + let schema = document_schema_with_refers_to(json!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": requirements + })); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_ok(), + "expected keyRequirements {requirements} to be valid" + ); + } + } + + #[test] + fn should_reject_malformed_key_requirements_in_v3_document_schema() { + for refers_to in [ + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": {} }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "system" } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "DECRYPTION" } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": 2 } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "boundTo": "" } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "boundTo": "a.b" } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "boundTo": "a-b" } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "securityLevel": "high" } }), + json!({ "type": "identity", "keyRequirements": { "purpose": "decryption" } }), + json!({ "type": "contract", "keyRequirements": { "purpose": "decryption" } }), + ] { + let schema = document_schema_with_refers_to(refers_to.clone()); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_err(), + "expected refersTo {refers_to} to be invalid" + ); + } + } + + #[test] + fn should_accept_a_lookup_on_a_permanent_document_refers_to_in_v3_document_schema() { + for reference_type in ["permanentDocument"] { + for keys in [ + json!({ "submittedCharterId": "submittedCharterId", "$ownerId": "." }), + json!({ "submittedCharterId": ".", "$ownerId": "$ownerId" }), + json!({ "meta.charterId": "meta.charterId", "$ownerId": "." }), + ] { + let schema = document_schema_with_refers_to(json!({ + "type": reference_type, + "documentType": "joinRequest", + "lookup": { "index": "bySubmittedCharter", "keys": keys } + })); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_ok(), + "expected a {reference_type} lookup with keys {keys} to be valid" + ); + } + } + } + + #[test] + fn should_reject_malformed_or_misplaced_lookups_in_v3_document_schema() { + let keys = json!({ "$ownerId": "." }); + for refers_to in [ + json!({ "type": "identity", "lookup": { "index": "byOwner", "keys": keys } }), + json!({ "type": "contract", "lookup": { "index": "byOwner", "keys": keys } }), + json!({ "type": "token", "lookup": { "index": "byOwner", "keys": keys } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "keyId", "lookup": { "index": "byOwner", "keys": keys } }), + json!({ "type": "deletableDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": keys } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "keys": keys } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner" } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "", "keys": keys } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "x".repeat(33), "keys": keys } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": {} } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": { "$ownerId": 1 } } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": { "$ownerId": "$createdAt" } } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": { "$ownerId": "bad-name" } } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": keys, "extra": 1 } }), + ] { + let schema = document_schema_with_refers_to(refers_to.clone()); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_err(), + "expected refersTo {refers_to} to be invalid" + ); + } + } + #[test] fn should_accept_permanent_document_refers_to_in_v3_document_schema() { let schema = document_schema_with_refers_to(json!({ diff --git a/packages/rs-dpp/src/voting/vote_choices/yes_no_abstain_vote_choice/mod.rs b/packages/rs-dpp/src/voting/vote_choices/yes_no_abstain_vote_choice/mod.rs index 78af1ddccee..95a835c94ec 100644 --- a/packages/rs-dpp/src/voting/vote_choices/yes_no_abstain_vote_choice/mod.rs +++ b/packages/rs-dpp/src/voting/vote_choices/yes_no_abstain_vote_choice/mod.rs @@ -1,18 +1,53 @@ use bincode::{Decode, DecodeUntrusted, Encode}; #[cfg(feature = "serde-conversion")] use serde::{Deserialize, Serialize}; +use std::fmt; -#[derive(Debug, Clone, Copy, Encode, Decode, PartialEq, Default, DecodeUntrusted)] +/// The answer a masternode gives a yes/no vote poll. Abstaining counts towards nothing: the +/// poll's supermajority and minimum are measured over yes plus no only. +#[derive( + Debug, + Clone, + Copy, + Encode, + Decode, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Default, + DecodeUntrusted, +)] #[cfg_attr( feature = "serde-conversion", derive(Serialize, Deserialize), serde(rename_all = "camelCase") )] pub enum YesNoAbstainVoteChoice { - YES, - NO, + Yes, + No, #[default] - ABSTAIN, + Abstain, +} + +impl YesNoAbstainVoteChoice { + /// Every choice, in tree key order. + pub const ALL: [YesNoAbstainVoteChoice; 3] = [ + YesNoAbstainVoteChoice::Yes, + YesNoAbstainVoteChoice::No, + YesNoAbstainVoteChoice::Abstain, + ]; +} + +impl fmt::Display for YesNoAbstainVoteChoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + YesNoAbstainVoteChoice::Yes => write!(f, "Yes"), + YesNoAbstainVoteChoice::No => write!(f, "No"), + YesNoAbstainVoteChoice::Abstain => write!(f, "Abstain"), + } + } } // --- canonical conversion trait impls (unification pass 1) --- @@ -33,23 +68,15 @@ mod json_convertible_tests_yesnoabstainvotechoice { use platform_value::platform_value; use serde_json::json; - // `YesNoAbstainVoteChoice` is a unit-only enum with `rename_all = "camelCase"`, - // so each variant serializes as a plain string: `"yes"` / `"no"` / `"abstain"`. - - // Surprise wire shape: the variants are SCREAMING_CASE in source - // (`YES`/`NO`/`ABSTAIN`) and the type carries `rename_all = "camelCase"`. - // serde's camelCase rule lowercases the FIRST letter only, leaving the - // rest as-is — so the wire emits `"yES"` / `"nO"` / `"aBSTAIN"` rather - // than the lowercase-clean strings a casual reader would expect. These - // tests pin that behaviour so a future "looks-like-a-typo" rename to - // lowercase doesn't silently change the on-the-wire format. + // A unit-only enum with `rename_all = "camelCase"`: each variant is a plain lowercase + // string on the wire. #[test] fn json_round_trip_yes() { use crate::serialization::JsonConvertible; - let original = YesNoAbstainVoteChoice::YES; + let original = YesNoAbstainVoteChoice::Yes; let json = original.to_json().expect("to_json"); - assert_eq!(json, json!("yES")); + assert_eq!(json, json!("yes")); let recovered = YesNoAbstainVoteChoice::from_json(json).expect("from_json"); assert_eq!(original, recovered); } @@ -57,9 +84,9 @@ mod json_convertible_tests_yesnoabstainvotechoice { #[test] fn json_round_trip_no() { use crate::serialization::JsonConvertible; - let original = YesNoAbstainVoteChoice::NO; + let original = YesNoAbstainVoteChoice::No; let json = original.to_json().expect("to_json"); - assert_eq!(json, json!("nO")); + assert_eq!(json, json!("no")); let recovered = YesNoAbstainVoteChoice::from_json(json).expect("from_json"); assert_eq!(original, recovered); } @@ -67,9 +94,9 @@ mod json_convertible_tests_yesnoabstainvotechoice { #[test] fn json_round_trip_abstain() { use crate::serialization::JsonConvertible; - let original = YesNoAbstainVoteChoice::ABSTAIN; + let original = YesNoAbstainVoteChoice::Abstain; let json = original.to_json().expect("to_json"); - assert_eq!(json, json!("aBSTAIN")); + assert_eq!(json, json!("abstain")); let recovered = YesNoAbstainVoteChoice::from_json(json).expect("from_json"); assert_eq!(original, recovered); } @@ -77,9 +104,9 @@ mod json_convertible_tests_yesnoabstainvotechoice { #[test] fn value_round_trip_yes() { use crate::serialization::ValueConvertible; - let original = YesNoAbstainVoteChoice::YES; + let original = YesNoAbstainVoteChoice::Yes; let value = original.to_object().expect("to_object"); - assert_eq!(value, platform_value!("yES")); + assert_eq!(value, platform_value!("yes")); let recovered = YesNoAbstainVoteChoice::from_object(value).expect("from_object"); assert_eq!(original, recovered); } @@ -87,9 +114,9 @@ mod json_convertible_tests_yesnoabstainvotechoice { #[test] fn value_round_trip_no() { use crate::serialization::ValueConvertible; - let original = YesNoAbstainVoteChoice::NO; + let original = YesNoAbstainVoteChoice::No; let value = original.to_object().expect("to_object"); - assert_eq!(value, platform_value!("nO")); + assert_eq!(value, platform_value!("no")); let recovered = YesNoAbstainVoteChoice::from_object(value).expect("from_object"); assert_eq!(original, recovered); } @@ -97,9 +124,9 @@ mod json_convertible_tests_yesnoabstainvotechoice { #[test] fn value_round_trip_abstain() { use crate::serialization::ValueConvertible; - let original = YesNoAbstainVoteChoice::ABSTAIN; + let original = YesNoAbstainVoteChoice::Abstain; let value = original.to_object().expect("to_object"); - assert_eq!(value, platform_value!("aBSTAIN")); + assert_eq!(value, platform_value!("abstain")); let recovered = YesNoAbstainVoteChoice::from_object(value).expect("from_object"); assert_eq!(original, recovered); } diff --git a/packages/rs-dpp/src/voting/vote_info_storage/mod.rs b/packages/rs-dpp/src/voting/vote_info_storage/mod.rs index a69f0aee63a..740f6cddc3f 100644 --- a/packages/rs-dpp/src/voting/vote_info_storage/mod.rs +++ b/packages/rs-dpp/src/voting/vote_info_storage/mod.rs @@ -1,2 +1,3 @@ pub mod contested_document_vote_poll_stored_info; pub mod contested_document_vote_poll_winner_info; +pub mod yes_no_vote_poll_stored_info; diff --git a/packages/rs-dpp/src/voting/vote_info_storage/yes_no_vote_poll_stored_info/mod.rs b/packages/rs-dpp/src/voting/vote_info_storage/yes_no_vote_poll_stored_info/mod.rs new file mode 100644 index 00000000000..4ec9a614071 --- /dev/null +++ b/packages/rs-dpp/src/voting/vote_info_storage/yes_no_vote_poll_stored_info/mod.rs @@ -0,0 +1,232 @@ +pub mod v0; + +use crate::block::block_info::BlockInfo; +use crate::voting::vote_info_storage::yes_no_vote_poll_stored_info::v0::YesNoVotePollStoredInfoV0; +use crate::voting::vote_polls::yes_no_vote_poll::VotingPower; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use derive_more::From; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_version::version::PlatformVersion; +use std::fmt; + +/// How a yes/no vote poll ended: the final tallies and whether they passed the poll's rule. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default, Encode, Decode, DecodeUntrusted)] +pub struct YesNoVotePollResult { + /// Whether the poll passed. + pub passed: bool, + /// The yes voting power at the end. + pub yes_voting_power: VotingPower, + /// The no voting power at the end. + pub no_voting_power: VotingPower, + /// The abstaining voting power at the end. Recorded, but it plays no part in the result. + pub abstain_voting_power: VotingPower, + /// The yes plus no voting power the poll needed, resolved in the block that closed it (a + /// share of the total is measured then). + pub required_voting_power: VotingPower, + /// The block the poll opened in. + pub start_block: BlockInfo, + /// The block the poll was closed in. + pub finalization_block: BlockInfo, +} + +impl fmt::Display for YesNoVotePollResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "YesNoVotePollResult {{ passed: {}, yes: {}, no: {}, abstain: {}, required: {}, start_block: {}, finalization_block: {} }}", + self.passed, + self.yes_voting_power, + self.no_voting_power, + self.abstain_voting_power, + self.required_voting_power, + self.start_block, + self.finalization_block + ) + } +} + +/// Where a yes/no vote poll is in its life. A poll runs once: finished, it stays finished, and +/// its result is the record of the decision. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Encode, Decode, DecodeUntrusted)] +pub enum YesNoVotePollStatus { + /// Votes are being taken since this block. + Started(BlockInfo), + /// The poll was closed with this result. + Finished(YesNoVotePollResult), +} + +impl fmt::Display for YesNoVotePollStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + YesNoVotePollStatus::Started(block_info) => write!(f, "Started({})", block_info), + YesNoVotePollStatus::Finished(result) => write!(f, "Finished({})", result), + } + } +} + +/// What Drive stores about a yes/no vote poll beside its votes. +#[derive( + Debug, + PartialEq, + Eq, + Clone, + From, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[platform_serialize(unversioned)] +pub enum YesNoVotePollStoredInfo { + V0(YesNoVotePollStoredInfoV0), +} + +impl fmt::Display for YesNoVotePollStoredInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + YesNoVotePollStoredInfo::V0(info) => write!(f, "V0({})", info), + } + } +} + +impl YesNoVotePollStoredInfo { + /// The stored info of a poll that opens in `start_block`. + pub fn new( + start_block: BlockInfo, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .voting_versions + .yes_no_vote_poll_stored_info_version + { + 0 => Ok(YesNoVotePollStoredInfoV0::new(start_block).into()), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "YesNoVotePollStoredInfo::new".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + /// Rewrites the stored info in the version the platform version wants stored. + pub fn update_to_latest_version( + self, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .voting_versions + .yes_no_vote_poll_stored_info_version + { + 0 => match self { + YesNoVotePollStoredInfo::V0(_) => Ok(self), + }, + version => Err(ProtocolError::UnknownVersionMismatch { + method: "YesNoVotePollStoredInfo::update_to_latest_version".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + /// Closes a started poll with its final tallies. `passed` is the poll's own verdict on + /// them; the stored info records it without judging. + pub fn finalize_vote_poll( + &mut self, + yes_voting_power: VotingPower, + no_voting_power: VotingPower, + abstain_voting_power: VotingPower, + required_voting_power: VotingPower, + passed: bool, + finalization_block: BlockInfo, + ) -> Result { + match self { + YesNoVotePollStoredInfo::V0(v0) => v0.finalize_vote_poll( + yes_voting_power, + no_voting_power, + abstain_voting_power, + required_voting_power, + passed, + finalization_block, + ), + } + } + + /// Where the poll is in its life. + pub fn status(&self) -> &YesNoVotePollStatus { + match self { + YesNoVotePollStoredInfo::V0(v0) => &v0.status, + } + } + + /// The result, once the poll has finished. + pub fn result(&self) -> Option<&YesNoVotePollResult> { + match self.status() { + YesNoVotePollStatus::Started(_) => None, + YesNoVotePollStatus::Finished(result) => Some(result), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::serialization::{PlatformDeserializableTrusted, PlatformSerializable}; + + #[test] + fn should_finalize_a_started_poll_once() { + let platform_version = PlatformVersion::latest(); + let start_block = BlockInfo::default_with_time(10); + let mut stored_info = + YesNoVotePollStoredInfo::new(start_block, platform_version).expect("stored info"); + assert_eq!( + stored_info.status(), + &YesNoVotePollStatus::Started(start_block) + ); + assert!(stored_info.result().is_none()); + + let finalization_block = BlockInfo::default_with_time(20); + let result = stored_info + .finalize_vote_poll(300, 100, 7, 400, true, finalization_block) + .expect("finalize"); + assert_eq!( + result, + YesNoVotePollResult { + passed: true, + yes_voting_power: 300, + no_voting_power: 100, + abstain_voting_power: 7, + required_voting_power: 400, + start_block, + finalization_block, + } + ); + assert_eq!(stored_info.result(), Some(&result)); + + // A finished poll is not finalized twice. + assert!(stored_info + .finalize_vote_poll(1, 1, 1, 400, false, finalization_block) + .is_err()); + } + + #[test] + fn should_round_trip_through_platform_serialization() { + let platform_version = PlatformVersion::latest(); + let mut stored_info = + YesNoVotePollStoredInfo::new(BlockInfo::default_with_time(10), platform_version) + .expect("stored info"); + stored_info + .finalize_vote_poll(5, 4, 3, 400, false, BlockInfo::default_with_time(20)) + .expect("finalize"); + let bytes = stored_info.serialize_to_bytes().expect("serialize"); + let recovered = + YesNoVotePollStoredInfo::deserialize_from_bytes_trusted(&bytes).expect("deserialize"); + assert_eq!(recovered, stored_info); + } +} diff --git a/packages/rs-dpp/src/voting/vote_info_storage/yes_no_vote_poll_stored_info/v0/mod.rs b/packages/rs-dpp/src/voting/vote_info_storage/yes_no_vote_poll_stored_info/v0/mod.rs new file mode 100644 index 00000000000..d89954e853d --- /dev/null +++ b/packages/rs-dpp/src/voting/vote_info_storage/yes_no_vote_poll_stored_info/v0/mod.rs @@ -0,0 +1,56 @@ +use crate::block::block_info::BlockInfo; +use crate::voting::vote_info_storage::yes_no_vote_poll_stored_info::{ + YesNoVotePollResult, YesNoVotePollStatus, +}; +use crate::voting::vote_polls::yes_no_vote_poll::VotingPower; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use std::fmt; + +/// Version 0 of the stored info of a yes/no vote poll: only its status. +#[derive(Debug, PartialEq, Eq, Clone, Encode, Decode, DecodeUntrusted)] +pub struct YesNoVotePollStoredInfoV0 { + /// Where the poll is in its life. + pub status: YesNoVotePollStatus, +} + +impl fmt::Display for YesNoVotePollStoredInfoV0 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "YesNoVotePollStoredInfoV0 {{ status: {} }}", self.status) + } +} + +impl YesNoVotePollStoredInfoV0 { + pub fn new(start_block: BlockInfo) -> YesNoVotePollStoredInfoV0 { + YesNoVotePollStoredInfoV0 { + status: YesNoVotePollStatus::Started(start_block), + } + } + + pub fn finalize_vote_poll( + &mut self, + yes_voting_power: VotingPower, + no_voting_power: VotingPower, + abstain_voting_power: VotingPower, + required_voting_power: VotingPower, + passed: bool, + finalization_block: BlockInfo, + ) -> Result { + let YesNoVotePollStatus::Started(start_block) = self.status else { + return Err(ProtocolError::CorruptedCodeExecution( + "trying to finalize a yes/no vote poll that is not started".to_string(), + )); + }; + let result = YesNoVotePollResult { + passed, + yes_voting_power, + no_voting_power, + abstain_voting_power, + required_voting_power, + start_block, + finalization_block, + }; + self.status = YesNoVotePollStatus::Finished(result); + Ok(result) + } +} diff --git a/packages/rs-dpp/src/voting/vote_polls/mod.rs b/packages/rs-dpp/src/voting/vote_polls/mod.rs index 8664016e4e5..6f8b400be2a 100644 --- a/packages/rs-dpp/src/voting/vote_polls/mod.rs +++ b/packages/rs-dpp/src/voting/vote_polls/mod.rs @@ -3,6 +3,7 @@ use crate::serialization::JsonConvertible; #[cfg(feature = "value-conversion")] use crate::serialization::ValueConvertible; use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +use crate::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::From; @@ -15,6 +16,7 @@ use serde::{Deserialize, Serialize}; use std::fmt; pub mod contested_document_resource_vote_poll; +pub mod yes_no_vote_poll; #[cfg_attr( all(feature = "json-conversion", feature = "serde-conversion"), @@ -44,6 +46,10 @@ pub mod contested_document_resource_vote_poll; #[platform_serialize(limit = 100000)] pub enum VotePoll { ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll), + /// A yes/no poll keyed by a resource path, resolved by a supermajority once enough voting + /// power has been cast (protocol version 14). Appended: the bincode discriminant is the + /// variant position. + YesNoVotePoll(YesNoVotePoll), } impl fmt::Display for VotePoll { @@ -52,6 +58,7 @@ impl fmt::Display for VotePoll { VotePoll::ContestedDocumentResourceVotePoll(poll) => { write!(f, "ContestedDocumentResourceVotePoll({})", poll) } + VotePoll::YesNoVotePoll(poll) => write!(f, "YesNoVotePoll({})", poll), } } } @@ -70,6 +77,9 @@ impl VotePoll { contested_document_resource_vote_poll.specialized_balance_id()?, )) } + VotePoll::YesNoVotePoll(yes_no_vote_poll) => { + Ok(Some(yes_no_vote_poll.specialized_balance_id()?)) + } } } @@ -78,6 +88,7 @@ impl VotePoll { VotePoll::ContestedDocumentResourceVotePoll(contested_document_resource_vote_poll) => { contested_document_resource_vote_poll.unique_id() } + VotePoll::YesNoVotePoll(yes_no_vote_poll) => yes_no_vote_poll.unique_id(), } } } diff --git a/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs b/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs new file mode 100644 index 00000000000..452067b6d6b --- /dev/null +++ b/packages/rs-dpp/src/voting/vote_polls/yes_no_vote_poll/mod.rs @@ -0,0 +1,704 @@ +#[cfg(feature = "json-conversion")] +use crate::serialization::json_safe_fields; +#[cfg(feature = "json-conversion")] +use crate::serialization::JsonConvertible; +use crate::serialization::PlatformSerializable; +#[cfg(feature = "value-conversion")] +use crate::serialization::ValueConvertible; +use crate::util::hash::hash_double; +use crate::voting::vote_polls::VotePoll; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::{BinaryData, Identifier}; +use platform_version::version::PlatformVersion; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Voting power: a masternode vote weighs 1, an evonode vote weighs 4. The sum over the whole +/// masternode list fits a `u32`. +pub type VotingPower = u32; + +/// Prefixed to the serialized poll before its id is hashed. A contested poll's id hashes its +/// bare encoding, which starts with a 32-byte contract id; matching this 33-byte tag would take +/// a contract id equal to its first 32 bytes, so the two kinds never share an id (and with it a +/// prefunded balance and an end date index entry). +const YES_NO_VOTE_POLL_ID_DOMAIN: &[u8] = b"dash platform yes/no vote poll id"; + +/// Which way a fraction of the total voting power rounds to a whole voting power. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub enum VotingPowerRounding { + /// Toward zero: 1/3 of 10 is 3, 1/3 of 9 is 3. + Down, + /// Away from zero: 1/3 of 10 is 4, 1/3 of 9 is 3. + Up, + /// Rounded down, plus one: 1/3 of 10 is 4, 1/3 of 9 is 4. Half the total this way is a + /// strict majority of it. + DownPlusOne, + /// Rounded up, minus one, never below zero: 1/3 of 10 is 3, 1/3 of 9 is 2. + UpMinusOne, +} + +/// The least yes plus no voting power a yes/no poll needs to pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +pub enum YesNoMinimumVotingPower { + /// A fixed voting power, at most `max_yes_no_vote_poll_minimum_voting_power`. + Absolute(VotingPower), + /// A share of the total voting power of the masternode list (masternode 1, evonode 4) in + /// the block that closes the poll, rounded as asked, at most + /// `max_yes_no_vote_poll_minimum_voting_power_percent_of_total` percent of it. + #[cfg_attr(feature = "serde-conversion", serde(rename_all = "camelCase"))] + FractionOfTotal { + numerator: u8, + denominator: u8, + rounding: VotingPowerRounding, + }, +} + +impl YesNoMinimumVotingPower { + /// The minimum as a voting power, given the total voting power of the masternode list when + /// the poll closes. A fraction with a zero denominator (refused when the poll opens) needs + /// more than any poll can gather. + pub fn resolve(&self, total_voting_power: VotingPower) -> VotingPower { + match *self { + YesNoMinimumVotingPower::Absolute(voting_power) => voting_power, + YesNoMinimumVotingPower::FractionOfTotal { + numerator, + denominator, + rounding, + } => { + if denominator == 0 { + return VotingPower::MAX; + } + let share = total_voting_power as u64 * numerator as u64; + let denominator = denominator as u64; + let resolved = match rounding { + VotingPowerRounding::Down => share / denominator, + VotingPowerRounding::Up => share.div_ceil(denominator), + VotingPowerRounding::DownPlusOne => share / denominator + 1, + VotingPowerRounding::UpMinusOne => { + share.div_ceil(denominator).saturating_sub(1) + } + }; + VotingPower::try_from(resolved).unwrap_or(VotingPower::MAX) + } + } + } +} + +impl fmt::Display for YesNoMinimumVotingPower { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + YesNoMinimumVotingPower::Absolute(voting_power) => write!(f, "{}", voting_power), + YesNoMinimumVotingPower::FractionOfTotal { + numerator, + denominator, + rounding, + } => write!( + f, + "{}/{} of the total, rounded {}", + numerator, + denominator, + match rounding { + VotingPowerRounding::Down => "down", + VotingPowerRounding::Up => "up", + VotingPowerRounding::DownPlusOne => "down plus one", + VotingPowerRounding::UpMinusOne => "up minus one", + } + ), + } + } +} + +/// A poll the masternodes answer with yes, no or abstain. +/// +/// The poll is keyed by a resource path the feature that opens it chooses (a contract id and a +/// purpose, for example). Two polls with the same resource path and the same parameters are the +/// same poll, so a feature that may run the same question again includes something that +/// distinguishes the rounds in the path. +/// +/// The poll passes when the yes voting power is strictly more than +/// `supermajority_numerator / supermajority_denominator` of the yes plus no voting power, the +/// abstaining power left out of both sides, and the yes plus no voting power is at least +/// `minimum_voting_power`. Otherwise it fails. The minimum is a fixed voting power or a share of +/// the masternode list's total voting power when the poll closes. The whole rule is carried by +/// the poll so every feature sets its own. Two thirds with a floor of 400 is numerator 2, +/// denominator 3 and an absolute minimum of 400. +/// +/// The parameters are part of the poll's identity: [`Self::unique_id`] hashes the serialized +/// [`VotePoll`] that carries the poll, so a vote names the exact poll it answers. The prefunded +/// balance that pays for the votes has the same id. +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[cfg_attr( + all(feature = "json-conversion", feature = "serde-conversion"), + derive(JsonConvertible) +)] +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + PartialEq, + Eq, + DecodeUntrusted, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] +#[platform_serialize(unversioned)] +#[platform_serialize(limit = 100000)] +pub struct YesNoVotePoll { + /// What the poll decides on, as opaque segments chosen by the feature that opened it. + pub resource_path: Vec, + /// The numerator of the share of yes plus no voting power that yes must exceed. + pub supermajority_numerator: u8, + /// The denominator of the share of yes plus no voting power that yes must exceed. + pub supermajority_denominator: u8, + /// The least yes plus no voting power for the poll to pass. + pub minimum_voting_power: YesNoMinimumVotingPower, +} + +impl fmt::Display for YesNoVotePoll { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let resource_path: Vec = self + .resource_path + .iter() + .map(|segment| hex::encode(segment.as_slice())) + .collect(); + write!( + f, + "YesNoVotePoll {{ resource_path: [{}], supermajority: {}/{}, minimum_voting_power: {} }}", + resource_path.join("/"), + self.supermajority_numerator, + self.supermajority_denominator, + self.minimum_voting_power + ) + } +} + +impl YesNoVotePoll { + /// Refuses parameters no poll can be opened with: an empty or oversized resource path (the + /// poll rides in every vote, whose wire size is bounded), a zero denominator, a zero + /// numerator (any single yes would pass against every no), a numerator that is not below + /// the denominator (yes could never exceed the whole of yes plus no), and a minimum above + /// its cap (a voting power, or a share of the total with a zero denominator or above the + /// allowed percentage). + pub fn validate_parameters( + &self, + platform_version: &PlatformVersion, + ) -> Result<(), ProtocolError> { + if self.resource_path.is_empty() { + return Err(ProtocolError::VoteError( + "a yes/no vote poll needs a resource path".to_string(), + )); + } + let max_segments = platform_version + .system_limits + .max_yes_no_vote_poll_resource_path_segments; + if self.resource_path.len() > max_segments as usize { + return Err(ProtocolError::VoteError(format!( + "a yes/no vote poll resource path has at most {} segments, got {}", + max_segments, + self.resource_path.len() + ))); + } + let max_bytes = platform_version + .system_limits + .max_yes_no_vote_poll_resource_path_bytes; + let bytes: usize = self.resource_path.iter().map(|segment| segment.len()).sum(); + if bytes > max_bytes as usize { + return Err(ProtocolError::VoteError(format!( + "a yes/no vote poll resource path holds at most {} bytes, got {}", + max_bytes, bytes + ))); + } + if self.supermajority_denominator == 0 { + return Err(ProtocolError::VoteError( + "a yes/no vote poll supermajority denominator must not be zero".to_string(), + )); + } + if self.supermajority_numerator == 0 { + return Err(ProtocolError::VoteError( + "a yes/no vote poll supermajority numerator must not be zero".to_string(), + )); + } + if self.supermajority_numerator >= self.supermajority_denominator { + return Err(ProtocolError::VoteError(format!( + "a yes/no vote poll supermajority numerator ({}) must be below its denominator ({})", + self.supermajority_numerator, self.supermajority_denominator + ))); + } + match self.minimum_voting_power { + YesNoMinimumVotingPower::Absolute(voting_power) => { + let max_voting_power = platform_version + .system_limits + .max_yes_no_vote_poll_minimum_voting_power; + if voting_power > max_voting_power { + return Err(ProtocolError::VoteError(format!( + "a yes/no vote poll minimum voting power is at most {}, got {}", + max_voting_power, voting_power + ))); + } + } + YesNoMinimumVotingPower::FractionOfTotal { + numerator, + denominator, + .. + } => { + if denominator == 0 { + return Err(ProtocolError::VoteError( + "a yes/no vote poll minimum share denominator must not be zero".to_string(), + )); + } + let max_percent = platform_version + .system_limits + .max_yes_no_vote_poll_minimum_voting_power_percent_of_total; + if numerator as u32 * 100 > denominator as u32 * max_percent as u32 { + return Err(ProtocolError::VoteError(format!( + "a yes/no vote poll minimum share is at most {}% of the total voting power, got {}/{}", + max_percent, numerator, denominator + ))); + } + } + } + Ok(()) + } + + /// The least yes plus no voting power the poll needs, given the total voting power of the + /// masternode list in the block that closes it. + pub fn required_voting_power(&self, total_voting_power: VotingPower) -> VotingPower { + self.minimum_voting_power.resolve(total_voting_power) + } + + /// Whether the poll passes with these final tallies, given the total voting power of the + /// masternode list in the block that closes it. Abstaining power plays no part. + pub fn passes( + &self, + yes_voting_power: VotingPower, + no_voting_power: VotingPower, + total_voting_power: VotingPower, + ) -> bool { + let yes = yes_voting_power as u64; + let cast = yes + no_voting_power as u64; + if cast < self.required_voting_power(total_voting_power) as u64 { + return false; + } + yes * self.supermajority_denominator as u64 > cast * self.supermajority_numerator as u64 + } + + /// The double SHA-256 of the yes/no poll id domain tag followed by the serialized + /// `VotePoll` carrying this poll. + pub fn sha256_2_hash(&self) -> Result<[u8; 32], ProtocolError> { + let mut preimage = YES_NO_VOTE_POLL_ID_DOMAIN.to_vec(); + preimage.extend(VotePoll::YesNoVotePoll(self.clone()).serialize_to_bytes()?); + Ok(hash_double(preimage)) + } + + /// The prefunded balance the votes on this poll are paid from: the poll's unique id. + pub fn specialized_balance_id(&self) -> Result { + self.unique_id() + } + + /// The id the poll is stored and voted on under. + pub fn unique_id(&self) -> Result { + self.sha256_2_hash().map(Identifier::new) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn poll() -> YesNoVotePoll { + YesNoVotePoll { + resource_path: vec![ + BinaryData::new(vec![0xc1; 32]), + BinaryData::new(b"challenge".to_vec()), + ], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute(400), + } + } + + #[test] + fn should_pass_strictly_above_two_thirds_and_fail_at_or_below_it() { + // The minimum is met by every tally below; only the share decides. + let poll = YesNoVotePoll { + minimum_voting_power: YesNoMinimumVotingPower::Absolute(300), + ..poll() + }; + // 300 cast: two thirds is 200, so 201 passes and 200 does not. + assert!(poll.passes(201, 99, 0)); + assert!(!poll.passes(200, 100, 0)); + // 301 cast: two thirds is 200.67, so 201 passes and 200 does not. + assert!(poll.passes(201, 100, 0)); + assert!(!poll.passes(200, 101, 0)); + } + + #[test] + fn should_fail_below_the_minimum_voting_power_even_when_unanimous() { + let poll = poll(); + assert!(!poll.passes(399, 0, 0)); + assert!(poll.passes(400, 0, 0)); + } + + #[test] + fn should_resolve_a_share_of_the_total_rounded_as_asked() { + let third = |rounding| YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 3, + rounding, + }; + assert_eq!(third(VotingPowerRounding::Down).resolve(10), 3); + assert_eq!(third(VotingPowerRounding::Up).resolve(10), 4); + // An exact share is the same either way. + assert_eq!(third(VotingPowerRounding::Down).resolve(9), 3); + assert_eq!(third(VotingPowerRounding::Up).resolve(9), 3); + assert_eq!(third(VotingPowerRounding::Up).resolve(0), 0); + // Rounded down plus one, and rounded up minus one (never below zero). + assert_eq!(third(VotingPowerRounding::DownPlusOne).resolve(10), 4); + assert_eq!(third(VotingPowerRounding::DownPlusOne).resolve(9), 4); + assert_eq!(third(VotingPowerRounding::DownPlusOne).resolve(0), 1); + assert_eq!(third(VotingPowerRounding::UpMinusOne).resolve(10), 3); + assert_eq!(third(VotingPowerRounding::UpMinusOne).resolve(9), 2); + assert_eq!(third(VotingPowerRounding::UpMinusOne).resolve(0), 0); + // Half rounded down plus one is a strict majority. + let majority = YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 2, + rounding: VotingPowerRounding::DownPlusOne, + }; + assert_eq!(majority.resolve(10), 6); + assert_eq!(majority.resolve(9), 5); + assert_eq!(majority.resolve(u32::MAX), u32::MAX / 2 + 1); + assert_eq!(YesNoMinimumVotingPower::Absolute(400).resolve(10), 400); + // No overflow on the largest total. + let half = YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 2, + rounding: VotingPowerRounding::Up, + }; + assert_eq!(half.resolve(u32::MAX), u32::MAX / 2 + 1); + } + + #[test] + fn should_judge_a_share_minimum_against_the_total_at_close() { + // Two thirds with at least 1/10 of the total cast, rounded up. + let poll = YesNoVotePoll { + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 10, + rounding: VotingPowerRounding::Up, + }, + ..poll() + }; + // A total of 3,801 needs 381 cast. + assert_eq!(poll.required_voting_power(3_801), 381); + assert!(!poll.passes(380, 0, 3_801)); + assert!(poll.passes(381, 0, 3_801)); + // The same tally fails once the list has grown. + assert!(!poll.passes(381, 0, 3_811)); + } + + #[test] + fn should_bound_the_minimum() { + let platform_version = PlatformVersion::latest(); + let max_voting_power = platform_version + .system_limits + .max_yes_no_vote_poll_minimum_voting_power; + let with_minimum = |minimum_voting_power| YesNoVotePoll { + minimum_voting_power, + ..poll() + }; + let share = |numerator, denominator| YesNoMinimumVotingPower::FractionOfTotal { + numerator, + denominator, + rounding: VotingPowerRounding::Up, + }; + for accepted in [ + YesNoMinimumVotingPower::Absolute(0), + YesNoMinimumVotingPower::Absolute(max_voting_power), + share(0, 1), + share(1, 3), + share(1, 2), + share(50, 100), + ] { + assert!( + with_minimum(accepted) + .validate_parameters(platform_version) + .is_ok(), + "{accepted} should be accepted" + ); + } + for refused in [ + YesNoMinimumVotingPower::Absolute(max_voting_power + 1), + share(1, 0), + share(51, 100), + share(2, 3), + ] { + assert!( + with_minimum(refused) + .validate_parameters(platform_version) + .is_err(), + "{refused} should be refused" + ); + } + } + + #[test] + fn should_not_overflow_on_the_largest_tallies() { + let poll = YesNoVotePoll { + supermajority_numerator: 254, + supermajority_denominator: 255, + ..poll() + }; + assert!(poll.passes(u32::MAX, 0, 0)); + assert!(!poll.passes(u32::MAX, u32::MAX, 0)); + } + + #[test] + fn should_refuse_parameters_no_poll_can_run_with() { + let platform_version = PlatformVersion::latest(); + assert!(poll().validate_parameters(platform_version).is_ok()); + assert!(YesNoVotePoll { + resource_path: vec![], + ..poll() + } + .validate_parameters(platform_version) + .is_err()); + assert!(YesNoVotePoll { + supermajority_denominator: 0, + ..poll() + } + .validate_parameters(platform_version) + .is_err()); + assert!(YesNoVotePoll { + supermajority_numerator: 3, + supermajority_denominator: 3, + ..poll() + } + .validate_parameters(platform_version) + .is_err()); + // A zero numerator would let one yes pass against any amount of no. + assert!(YesNoVotePoll { + supermajority_numerator: 0, + ..poll() + } + .validate_parameters(platform_version) + .is_err()); + // A simple majority is a valid rule. + assert!(YesNoVotePoll { + supermajority_numerator: 1, + supermajority_denominator: 2, + ..poll() + } + .validate_parameters(platform_version) + .is_ok()); + } + + #[test] + fn should_bound_the_resource_path() { + let platform_version = PlatformVersion::latest(); + let max_segments = platform_version + .system_limits + .max_yes_no_vote_poll_resource_path_segments as usize; + let max_bytes = platform_version + .system_limits + .max_yes_no_vote_poll_resource_path_bytes as usize; + let at_most_segments = YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![1]); max_segments], + ..poll() + }; + assert!(at_most_segments + .validate_parameters(platform_version) + .is_ok()); + let too_many_segments = YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![1]); max_segments + 1], + ..poll() + }; + assert!(too_many_segments + .validate_parameters(platform_version) + .is_err()); + let at_most_bytes = YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![1; max_bytes])], + ..poll() + }; + assert!(at_most_bytes.validate_parameters(platform_version).is_ok()); + let too_many_bytes = YesNoVotePoll { + resource_path: vec![ + BinaryData::new(vec![1; max_bytes]), + BinaryData::new(vec![1]), + ], + ..poll() + }; + assert!(too_many_bytes + .validate_parameters(platform_version) + .is_err()); + } + + #[test] + fn should_change_the_id_with_every_parameter() { + let base = poll().unique_id().expect("id"); + let other_path = YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![0xc1; 32])], + ..poll() + }; + let other_rule = YesNoVotePoll { + supermajority_numerator: 1, + supermajority_denominator: 2, + ..poll() + }; + let other_minimum = YesNoVotePoll { + minimum_voting_power: YesNoMinimumVotingPower::Absolute(401), + ..poll() + }; + for other in [other_path, other_rule, other_minimum] { + assert_ne!(other.unique_id().expect("id"), base); + } + assert_eq!(poll().specialized_balance_id().expect("balance id"), base); + } + + #[test] + fn should_hash_the_id_under_the_yes_no_domain_tag() { + let encoded = VotePoll::YesNoVotePoll(poll()) + .serialize_to_bytes() + .expect("encoded poll"); + let untagged = Identifier::new(hash_double(&encoded)); + let tagged = Identifier::new(hash_double( + [YES_NO_VOTE_POLL_ID_DOMAIN, encoded.as_slice()].concat(), + )); + let id = poll().unique_id().expect("id"); + assert_eq!(id, tagged); + assert_ne!(id, untagged); + } +} + +#[cfg(all( + test, + feature = "json-conversion", + feature = "value-conversion", + feature = "serde-conversion" +))] +mod json_convertible_tests { + use super::*; + use platform_value::platform_value; + use serde_json::json; + + fn fixture() -> YesNoVotePoll { + YesNoVotePoll { + resource_path: vec![ + BinaryData::new(vec![0xc1; 4]), + BinaryData::new(b"challenge".to_vec()), + ], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute(400), + } + } + + #[test] + fn json_round_trip_with_full_wire_shape() { + use crate::serialization::JsonConvertible; + let original = fixture(); + let json = original.to_json().expect("to_json"); + // Path segments are `BinaryData`, base64 strings in JSON. + assert_eq!( + json, + json!({ + "resourcePath": ["wcHBwQ==", "Y2hhbGxlbmdl"], + "supermajorityNumerator": 2, + "supermajorityDenominator": 3, + "minimumVotingPower": { "absolute": 400 }, + }) + ); + let recovered = YesNoVotePoll::from_json(json).expect("from_json"); + assert_eq!(original, recovered); + } + + #[test] + fn should_round_trip_a_share_minimum_through_json() { + use crate::serialization::JsonConvertible; + let original = YesNoVotePoll { + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 3, + rounding: VotingPowerRounding::Up, + }, + ..fixture() + }; + let json = original.to_json().expect("to_json"); + assert_eq!( + json["minimumVotingPower"], + json!({ "fractionOfTotal": { "numerator": 1, "denominator": 3, "rounding": "up" } }) + ); + let recovered = YesNoVotePoll::from_json(json).expect("from_json"); + assert_eq!(original, recovered); + } + + #[test] + fn should_name_every_rounding_in_camel_case() { + use crate::serialization::JsonConvertible; + for (rounding, name) in [ + (VotingPowerRounding::Down, "down"), + (VotingPowerRounding::Up, "up"), + (VotingPowerRounding::DownPlusOne, "downPlusOne"), + (VotingPowerRounding::UpMinusOne, "upMinusOne"), + ] { + let original = YesNoVotePoll { + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 2, + rounding, + }, + ..fixture() + }; + let json = original.to_json().expect("to_json"); + assert_eq!( + json["minimumVotingPower"]["fractionOfTotal"]["rounding"], + json!(name) + ); + assert_eq!(YesNoVotePoll::from_json(json).expect("from_json"), original); + } + } + + #[test] + fn value_round_trip_with_full_wire_shape() { + use crate::serialization::ValueConvertible; + let original = fixture(); + let value = original.to_object().expect("to_object"); + assert_eq!( + value, + platform_value!({ + "resourcePath": [ + platform_value::Value::Bytes(vec![0xc1; 4]), + platform_value::Value::Bytes(b"challenge".to_vec()), + ], + "supermajorityNumerator": 2u8, + "supermajorityDenominator": 3u8, + "minimumVotingPower": { "absolute": 400u32 }, + }) + ); + let recovered = YesNoVotePoll::from_object(value).expect("from_object"); + assert_eq!(original, recovered); + } +} diff --git a/packages/rs-dpp/src/voting/votes/mod.rs b/packages/rs-dpp/src/voting/votes/mod.rs index 4b19cd9757b..9d4e876b040 100644 --- a/packages/rs-dpp/src/voting/votes/mod.rs +++ b/packages/rs-dpp/src/voting/votes/mod.rs @@ -1,4 +1,5 @@ pub mod resource_vote; +pub mod yes_no_vote; #[cfg(feature = "json-conversion")] use crate::serialization::JsonConvertible; @@ -6,6 +7,8 @@ use crate::serialization::JsonConvertible; use crate::serialization::ValueConvertible; use crate::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; use crate::voting::votes::resource_vote::ResourceVote; +use crate::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; +use crate::voting::votes::yes_no_vote::YesNoVote; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; use derive_more::From; @@ -40,6 +43,9 @@ use serde::{Deserialize, Serialize}; #[platform_serialize(limit = 15000, unversioned)] pub enum Vote { ResourceVote(ResourceVote), + /// A vote on a yes/no poll (protocol version 14). Appended: the bincode discriminant is + /// the variant position. + YesNoVote(YesNoVote), } // Manual impl because Vote is a flat enum (not versioned V0/V1). @@ -56,11 +62,15 @@ impl Vote { pub fn specialized_balance_id(&self) -> Result, ProtocolError> { match self { Vote::ResourceVote(resource_vote) => resource_vote.vote_poll().specialized_balance_id(), + Vote::YesNoVote(yes_no_vote) => { + Ok(Some(yes_no_vote.vote_poll().specialized_balance_id()?)) + } } } pub fn vote_poll_unique_id(&self) -> Result { match self { Vote::ResourceVote(resource_vote) => resource_vote.vote_poll().unique_id(), + Vote::YesNoVote(yes_no_vote) => yes_no_vote.vote_poll().unique_id(), } } } diff --git a/packages/rs-dpp/src/voting/votes/yes_no_vote/accessors/mod.rs b/packages/rs-dpp/src/voting/votes/yes_no_vote/accessors/mod.rs new file mode 100644 index 00000000000..4b6295a18ce --- /dev/null +++ b/packages/rs-dpp/src/voting/votes/yes_no_vote/accessors/mod.rs @@ -0,0 +1,26 @@ +use crate::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use crate::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use crate::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; +use crate::voting::votes::yes_no_vote::YesNoVote; + +pub mod v0; + +impl YesNoVoteGettersV0 for YesNoVote { + fn vote_poll(&self) -> &YesNoVotePoll { + match self { + YesNoVote::V0(v0) => &v0.vote_poll, + } + } + + fn vote_poll_owned(self) -> YesNoVotePoll { + match self { + YesNoVote::V0(v0) => v0.vote_poll, + } + } + + fn vote_choice(&self) -> YesNoAbstainVoteChoice { + match self { + YesNoVote::V0(v0) => v0.vote_choice, + } + } +} diff --git a/packages/rs-dpp/src/voting/votes/yes_no_vote/accessors/v0/mod.rs b/packages/rs-dpp/src/voting/votes/yes_no_vote/accessors/v0/mod.rs new file mode 100644 index 00000000000..9ac03954776 --- /dev/null +++ b/packages/rs-dpp/src/voting/votes/yes_no_vote/accessors/v0/mod.rs @@ -0,0 +1,14 @@ +use crate::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use crate::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; + +/// Getters of a yes/no vote. +pub trait YesNoVoteGettersV0 { + /// The poll answered. + fn vote_poll(&self) -> &YesNoVotePoll; + + /// The poll answered, owned. + fn vote_poll_owned(self) -> YesNoVotePoll; + + /// The answer. + fn vote_choice(&self) -> YesNoAbstainVoteChoice; +} diff --git a/packages/rs-dpp/src/voting/votes/yes_no_vote/mod.rs b/packages/rs-dpp/src/voting/votes/yes_no_vote/mod.rs new file mode 100644 index 00000000000..09b0bdf3ad4 --- /dev/null +++ b/packages/rs-dpp/src/voting/votes/yes_no_vote/mod.rs @@ -0,0 +1,114 @@ +#[cfg(feature = "json-conversion")] +use crate::serialization::JsonConvertible; +#[cfg(feature = "value-conversion")] +use crate::serialization::ValueConvertible; +use crate::voting::votes::yes_no_vote::v0::YesNoVoteV0; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +pub mod accessors; +pub mod v0; + +/// A masternode's answer to a yes/no vote poll (protocol version 14). +#[cfg_attr( + all(feature = "json-conversion", feature = "serde-conversion"), + derive(JsonConvertible) +)] +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + PartialEq, + Eq, + DecodeUntrusted, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(tag = "$formatVersion") +)] +#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] +#[platform_serialize(limit = 15000, unversioned)] +pub enum YesNoVote { + #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] + V0(YesNoVoteV0), +} + +#[cfg(all( + test, + feature = "json-conversion", + feature = "value-conversion", + feature = "serde-conversion" +))] +mod json_convertible_tests_yes_no_vote { + use super::*; + use crate::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; + use crate::voting::vote_polls::yes_no_vote_poll::{YesNoMinimumVotingPower, YesNoVotePoll}; + use platform_value::{platform_value, BinaryData}; + use serde_json::json; + + fn fixture() -> YesNoVote { + YesNoVote::V0(YesNoVoteV0 { + vote_poll: YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![0xc1; 4])], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute(400), + }, + vote_choice: YesNoAbstainVoteChoice::Yes, + }) + } + + #[test] + fn json_round_trip_with_full_wire_shape() { + use crate::serialization::JsonConvertible; + let original = fixture(); + let json = original.to_json().expect("to_json"); + assert_eq!( + json, + json!({ + "$formatVersion": "0", + "votePoll": { + "resourcePath": ["wcHBwQ=="], + "supermajorityNumerator": 2, + "supermajorityDenominator": 3, + "minimumVotingPower": { "absolute": 400 }, + }, + "voteChoice": "yes", + }) + ); + let recovered = YesNoVote::from_json(json).expect("from_json"); + assert_eq!(original, recovered); + } + + #[test] + fn value_round_trip_with_full_wire_shape() { + use crate::serialization::ValueConvertible; + let original = fixture(); + let value = original.to_object().expect("to_object"); + assert_eq!( + value, + platform_value!({ + "$formatVersion": "0", + "votePoll": { + "resourcePath": [platform_value::Value::Bytes(vec![0xc1; 4])], + "supermajorityNumerator": 2u8, + "supermajorityDenominator": 3u8, + "minimumVotingPower": { "absolute": 400u32 }, + }, + "voteChoice": "yes", + }) + ); + let recovered = YesNoVote::from_object(value).expect("from_object"); + assert_eq!(original, recovered); + } +} diff --git a/packages/rs-dpp/src/voting/votes/yes_no_vote/v0/mod.rs b/packages/rs-dpp/src/voting/votes/yes_no_vote/v0/mod.rs new file mode 100644 index 00000000000..e7df7111966 --- /dev/null +++ b/packages/rs-dpp/src/voting/votes/yes_no_vote/v0/mod.rs @@ -0,0 +1,45 @@ +#[cfg(feature = "json-conversion")] +use crate::serialization::json_safe_fields; +use crate::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use crate::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +/// Version 0 of a yes/no vote: the poll answered and the answer. +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + PlatformSerialize, + PartialEq, + Eq, + DecodeUntrusted, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +#[platform_serialize(unversioned)] +pub struct YesNoVoteV0 { + /// The poll answered. + pub vote_poll: YesNoVotePoll, + /// The answer. + pub vote_choice: YesNoAbstainVoteChoice, +} + +impl YesNoVoteV0 { + pub fn vote_poll_unique_id(&self) -> Result { + self.vote_poll.unique_id() + } +} diff --git a/packages/rs-dpp/tests/untrusted_decode.rs b/packages/rs-dpp/tests/untrusted_decode.rs index c48163447fd..e87d6ef6261 100644 --- a/packages/rs-dpp/tests/untrusted_decode.rs +++ b/packages/rs-dpp/tests/untrusted_decode.rs @@ -1,7 +1,10 @@ use bincode::config; use dpp::address_funds::AddressWitness; use dpp::identity::core_script::CoreScript; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; use dpp::prelude::AssetLockProof; use dpp::serialization::PlatformDeserializableUntrusted; use dpp::state_transition::StateTransition; @@ -132,3 +135,64 @@ fn should_preserve_foreign_txid_serde_encoding_in_consensus_errors() { .unwrap(); assert_eq!(ordinary, (value, bytes.len())); } + +/// The derived decoders report how many bytes the value took, so the exact +/// entry points can refuse a suffix the plain ones ignore. +#[test] +fn should_report_consumed_bytes_and_refuse_a_suffix() { + use dpp::serialization::PlatformDeserializableTrusted; + + let config = config::standard().with_big_endian(); + let mut bytes = bincode::encode_to_vec(LocalFixture(vec![1, 2, 3]), config).unwrap(); + let value_len = bytes.len(); + bytes.extend([9, 9]); + + let (fixture, consumed) = + LocalFixture::deserialize_from_bytes_trusted_with_bytes_len(&bytes).unwrap(); + assert_eq!(fixture.0, [1, 2, 3]); + assert_eq!(consumed, value_len); + + assert_eq!( + LocalFixture::deserialize_from_bytes_trusted(&bytes) + .unwrap() + .0, + [1, 2, 3] + ); + assert!(matches!( + LocalFixture::deserialize_from_bytes_trusted_exact(&bytes), + Err(ProtocolError::PlatformDeserializationError(message)) + if message.contains("2 bytes left over") + )); + assert_eq!( + LocalFixture::deserialize_from_bytes_trusted_exact(&bytes[..value_len]) + .unwrap() + .0, + [1, 2, 3] + ); +} + +/// The exact decoder runs under the type's own byte budget: an identity +/// public key is capped at 2000 bytes, whatever other types allow. +#[test] +fn should_apply_the_types_own_budget_to_the_exact_untrusted_decoder() { + let key: IdentityPublicKey = IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![0; 2_100]), + disabled_at: None, + } + .into(); + let bytes = bincode::encode_to_vec(&key, config::standard().with_big_endian()).unwrap(); + + assert!(matches!( + IdentityPublicKey::deserialize_from_bytes_untrusted_exact(&bytes), + Err(ProtocolError::MaxEncodedBytesReachedError { + max_size_kbytes: 2000, + .. + }) + )); +} 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 5820f88c0c3..7d3a7d468f6 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 @@ -313,6 +313,11 @@ impl Platform { VotePoll::ContestedDocumentResourceVotePoll(contested) => { contested.specialized_balance_id().map_err(Error::Protocol) } + // Unreachable: this runs once, on the block entering protocol version 8, + // and yes/no polls exist from 14. The arm only keeps the match exhaustive. + VotePoll::YesNoVotePoll(yes_no) => { + yes_no.specialized_balance_id().map_err(Error::Protocol) + } } }) .collect::, Error>>()?; @@ -812,6 +817,13 @@ impl Platform { self.drive .insert_contract_fee_pot_trees(Some(transaction), platform_version)?; + // Vote decisions trees: the active yes/no polls and the identity votes index of the + // decisions branch. Fresh chains call the same helper from + // `create_initial_state_structure` v4, after the fee pot trees as here. Like everything + // in this function it runs only on the block entering protocol version 14. + self.drive + .insert_vote_decisions_trees(Some(transaction), platform_version)?; + Ok(()) } } @@ -2026,6 +2038,74 @@ mod tests { ); } + /// The only way a running network gets the vote decisions trees is this upgrade hook. + /// They are absent at 13 and present after the transition, and the votes subtree is then + /// byte-identical to the one of a chain born at 14, so both node populations build the + /// same Merk when the first yes/no poll opens. + #[test] + fn test_transition_to_version_14_creates_vote_decisions_trees() { + use drive::drive::votes::paths::{ + vote_decisions_tree_path, ACTIVE_POLLS_TREE_KEY, IDENTITY_VOTES_TREE_KEY, + }; + use drive::util::grove_operations::DirectQueryType; + + let platform_version = PlatformVersion::latest(); + let born_at_14 = TestPlatformBuilder::new() + .with_initial_protocol_version(14) + .build_with_mock_rpc() + .set_genesis_state(); + let upgraded = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + + let transaction = upgraded.drive.grove.start_transaction(); + + let decisions_tree_exists = |key: char, transaction: &Transaction| { + upgraded + .drive + .grove_has_raw( + (&vote_decisions_tree_path()).into(), + &[key as u8], + DirectQueryType::StatefulDirectQuery, + Some(transaction), + &mut vec![], + &platform_version.drive, + ) + .expect("expected to query the decisions tree") + }; + for key in [ACTIVE_POLLS_TREE_KEY, IDENTITY_VOTES_TREE_KEY] { + assert!( + !decisions_tree_exists(key, &transaction), + "protocol version 13 has no decisions {key} tree" + ); + } + + upgraded + .transition_to_version_14(&BlockInfo::default(), &transaction, platform_version) + .expect("expected version 14 transition to succeed"); + + for key in [ACTIVE_POLLS_TREE_KEY, IDENTITY_VOTES_TREE_KEY] { + assert!( + decisions_tree_exists(key, &transaction), + "the decisions {key} tree must exist after the transition" + ); + } + + let diffs = collect_subtree_diffs( + &born_at_14, + &upgraded, + &transaction, + vec![vec![RootTree::Votes as u8]], + ); + assert!( + diffs.is_empty(), + "the votes subtree differs between a chain born at version 14 and one upgraded to \ + it:\n{}", + diffs.join("\n") + ); + } + #[test] fn test_transition_to_version_14_creates_total_credits_history_tree() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v0/mod.rs index 94d8ab0c3fb..44966c7c53b 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v0/mod.rs @@ -1,3 +1,4 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::platform::Platform; use crate::platform_types::platform_state::PlatformState; @@ -175,6 +176,16 @@ where identifiers_voting_for_contenders, )) } + // Unreachable: this v0 is selected up to protocol version 13, and a + // yes/no poll enters the end date index only through + // `Drive::open_yes_no_vote_poll`, which writes under the `Votes/d/p` + // tree that exists from 14 (genesis structure v4, + // `transition_to_version_14`). + ResolvedVotePoll::YesNoVotePoll(_) => Err(Error::Execution( + ExecutionError::CorruptedCodeExecution( + "yes/no vote polls need check_for_ended_vote_polls version 1", + ), + )), } }).collect::, Error>>()?; Ok((end_date, vote_polls_with_votes)) @@ -300,6 +311,16 @@ where )?; Ok(ResolvedVotePollWithVotes::ContestedDocumentResourceVotePollWithContractInfoAndVotes(resolved_contested_document_resource_vote_poll, identifiers_voting_for_contenders)) } + // Unreachable: this v0 is selected up to protocol version 13, and a + // yes/no poll enters the end date index only through + // `Drive::open_yes_no_vote_poll`, which writes under the `Votes/d/p` + // tree that exists from 14 (genesis structure v4, + // `transition_to_version_14`). + ResolvedVotePoll::YesNoVotePoll(_) => Err(Error::Execution( + ExecutionError::CorruptedCodeExecution( + "yes/no vote polls need check_for_ended_vote_polls version 1", + ), + )), } }).collect::, Error>>()?; Ok((end_date, vote_polls_with_votes)) diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs index 39f9378b96b..f91bccfe7dc 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs @@ -1,14 +1,17 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::platform::Platform; -use crate::platform_types::platform_state::PlatformState; +use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; +use dpp::dashcore_rpc::dashcore_rpc_json::MasternodeType; use dpp::document::DocumentV0Getters; use dpp::prelude::TimestampMillis; use dpp::version::PlatformVersion; use dpp::voting::contender_structs::FinalizedContender; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::TowardsIdentity; use dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::{VotingPower, YesNoVotePoll}; use drive::drive::votes::resolved::vote_polls::resolve::VotePollResolver; use drive::drive::votes::resolved::vote_polls::{ResolvedVotePoll, ResolvedVotePollWithVotes}; use drive::grovedb::TransactionArg; @@ -20,7 +23,9 @@ impl Platform where C: CoreRPCLike, { - /// Checks for ended vote polls, awarding a tie to the earliest contender + /// Checks for ended vote polls, awarding a tie to the earliest contender. Version 1 + /// (protocol version 14) also closes yes/no polls: it records their result, hands it to + /// the feature that opened them and cleans their votes up. #[inline(always)] pub(super) fn check_for_ended_vote_polls_v1( &self, @@ -172,6 +177,14 @@ where )?; Ok(ResolvedVotePollWithVotes::ContestedDocumentResourceVotePollWithContractInfoAndVotes(resolved_contested_document_resource_vote_poll, identifiers_voting_for_contenders)) } + ResolvedVotePoll::YesNoVotePoll(yes_no_vote_poll) => self + .finish_yes_no_vote_poll( + block_platform_state, + block_info, + yes_no_vote_poll, + transaction, + platform_version, + ), } }).collect::, Error>>()?; Ok((end_date, vote_polls_with_votes)) @@ -192,4 +205,56 @@ where Ok(()) } + + /// Closes one yes/no poll whose time has come: tallies it, records the result in its + /// stored info, hands the result to the feature that opened the poll, and returns the + /// voters so the clean-up can remove their votes. + fn finish_yes_no_vote_poll( + &self, + block_platform_state: &PlatformState, + block_info: &BlockInfo, + vote_poll: YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let tally = + self.tally_votes_for_yes_no_vote_poll(&vote_poll, transaction, platform_version)?; + // A minimum given as a share of the total is measured against the masternode list of + // the closing block, each masternode weighed as its vote is. + let total_voting_power = block_platform_state + .full_masternode_list() + .values() + .try_fold(0 as VotingPower, |total, masternode| { + total.checked_add(match masternode.node_type { + MasternodeType::Regular => 1, + MasternodeType::Evo => 4, + }) + }) + .ok_or(Error::Execution(ExecutionError::Overflow( + "the masternode list's total voting power overflows", + )))?; + let result = self.keep_record_of_finished_yes_no_vote_poll( + block_info, + &vote_poll, + &tally, + total_voting_power, + transaction, + platform_version, + )?; + self.on_yes_no_vote_poll_finished( + block_info, + &vote_poll, + &result, + transaction, + platform_version, + )?; + let voters = self.drive.fetch_identities_voting_in_yes_no_vote_poll( + &vote_poll, + transaction, + platform_version, + )?; + Ok(ResolvedVotePollWithVotes::YesNoVotePollWithVotes( + vote_poll, voters, + )) + } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/mod.rs index 32e3245fc95..69b4cafc292 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/mod.rs @@ -13,6 +13,7 @@ use std::collections::BTreeMap; mod v0; mod v1; +mod v2; impl Platform where @@ -52,9 +53,16 @@ where transaction, platform_version, ), + 2 => self.clean_up_after_contested_resources_vote_polls_end_v2( + block_info, + vote_polls, + clean_up_testnet_corrupted_reference_issue, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "clean_up_after_contested_resources_vote_polls_end".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/v2/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/v2/mod.rs new file mode 100644 index 00000000000..45117693595 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_contested_resources_vote_polls_end/v2/mod.rs @@ -0,0 +1,203 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::ProtocolError; +use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use drive::fees::op::LowLevelDriveOperation; +use drive::grovedb::TransactionArg; +use std::collections::BTreeMap; + +impl Platform +where + C: CoreRPCLike, +{ + /// Cleans up after contested resource vote polls that ended. + /// Version 2 (protocol version 14) leaves the end date index alone: + /// `clean_up_after_vote_polls_end` removes every finished poll of every kind from it, so + /// that an end date's tree goes only once nothing is left under it. + #[inline(always)] + // TODO: Use type or struct + #[allow(clippy::type_complexity)] + pub(super) fn clean_up_after_contested_resources_vote_polls_end_v2( + &self, + block_info: &BlockInfo, + vote_polls: Vec<( + &ContestedDocumentResourceVotePollWithContractInfo, + &TimestampMillis, + &BTreeMap>, + )>, + clean_up_testnet_corrupted_reference_issue: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let mut operations = self.clean_up_after_contested_resources_vote_polls_end_operations_v1( + vote_polls.as_slice(), + clean_up_testnet_corrupted_reference_issue, + transaction, + platform_version, + )?; + + // We also need to clean out the specialized balances + + let mut total_credits_to_add_to_processing: Credits = 0; + + for specialized_balance_id in vote_polls + .iter() + .map(|(vote_poll, _, _)| vote_poll.specialized_balance_id()) + { + let (credits, mut empty_specialized_balance_operation) = + self.drive.empty_prefunded_specialized_balance_operations( + specialized_balance_id?, + false, + &mut None, + transaction, + platform_version, + )?; + operations.append(&mut empty_specialized_balance_operation); + total_credits_to_add_to_processing = total_credits_to_add_to_processing + .checked_add(credits) + .ok_or(Error::Execution(ExecutionError::Overflow( + "Credits from specialized balances are overflowing", + )))?; + } + + if total_credits_to_add_to_processing > 0 { + operations.push( + self.drive + .add_epoch_processing_credits_for_distribution_operation( + &block_info.epoch, + total_credits_to_add_to_processing, + transaction, + platform_version, + )?, + ); + } + + if !operations.is_empty() { + self.drive.apply_batch_low_level_drive_operations( + None, + transaction, + operations, + &mut vec![], + &platform_version.drive, + )?; + } + + Ok(()) + } + + #[inline(always)] + // TODO: Use type or struct + #[allow(clippy::type_complexity)] + fn clean_up_after_contested_resources_vote_polls_end_operations_v1( + &self, + vote_polls: &[( + &ContestedDocumentResourceVotePollWithContractInfo, + &TimestampMillis, + &BTreeMap>, + )], + clean_up_testnet_corrupted_reference_issue: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut operations = vec![]; + + // We remove the votes from under the contenders votes received + self.drive + .remove_contested_resource_vote_poll_votes_operations( + vote_polls, + true, + &mut operations, + transaction, + platform_version, + )?; + + // We remove the documents that contenders have + self.drive + .remove_contested_resource_vote_poll_documents_operations( + vote_polls, + clean_up_testnet_corrupted_reference_issue, + &mut operations, + transaction, + platform_version, + )?; + + // We remove the contenders + self.drive + .remove_contested_resource_vote_poll_contenders_operations( + vote_polls, + &mut operations, + transaction, + platform_version, + )?; + + let vote_poll_ids = vote_polls + .iter() + .map(|(vote_poll, _, _)| Ok((*vote_poll, vote_poll.unique_id()?))) + .collect::, + ProtocolError, + >>()?; + + let mut identity_to_vote_ids_map: BTreeMap<&Identifier, Vec<&Identifier>> = BTreeMap::new(); + + for (vote_poll, _, voters_for_contender) in vote_polls { + let vote_id = vote_poll_ids + .iter() + .find_map(|(vp, vid)| if vp == vote_poll { Some(vid) } else { None }) + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "the vote poll must exist in this lookup table", + )))?; + + for identifiers in voters_for_contender.values() { + for identity_id in identifiers { + identity_to_vote_ids_map + .entry(identity_id) + .or_default() + .push(vote_id); + } + } + } + + for (identity, vote_ids) in identity_to_vote_ids_map { + // We remove the identity votes given + self.drive + .remove_specific_vote_references_given_by_identity( + identity, + vote_ids.as_slice(), + &mut operations, + transaction, + platform_version, + )?; + } + + if clean_up_testnet_corrupted_reference_issue { + self.drive.remove_contested_resource_info_operations( + vote_polls, + &mut operations, + transaction, + platform_version, + )?; + // We remove the last index + self.drive + .remove_contested_resource_top_level_index_operations( + vote_polls, + &mut operations, + transaction, + platform_version, + )?; + } + + Ok(operations) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_vote_polls_end/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_vote_polls_end/mod.rs index 6580b030998..b5186a05005 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_vote_polls_end/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_vote_polls_end/mod.rs @@ -10,6 +10,7 @@ use drive::grovedb::TransactionArg; use std::collections::BTreeMap; mod v0; +mod v1; impl Platform where @@ -37,9 +38,16 @@ where transaction, platform_version, ), + 1 => self.clean_up_after_vote_polls_end_v1( + block_info, + vote_polls, + clean_up_testnet_corrupted_reference_issue, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "clean_up_after_vote_polls_end".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_vote_polls_end/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_vote_polls_end/v0/mod.rs index 1d1b4186e9f..2ced3c3c320 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_vote_polls_end/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_vote_polls_end/v0/mod.rs @@ -1,3 +1,4 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::platform::Platform; use crate::rpc::core::CoreRPCLike; @@ -40,7 +41,15 @@ where match vote_poll { ResolvedVotePollWithVotes::ContestedDocumentResourceVotePollWithContractInfoAndVotes(contested_poll, vote_info) => { contested_polls.push((contested_poll, end_date, vote_info)); - } // Add more match arms here for other types of vote polls in the future + } + // Unreachable: only `check_for_ended_vote_polls` v1 builds this variant, + // and every table that selects this v0 (protocol versions 1 to 13) selects + // check v0. + ResolvedVotePollWithVotes::YesNoVotePollWithVotes(..) => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "yes/no vote polls need clean_up_after_vote_polls_end version 1", + ))) + } } } } diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_vote_polls_end/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_vote_polls_end/v1/mod.rs new file mode 100644 index 00000000000..7976540e8e6 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_vote_polls_end/v1/mod.rs @@ -0,0 +1,99 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use drive::drive::votes::resolved::vote_polls::ResolvedVotePollWithVotes; +use drive::grovedb::TransactionArg; +use std::collections::BTreeMap; + +impl Platform +where + C: CoreRPCLike, +{ + /// Cleans up after the polls that ended in this block. Version 1 (protocol version 14) + /// handles both kinds. It removes every finished poll from the end date index itself, + /// so that an end date's tree is deleted only once nothing of either kind is left under + /// it, then cleans up the polls of each kind. + #[inline(always)] + pub(super) fn clean_up_after_vote_polls_end_v1( + &self, + block_info: &BlockInfo, + vote_polls: &BTreeMap>, + clean_up_testnet_corrupted_reference_issue: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + // TODO: Use type or struct + #[allow(clippy::type_complexity)] + let mut contested_polls: Vec<( + &ContestedDocumentResourceVotePollWithContractInfo, + &TimestampMillis, + &BTreeMap>, + )> = Vec::new(); + let mut yes_no_polls: Vec<( + &YesNoVotePoll, + &BTreeMap>, + )> = Vec::new(); + let mut end_date_entries: Vec<(Identifier, TimestampMillis)> = Vec::new(); + + for (end_date, vote_polls_for_time) in vote_polls { + for vote_poll in vote_polls_for_time { + match vote_poll { + ResolvedVotePollWithVotes::ContestedDocumentResourceVotePollWithContractInfoAndVotes(contested_poll, vote_info) => { + end_date_entries.push((contested_poll.unique_id()?, *end_date)); + contested_polls.push((contested_poll, end_date, vote_info)); + } + ResolvedVotePollWithVotes::YesNoVotePollWithVotes(yes_no_poll, voters) => { + end_date_entries.push((yes_no_poll.unique_id()?, *end_date)); + yes_no_polls.push((yes_no_poll, voters)); + } + } + } + } + + if !end_date_entries.is_empty() { + let mut operations = vec![]; + self.drive.remove_vote_poll_end_date_query_operations( + end_date_entries.as_slice(), + &mut operations, + transaction, + platform_version, + )?; + self.drive.apply_batch_low_level_drive_operations( + None, + transaction, + operations, + &mut vec![], + &platform_version.drive, + )?; + } + + if !contested_polls.is_empty() { + self.clean_up_after_contested_resources_vote_polls_end( + block_info, + contested_polls, + clean_up_testnet_corrupted_reference_issue, + transaction, + platform_version, + )?; + } + + if !yes_no_polls.is_empty() { + self.clean_up_after_yes_no_vote_polls_end( + block_info, + yes_no_polls, + transaction, + platform_version, + )?; + } + + Ok(()) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_yes_no_vote_polls_end/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_yes_no_vote_polls_end/mod.rs new file mode 100644 index 00000000000..3b14bb8be66 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_yes_no_vote_polls_end/mod.rs @@ -0,0 +1,52 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use drive::grovedb::TransactionArg; +use std::collections::BTreeMap; + +mod v0; + +impl Platform +where + C: CoreRPCLike, +{ + /// Cleans up after yes/no polls that ended: their votes and vote trees, the voters' + /// entries in the identity votes index, and what is left of the prefunded balance that + /// paid for the votes, which goes to the processing pool. The end date index was already + /// cleared by the caller. The stored info stays as the record of the decision. + pub(in crate::execution) fn clean_up_after_yes_no_vote_polls_end( + &self, + block_info: &BlockInfo, + vote_polls: Vec<( + &YesNoVotePoll, + &BTreeMap>, + )>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive_abci + .methods + .voting + .clean_up_after_yes_no_vote_polls_end + { + 0 => self.clean_up_after_yes_no_vote_polls_end_v0( + block_info, + vote_polls, + transaction, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "clean_up_after_yes_no_vote_polls_end".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_yes_no_vote_polls_end/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_yes_no_vote_polls_end/v0/mod.rs new file mode 100644 index 00000000000..278d528baf6 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/clean_up_after_yes_no_vote_polls_end/v0/mod.rs @@ -0,0 +1,105 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::fee::Credits; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use drive::grovedb::TransactionArg; +use std::collections::BTreeMap; + +impl Platform +where + C: CoreRPCLike, +{ + #[inline(always)] + pub(super) fn clean_up_after_yes_no_vote_polls_end_v0( + &self, + block_info: &BlockInfo, + vote_polls: Vec<( + &YesNoVotePoll, + &BTreeMap>, + )>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let mut operations = vec![]; + + // The votes and the three vote trees of every poll. + self.drive.remove_yes_no_vote_poll_votes_operations( + vote_polls.as_slice(), + &mut operations, + transaction, + platform_version, + )?; + + // Each voter's entries in the identity votes index. + let mut poll_ids_by_voter: BTreeMap<&Identifier, Vec> = BTreeMap::new(); + for (vote_poll, voters_by_choice) in &vote_polls { + let vote_poll_id = vote_poll.unique_id()?; + for voters in voters_by_choice.values() { + for voter in voters { + poll_ids_by_voter + .entry(voter) + .or_default() + .push(vote_poll_id); + } + } + } + for (voter, vote_poll_ids) in poll_ids_by_voter { + let vote_poll_ids: Vec<&Identifier> = vote_poll_ids.iter().collect(); + self.drive + .remove_yes_no_vote_references_given_by_identity_operations( + voter, + vote_poll_ids.as_slice(), + &mut operations, + transaction, + platform_version, + )?; + } + + // What is left of the prefunded balances goes to processing. + let mut total_credits_to_add_to_processing: Credits = 0; + for (vote_poll, _) in &vote_polls { + let (credits, mut empty_specialized_balance_operations) = + self.drive.empty_prefunded_specialized_balance_operations( + vote_poll.specialized_balance_id()?, + false, + &mut None, + transaction, + platform_version, + )?; + operations.append(&mut empty_specialized_balance_operations); + total_credits_to_add_to_processing = total_credits_to_add_to_processing + .checked_add(credits) + .ok_or(Error::Execution(ExecutionError::Overflow( + "Credits from specialized balances are overflowing", + )))?; + } + if total_credits_to_add_to_processing > 0 { + operations.push( + self.drive + .add_epoch_processing_credits_for_distribution_operation( + &block_info.epoch, + total_credits_to_add_to_processing, + transaction, + platform_version, + )?, + ); + } + + if !operations.is_empty() { + self.drive.apply_batch_low_level_drive_operations( + None, + transaction, + operations, + &mut vec![], + &platform_version.drive, + )?; + } + Ok(()) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/keep_record_of_finished_yes_no_vote_poll/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/keep_record_of_finished_yes_no_vote_poll/mod.rs new file mode 100644 index 00000000000..1d0665cb72e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/keep_record_of_finished_yes_no_vote_poll/mod.rs @@ -0,0 +1,51 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollResult; +use dpp::voting::vote_polls::yes_no_vote_poll::{VotingPower, YesNoVotePoll}; +use drive::grovedb::TransactionArg; +use drive::query::yes_no_vote_poll_state_query::YesNoVotePollState; + +mod v0; + +impl Platform +where + C: CoreRPCLike, +{ + /// Decides a finished yes/no poll from its tallies and the total voting power of the + /// masternode list in the closing block, and writes the result into its stored info, which + /// stays as the record of the decision after the votes are cleaned up. + pub(in crate::execution) fn keep_record_of_finished_yes_no_vote_poll( + &self, + block_info: &BlockInfo, + vote_poll: &YesNoVotePoll, + tally: &YesNoVotePollState, + total_voting_power: VotingPower, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .methods + .voting + .keep_record_of_finished_yes_no_vote_poll + { + 0 => self.keep_record_of_finished_yes_no_vote_poll_v0( + block_info, + vote_poll, + tally, + total_voting_power, + transaction, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "keep_record_of_finished_yes_no_vote_poll".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/keep_record_of_finished_yes_no_vote_poll/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/keep_record_of_finished_yes_no_vote_poll/v0/mod.rs new file mode 100644 index 00000000000..fb280c710b7 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/keep_record_of_finished_yes_no_vote_poll/v0/mod.rs @@ -0,0 +1,60 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollResult; +use dpp::voting::vote_polls::yes_no_vote_poll::{VotingPower, YesNoVotePoll}; +use drive::error::drive::DriveError; +use drive::error::Error as DriveCrateError; +use drive::grovedb::TransactionArg; +use drive::query::yes_no_vote_poll_state_query::YesNoVotePollState; + +impl Platform +where + C: CoreRPCLike, +{ + #[inline(always)] + pub(super) fn keep_record_of_finished_yes_no_vote_poll_v0( + &self, + block_info: &BlockInfo, + vote_poll: &YesNoVotePoll, + tally: &YesNoVotePollState, + total_voting_power: VotingPower, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let stored_info_from_disk = + tally + .stored_info + .clone() + .ok_or(Error::Drive(DriveCrateError::Drive( + DriveError::CorruptedDriveState( + "there must be a record of the yes/no vote poll in the state".to_string(), + ), + )))?; + // We perform an upgrade of the stored version just in case, most of the time this does nothing + let mut stored_info = stored_info_from_disk.update_to_latest_version(platform_version)?; + let required_voting_power = vote_poll.required_voting_power(total_voting_power); + let passed = vote_poll.passes( + tally.yes_voting_power, + tally.no_voting_power, + total_voting_power, + ); + let result = stored_info.finalize_vote_poll( + tally.yes_voting_power, + tally.no_voting_power, + tally.abstain_voting_power, + required_voting_power, + passed, + *block_info, + )?; + self.drive.insert_stored_info_for_yes_no_vote_poll( + vote_poll, + stored_info, + transaction, + platform_version, + )?; + Ok(result) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/mod.rs index 326867bbcb7..394da430ef1 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/mod.rs @@ -2,7 +2,11 @@ mod award_document_to_winner; mod check_for_ended_vote_polls; mod clean_up_after_contested_resources_vote_polls_end; mod clean_up_after_vote_polls_end; +mod clean_up_after_yes_no_vote_polls_end; +mod keep_record_of_finished_yes_no_vote_poll; mod keep_record_of_vote_poll; +mod on_yes_no_vote_poll_finished; mod remove_votes_for_removed_masternodes; mod run_dao_platform_events; mod tally_votes_for_contested_document_resource_vote_poll; +mod tally_votes_for_yes_no_vote_poll; diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/on_yes_no_vote_poll_finished/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/on_yes_no_vote_poll_finished/mod.rs new file mode 100644 index 00000000000..640ffc7edfc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/on_yes_no_vote_poll_finished/mod.rs @@ -0,0 +1,48 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollResult; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use drive::grovedb::TransactionArg; + +mod v0; + +impl Platform +where + C: CoreRPCLike, +{ + /// Acts on the result of a yes/no poll that just finished, in the same block, before its + /// votes are cleaned up. The feature that opened the poll recognises it by its resource + /// path. Nothing opens a yes/no poll yet, so version 0 acts on nothing. + pub(in crate::execution) fn on_yes_no_vote_poll_finished( + &self, + block_info: &BlockInfo, + vote_poll: &YesNoVotePoll, + result: &YesNoVotePollResult, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive_abci + .methods + .voting + .on_yes_no_vote_poll_finished + { + 0 => self.on_yes_no_vote_poll_finished_v0( + block_info, + vote_poll, + result, + transaction, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "on_yes_no_vote_poll_finished".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/on_yes_no_vote_poll_finished/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/on_yes_no_vote_poll_finished/v0/mod.rs new file mode 100644 index 00000000000..f33ba6278a4 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/on_yes_no_vote_poll_finished/v0/mod.rs @@ -0,0 +1,28 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollResult; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use drive::grovedb::TransactionArg; + +impl Platform +where + C: CoreRPCLike, +{ + /// No feature opens a yes/no poll yet, so no feature acts on one. The moderation team + /// challenge and charter amendment features add their arms here, matched on the poll's + /// resource path. + #[inline(always)] + pub(super) fn on_yes_no_vote_poll_finished_v0( + &self, + _block_info: &BlockInfo, + _vote_poll: &YesNoVotePoll, + _result: &YesNoVotePollResult, + _transaction: TransactionArg, + _platform_version: &PlatformVersion, + ) -> Result<(), Error> { + Ok(()) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_yes_no_vote_poll/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_yes_no_vote_poll/mod.rs new file mode 100644 index 00000000000..ccbfb229f2b --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_yes_no_vote_poll/mod.rs @@ -0,0 +1,37 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use drive::grovedb::TransactionArg; +use drive::query::yes_no_vote_poll_state_query::YesNoVotePollState; + +mod v0; + +impl Platform +where + C: CoreRPCLike, +{ + /// The tallies of a yes/no poll, read from its vote sum trees, with its stored info. + pub(in crate::execution) fn tally_votes_for_yes_no_vote_poll( + &self, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive_abci + .methods + .voting + .tally_votes_for_yes_no_vote_poll + { + 0 => self.tally_votes_for_yes_no_vote_poll_v0(vote_poll, transaction, platform_version), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "tally_votes_for_yes_no_vote_poll".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_yes_no_vote_poll/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_yes_no_vote_poll/v0/mod.rs new file mode 100644 index 00000000000..9f249f2fb9f --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/tally_votes_for_yes_no_vote_poll/v0/mod.rs @@ -0,0 +1,27 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::rpc::core::CoreRPCLike; +use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use drive::grovedb::TransactionArg; +use drive::query::yes_no_vote_poll_state_query::{ + YesNoVotePollState, YesNoVotePollStateDriveQuery, +}; + +impl Platform +where + C: CoreRPCLike, +{ + #[inline(always)] + pub(super) fn tally_votes_for_yes_no_vote_poll_v0( + &self, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let query = YesNoVotePollStateDriveQuery { + vote_poll: vote_poll.clone(), + }; + Ok(query.execute_no_proof(&self.drive, transaction, &mut vec![], platform_version)?) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs index f7bb13051c1..07d29aceb2f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs @@ -6,7 +6,7 @@ use dpp::consensus::state::document::document_contest_not_required_error::Docume use dpp::dashcore::Network; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::methods::{DocumentTypeBasicMethods, DocumentTypeV0Methods}; use dpp::data_contract::document_type::restricted_creation::CreationRestrictionMode; use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; use dpp::identifier::Identifier; @@ -16,6 +16,7 @@ use dpp::voting::vote_polls::VotePoll; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0}; use dpp::version::PlatformVersion; +use crate::error::execution::ExecutionError; use crate::error::Error; pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait DocumentCreateTransitionActionStructureValidationV1 { @@ -119,6 +120,13 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans } // -->> End Introduced in V1 <<-- (None, None) => {} + // A document type's contested index only ever resolves to a contested + // document resource poll. + (Some(VotePoll::YesNoVotePoll(_)), Some(_)) => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a contested index never resolves to a yes/no vote poll", + ))); + } } } @@ -149,9 +157,32 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans } // Validate user defined properties - data_contract + let result = data_contract .validate_document_properties(document_type_name, self.data().into(), platform_version) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + + // -->> Introduced in V1 <<-- + // A `distinctFrom` identifier property must differ from the named sibling + // property or from the writer's `$ownerId`. Both are on the transition, so + // this is a structure check; it runs after the schema validation above so + // every value it compares is already known to be a 32-byte identifier. + let result = document_type + .validate_distinct_from_properties(self.data(), owner_id, platform_version) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + + // The schema validation above established every supplied value is a byte array + // where the type says so; what is left is whether an `encryptedFor` property has + // the shape its scheme produces, which is all consensus can tell about a ciphertext. + document_type + .validate_encrypted_property_shapes(self.data(), platform_version) .map_err(Error::Protocol) + // -->> End Introduced in V1 <<-- } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs index 2ff22312056..2898dec7ea3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs @@ -57,9 +57,12 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio return Ok(validation_result); } + // The creator of a document being created is its writer let reference_result = self.base().validate_document_references( self.data(), owner_id, + Some(owner_id), + None, None, platform, block_info, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs index 7c65043d3e5..74ef284a7f9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_purchase_transition_action/advanced_structure_v0/mod.rs @@ -1,6 +1,7 @@ use dpp::consensus::basic::document::{InvalidDocumentTransitionActionError, InvalidDocumentTypeError}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::document::DocumentV0Getters; use dpp::nft::TradeMode; use dpp::validation::SimpleConsensusValidationResult; @@ -18,7 +19,7 @@ pub(in crate::execution::validation::state_transition::state_transitions::batch: impl DocumentPurchaseTransitionActionStructureValidationV0 for DocumentPurchaseTransitionAction { fn validate_structure_v0( &self, - _platform_version: &PlatformVersion, + platform_version: &PlatformVersion, ) -> Result { let contract_fetch_info = self.base().data_contract_fetch_info(); let data_contract = &contract_fetch_info.contract; @@ -47,15 +48,29 @@ impl DocumentPurchaseTransitionActionStructureValidationV0 for DocumentPurchaseT } if document_type.trade_mode() != TradeMode::DirectPurchase { - Ok(SimpleConsensusValidationResult::new_with_error( + return Ok(SimpleConsensusValidationResult::new_with_error( InvalidDocumentTransitionActionError::new(format!( "{} trade mode is not direct purchase but we are trying to purchase directly", document_type_name )) .into(), - )) - } else { - Ok(SimpleConsensusValidationResult::default()) + )); } + + // Added in place at protocol version 14, inert for every earlier version this + // generation serves: their meta-schemas refuse `distinctFrom`, their parser ignores + // it (`apply_distinct_from` is `None`), and `validate_distinct_from` is `None` there, + // so the call sees no declaration and returns an empty result. From 14, the + // document changes owner and a `distinctFrom: $ownerId` property of the stored + // document must differ from the new owner, which the action already carries on the + // document. The data was schema-validated when it was written, so every value + // compared is a 32-byte identifier. + document_type + .validate_distinct_from_properties( + self.document().properties(), + self.document().owner_id(), + platform_version, + ) + .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs index 530d3df845c..d350462403b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs @@ -37,23 +37,36 @@ pub(crate) trait DocumentReferenceValidation { platform_version: &PlatformVersion, ) -> Result; - /// Validates the document's `refersTo` references against platform state. + /// Validates the document's `refersTo` references against platform state: + /// an identifier property's value, and each element of a typed array whose + /// `items` declare one, which is refused with the error a single reference + /// would give and named by its list path (`reasons[2]`). /// /// When `changed_fields` is provided (replace transitions), only references on /// those fields are validated. A reference also counts as changed when a /// property bound to it changed: a `propertyAgreement` referring property /// or an `identityPublicKey` key id property. A writer gate, an agreement /// keyed by `$ownerId`, is validated on every replace regardless. + /// `stored_values` (replace transitions) holds the stored value of each + /// changed property: a changed typed array of references re-validates + /// only the elements the stored list did not hold, unless a bound + /// property changed, a writer gate applies or its target is deletable. /// /// `owner_id` is the writer, the transition's owner: a `propertyAgreement` - /// whose referring side is `$ownerId` compares it, since it lives on the - /// transition rather than in `document_data`. + /// whose referring side is `$ownerId` compares it, and an `identityPublicKey` + /// reference on a key id property with `identityProperty: $ownerId` names + /// its key, since it lives on the transition rather than in `document_data`. + /// `creator_id` is the document's creator for the `$creatorId` form: the + /// writer on a create, the stored creator on a replace, `None` when the + /// document type records none (registration then admits no such form). #[allow(clippy::too_many_arguments)] fn validate_document_references( &self, document_data: &BTreeMap, owner_id: Identifier, + creator_id: Option, changed_fields: Option<&BTreeSet>, + stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, transaction: TransactionArg, @@ -102,7 +115,9 @@ impl DocumentReferenceValidation for DocumentBaseTransitionAction { &self, document_data: &BTreeMap, owner_id: Identifier, + creator_id: Option, changed_fields: Option<&BTreeSet>, + stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, transaction: TransactionArg, @@ -119,7 +134,9 @@ impl DocumentReferenceValidation for DocumentBaseTransitionAction { 0 => self.validate_document_references_v0( document_data, owner_id, + creator_id, changed_fields, + stored_values, platform, block_info, transaction, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index 6487aa68c26..c3954b5fb8a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -12,7 +12,9 @@ use dpp::data_contract::document_type::accessors::{ use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::{ is_referring_system_agreement_property, DocumentPropertyReferenceTarget, - DocumentPropertyType, DocumentTypeRef, ReferringWrite, + DocumentPropertyType, DocumentReferenceLookup, DocumentTypeRef, + IdentityKeyReferenceRequirements, KeyReferenceIdentityProperty, PropertyReference, + ReferringWrite, }; use dpp::data_contract::DataContract; use dpp::document::property_names::{CREATOR_ID, OWNER_ID}; @@ -25,6 +27,7 @@ use dpp::errors::consensus::state::document::referenced_contract_requirement_not use dpp::errors::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; use dpp::errors::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; use dpp::errors::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; +use dpp::errors::consensus::state::document::referenced_identity_key_requirement_not_met_error::ReferencedIdentityKeyRequirementNotMetError; use dpp::errors::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; use dpp::identifier::Identifier; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -34,6 +37,8 @@ use dpp::platform_value::Value; use dpp::validation::SimpleConsensusValidationResult; use dpp::version::PlatformVersion; use std::borrow::Cow; +use std::sync::Arc; +use drive::drive::contract::DataContractFetchInfo; use drive::drive::identity::key::fetch::{ IdentityKeysRequest, OptionalSingleIdentityPublicKeyOutcome, }; @@ -47,7 +52,9 @@ use crate::execution::types::execution_operation::{RetrieveIdentityInfo, Validat use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; -use crate::execution::validation::state_transition::batch::state::v0::fetch_documents::fetch_document_with_id; +use crate::execution::validation::state_transition::batch::state::v0::fetch_documents::{ + fetch_document_through_lookup, fetch_document_with_id, +}; use crate::platform_types::platform::PlatformStateRef; /// Versioned, stateful validation of document references using the v0 rules. @@ -74,7 +81,9 @@ pub(crate) trait DocumentReferenceValidationV0 { &self, document_data: &BTreeMap, owner_id: Identifier, + creator_id: Option, changed_fields: Option<&BTreeSet>, + stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, transaction: TransactionArg, @@ -112,7 +121,10 @@ impl DocumentReferenceValidationV0 for DocumentBaseTransitionAction { .get(property) .map(|property| &property.property_type) else { - // Not a deletableDocument reference: nothing can be "gone" + // Not a deletableDocument reference: nothing can be "gone". A + // typed array of deletableDocument references never gets here + // either: the parser refuses one on an immutable property, the + // only kind this exception serves return Ok(false); }; @@ -162,7 +174,9 @@ impl DocumentReferenceValidationV0 for DocumentBaseTransitionAction { &self, document_data: &BTreeMap, owner_id: Identifier, + creator_id: Option, changed_fields: Option<&BTreeSet>, + stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, transaction: TransactionArg, @@ -185,7 +199,9 @@ impl DocumentReferenceValidationV0 for DocumentBaseTransitionAction { document_type, document_data, owner_id, + creator_id, changed_fields, + stored_values, platform, block_info, transaction, @@ -201,7 +217,9 @@ fn validate_document_type_references_v0( document_type: DocumentTypeRef<'_>, document_data: &BTreeMap, owner_id: Identifier, + creator_id: Option, changed_fields: Option<&BTreeSet>, + stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, transaction: TransactionArg, @@ -209,25 +227,88 @@ fn validate_document_type_references_v0( platform_version: &PlatformVersion, ) -> Result { for (path, property) in document_type.flattened_properties() { - let DocumentPropertyType::IdentifierWithReference(reference_target) = - &property.property_type - else { + // A reference is an identifier property's value, or each element of + // a typed array of identifiers whose `items` declare it (protocol + // version 14, the version whose document create and replace state + // validation call this; no document type of an earlier version can + // hold a typed array, so the element arm is never reached there) + let Some(reference) = property.property_type.reference() else { continue; }; + let (reference_target, holds_elements) = match reference { + // A key reference on the key id property itself: the value is the + // key id and the declaration names whose key it is. A transfer + // itself is not checked, so the reference governs writing, not + // holding; which replaces re-validate it depends on where the + // identity comes from. + PropertyReference::KeyId(reference) => { + let identity_property = &reference.identity_property; + if let Some(changed) = changed_fields { + let must_revalidate = match identity_property { + // The owner is transition metadata, never among the + // changed fields, and may have changed since the key + // id was written (a transfer or a purchase), so every + // replace re-validates, as a writer gate is + KeyReferenceIdentityProperty::OwnerId => true, + // The creator never changes + KeyReferenceIdentityProperty::CreatorId => is_changed_field(changed, path), + // The identity is in the document: either side of the + // (identity, key id) pair changing re-validates it + KeyReferenceIdentityProperty::Property(identity_path) => { + is_changed_field(changed, path) + || is_changed_field(changed, identity_path) + } + }; + if !must_revalidate { + continue; + } + } + let result = validate_key_id_reference_v0( + path, + identity_property, + &reference.key_requirements, + document_type.name(), + contract.id(), + document_data, + owner_id, + creator_id, + platform, + transaction, + execution_context, + platform_version, + )?; + if !result.is_valid() { + return Ok(result); + } + continue; + } + PropertyReference::Value(target) => (target, false), + PropertyReference::Elements { target, .. } => (target, true), + }; - if let Some(changed) = changed_fields { + let bound_property_changed = if let Some(changed) = changed_fields { // Some targets bind a sibling property of the same document to // the reference; replacing that sibling must re-validate the // reference even when the reference property itself is untouched: // - a propertyAgreement pair binds each referring property; // - an identityPublicKey reference binds the key id property, // since the referenced key is the (identity id, key id) pair - // and a freshly written key id must exist and not be disabled. + // and a freshly written key id must exist and not be disabled; + // - a lookup binds every property its key reads, since the + // referenced document is the one the whole key finds. // A writer gate (an agreement keyed by `$ownerId`) is re-checked // on EVERY replace: the writer is transition metadata that never // appears among the changed fields, and either document may have // been transferred since the last write, so a replace of an - // unrelated field by a now-unauthorized owner must still fail. + // unrelated field by a now-unauthorized owner must still fail. A + // lookup whose key reads `$ownerId` needs no such rule: its + // declaring type can be neither transferred nor traded + // (registration refuses it otherwise), so the writer never moves. + // The same rules hold for the elements of a typed array, which + // share one declaration: the array is one field, so a replace + // that changes it re-validates the elements the stored list did + // not hold, and a changed bound property, a writer gate or a + // deletableDocument target re-validates them all. let bound_property_changed = match reference_target { DocumentPropertyReferenceTarget::PermanentDocument { property_agreement, .. @@ -235,6 +316,16 @@ fn validate_document_type_references_v0( is_referring_system_agreement_property(referring_property) || is_changed_field(changed, referring_property) }), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + property_agreement, + lookup, + .. + } => { + property_agreement.keys().any(|referring_property| { + is_referring_system_agreement_property(referring_property) + || is_changed_field(changed, referring_property) + }) || lookup_key_may_have_changed(lookup, changed) + } // A deletableDocument reference is re-validated on EVERY // replace, touched or not: its target may have been deleted // since the last write, and a referring document is not @@ -246,9 +337,9 @@ fn validate_document_type_references_v0( // it is checked against the new target, or not at all once // the reference is cleared. DocumentPropertyReferenceTarget::DeletableDocument { .. } => true, - DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { - is_changed_field(changed, key_id_property) - } + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, .. + } => is_changed_field(changed, key_id_property), DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Contract { .. } | DocumentPropertyReferenceTarget::Token => false, @@ -256,153 +347,292 @@ fn validate_document_type_references_v0( if !is_changed_field(changed, path) && !bound_property_changed { continue; } - } - - let referenced_id = match document_data.get_optional_identifier_at_path(path) { - Ok(Some(referenced_id)) => referenced_id, - // A reference property that is not set is not validated; whether it may be - // absent at all is enforced by the document type's required fields - Ok(None) => continue, - Err(err) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - InvalidIdentifierError::new(path.to_string(), err.to_string()).into(), - )) - } + bound_property_changed + } else { + false }; - let exists = match reference_target { - DocumentPropertyReferenceTarget::Identity => { - execution_context.add_operation(ValidationOperation::RetrieveIdentity( - RetrieveIdentityInfo::only_revision(), - )); + // The referenced contracts this declaration resolved, so the + // elements of a typed array fetch a foreign contract once + let mut referenced_contracts = BTreeMap::new(); - platform - .drive - .fetch_identity_revision(referenced_id, true, transaction, platform_version)? - .is_some() + if !holds_elements { + let referenced_id = match document_data.get_optional_identifier_at_path(path) { + Ok(Some(referenced_id)) => referenced_id, + // A reference property that is not set is not validated; whether it may be + // absent at all is enforced by the document type's required fields + Ok(None) => continue, + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new(path.to_string(), err.to_string()).into(), + )) + } + }; + let result = validate_reference_v0( + contract, + document_type, + document_data, + owner_id, + reference_target, + referenced_id, + path, + &mut referenced_contracts, + platform, + block_info, + transaction, + execution_context, + platform_version, + )?; + if !result.is_valid() { + return Ok(result); } - DocumentPropertyReferenceTarget::Contract { - contract_requirements, - } => { - let (fee, referenced_contract) = - platform.drive.get_contract_with_fetch_info_and_fee( - referenced_id, - Some(&block_info.epoch), - false, - transaction, - platform_version, - )?; - - let fee = fee.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( - "fee must exist when fetching a referenced contract with an epoch", - )))?; - - // The cost is added even if the referenced contract does not exist or was cached - execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); - - match referenced_contract { - None => false, - Some(fetch_info) => { - // The declaration's requirements are checked against the contract - // just fetched and the write itself (its owner and block time), so - // they cost no further read; the first unmet one refuses the write - let write = ReferringWrite { - owner_id, - block_time_ms: block_info.time_ms, - }; - if let Some(requirement) = - contract_requirements.first_unmet_by(&fetch_info.contract, write) - { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedContractRequirementNotMetError::new( - Identifier::from(referenced_id), - requirement.field().to_string(), - requirement.required(), - path.to_string(), - ) - .into(), - )); - } - true + } else { + let elements = match document_data.get_optional_at_path(path) { + Ok(Some(Value::Array(elements))) => elements, + // An absent list, like an absent reference, is not + // validated; an empty one has nothing to validate + Ok(None) => continue, + Ok(Some(_)) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new( + path.to_string(), + "a typed array of identifiers must be a list".to_string(), + ) + .into(), + )) + } + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new(path.to_string(), err.to_string()).into(), + )) + } + }; + // Each element is checked as a single reference is, in list + // order, and the first that fails refuses the write with the + // error a single reference would give, naming the element by + // its list path (`reasons[2]` for the third). The count is + // bounded by `maxItems`, which registration counts against + // `SystemLimits::max_references_per_document` with the + // type's other references, and every fetch is billed as a + // single reference's is. A replace re-validating the list + // only because it changed leaves out the elements the stored + // list already held, unchanged references, as an unchanged + // single reference is left alone; a changed bound property, + // a writer gate or a deletableDocument target re-validates + // them all. An element repeating an earlier one has its + // outcome already, so it is not fetched again + let mut checked: BTreeSet<[u8; 32]> = BTreeSet::new(); + if !bound_property_changed { + if let Some(Ok(Some(Value::Array(stored_elements)))) = + stored_values.map(|stored| stored.get_optional_at_path(path)) + { + checked.extend( + stored_elements + .iter() + .filter_map(|element| element.to_hash256().ok()), + ); + } + } + for (index, element) in elements.iter().enumerate() { + let element_path = format!("{path}[{index}]"); + let referenced_id = match element.to_hash256() { + Ok(referenced_id) => referenced_id, + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new(element_path, err.to_string()).into(), + )) } + }; + if !checked.insert(referenced_id) { + continue; + } + let result = validate_reference_v0( + contract, + document_type, + document_data, + owner_id, + reference_target, + referenced_id, + &element_path, + &mut referenced_contracts, + platform, + block_info, + transaction, + execution_context, + platform_version, + )?; + if !result.is_valid() { + return Ok(result); } } - DocumentPropertyReferenceTarget::Token => { - // Token contract info is written for every token when its contract is - // inserted and is never deleted, so it serves as the existence record - let (referenced_token_info, fee) = - platform.drive.fetch_token_contract_info_with_costs( - referenced_id, - block_info, - true, - transaction, - platform_version, - )?; + } + } - execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + Ok(SimpleConsensusValidationResult::new()) +} - referenced_token_info.is_some() - } - DocumentPropertyReferenceTarget::PermanentDocument { - contract_id: referenced_contract_id, - document_type_name, - property_agreement, - } - | DocumentPropertyReferenceTarget::DeletableDocument { - contract_id: referenced_contract_id, - document_type_name, - property_agreement, - } => { - let permanent = matches!( - reference_target, - DocumentPropertyReferenceTarget::PermanentDocument { .. } - ); - // An absent contract id targets the declaring contract itself; the - // declaring contract may also name its own id explicitly. Either - // way it is already loaded for this transition, so no fetch is - // billed for it - let effective_contract_id = referenced_contract_id.unwrap_or(contract.id()); - let referenced_contract_fetch_info; - let referenced_contract = if effective_contract_id == contract.id() { - contract - } else { - let (fee, fetch_info) = platform.drive.get_contract_with_fetch_info_and_fee( - effective_contract_id.to_buffer(), - Some(&block_info.epoch), - false, - transaction, - platform_version, - )?; +/// Checks one reference against platform state: the referenced id +/// `referenced_id`, declared by `reference_target`, is an identifier +/// property's value or one element of a typed array of them, and `path` is +/// how the errors name it (the property path, or the element's list path). +/// The target must exist and meet the declaration's contract requirements, +/// a referenced document's type must be deletable or not as declared, and +/// each `propertyAgreement` pair must hold between `document_data` (or the +/// writer `owner_id`) and the referenced document. Every read is billed to +/// `execution_context`; a foreign contract holding a referenced document +/// type is resolved through `referenced_contracts`, which the caller shares +/// among the elements of one array. +#[allow(clippy::too_many_arguments)] +fn validate_reference_v0( + contract: &DataContract, + document_type: DocumentTypeRef<'_>, + document_data: &BTreeMap, + owner_id: Identifier, + reference_target: &DocumentPropertyReferenceTarget, + referenced_id: [u8; 32], + path: &str, + referenced_contracts: &mut BTreeMap>>, + platform: &PlatformStateRef, + block_info: &BlockInfo, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, +) -> Result { + let exists = match reference_target { + DocumentPropertyReferenceTarget::Identity => { + execution_context.add_operation(ValidationOperation::RetrieveIdentity( + RetrieveIdentityInfo::only_revision(), + )); - let fee = - fee.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( - "fee must exist when fetching a referenced contract with an epoch", - )))?; + platform + .drive + .fetch_identity_revision(referenced_id, true, transaction, platform_version)? + .is_some() + } + DocumentPropertyReferenceTarget::Contract { + contract_requirements, + } => { + let (fee, referenced_contract) = platform.drive.get_contract_with_fetch_info_and_fee( + referenced_id, + Some(&block_info.epoch), + false, + transaction, + platform_version, + )?; - // The cost is added even if the referenced contract does not exist or was cached - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee)); + let fee = fee.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "fee must exist when fetching a referenced contract with an epoch", + )))?; + + // The cost is added even if the referenced contract does not exist or was cached + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); - let Some(fetch_info) = fetch_info else { - // A missing contract and a missing document type resolve to the - // same failure: the declared document type could not be found + match referenced_contract { + None => false, + Some(fetch_info) => { + // The declaration's requirements are checked against the contract + // just fetched and the write itself (its owner and block time), so + // they cost no further read; the first unmet one refuses the write + let write = ReferringWrite { + owner_id, + block_time_ms: block_info.time_ms, + }; + if let Some(requirement) = + contract_requirements.first_unmet_by(&fetch_info.contract, write) + { return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedDocumentTypeNotFoundError::new( - effective_contract_id, - document_type_name.clone(), + ReferencedContractRequirementNotMetError::new( + Identifier::from(referenced_id), + requirement.field().to_string(), + requirement.required(), path.to_string(), ) .into(), )); - }; + } + true + } + } + } + DocumentPropertyReferenceTarget::Token => { + // Token contract info is written for every token when its contract is + // inserted and is never deleted, so it serves as the existence record + let (referenced_token_info, fee) = + platform.drive.fetch_token_contract_info_with_costs( + referenced_id, + block_info, + true, + transaction, + platform_version, + )?; + + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + referenced_token_info.is_some() + } + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: referenced_contract_id, + document_type_name, + property_agreement, + } + | DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: referenced_contract_id, + document_type_name, + property_agreement, + .. + } + | DocumentPropertyReferenceTarget::DeletableDocument { + contract_id: referenced_contract_id, + document_type_name, + property_agreement, + } => { + let permanent = matches!( + reference_target, + DocumentPropertyReferenceTarget::PermanentDocument { .. } + | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } + ); + // An absent contract id targets the declaring contract itself; the + // declaring contract may also name its own id explicitly. Either + // way it is already loaded for this transition, so no fetch is + // billed for it + let effective_contract_id = referenced_contract_id.unwrap_or(contract.id()); + let referenced_contract_fetch_info; + let referenced_contract = if effective_contract_id == contract.id() { + contract + } else { + // The elements of one typed array share their declaration, + // so they resolve its contract once: the first element's + // fetch is billed and the rest reuse it. A single + // reference comes with a map of its own, one fetch + let fetch_info = match referenced_contracts.get(&effective_contract_id) { + Some(resolved) => resolved.clone(), + None => { + let (fee, fetch_info) = + platform.drive.get_contract_with_fetch_info_and_fee( + effective_contract_id.to_buffer(), + Some(&block_info.epoch), + false, + transaction, + platform_version, + )?; + + let fee = + fee.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "fee must exist when fetching a referenced contract with an epoch", + )))?; + + // The cost is added even if the referenced contract does not exist or was cached + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee)); - referenced_contract_fetch_info = fetch_info; - &referenced_contract_fetch_info.contract + referenced_contracts.insert(effective_contract_id, fetch_info.clone()); + fetch_info + } }; - let Some(referenced_document_type) = - referenced_contract.document_type_optional_for_name(document_type_name) - else { + let Some(fetch_info) = fetch_info else { + // A missing contract and a missing document type resolve to the + // same failure: the declared document type could not be found return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedDocumentTypeNotFoundError::new( effective_contract_id, @@ -413,40 +643,68 @@ fn validate_document_type_references_v0( )); }; - // A `permanentDocument` reference admits only document types - // whose documents can never be deleted: `canBeDeleted` is - // immutable on contract updates and document types can not be - // removed, so a reference validated here can never dangle. A - // `deletableDocument` reference makes no such promise, and - // admits only document types whose documents CAN be deleted: - // the referenced document must exist now, and may be deleted - // later. Deletable means by anyone, the contract's moderators - // included (`canBeDeletedByModerators`), as at contract - // registration - let target_is_deletable = referenced_document_type.documents_can_be_deleted() - || referenced_document_type.documents_can_be_deleted_by_moderators(); - if permanent && target_is_deletable { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedDocumentTypeDeletableError::new( - effective_contract_id, - document_type_name.clone(), - path.to_string(), - ) - .into(), - )); - } - if !permanent && !target_is_deletable { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedDocumentTypeNotDeletableError::new( - effective_contract_id, - document_type_name.clone(), - path.to_string(), - ) - .into(), - )); - } + referenced_contract_fetch_info = fetch_info; + &referenced_contract_fetch_info.contract + }; + + let Some(referenced_document_type) = + referenced_contract.document_type_optional_for_name(document_type_name) + else { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentTypeNotFoundError::new( + effective_contract_id, + document_type_name.clone(), + path.to_string(), + ) + .into(), + )); + }; + + // A `permanentDocument` reference admits only document types + // whose documents can never be deleted: `canBeDeleted` is + // immutable on contract updates and document types can not be + // removed, so a reference validated here can never dangle. A + // `deletableDocument` reference makes no such promise, and + // admits only document types whose documents CAN be deleted: + // the referenced document must exist now, and may be deleted + // later. Deletable means by anyone, the contract's moderators + // included (`canBeDeletedByModerators`), as at contract + // registration + let target_is_deletable = referenced_document_type.documents_can_be_deleted() + || referenced_document_type.documents_can_be_deleted_by_moderators(); + if permanent && target_is_deletable { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentTypeDeletableError::new( + effective_contract_id, + document_type_name.clone(), + path.to_string(), + ) + .into(), + )); + } + if !permanent && !target_is_deletable { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentTypeNotDeletableError::new( + effective_contract_id, + document_type_name.clone(), + path.to_string(), + ) + .into(), + )); + } - let referenced_document = fetch_document_with_id( + // The value is the referenced document's id, unless the + // (permanentDocument) declaration looks the document up through a + // unique index of its type, with the value, or the element, as one + // part of the key + let lookup = match reference_target { + DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. } => { + Some(lookup) + } + _ => None, + }; + let referenced_document = match lookup { + None => fetch_document_with_id( platform.drive, referenced_contract, referenced_document_type, @@ -455,206 +713,359 @@ fn validate_document_type_references_v0( execution_context, transaction, platform_version, - )?; + )?, + Some(lookup) => fetch_document_through_lookup( + platform.drive, + referenced_contract, + referenced_document_type, + lookup, + Identifier::from(referenced_id), + document_data, + owner_id, + &block_info.epoch, + execution_context, + transaction, + platform_version, + )?, + }; - // Property agreement: the referenced document is already in - // hand for the existence check, so comparing the declared - // pairs adds no reads. Each side is normalized through its - // OWN document type's key encoding — one deterministic - // normal form per value kind, so an identifier stored as - // bytes and one carried as an identifier compare equal. - // - // Absence is part of the agreement, strictly: both sides - // absent agree, one side absent is a mismatch. Anything - // laxer breaks the properties agreements exist for — with - // referring-absent-always-ok, a document could opt out of - // echoing a value its referenced document carries (e.g. a - // like on a TAGGED post omitting the tag, silently - // deflating every per-tag aggregate), and the referenced - // side's absence is what lets a referring doctype whose - // agreement key triggers a skipIfAbsent index stay - // consistently absent for untagged targets. - if let Some(referenced_document) = &referenced_document { - for (referring_property, referenced_property) in property_agreement { - let mismatch = || { - SimpleConsensusValidationResult::new_with_error( - ReferencedDocumentPropertyMismatchError::new( - path.to_string(), - referring_property.clone(), - referenced_property.clone(), - ) - .into(), + // Property agreement: the referenced document is already in + // hand for the existence check, so comparing the declared + // pairs adds no reads. Each side is normalized through its + // OWN document type's key encoding — one deterministic + // normal form per value kind, so an identifier stored as + // bytes and one carried as an identifier compare equal. + // + // Absence is part of the agreement, strictly: both sides + // absent agree, one side absent is a mismatch. Anything + // laxer breaks the properties agreements exist for — with + // referring-absent-always-ok, a document could opt out of + // echoing a value its referenced document carries (e.g. a + // like on a TAGGED post omitting the tag, silently + // deflating every per-tag aggregate), and the referenced + // side's absence is what lets a referring doctype whose + // agreement key triggers a skipIfAbsent index stay + // consistently absent for untagged targets. + if let Some(referenced_document) = &referenced_document { + for (referring_property, referenced_property) in property_agreement { + let mismatch = || { + SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentPropertyMismatchError::new( + path.to_string(), + referring_property.clone(), + referenced_property.clone(), ) + .into(), + ) + }; + // A lookup ERROR (a non-map value where the dotted + // path expects an intermediate object) is a + // mismatch, never absence — folding it into `None` + // would let two malformed sides "agree" as + // both-absent. + // The referring side is a schema property of the document + // being written, or the writer's own `$ownerId`, which + // lives on the transition rather than in its data: that + // pair is a write gate, and the writer is `owner_id`. + let referring_value: Option> = if referring_property == OWNER_ID { + Some(Cow::Owned(Value::Identifier(owner_id.to_buffer()))) + } else { + let Ok(referring_value) = + document_data.get_optional_at_path(referring_property) + else { + return Ok(mismatch()); }; - // A lookup ERROR (a non-map value where the dotted - // path expects an intermediate object) is a - // mismatch, never absence — folding it into `None` - // would let two malformed sides "agree" as - // both-absent. - // The referring side is a schema property of the document - // being written, or the writer's own `$ownerId`, which - // lives on the transition rather than in its data: that - // pair is a write gate, and the writer is `owner_id`. - let referring_value: Option> = if referring_property == OWNER_ID - { - Some(Cow::Owned(Value::Identifier(owner_id.to_buffer()))) - } else { - let Ok(referring_value) = - document_data.get_optional_at_path(referring_property) + referring_value.map(Cow::Borrowed) + }; + // The referenced side may name one of the two system + // identifiers a document carries outside its data: + // `$ownerId`, which follows the document through + // transfers, and `$creatorId`, set once at creation + // and absent on document types that do not record + // it. Contract registration validated that either + // faces an identifier property on the referring + // side, and the key serializer below already encodes + // both names as 32-byte identifiers. + let referenced_value: Option> = match referenced_property.as_str() { + OWNER_ID => Some(Cow::Owned(Value::Identifier( + referenced_document.owner_id().to_buffer(), + ))), + CREATOR_ID => referenced_document.creator_id().map(|creator_id| { + Cow::Owned(Value::Identifier(creator_id.to_buffer())) + }), + _ => { + let Ok(referenced_value) = referenced_document + .properties() + .get_optional_at_path(referenced_property) else { return Ok(mismatch()); }; - referring_value.map(Cow::Borrowed) - }; - // The referenced side may name one of the two system - // identifiers a document carries outside its data: - // `$ownerId`, which follows the document through - // transfers, and `$creatorId`, set once at creation - // and absent on document types that do not record - // it. Contract registration validated that either - // faces an identifier property on the referring - // side, and the key serializer below already encodes - // both names as 32-byte identifiers. - let referenced_value: Option> = - match referenced_property.as_str() { - OWNER_ID => Some(Cow::Owned(Value::Identifier( - referenced_document.owner_id().to_buffer(), - ))), - CREATOR_ID => referenced_document.creator_id().map(|creator_id| { - Cow::Owned(Value::Identifier(creator_id.to_buffer())) - }), - _ => { - let Ok(referenced_value) = referenced_document - .properties() - .get_optional_at_path(referenced_property) - else { - return Ok(mismatch()); - }; - referenced_value.map(Cow::Borrowed) - } - }; - let (referring_value, referenced_value) = - match (referring_value, referenced_value) { - (Some(referring_value), Some(referenced_value)) => { - (referring_value, referenced_value) - } - // Both absent: the sides agree. - (None, None) => continue, - // One side absent: a mismatch, exactly as a - // differing value would be. - (Some(_), None) | (None, Some(_)) => return Ok(mismatch()), - }; - let Ok(referring_encoded) = document_type.serialize_value_for_key( - referring_property, - &referring_value, - platform_version, - ) else { - return Ok(mismatch()); - }; - let Ok(referenced_encoded) = referenced_document_type - .serialize_value_for_key( - referenced_property, - &referenced_value, - platform_version, - ) - else { - return Ok(mismatch()); - }; - if referring_encoded != referenced_encoded { - return Ok(mismatch()); + referenced_value.map(Cow::Borrowed) } + }; + let (referring_value, referenced_value) = + match (referring_value, referenced_value) { + (Some(referring_value), Some(referenced_value)) => { + (referring_value, referenced_value) + } + // Both absent: the sides agree. + (None, None) => continue, + // One side absent: a mismatch, exactly as a + // differing value would be. + (Some(_), None) | (None, Some(_)) => return Ok(mismatch()), + }; + let Ok(referring_encoded) = document_type.serialize_value_for_key( + referring_property, + &referring_value, + platform_version, + ) else { + return Ok(mismatch()); + }; + let Ok(referenced_encoded) = referenced_document_type.serialize_value_for_key( + referenced_property, + &referenced_value, + platform_version, + ) else { + return Ok(mismatch()); + }; + if referring_encoded != referenced_encoded { + return Ok(mismatch()); } } - - referenced_document.is_some() } - DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { - // The referenced key id is carried by the named sibling property - let key_id: KeyID = - match document_data.get_optional_integer_at_path(key_id_property) { - Ok(Some(key_id)) => key_id, - Ok(None) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedKeyIdPropertyInvalidError::new( - key_id_property.clone(), - path.to_string(), - "the key id property is not set".to_string(), - ) - .into(), - )) - } - Err(err) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedKeyIdPropertyInvalidError::new( - key_id_property.clone(), - path.to_string(), - err.to_string(), - ) - .into(), - )) - } - }; - - execution_context.add_operation(ValidationOperation::RetrieveIdentity( - RetrieveIdentityInfo::one_key(), - )); - // A missing identity and a missing key resolve to the same - // failure: the referenced key could not be found - let Some(key) = platform - .drive - .fetch_identity_keys::( - IdentityKeysRequest::new_specific_key_query(&referenced_id, key_id), - transaction, - platform_version, - )? - else { + referenced_document.is_some() + } + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, + key_requirements, + } => { + // The referenced key id is carried by the named sibling property + let key_id: KeyID = match document_data.get_optional_integer_at_path(key_id_property) { + Ok(Some(key_id)) => key_id, + Ok(None) => { return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedIdentityKeyNotFoundError::new( - Identifier::from(referenced_id), - key_id, + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), path.to_string(), + "the key id property is not set".to_string(), ) .into(), - )); - }; - - // Keys can never be removed, so an existing reference can not - // dangle; a disabled key is still rejected for fresh writes - if key.is_disabled() { + )) + } + Err(err) => { return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedIdentityKeyDisabledError::new( - Identifier::from(referenced_id), - key_id, + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), path.to_string(), + err.to_string(), ) .into(), - )); + )) } + }; - true + let result = validate_referenced_identity_key_v0( + Identifier::from(referenced_id), + key_id, + path, + key_requirements, + document_type.name(), + contract.id(), + platform, + transaction, + execution_context, + platform_version, + )?; + if !result.is_valid() { + return Ok(result); } - }; - if !exists { - let missing_id = - Identifier::from_bytes(&referenced_id).map_err(|e| Error::Protocol(e.into()))?; + true + } + }; + + if !exists { + let missing_id = + Identifier::from_bytes(&referenced_id).map_err(|e| Error::Protocol(e.into()))?; + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError( + ReferencedEntityNotFoundError::new( + missing_id, + reference_target.clone(), + path.to_string(), + ), + )), + )); + } + + Ok(SimpleConsensusValidationResult::new()) +} + +/// A key reference declared on the key id property at `path` +/// (`DocumentPropertyType::KeyIdWithReference`): the value is the key id, and +/// `identity_property` names whose key it is. For `$ownerId` that is the +/// writer, `owner_id`, whose existence the transition already proved, and for +/// `$creatorId` the document's creator, `creator_id` (the writer on a create, +/// the stored creator on a replace), so the key fetch is the only read; for a +/// property path the identity is read from the document, and a key id set +/// while that property is not is refused. An unset key id is not validated; +/// whether it may be absent is the document type's required list. +#[allow(clippy::too_many_arguments)] +fn validate_key_id_reference_v0( + path: &str, + identity_property: &KeyReferenceIdentityProperty, + key_requirements: &IdentityKeyReferenceRequirements, + document_type_name: &str, + declaring_contract_id: Identifier, + document_data: &BTreeMap, + owner_id: Identifier, + creator_id: Option, + platform: &PlatformStateRef, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, +) -> Result { + let key_id: KeyID = match document_data.get_optional_integer_at_path(path) { + Ok(Some(key_id)) => key_id, + Ok(None) => return Ok(SimpleConsensusValidationResult::new()), + // The declaring property is the key id property itself + Err(err) => { return Ok(SimpleConsensusValidationResult::new_with_error( - ConsensusError::StateError(StateError::ReferencedEntityNotFoundError( - ReferencedEntityNotFoundError::new( - missing_id, - reference_target.clone(), - path.to_string(), - ), - )), - )); + ReferencedKeyIdPropertyInvalidError::new( + path.to_string(), + path.to_string(), + err.to_string(), + ) + .into(), + )) } + }; + + let identity_id = match identity_property { + KeyReferenceIdentityProperty::OwnerId => owner_id, + // Contract registration admits `$creatorId` only on a document type + // that records creator ids, so a document of such a type has one + KeyReferenceIdentityProperty::CreatorId => { + creator_id.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a $creatorId key reference needs a document type that records creator ids", + )))? + } + KeyReferenceIdentityProperty::Property(identity_path) => { + match document_data.get_optional_identifier_at_path(identity_path) { + Ok(Some(identity_id)) => Identifier::from(identity_id), + Ok(None) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + path.to_string(), + path.to_string(), + format!("the identity property {identity_path} is not set"), + ) + .into(), + )) + } + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new(identity_path.clone(), err.to_string()).into(), + )) + } + } + } + }; + + validate_referenced_identity_key_v0( + identity_id, + key_id, + path, + key_requirements, + document_type_name, + declaring_contract_id, + platform, + transaction, + execution_context, + platform_version, + ) +} + +/// The state check both `identityPublicKey` forms share: key `key_id` of +/// `identity_id`, referenced from `path`, must exist, not be disabled and +/// meet the declaration's `key_requirements`. A missing identity and a +/// missing key resolve to the same failure, the referenced key could not be +/// found. Keys can never be removed, so an existing reference can not +/// dangle; a disabled key is still rejected for fresh writes. The +/// requirements are checked against the key just fetched, so they cost no +/// further read, and the first unmet one refuses the write; a bound names +/// the declaring contract and one of its own document types (checked when +/// the contract was registered), so the check needs nothing beyond the key +/// and the contract in hand. +#[allow(clippy::too_many_arguments)] +fn validate_referenced_identity_key_v0( + identity_id: Identifier, + key_id: KeyID, + path: &str, + key_requirements: &IdentityKeyReferenceRequirements, + document_type_name: &str, + declaring_contract_id: Identifier, + platform: &PlatformStateRef, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, +) -> Result { + execution_context.add_operation(ValidationOperation::RetrieveIdentity( + RetrieveIdentityInfo::one_key(), + )); + + let Some(key) = platform + .drive + .fetch_identity_keys::( + IdentityKeysRequest::new_specific_key_query(&identity_id.to_buffer(), key_id), + transaction, + platform_version, + )? + else { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedIdentityKeyNotFoundError::new(identity_id, key_id, path.to_string()).into(), + )); + }; + + if key.is_disabled() { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedIdentityKeyDisabledError::new(identity_id, key_id, path.to_string()).into(), + )); + } + + if let Some(requirement) = key_requirements.first_unmet_by(&key, declaring_contract_id) { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedIdentityKeyRequirementNotMetError::new( + document_type_name.to_string(), + path.to_string(), + identity_id, + key_id, + requirement.field().to_string(), + requirement.required(), + requirement.actual_of(&key), + ) + .into(), + )); } Ok(SimpleConsensusValidationResult::new()) } +/// Whether a replace may have changed the key of a lookup: a property the key +/// reads changed. The reference's own value is covered by the changed-field +/// check of the property itself, and a `$ownerId` part is fixed, since +/// registration admits it only on a type that cannot be transferred or traded. +fn lookup_key_may_have_changed( + lookup: &DocumentReferenceLookup, + changed_fields: &BTreeSet, +) -> bool { + lookup + .referring_properties() + .any(|path| is_changed_field(changed_fields, path)) +} + /// A flattened property path counts as changed when the replace transition changed /// the path itself or any of its ancestors: `changed_data_fields` holds top-level /// document keys, so a changed object key replaces its entire subtree, including diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs index 582db214f0d..c62af0551ee 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs @@ -1,6 +1,8 @@ use dpp::consensus::basic::document::{InvalidDocumentTransitionActionError, InvalidDocumentTypeError}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::methods::{DocumentTypeBasicMethods, DocumentTypeV0Methods}; +use dpp::identifier::Identifier; use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; use dpp::validation::SimpleConsensusValidationResult; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; @@ -11,12 +13,14 @@ use crate::error::Error; pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait DocumentReplaceTransitionActionStructureValidationV0 { fn validate_structure_v0( &self, + owner_id: Identifier, platform_version: &PlatformVersion, ) -> Result; } impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTransitionAction { fn validate_structure_v0( &self, + owner_id: Identifier, platform_version: &PlatformVersion, ) -> Result { let contract_fetch_info = self.base().data_contract_fetch_info(); @@ -44,8 +48,37 @@ impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTra // Validate user defined properties - data_contract + let result = data_contract .validate_document_properties(document_type_name, self.data().into(), platform_version) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + + // Added in place at protocol version 14, inert for every earlier version this + // generation serves: their meta-schemas refuse `distinctFrom`, their parser ignores + // it (`apply_distinct_from` is `None`), and `validate_distinct_from` is `None` there, + // so the call sees no declaration and returns an empty result. From 14, a + // `distinctFrom` identifier property must differ from the named sibling property or + // from the writer's `$ownerId`; both are on the transition, and the schema + // validation above already made every value compared a 32-byte identifier. + let result = document_type + .validate_distinct_from_properties(self.data(), owner_id, platform_version) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + + // Added in place at protocol version 14, inert for every earlier version this + // generation serves: their meta-schemas refuse `encryptedFor`, their parser + // ignores it (`apply_encrypted_for` is `None`, so no parsed property carries a + // declaration), and `validate_encrypted_property_shapes` is `None` there, so the + // call returns an empty result. From 14, + // an `encryptedFor` property the replace supplies must have the shape its scheme + // produces, which is all consensus can tell about a ciphertext; the schema + // validation above already made every such value a byte array. + document_type + .validate_encrypted_property_shapes(self.data(), platform_version) .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/mod.rs index 9f964c616ef..395d5bbc538 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/mod.rs @@ -20,6 +20,7 @@ mod state_v1; pub trait DocumentReplaceTransitionActionValidation { fn validate_structure( &self, + owner_id: Identifier, platform_version: &PlatformVersion, ) -> Result; @@ -37,6 +38,7 @@ pub trait DocumentReplaceTransitionActionValidation { impl DocumentReplaceTransitionActionValidation for DocumentReplaceTransitionAction { fn validate_structure( &self, + owner_id: Identifier, platform_version: &PlatformVersion, ) -> Result { match platform_version @@ -46,7 +48,7 @@ impl DocumentReplaceTransitionActionValidation for DocumentReplaceTransitionActi .batch_state_transition .document_replace_transition_structure_validation { - 0 => self.validate_structure_v0(platform_version), + 0 => self.validate_structure_v0(owner_id, platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "DocumentReplaceTransitionAction::validate_structure".to_string(), known_versions: vec![0], diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/state_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/state_v1/mod.rs index 3c17a32ec27..24ecb044015 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/state_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/state_v1/mod.rs @@ -113,7 +113,9 @@ impl DocumentReplaceTransitionActionStateValidationV1 for DocumentReplaceTransit let reference_result = self.base().validate_document_references( self.data(), owner_id, + self.creator_id(), Some(self.changed_data_fields()), + Some(self.stored_changed_values()), platform, block_info, transaction, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs index c8c27c46303..d60f9d0e901 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_transfer_transition_action/advanced_structure_v0/mod.rs @@ -1,6 +1,8 @@ use dpp::consensus::basic::document::{InvalidDocumentTransitionActionError, InvalidDocumentTypeError}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::document::DocumentV0Getters; use dpp::validation::SimpleConsensusValidationResult; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::{DocumentTransferTransitionAction, DocumentTransferTransitionActionAccessorsV0}; @@ -16,7 +18,7 @@ pub(in crate::execution::validation::state_transition::state_transitions::batch: impl DocumentTransferTransitionActionStructureValidationV0 for DocumentTransferTransitionAction { fn validate_structure_v0( &self, - _platform_version: &PlatformVersion, + platform_version: &PlatformVersion, ) -> Result { let contract_fetch_info = self.base().data_contract_fetch_info(); let data_contract = &contract_fetch_info.contract; @@ -32,15 +34,29 @@ impl DocumentTransferTransitionActionStructureValidationV0 for DocumentTransferT }; if !document_type.documents_transferable().is_transferable() { - Ok(SimpleConsensusValidationResult::new_with_error( + return Ok(SimpleConsensusValidationResult::new_with_error( InvalidDocumentTransitionActionError::new(format!( "{} is not a transferable document type", document_type_name )) .into(), - )) - } else { - Ok(SimpleConsensusValidationResult::default()) + )); } + + // Added in place at protocol version 14, inert for every earlier version this + // generation serves: their meta-schemas refuse `distinctFrom`, their parser ignores + // it (`apply_distinct_from` is `None`), and `validate_distinct_from` is `None` there, + // so the call sees no declaration and returns an empty result. From 14, the + // document changes owner and a `distinctFrom: $ownerId` property of the stored + // document must differ from the new owner, which the action already carries on the + // document. The data was schema-validated when it was written, so every value + // compared is a 32-byte identifier. + document_type + .validate_distinct_from_properties( + self.document().properties(), + self.document().owner_id(), + platform_version, + ) + .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/mod.rs index 773bf41d016..29bef5d5f95 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_update_price_transition_action/mod.rs @@ -42,7 +42,7 @@ impl DocumentUpdatePriceTransitionActionValidation for DocumentUpdatePriceTransi .validation_and_processing .state_transitions .batch_state_transition - .document_transfer_transition_structure_validation + .document_update_price_transition_structure_validation { 0 => self.validate_structure_v0(platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v0/mod.rs index 0eafc05f797..33b6a0aa94b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v0/mod.rs @@ -153,7 +153,10 @@ impl DocumentsBatchStateTransitionStructureValidationV0 for BatchTransition { } } DocumentTransitionAction::ReplaceAction(replace_action) => { - let result = replace_action.validate_structure(platform_version)?; + // The owner id feeds the `distinctFrom` judgement added to replace + // structure v0 in place; it is inert before protocol version 14 + let result = + replace_action.validate_structure(identity.id, platform_version)?; if !result.is_valid() { let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(replace_action.base(), self.owner_id(), self.user_fee_increase()), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs index f0e9e5faa6c..f3f00431ddc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs @@ -283,7 +283,8 @@ impl DocumentsBatchStateTransitionStructureValidationV1 for BatchTransition { } } DocumentTransitionAction::ReplaceAction(replace_action) => { - let result = replace_action.validate_structure(platform_version)?; + let result = + replace_action.validate_structure(identity.id, platform_version)?; if !result.is_valid() { let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(replace_action.base(), self.owner_id(), self.user_fee_increase()), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs index 2486f1574c4..e26fb251567 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs @@ -4,7 +4,8 @@ use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; use dpp::block::epoch::Epoch; -use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::{DocumentReferenceLookup, DocumentTypeRef}; use dpp::data_contract::DataContract; use dpp::document::Document; use dpp::fee::fee_result::FeeResult; @@ -22,6 +23,7 @@ use drive::query::drive_contested_document_query::{ DriveContestedDocumentQuery, PrimaryContestedInternalClauses, }; use drive::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; +use std::collections::BTreeMap; // ============================================================================ // fetch_documents_for_transitions_knowing_contract_and_document_type @@ -404,6 +406,103 @@ fn fetch_document_with_id_v1( } } +// ============================================================================ +// fetch_document_through_lookup +// ============================================================================ + +/// The document a `refersTo` lookup resolves to for one value: the one the +/// declared unique index of `document_type` finds for the key assembled from +/// `reference_value` (the property's value, or one array element's), the +/// writer `owner_id` and the sources in `document_data`. `None` when no +/// document matches, and when the key cannot be assembled or the index is +/// missing or not unique. Contract registration and update refuse the last +/// three (an optional source, a missing or non-unique index), and a referenced +/// type's indexes cannot change on update, so for a validated contract they +/// cannot happen; if one did, the write is refused rather than judged against +/// a partial key. +/// +/// The query is billed exactly as [`fetch_document_with_id`] v1 bills an id +/// fetch: an equality query over the index's properties (the shape the unique +/// index conflict check builds), limit 1, whose processing cost is added to +/// `execution_context`. Only reached from the document reference validation, +/// which exists from protocol version 14, so it carries no version of its own. +#[allow(clippy::too_many_arguments)] +pub(crate) fn fetch_document_through_lookup( + drive: &Drive, + contract: &DataContract, + document_type: DocumentTypeRef, + lookup: &DocumentReferenceLookup, + reference_value: Identifier, + document_data: &BTreeMap, + owner_id: Identifier, + epoch: &Epoch, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result, Error> { + if !document_type + .indexes() + .get(&lookup.index) + .is_some_and(|index| index.unique) + { + return Ok(None); + } + let Some(key_values) = lookup.key_values(reference_value, document_data, owner_id) else { + return Ok(None); + }; + let equal_clauses = key_values + .into_iter() + .map(|(field, value)| { + ( + field.clone(), + WhereClause { + field, + operator: WhereOperator::Equal, + value, + }, + ) + }) + .collect(); + + let drive_query = DriveDocumentQuery { + contract, + document_type, + internal_clauses: InternalClauses { + primary_key_in_clause: None, + primary_key_equal_clause: None, + in_clauses: Vec::new(), + range_clause: None, + equal_clauses, + }, + offset: None, + // The index is unique and every one of its properties is fixed, so at + // most one document matches + limit: Some(1), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + }; + + let documents_outcome = drive.query_documents( + drive_query, + Some(epoch), + false, + transaction, + Some(platform_version.protocol_version), + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(FeeResult { + storage_fee: 0, + processing_fee: documents_outcome.cost(), + fee_refunds: Default::default(), + removed_bytes_from_system: 0, + })); + + Ok(documents_outcome.documents_owned().into_iter().next()) +} + // ============================================================================ // has_contested_document_with_document_id — unchanged // ============================================================================ diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 30a8ba16ded..b733147c676 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -6990,4 +6990,792 @@ mod creation_tests { } ); } + + /// Registers the key-requirements fixture contract, adds the keys of + /// [`IdentityKeyRequirementTargets`] to the test identity, then creates a `message` + /// document mutated by the test and returns the execution result. + async fn run_identity_key_requirement_creation(mutator: F) -> StateTransitionExecutionResult + where + F: FnOnce(&mut Document, &IdentityKeyRequirementTargets), + { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(433); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_IDENTITY_KEY_REQUIREMENTS_CONTRACT_PATH, + None, + None, + None::, + None, + None, + ); + + let targets = add_identity_key_requirement_targets( + &mut platform, + &identity, + key.id(), + contract.id(), + platform_version, + ); + + let message = contract + .document_type_for_name("message") + .expect("expected a message document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = message + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + document + .set_id_for_creation(message, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + mutator(&mut document, &targets); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document, + message, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_create_serialized_transition = documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_create_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone() + } + + #[tokio::test] + async fn should_document_creation_succeed_when_referenced_key_meets_its_requirements() { + let result = run_identity_key_requirement_creation(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + document.set( + "recipientKeyId", + (targets.decryption_key_bound_to_inbox_id as i64).into(), + ); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_key_has_the_wrong_purpose() { + let cases: [(fn(&IdentityKeyRequirementTargets) -> KeyID, &str); 2] = [ + ( + |t: &IdentityKeyRequirementTargets| t.encryption_key_bound_to_inbox_id, + "encryption", + ), + ( + |t: &IdentityKeyRequirementTargets| t.authentication_key_id, + "authentication", + ), + ]; + for (key_id, actual) in cases { + let result = run_identity_key_requirement_creation(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + document.set("recipientKeyId", (key_id(targets) as i64).into()); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedIdentityKeyRequirementNotMetError(ref e) + ), + .. + } if e.document_type_name() == "message" + && e.path() == "recipientId" + && e.field() == "purpose" + && e.required() == "decryption" + && e.actual() == actual + ); + } + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_key_is_bound_to_another_document_type() { + let result = run_identity_key_requirement_creation(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + document.set( + "recipientKeyId", + (targets.decryption_key_bound_to_message_id as i64).into(), + ); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedIdentityKeyRequirementNotMetError(ref e) + ), + .. + } if e.document_type_name() == "message" + && e.path() == "recipientId" + && e.key_id() == 4 + && e.field() == "boundTo" + && e.required() == "inbox" + && e.actual().ends_with(" document type message") + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_key_has_no_contract_bounds() { + let result = run_identity_key_requirement_creation(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + document.set( + "recipientKeyId", + (targets.unbound_decryption_key_id as i64).into(), + ); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedIdentityKeyRequirementNotMetError(ref e) + ), + .. + } if e.field() == "boundTo" + && e.required() == "inbox" + && e.actual() == "no contract bounds" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_as_key_not_found_when_required_key_is_missing() { + // A key that does not exist is still reported as missing, not as unmet + let result = run_identity_key_requirement_creation(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + document.set("recipientKeyId", 99i64.into()); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + _ + )), + .. + } + ); + + // And an unset key id property is still reported as invalid + let result = run_identity_key_requirement_creation(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedKeyIdPropertyInvalidError( + _ + )), + .. + } + ); + } + + const REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key.json"; + + /// Committed state the key id reference tests can point at: the writer + /// (its enabled critical key, its master key disabled in state) and a + /// second identity in the same state, for the form naming an identity + /// property. + struct KeyIdReferenceTargets { + writer_id: Identifier, + enabled_key_id: KeyID, + disabled_key_id: KeyID, + other_id: Identifier, + other_enabled_key_id: KeyID, + other_disabled_key_id: KeyID, + } + + /// Registers the key id reference fixture at `contract_path` (a `message` + /// type whose key id property carries `refersTo: identityPublicKey` with + /// an `identityProperty`), disables the master key of the writer and of + /// a second identity in state, then creates a `message` document mutated + /// by the test and returns the execution result. + async fn run_key_id_reference_creation( + contract_path: &str, + mutator: F, + ) -> StateTransitionExecutionResult + where + F: FnOnce(&mut Document, &KeyIdReferenceTargets), + { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(434); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 959, dash_to_credits!(0.1)); + let (other, _, other_key) = setup_identity(&mut platform, 452, dash_to_credits!(0.1)); + + // Key 0 is the master key; documents are signed with the critical key, + // so disabling it leaves the transition below valid + for identity_id in [identity.id(), other.id()] { + platform + .drive + .disable_identity_keys( + identity_id.to_buffer(), + vec![0], + 1, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to disable the master key"); + } + + let targets = KeyIdReferenceTargets { + writer_id: identity.id(), + enabled_key_id: key.id(), + disabled_key_id: 0, + other_id: other.id(), + other_enabled_key_id: other_key.id(), + other_disabled_key_id: 0, + }; + + let contract = setup_contract( + &platform.drive, + contract_path, + None, + None, + None::, + None, + None, + ); + + let message = contract + .document_type_for_name("message") + .expect("expected a message document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = message + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + // The key id property is optional; each test sets what it + // exercises + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + document + .set_id_for_creation(message, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + mutator(&mut document, &targets); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document, + message, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_create_serialized_transition = documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_create_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone() + } + + #[tokio::test] + async fn should_document_creation_succeed_when_owner_key_reference_names_an_existing_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.enabled_key_id as i64).into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// The reference names a key of the writer's own identity, so a key id + /// the owner does not have is a missing key, whatever other identity may + /// hold a key under that id: there is no property naming another + /// identity, so "another identity's key" is not expressible and needs no + /// test of its own. Refused paid, the identity exists. + #[tokio::test] + async fn should_document_creation_fail_when_owner_key_reference_names_a_missing_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, _| { + document.set("senderKeyId", 99i64.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + e + )), + .. + } if e.key_id() == 99 && e.path() == "senderKeyId" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_owner_key_reference_names_a_disabled_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.disabled_key_id as i64).into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyDisabledError( + _ + )), + .. + } + ); + } + + /// The form on a nested property is validated at its dotted path, the + /// same path the flattened properties and the error report. + #[tokio::test] + async fn should_document_creation_fail_when_nested_owner_key_reference_names_a_missing_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, _| { + document.set( + "meta", + dpp::platform_value::platform_value!({ "senderKeyId": 99u32 }), + ); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + e + )), + .. + } if e.key_id() == 99 && e.path() == "meta.senderKeyId" + ); + } + + /// An unset key id is not a reference to validate; whether the property + /// may be absent is the document type's required list (it is optional + /// in the fixture). + const REFERENCE_VALIDATION_OWNER_KEY_REQUIREMENTS_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-requirements.json"; + + /// Registers the owner-key fixture whose `senderKeyId` requires a + /// decryption key bound to the contract's `inbox` type, adds the keys of + /// [`IdentityKeyRequirementTargets`] to the writer, then creates a + /// `message` document mutated by the test and returns the execution + /// result. + async fn run_owner_key_requirement_creation(mutator: F) -> StateTransitionExecutionResult + where + F: FnOnce(&mut Document, &IdentityKeyRequirementTargets), + { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(436); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 960, dash_to_credits!(0.1)); + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_OWNER_KEY_REQUIREMENTS_CONTRACT_PATH, + None, + None, + None::, + None, + None, + ); + + let targets = add_identity_key_requirement_targets( + &mut platform, + &identity, + key.id(), + contract.id(), + platform_version, + ); + + let message = contract + .document_type_for_name("message") + .expect("expected a message document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = message + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + document + .set_id_for_creation(message, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + mutator(&mut document, &targets); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document, + message, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_create_transition + .serialize_to_bytes() + .expect("expected a serialized create transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone() + } + + /// `keyRequirements` apply to the key id form exactly as to the + /// identifier form, through the shared key check. + #[tokio::test] + async fn should_document_creation_succeed_when_owner_key_meets_its_requirements() { + let result = run_owner_key_requirement_creation(|document, targets| { + document.set( + "senderKeyId", + (targets.decryption_key_bound_to_inbox_id as i64).into(), + ); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_owner_key_does_not_meet_its_requirements() { + let result = run_owner_key_requirement_creation(|document, targets| { + document.set("senderKeyId", (targets.authentication_key_id as i64).into()); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedIdentityKeyRequirementNotMetError(ref e) + ), + .. + } if e.path() == "senderKeyId" && e.field() == "purpose" + ); + } + + const REFERENCE_VALIDATION_CREATOR_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key.json"; + const REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key.json"; + + /// `$creatorId` on a create names the writer, the document's creator. + #[tokio::test] + async fn should_document_creation_succeed_when_creator_key_reference_names_an_existing_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_CREATOR_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.enabled_key_id as i64).into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_creator_key_reference_names_a_missing_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_CREATOR_KEY_CONTRACT_PATH, + |document, _| { + document.set("senderKeyId", 99i64.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + e + )), + .. + } if e.key_id() == 99 && e.path() == "senderKeyId" + ); + } + + /// A property path: `toUserId` carries the identity, `recipientKeyId` the + /// id of one of its keys. + #[tokio::test] + async fn should_document_creation_succeed_when_key_reference_names_an_identity_property() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH, + |document, targets| { + document.set("toUserId", targets.other_id.into()); + document.set( + "recipientKeyId", + (targets.other_enabled_key_id as i64).into(), + ); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// The key is looked up on the named identity, not the writer: the + /// writer's critical key id is enabled on the writer but names the other + /// identity's disabled master key when the ids coincide, and a missing + /// one is missing on the other identity. + #[tokio::test] + async fn should_document_creation_fail_when_named_identity_lacks_or_disabled_the_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH, + |document, targets| { + document.set("toUserId", targets.other_id.into()); + document.set( + "recipientKeyId", + (targets.other_disabled_key_id as i64).into(), + ); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyDisabledError( + e + )), + .. + } if e.path() == "recipientKeyId" + ); + + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH, + |document, targets| { + document.set("toUserId", targets.other_id.into()); + document.set("recipientKeyId", 99i64.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + e + )), + .. + } if e.key_id() == 99 + ); + } + + /// A key id without the identity it belongs to is refused: the pair is + /// the reference. + #[tokio::test] + async fn should_document_creation_fail_when_key_reference_identity_property_is_not_set() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH, + |document, targets| { + document.set("recipientKeyId", (targets.enabled_key_id as i64).into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedKeyIdPropertyInvalidError( + e + )), + .. + } if e.path() == "recipientKeyId" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_owner_key_reference_is_not_set() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, _| { + document.set("note", "no key named".into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletable_document_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletable_document_reference.rs index aaf8b6ec106..56fd98ae3f6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletable_document_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletable_document_reference.rs @@ -13,19 +13,13 @@ use super::*; mod deletable_document_reference_tests { + use super::super::reference_test_setup::{ + assert_successful, create_document, ReferenceTestSetup as Setup, + }; use super::*; - use crate::platform_types::platform_state::PlatformState; use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; - use crate::rpc::core::MockCoreRPCLike; - use crate::test::helpers::setup::TempPlatform; - use dpp::data_contract::accessors::v0::DataContractV0Setters; use dpp::document::Document; use dpp::identifier::Identifier; - use dpp::identity::signer::Signer; - use dpp::identity::IdentityPublicKey; - use dpp::prelude::DataContract; - use dpp::state_transition::StateTransition; - use simple_signer::signer::SimpleSigner; /// Shared with the contract-create registration tests, which pin that /// the declarations themselves are accepted. @@ -36,76 +30,6 @@ mod deletable_document_reference_tests { /// exercises the write-time half of the same rule. const NOT_DELETABLE_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-deletable-doc-registration-not-deletable.json"; - fn register_contract( - platform: &TempPlatform, - owner_id: Identifier, - platform_version: &PlatformVersion, - ) -> DataContract { - register_contract_at(platform, CONTRACT_PATH, owner_id, platform_version) - } - - fn register_contract_at( - platform: &TempPlatform, - path: &str, - owner_id: Identifier, - platform_version: &PlatformVersion, - ) -> DataContract { - let mut contract = json_document_to_contract(path, true, platform_version) - .expect("expected to parse the deletable document contract"); - contract.set_owner_id(owner_id); - platform - .drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("expected to apply the deletable document contract"); - contract - } - - fn process_and_commit( - platform: &TempPlatform, - platform_state: &PlatformState, - transition: &StateTransition, - platform_version: &PlatformVersion, - ) -> StateTransitionsProcessingResult { - let serialized = transition - .serialize_to_bytes() - .expect("expected the batch transition to serialize"); - let transaction = platform.drive.grove.start_transaction(); - let processing_result = platform - .platform - .process_raw_state_transitions( - &[serialized], - platform_state, - &BlockInfo::default(), - &transaction, - platform_version, - false, - None, - ) - .expect("expected to process state transition"); - platform - .drive - .grove - .commit_transaction(transaction) - .unwrap() - .expect("expected to commit transaction"); - processing_result - } - - fn assert_successful(result: &StateTransitionsProcessingResult, because: &str) { - assert_matches!( - result.execution_results().as_slice(), - [StateTransitionExecutionResult::SuccessfulExecution { .. }], - "{because}" - ); - } - fn assert_referenced_entity_not_found( result: &StateTransitionsProcessingResult, because: &str, @@ -120,237 +44,11 @@ mod deletable_document_reference_tests { ); } - /// Creates a document of `type_name` with exactly `properties` set and - /// returns it with the processing result. - #[allow(clippy::too_many_arguments)] - async fn create_document>( - platform: &TempPlatform, - platform_state: &PlatformState, - contract: &DataContract, - type_name: &str, - properties: &[(&str, Value)], - owner: Identifier, - key: &IdentityPublicKey, - nonce: u64, - signer: &S, - rng: &mut StdRng, - platform_version: &PlatformVersion, - ) -> (Document, StateTransitionsProcessingResult) { - let document_type = contract - .document_type_for_name(type_name) - .expect("doctype exists"); - let entropy = Bytes32::random_with_rng(rng); - let mut document = document_type - .random_document_with_identifier_and_entropy( - rng, - owner, - entropy, - DocumentFieldFillType::DoNotFillIfNotRequired, - DocumentFieldFillSize::AnyDocumentFillSize, - platform_version, - ) - .expect("expected a random document"); - for (property, value) in properties { - document.set(property, value.clone()); - } - // The id commits to the create transition's nonce: give the local - // copy the id the transition will carry, since the tests reference - // and act on it afterwards. - document - .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) - .expect("expected the creation id"); - let create = BatchTransition::new_document_creation_transition_from_document( - document.clone(), - document_type, - entropy.0, - key, - nonce, - 0, - None, - signer, - platform_version, - None, - ) - .await - .expect("expected the create transition"); - let result = process_and_commit(platform, platform_state, &create, platform_version); - (document, result) - } - - #[allow(clippy::too_many_arguments)] - async fn replace_document>( - platform: &TempPlatform, - platform_state: &PlatformState, - contract: &DataContract, - type_name: &str, - document: &Document, - key: &IdentityPublicKey, - nonce: u64, - signer: &S, - platform_version: &PlatformVersion, - ) -> StateTransitionsProcessingResult { - let document_type = contract - .document_type_for_name(type_name) - .expect("doctype exists"); - let replace = BatchTransition::new_document_replacement_transition_from_document( - document.clone(), - document_type, - key, - nonce, - 0, - None, - signer, - platform_version, - None, - ) - .await - .expect("expected the replace transition"); - process_and_commit(platform, platform_state, &replace, platform_version) - } - - #[allow(clippy::too_many_arguments)] - async fn delete_document>( - platform: &TempPlatform, - platform_state: &PlatformState, - contract: &DataContract, - type_name: &str, - document: &Document, - key: &IdentityPublicKey, - nonce: u64, - signer: &S, - platform_version: &PlatformVersion, - ) -> StateTransitionsProcessingResult { - let document_type = contract - .document_type_for_name(type_name) - .expect("doctype exists"); - let delete = BatchTransition::new_document_deletion_transition_from_document( - document.clone(), - document_type, - key, - nonce, - 0, - None, - signer, - platform_version, - None, - ) - .await - .expect("expected the delete transition"); - process_and_commit(platform, platform_state, &delete, platform_version) - } - fn identifier_value(id: Identifier) -> Value { Value::Identifier(id.to_buffer()) } - struct Setup { - platform: TempPlatform, - platform_state: std::sync::Arc, - contract: DataContract, - owner: Identifier, - signer: SimpleSigner, - key: IdentityPublicKey, - rng: StdRng, - nonce: u64, - } - impl Setup { - fn new(contract_path: &str, seed: u64) -> Self { - let platform_version = PlatformVersion::latest(); - let mut platform = TestPlatformBuilder::new() - .build_with_mock_rpc() - .set_genesis_state(); - let platform_state = platform.state.load_full(); - let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); - let contract = - register_contract_at(&platform, contract_path, identity.id(), platform_version); - Self { - platform, - platform_state, - contract, - owner: identity.id(), - signer, - key, - rng: StdRng::seed_from_u64(seed), - nonce: 1, - } - } - - fn next_nonce(&mut self) -> u64 { - self.nonce += 1; - self.nonce - } - - async fn create( - &mut self, - type_name: &str, - properties: &[(&str, Value)], - ) -> (Document, StateTransitionsProcessingResult) { - let nonce = self.next_nonce(); - create_document( - &self.platform, - &self.platform_state, - &self.contract, - type_name, - properties, - self.owner, - &self.key, - nonce, - &self.signer, - &mut self.rng, - PlatformVersion::latest(), - ) - .await - } - - /// Bumps the revision and replaces `document` as it now stands. - async fn replace( - &mut self, - type_name: &str, - document: &mut Document, - ) -> StateTransitionsProcessingResult { - document.increment_revision().expect("revision increments"); - let nonce = self.next_nonce(); - let result = replace_document( - &self.platform, - &self.platform_state, - &self.contract, - type_name, - document, - &self.key, - nonce, - &self.signer, - PlatformVersion::latest(), - ) - .await; - // A refused replace leaves the stored revision where it was. - if result.valid_count() == 0 { - let revision = document.revision().expect("revision set"); - document.set_revision(Some(revision - 1)); - } - result - } - - async fn delete( - &mut self, - type_name: &str, - document: &Document, - ) -> StateTransitionsProcessingResult { - let nonce = self.next_nonce(); - delete_document( - &self.platform, - &self.platform_state, - &self.contract, - type_name, - document, - &self.key, - nonce, - &self.signer, - PlatformVersion::latest(), - ) - .await - } - async fn draft(&mut self, topic: &str) -> Document { let (draft, result) = self.create("draft", &[("topic", topic.into())]).await; assert_successful(&result, "the draft is created"); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs new file mode 100644 index 00000000000..47076f2a490 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs @@ -0,0 +1,946 @@ +//! End-to-end coverage for the `distinctFrom` property keyword (protocol +//! version 14): an identifier property's value must differ from the value of +//! a named property of the same document, or from the document's `$ownerId`. +//! A create or replace whose two values are equal is consensus-rejected with +//! `DocumentPropertyNotDistinctError` (basic code 10419) and leaves the stored +//! document untouched; a declaration whose named property is absent from the +//! document passes. + +use super::*; + +mod distinct_from_tests { + use super::*; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use crate::execution::validation::state_transition::batch::action_validation::document::document_replace_transition_action::DocumentReplaceTransitionActionValidation; + use dpp::consensus::basic::BasicError; + use dpp::consensus::codes::ErrorWithCode; + use dpp::document::DocumentV0Setters; + use dpp::fee::Credits; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionV0}; + use crate::execution::validation::state_transition::batch::action_validation::document::document_purchase_transition_action::DocumentPurchaseTransitionActionValidation; + use crate::execution::validation::state_transition::batch::action_validation::document::document_transfer_transition_action::DocumentTransferTransitionActionValidation; + use drive::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::{DocumentPurchaseTransitionAction, DocumentPurchaseTransitionActionV0}; + use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::{DocumentReplaceTransitionAction, DocumentReplaceTransitionActionV0}; + use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::{DocumentTransferTransitionAction, DocumentTransferTransitionActionV0}; + use std::collections::{BTreeMap, BTreeSet}; + use dpp::data_contract::schema::DataContractSchemaMethodsV0; + use dpp::document::Document; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::platform_value::platform_value; + use dpp::prelude::{DataContract, Identifier, IdentityNonce}; + use dpp::state_transition::StateTransition; + use dpp::tests::fixtures::get_data_contract_fixture; + use drive::util::storage_flags::StorageFlags; + use simple_signer::signer::SimpleSigner; + + fn identifier_property(position: u32, distinct_from: Option<&str>) -> Value { + let mut property = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32_u32, + "maxItems": 32_u32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position + }); + if let Some(distinct_from) = distinct_from { + property + .insert("distinctFrom".to_string(), distinct_from.into()) + .expect("expected to insert distinctFrom"); + } + property + } + + /// A mutable, transferable and purchasable `delegation` type: `delegateId` + /// must differ from the owner, `backupId` from `delegateId`, the nested + /// `meta.reviewerId` from its sibling `meta.approverId`, and every element + /// of the `members` identifier array from the owner. Only the string `note` + /// is required, so any identifier may be left out of a document. + fn delegation_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "transferable": 1, + "tradeMode": 1, + "properties": { + "delegateId": identifier_property(0, Some("$ownerId")), + "backupId": identifier_property(1, Some("delegateId")), + "note": {"type": "string", "position": 2, "maxLength": 63_u32}, + "meta": { + "type": "object", + "position": 3, + "properties": { + "reviewerId": identifier_property(0, Some("meta.approverId")), + "approverId": identifier_property(1, None) + }, + "additionalProperties": false + }, + "members": { + "type": "array", + "maxItems": 8_u32, + "position": 4, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32_u32, + "maxItems": 32_u32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId" + } + } + }, + "required": ["note"], + "additionalProperties": false + }) + } + + fn id(byte: u8) -> Value { + Value::Identifier([byte; 32]) + } + + /// One identity and one contract whose `delegation` type declares the + /// rules above. `create` writes a document, `replace` rewrites the last + /// accepted one. + struct DelegationFixture { + platform: TempPlatform, + identity: Identity, + signer: SimpleSigner, + key: IdentityPublicKey, + contract: DataContract, + /// The document as last accepted by the chain, once one was. + document: Option, + /// The identity contract nonce the next transition uses. Every + /// processed transition consumes one, including the ones that fail + /// with a paid consensus error. + next_nonce: IdentityNonce, + } + + impl DelegationFixture { + fn new() -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.5)); + + let mut contract = get_data_contract_fixture( + Some(identity.id()), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + contract + .set_document_schema( + "delegation", + delegation_schema(), + true, + &mut Vec::new(), + platform_version, + ) + .expect("expected to add the delegation document type"); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + + Self { + platform, + identity, + signer, + key, + contract, + document: None, + next_nonce: 1, + } + } + + fn owner_id(&self) -> Value { + Value::Identifier(self.identity.id().to_buffer()) + } + + fn process(&self, transition: &StateTransition) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let platform_state = self.platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// Creates a delegation with `note` set and whatever `fill` adds. On + /// success it becomes the fixture's document. + async fn create( + &mut self, + fill: impl FnOnce(&mut Document), + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let delegation_type = self + .contract + .document_type_for_name("delegation") + .expect("expected the delegation document type"); + + let mut rng = StdRng::seed_from_u64(433); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = delegation_type + .random_document_with_identifier_and_entropy( + &mut rng, + self.identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random delegation"); + document + .set_id_for_creation( + delegation_type, + &entropy.0, + self.next_nonce, + platform_version, + ) + .expect("expected to set the document id"); + document.set("note", "first draft".into()); + fill(&mut document); + + let transition = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + delegation_type, + entropy.0, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + self.next_nonce += 1; + + let result = self.process(&transition); + if matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ) { + self.document = Some(document); + } + result + } + + /// Replaces the stored delegation with `mutate` applied to a copy of + /// it and the revision bumped. On success the fixture's document + /// becomes the accepted version. + async fn replace( + &mut self, + mutate: impl FnOnce(&mut Document), + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let mut replacement = self + .document + .clone() + .expect("a document must have been created first"); + mutate(&mut replacement); + replacement + .increment_revision() + .expect("expected the revision to increment"); + + let transition = { + let delegation_type = self + .contract + .document_type_for_name("delegation") + .expect("expected the delegation document type"); + BatchTransition::new_document_replacement_transition_from_document( + replacement.clone(), + delegation_type, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition") + }; + self.next_nonce += 1; + + let result = self.process(&transition); + if matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ) { + self.document = Some(replacement); + } + result + } + + /// Transfers the stored delegation to `recipient`. On success the + /// fixture's document becomes the transferred version. + async fn transfer(&mut self, recipient: Identifier) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let mut transferred = self + .document + .clone() + .expect("a document must have been created first"); + transferred + .increment_revision() + .expect("expected the revision to increment"); + + let transition = { + let delegation_type = self + .contract + .document_type_for_name("delegation") + .expect("expected the delegation document type"); + BatchTransition::new_document_transfer_transition_from_document( + transferred.clone(), + delegation_type, + recipient, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the transfer transition") + }; + self.next_nonce += 1; + + let result = self.process(&transition); + if matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ) { + transferred.set_owner_id(recipient); + self.document = Some(transferred); + } + result + } + + /// Puts the stored delegation up for sale at `price`. + async fn set_price(&mut self, price: Credits) { + let platform_version = PlatformVersion::latest(); + let mut priced = self + .document + .clone() + .expect("a document must have been created first"); + priced + .increment_revision() + .expect("expected the revision to increment"); + + let transition = { + let delegation_type = self + .contract + .document_type_for_name("delegation") + .expect("expected the delegation document type"); + BatchTransition::new_document_update_price_transition_from_document( + priced.clone(), + delegation_type, + price, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the update price transition") + }; + self.next_nonce += 1; + + assert_matches!( + self.process(&transition), + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "setting the price must succeed" + ); + self.document = Some(priced); + } + + /// A second funded identity on the fixture's platform. + fn other_identity(&mut self, seed: u64) -> (Identity, SimpleSigner, IdentityPublicKey) { + setup_identity(&mut self.platform, seed, dash_to_credits!(0.5)) + } + + /// `buyer` purchases the stored delegation at `price` (its first + /// transition, so nonce 1). + async fn purchase_by( + &mut self, + buyer: &(Identity, SimpleSigner, IdentityPublicKey), + price: Credits, + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let (buyer_identity, buyer_signer, buyer_key) = buyer; + let mut bought = self + .document + .clone() + .expect("a document must have been created first"); + bought + .increment_revision() + .expect("expected the revision to increment"); + + let transition = { + let delegation_type = self + .contract + .document_type_for_name("delegation") + .expect("expected the delegation document type"); + BatchTransition::new_document_purchase_transition_from_document( + bought, + delegation_type, + buyer_identity.id(), + price, + buyer_key, + 1, + 0, + None, + buyer_signer, + platform_version, + None, + ) + .await + .expect("expected the purchase transition") + }; + + self.process(&transition) + } + + /// The stored delegations, read back from Drive. + fn stored_delegations(&self) -> Vec { + let platform_version = PlatformVersion::latest(); + let query = DriveDocumentQuery::from_sql_expr( + "select * from delegation", + &self.contract, + Some(&self.platform.config.drive), + platform_version, + ) + .expect("expected a document query"); + self.platform + .drive + .query_documents(query, None, false, None, None) + .expect("expected a query result") + .documents() + .to_vec() + } + } + + fn expect_not_distinct_error( + result: StateTransitionExecutionResult, + expected_property: &str, + expected_distinct_from: &str, + ) { + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + let ConsensusError::BasicError(BasicError::DocumentPropertyNotDistinctError(error)) = error + else { + panic!("expected DocumentPropertyNotDistinctError, got {error:?}"); + }; + assert_eq!(error.document_type_name(), "delegation"); + assert_eq!(error.property(), expected_property); + assert_eq!(error.distinct_from(), expected_distinct_from); + assert_eq!(ConsensusError::from(error).code(), 10419); + } + + /// The replace structure dispatcher on both sides of the gate: structure + /// generation 0 gained the `distinctFrom` judgement in place, so at protocol + /// version 13 it must still accept the action (no property parsed there + /// carries the keyword and the dpp gate is `None`), and at 14 refuse it. + /// The action is built by hand the way the transformer would build it, + /// against the contract as Drive hands it back. + #[test] + fn should_not_judge_distinct_from_on_replace_before_protocol_version_14() { + let platform_version = PlatformVersion::latest(); + let fixture = DelegationFixture::new(); + let owner_id = fixture.identity.id(); + + let (_, contract_fetch_info) = fixture + .platform + .drive + .get_contract_with_fetch_info_and_fee( + fixture.contract.id().to_buffer(), + None, + false, + None, + platform_version, + ) + .expect("expected to fetch the contract"); + let contract_fetch_info = contract_fetch_info.expect("the contract is in state"); + + let action = DocumentReplaceTransitionAction::V0(DocumentReplaceTransitionActionV0 { + base: DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([0xAA; 32]), + identity_contract_nonce: 1, + document_type_name: "delegation".to_string(), + data_contract: contract_fetch_info, + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::default(), + contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, + }), + revision: 2, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + data: BTreeMap::from([ + ("note".to_string(), "draft".into()), + ("delegateId".to_string(), fixture.owner_id()), + ]), + changed_data_fields: BTreeSet::new(), + added_data_fields: BTreeSet::new(), + removed_identifier_fields: BTreeMap::new(), + stored_changed_values: BTreeMap::new(), + creator_id: None, + }); + + let before = action + .validate_structure( + owner_id, + PlatformVersion::get(13).expect("platform version 13 should exist"), + ) + .expect("structure validation should run"); + assert!( + before.is_valid(), + "structure generation 0 must not judge distinctFrom: {:?}", + before.errors + ); + + let at = action + .validate_structure(owner_id, platform_version) + .expect("structure validation should run"); + assert_matches!( + at.errors.as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertyNotDistinctError(e))] + if e.property() == "delegateId" && e.distinct_from() == "$ownerId" + ); + } + + /// Transfer and purchase structure validation v0 gained the `distinctFrom` + /// judgement in place. At protocol version 13 the same module must still + /// accept the action, as no property parsed there carries the keyword and + /// the dpp gate is `None`; at 14 it refuses the equal pair. + #[tokio::test] + async fn should_not_judge_distinct_from_on_transfer_or_purchase_before_protocol_version_14() { + let platform_version = PlatformVersion::latest(); + let platform_version_13 = + PlatformVersion::get(13).expect("platform version 13 should exist"); + let mut fixture = DelegationFixture::new(); + let recipient = Identifier::from([0xEE; 32]); + assert_matches!( + fixture + .create(|document| { + document.set("delegateId", Value::Identifier(recipient.to_buffer())) + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + // The transformer hands the validators the stored document already + // re-owned by the recipient + let mut transferred = fixture + .document + .clone() + .expect("the delegation was created"); + transferred.set_owner_id(recipient); + + let (_, contract_fetch_info) = fixture + .platform + .drive + .get_contract_with_fetch_info_and_fee( + fixture.contract.id().to_buffer(), + None, + false, + None, + platform_version, + ) + .expect("expected to fetch the contract"); + let contract_fetch_info = contract_fetch_info.expect("the contract is in state"); + let base = || { + DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: transferred.id(), + identity_contract_nonce: 2, + document_type_name: "delegation".to_string(), + data_contract: contract_fetch_info.clone(), + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::default(), + contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, + }) + }; + + let transfer = DocumentTransferTransitionAction::V0(DocumentTransferTransitionActionV0 { + base: base(), + document: transferred.clone(), + }); + let purchase = DocumentPurchaseTransitionAction::V0(DocumentPurchaseTransitionActionV0 { + base: base(), + document: transferred.clone(), + original_owner_id: fixture.identity.id(), + price: 1, + }); + + for (name, before, at) in [ + ( + "transfer", + transfer.validate_structure(platform_version_13), + transfer.validate_structure(platform_version), + ), + ( + "purchase", + purchase.validate_structure(platform_version_13), + purchase.validate_structure(platform_version), + ), + ] { + let before = before.expect("structure validation should run"); + assert!( + before.is_valid(), + "{name}: v0 must not judge distinctFrom before protocol version 14: {:?}", + before.errors + ); + let at = at.expect("structure validation should run"); + assert_matches!( + at.errors.as_slice(), + [ConsensusError::BasicError(BasicError::DocumentPropertyNotDistinctError(e))] + if e.property() == "delegateId" && e.distinct_from() == "$ownerId", + "{name}" + ); + } + } + + #[tokio::test] + async fn should_reject_a_create_whose_property_equals_the_owner_id() { + let mut fixture = DelegationFixture::new(); + let owner_id = fixture.owner_id(); + + let result = fixture + .create(|document| document.set("delegateId", owner_id)) + .await; + + expect_not_distinct_error(result, "delegateId", "$ownerId"); + assert!( + fixture.stored_delegations().is_empty(), + "the refused document must not be stored" + ); + } + + #[tokio::test] + async fn should_accept_a_create_whose_property_differs_from_the_owner_id() { + let mut fixture = DelegationFixture::new(); + + let result = fixture + .create(|document| document.set("delegateId", id(1))) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let stored = fixture.stored_delegations(); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].get("delegateId"), Some(&id(1))); + } + + #[tokio::test] + async fn should_reject_a_create_whose_property_equals_the_named_sibling_property() { + let mut fixture = DelegationFixture::new(); + + let result = fixture + .create(|document| { + document.set("delegateId", id(1)); + document.set("backupId", id(1)); + }) + .await; + + expect_not_distinct_error(result, "backupId", "delegateId"); + assert!(fixture.stored_delegations().is_empty()); + } + + #[tokio::test] + async fn should_accept_a_create_when_the_named_sibling_property_is_absent() { + let mut fixture = DelegationFixture::new(); + + // `backupId` must differ from `delegateId`, which the document leaves + // out: there is nothing to differ from + let result = fixture + .create(|document| document.set("backupId", id(1))) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_delegations().len(), 1); + } + + #[tokio::test] + async fn should_accept_a_create_whose_sibling_properties_differ() { + let mut fixture = DelegationFixture::new(); + + let result = fixture + .create(|document| { + document.set("delegateId", id(1)); + document.set("backupId", id(2)); + document.set( + "meta", + platform_value!({ "reviewerId": id(3), "approverId": id(4) }), + ); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_a_create_whose_nested_property_equals_its_named_sibling() { + let mut fixture = DelegationFixture::new(); + + let result = fixture + .create(|document| { + document.set( + "meta", + platform_value!({ "reviewerId": id(3), "approverId": id(3) }), + ); + }) + .await; + + expect_not_distinct_error(result, "meta.reviewerId", "meta.approverId"); + } + + #[tokio::test] + async fn should_reject_a_replace_that_makes_the_two_properties_equal() { + let mut fixture = DelegationFixture::new(); + assert_matches!( + fixture + .create(|document| { + document.set("delegateId", id(1)); + document.set("backupId", id(2)); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = fixture + .replace(|document| document.set("backupId", id(1))) + .await; + + expect_not_distinct_error(result, "backupId", "delegateId"); + let stored = fixture.stored_delegations(); + assert_eq!(stored.len(), 1); + assert_eq!( + stored[0].get("backupId"), + Some(&id(2)), + "the refused replace must leave the stored document untouched" + ); + } + + #[tokio::test] + async fn should_reject_a_replace_that_sets_the_property_to_the_owner_id() { + let mut fixture = DelegationFixture::new(); + assert_matches!( + fixture + .create(|document| document.set("delegateId", id(1))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let owner_id = fixture.owner_id(); + + let result = fixture + .replace(|document| document.set("delegateId", owner_id)) + .await; + + expect_not_distinct_error(result, "delegateId", "$ownerId"); + assert_eq!( + fixture.stored_delegations()[0].get("delegateId"), + Some(&id(1)) + ); + } + + #[tokio::test] + async fn should_reject_a_create_whose_array_element_equals_the_owner_id() { + let mut fixture = DelegationFixture::new(); + let owner_id = fixture.owner_id(); + + let result = fixture + .create(|document| document.set("members", Value::Array(vec![id(1), owner_id]))) + .await; + + expect_not_distinct_error(result, "members", "$ownerId"); + assert!(fixture.stored_delegations().is_empty()); + } + + #[tokio::test] + async fn should_accept_a_create_whose_array_elements_all_differ_from_the_owner_id() { + let mut fixture = DelegationFixture::new(); + + let result = fixture + .create(|document| document.set("members", Value::Array(vec![id(1), id(2)]))) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!( + fixture.stored_delegations()[0].get("members"), + Some(&Value::Array(vec![id(1), id(2)])) + ); + } + + #[tokio::test] + async fn should_reject_a_transfer_to_the_identity_the_property_must_differ_from() { + let mut fixture = DelegationFixture::new(); + let (recipient, _, _) = fixture.other_identity(450); + assert_matches!( + fixture + .create(|document| { + document.set("delegateId", Value::Identifier(recipient.id().to_buffer())) + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = fixture.transfer(recipient.id()).await; + + expect_not_distinct_error(result, "delegateId", "$ownerId"); + assert_eq!( + fixture.stored_delegations()[0].owner_id(), + fixture.identity.id(), + "the refused transfer must leave the owner unchanged" + ); + } + + #[tokio::test] + async fn should_accept_a_transfer_to_another_identity() { + let mut fixture = DelegationFixture::new(); + let (recipient, _, _) = fixture.other_identity(450); + assert_matches!( + fixture + .create(|document| document.set("delegateId", id(1))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = fixture.transfer(recipient.id()).await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_delegations()[0].owner_id(), recipient.id()); + } + + #[tokio::test] + async fn should_reject_a_purchase_by_the_identity_the_property_must_differ_from() { + let mut fixture = DelegationFixture::new(); + let buyer = fixture.other_identity(450); + assert_matches!( + fixture + .create(|document| { + document.set("delegateId", Value::Identifier(buyer.0.id().to_buffer())) + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + fixture.set_price(dash_to_credits!(0.1)).await; + + let result = fixture.purchase_by(&buyer, dash_to_credits!(0.1)).await; + + expect_not_distinct_error(result, "delegateId", "$ownerId"); + assert_eq!( + fixture.stored_delegations()[0].owner_id(), + fixture.identity.id(), + "the refused purchase must leave the owner unchanged" + ); + } + + #[tokio::test] + async fn should_accept_a_purchase_by_another_identity() { + let mut fixture = DelegationFixture::new(); + let buyer = fixture.other_identity(450); + assert_matches!( + fixture + .create(|document| document.set("delegateId", id(1))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + fixture.set_price(dash_to_credits!(0.1)).await; + + let result = fixture.purchase_by(&buyer, dash_to_credits!(0.1)).await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_delegations()[0].owner_id(), buyer.0.id()); + } + + #[tokio::test] + async fn should_accept_a_replace_that_keeps_the_properties_distinct() { + let mut fixture = DelegationFixture::new(); + assert_matches!( + fixture + .create(|document| { + document.set("delegateId", id(1)); + document.set("backupId", id(2)); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = fixture + .replace(|document| { + document.set("delegateId", id(2)); + document.set("backupId", id(3)); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let stored = fixture.stored_delegations(); + assert_eq!(stored[0].get("delegateId"), Some(&id(2))); + assert_eq!(stored[0].get("backupId"), Some(&id(3))); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs new file mode 100644 index 00000000000..a4cd5c079e2 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs @@ -0,0 +1,513 @@ +//! End-to-end coverage for the `encryptedFor` property keyword (protocol +//! version 14): a byte array property declaring how its ciphertext was made. +//! Consensus checks only the shape of the bytes, on every create and replace: +//! at least the IV plus one block and a multiple of the block for AES-CBC. +//! A value of the right shape is accepted, a value of any other shape is +//! consensus-rejected and leaves the stored document untouched. + +use super::*; + +mod encrypted_for_tests { + use super::*; + use crate::execution::validation::state_transition::batch::action_validation::document::document_replace_transition_action::DocumentReplaceTransitionActionValidation; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::consensus::basic::BasicError; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionV0}; + use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::{DocumentReplaceTransitionAction, DocumentReplaceTransitionActionV0}; + use std::collections::{BTreeMap, BTreeSet}; + use dpp::data_contract::schema::DataContractSchemaMethodsV0; + use dpp::document::Document; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::platform_value::platform_value; + use dpp::prelude::Identifier; + use dpp::prelude::{DataContract, IdentityNonce}; + use dpp::state_transition::StateTransition; + use dpp::tests::fixtures::get_data_contract_fixture; + use drive::util::storage_flags::StorageFlags; + use simple_signer::signer::SimpleSigner; + + /// A mutable `secret` type: the recipient identifier, the two key ids, + /// the `encryptedMessage` declared `encryptedFor`, and an optional `meta` + /// object whose `blob` is declared the same way (a nested, dotted path). + /// The byte arrays' own `minItems` are left at 1 so that every rejection + /// below is the shape check's: the JSON schema's bounds run first, and a + /// 16-byte value against `minItems: 32` would be refused as a schema error + /// before the shape check ever saw it. + fn secret_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "properties": { + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "recipientKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 1 }, + "senderKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 2 }, + "encryptedMessage": { + "type": "array", + "byteArray": true, + "minItems": 1, + "maxItems": 1040, + "position": 3, + "encryptedFor": { + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + } + }, + "meta": { + "type": "object", + "position": 4, + "properties": { + "blob": { + "type": "array", + "byteArray": true, + "minItems": 1, + "maxItems": 1040, + "position": 0, + "encryptedFor": { + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + } + } + }, + "additionalProperties": false + } + }, + "required": ["recipientId", "recipientKeyId", "senderKeyId", "encryptedMessage"], + "additionalProperties": false + }) + } + + /// One identity and one contract whose `secret` type is the one above. + struct SecretFixture { + platform: TempPlatform, + signer: SimpleSigner, + key: IdentityPublicKey, + identity: Identity, + contract: DataContract, + /// The identity contract nonce the next transition uses. Every + /// processed transition consumes one, including the ones that fail + /// with a paid consensus error. + next_nonce: IdentityNonce, + } + + impl SecretFixture { + fn new() -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.5)); + + let mut contract = get_data_contract_fixture( + Some(identity.id()), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + contract + .set_document_schema( + "secret", + secret_schema(), + true, + &mut Vec::new(), + platform_version, + ) + .expect("expected to add the secret document type"); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + + Self { + platform, + signer, + key, + identity, + contract, + next_nonce: 1, + } + } + + /// A secret addressed to a fixed recipient, whose `encryptedMessage` + /// is `ciphertext_length` bytes long. + fn secret(&self, ciphertext_length: usize) -> (Document, [u8; 32]) { + let platform_version = PlatformVersion::latest(); + let secret_type = self + .contract + .document_type_for_name("secret") + .expect("expected the secret document type"); + let mut rng = StdRng::seed_from_u64(433); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut secret = secret_type + .random_document_with_identifier_and_entropy( + &mut rng, + self.identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random secret"); + secret + .set_id_for_creation(secret_type, &entropy.0, self.next_nonce, platform_version) + .expect("expected to set the document id"); + secret.set("recipientId", Value::Identifier([7u8; 32])); + secret.set("recipientKeyId", Value::U32(3)); + secret.set("senderKeyId", Value::U32(1)); + secret.set( + "encryptedMessage", + Value::Bytes(vec![0xAB; ciphertext_length]), + ); + (secret, entropy.0) + } + + async fn create( + &mut self, + ciphertext_length: usize, + ) -> (Document, StateTransitionExecutionResult) { + self.create_with(ciphertext_length, |_| {}).await + } + + /// Like `create`, with `mutate` applied to the secret before it is sent. + async fn create_with( + &mut self, + ciphertext_length: usize, + mutate: impl FnOnce(&mut Document), + ) -> (Document, StateTransitionExecutionResult) { + let platform_version = PlatformVersion::latest(); + let (mut secret, entropy) = self.secret(ciphertext_length); + mutate(&mut secret); + let transition = { + let secret_type = self + .contract + .document_type_for_name("secret") + .expect("expected the secret document type"); + BatchTransition::new_document_creation_transition_from_document( + secret.clone(), + secret_type, + entropy, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the create transition") + }; + self.next_nonce += 1; + (secret, self.process(&transition)) + } + + /// Replaces `stored` with its `encryptedMessage` set to + /// `ciphertext_length` bytes and the revision bumped. + async fn replace( + &mut self, + stored: &Document, + ciphertext_length: usize, + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let mut replacement = stored.clone(); + replacement.set( + "encryptedMessage", + Value::Bytes(vec![0xCD; ciphertext_length]), + ); + replacement + .increment_revision() + .expect("expected the revision to increment"); + let transition = { + let secret_type = self + .contract + .document_type_for_name("secret") + .expect("expected the secret document type"); + BatchTransition::new_document_replacement_transition_from_document( + replacement, + secret_type, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition") + }; + self.next_nonce += 1; + self.process(&transition) + } + + fn process(&self, transition: &StateTransition) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let platform_state = self.platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// The stored secrets, read back from Drive. + fn stored_secrets(&self) -> Vec { + let platform_version = PlatformVersion::latest(); + let query = DriveDocumentQuery::from_sql_expr( + "select * from secret", + &self.contract, + Some(&self.platform.config.drive), + platform_version, + ) + .expect("expected a document query"); + self.platform + .drive + .query_documents(query, None, false, None, None) + .expect("expected a query result") + .documents() + .to_vec() + } + } + + fn expect_shape_error(result: StateTransitionExecutionResult, actual_length: u32) { + expect_shape_error_on(result, "encryptedMessage", actual_length); + } + + fn expect_shape_error_on( + result: StateTransitionExecutionResult, + property: &str, + actual_length: u32, + ) { + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + let ConsensusError::BasicError(BasicError::InvalidEncryptedPropertyShapeError(error)) = + error + else { + panic!("expected an InvalidEncryptedPropertyShapeError, got {error:?}"); + }; + assert_eq!(error.property(), property); + assert_eq!(error.scheme(), "ecdh-secp256k1-aes256-cbc"); + assert_eq!(error.actual_length(), actual_length); + assert_eq!(error.minimum_length(), 32); + assert_eq!(error.block_length(), 16); + } + + #[tokio::test] + async fn should_create_a_document_whose_ciphertext_is_an_iv_plus_whole_blocks() { + let mut fixture = SecretFixture::new(); + + let (secret, result) = fixture.create(48).await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let stored = fixture.stored_secrets(); + assert_eq!(stored.len(), 1); + assert_eq!( + stored[0].get("encryptedMessage"), + secret.get("encryptedMessage") + ); + } + + #[tokio::test] + async fn should_refuse_a_ciphertext_that_is_not_a_multiple_of_the_block() { + let mut fixture = SecretFixture::new(); + + let (_, result) = fixture.create(47).await; + + expect_shape_error(result, 47); + assert!(fixture.stored_secrets().is_empty()); + } + + #[tokio::test] + async fn should_refuse_a_ciphertext_of_the_iv_alone() { + let mut fixture = SecretFixture::new(); + + let (_, result) = fixture.create(16).await; + + expect_shape_error(result, 16); + assert!(fixture.stored_secrets().is_empty()); + } + + /// The nested declaration is checked through its dotted path, and a + /// declared property the document leaves out is not checked at all. + #[tokio::test] + async fn should_check_a_nested_encrypted_property_and_skip_an_omitted_one() { + let mut fixture = SecretFixture::new(); + + let (_, result) = fixture + .create_with(48, |secret| { + secret.set( + "meta", + platform_value!({ "blob": Value::Bytes(vec![0xEF; 47]) }), + ); + }) + .await; + expect_shape_error_on(result, "meta.blob", 47); + assert!(fixture.stored_secrets().is_empty()); + + let (_, result) = fixture + .create_with(48, |secret| { + secret.set( + "meta", + platform_value!({ "blob": Value::Bytes(vec![0xEF; 64]) }), + ); + }) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_secrets().len(), 1); + } + + /// The replace structure dispatcher on both sides of the gate: structure + /// generation 0 gained the shape check in place, so at protocol version 13 + /// it must still accept the action (no property parsed there carries the + /// keyword and the dpp gate is `None`), and at 14 refuse it. The action is + /// built by hand the way the transformer would build it, against the + /// contract as Drive hands it back. + #[test] + fn should_not_check_the_ciphertext_shape_on_replace_before_protocol_version_14() { + let platform_version = PlatformVersion::latest(); + let fixture = SecretFixture::new(); + let owner_id = fixture.identity.id(); + + let (_, contract_fetch_info) = fixture + .platform + .drive + .get_contract_with_fetch_info_and_fee( + fixture.contract.id().to_buffer(), + None, + false, + None, + platform_version, + ) + .expect("expected to fetch the contract"); + let contract_fetch_info = contract_fetch_info.expect("the contract is in state"); + + let action = DocumentReplaceTransitionAction::V0(DocumentReplaceTransitionActionV0 { + base: DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([0xAA; 32]), + identity_contract_nonce: 1, + document_type_name: "secret".to_string(), + data_contract: contract_fetch_info, + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::default(), + contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, + }), + revision: 2, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + data: BTreeMap::from([ + ("recipientId".to_string(), Value::Identifier([7u8; 32])), + ("recipientKeyId".to_string(), Value::U32(3)), + ("senderKeyId".to_string(), Value::U32(1)), + ("encryptedMessage".to_string(), Value::Bytes(vec![0xAB; 47])), + ]), + changed_data_fields: BTreeSet::new(), + added_data_fields: BTreeSet::new(), + removed_identifier_fields: BTreeMap::new(), + stored_changed_values: BTreeMap::new(), + creator_id: None, + }); + + let before = action + .validate_structure( + owner_id, + PlatformVersion::get(13).expect("platform version 13 should exist"), + ) + .expect("structure validation should run"); + assert!( + before.is_valid(), + "structure generation 0 must not check the ciphertext shape: {:?}", + before.errors + ); + + let at = action + .validate_structure(owner_id, platform_version) + .expect("structure validation should run"); + assert_matches!( + at.errors.as_slice(), + [ConsensusError::BasicError(BasicError::InvalidEncryptedPropertyShapeError(e))] + if e.property() == "encryptedMessage" && e.actual_length() == 47 + ); + } + + #[tokio::test] + async fn should_refuse_a_replace_that_shrinks_the_ciphertext_below_the_iv_plus_a_block() { + let mut fixture = SecretFixture::new(); + let (secret, result) = fixture.create(48).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = fixture.replace(&secret, 16).await; + + expect_shape_error(result, 16); + let stored = fixture.stored_secrets(); + assert_eq!(stored.len(), 1); + assert_eq!( + stored[0].get("encryptedMessage"), + secret.get("encryptedMessage"), + "the refused replace must leave the stored ciphertext untouched" + ); + + // A replace of the right shape still goes through + let result = fixture.replace(&secret, 32).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/index_only.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/index_only.rs index 5410b384280..1e27cd5d619 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/index_only.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/index_only.rs @@ -1525,13 +1525,26 @@ mod index_only_executed_proof_tests { // An indexOnly snapshot authenticates the resulting STATE (the // commitment-checked entry), never THIS transition's execution — // a second create with identical values shares the entry. - assert_matches!( - &outcome, - dpp::state_transition::proof_result::StateTransitionProofOutcome::AffectedState(_) + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {outcome:?}" ); + let owner_balance = outcome.owner_balance(); let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else { panic!("expected verified documents"); }; + // The owner's balance after the create rides in the same proof, read + // from the same state as the entry. + let alice_balance = platform + .drive + .fetch_identity_balance(alice.id().to_buffer(), None, platform_version) + .expect("expected to fetch alice's balance") + .expect("alice has a balance"); + assert_eq!(owner_balance, Some(alice_balance)); + assert!( + alice_balance < dash_to_credits!(1.0), + "the create cost credits" + ); let (_, verified_like) = documents.into_iter().next().expect("one document"); let verified_like = verified_like.expect("the created like is present"); assert_eq!( @@ -1576,13 +1589,21 @@ mod index_only_executed_proof_tests { ) .expect("expected the executed-delete proof to verify"); assert_ne!(root_hash, [0u8; 32]); - assert_matches!( - &outcome, - dpp::state_transition::proof_result::StateTransitionProofOutcome::AffectedState(_) + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {outcome:?}" ); + let owner_balance = outcome.owner_balance(); let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else { panic!("expected verified documents"); }; + assert_eq!( + owner_balance, + platform + .drive + .fetch_identity_balance(alice.id().to_buffer(), None, platform_version) + .expect("expected to fetch alice's balance") + ); let (_, absent) = documents.into_iter().next().expect("one entry"); assert!(absent.is_none(), "the unliked entry must be proven absent"); } @@ -2573,9 +2594,9 @@ mod index_only_executed_proof_tests { ) .expect("expected the executed-create proof to verify"); assert_ne!(root_hash, [0u8; 32]); - assert_matches!( - &outcome, - dpp::state_transition::proof_result::StateTransitionProofOutcome::AffectedState(_) + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {outcome:?}" ); let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else { panic!("expected verified documents"); @@ -2638,9 +2659,9 @@ mod index_only_executed_proof_tests { ) .expect("expected the executed-delete proof to verify"); assert_ne!(root_hash, [0u8; 32]); - assert_matches!( - &outcome, - dpp::state_transition::proof_result::StateTransitionProofOutcome::AffectedState(_) + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {outcome:?}" ); let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else { panic!("expected verified documents"); @@ -2772,9 +2793,9 @@ mod index_only_executed_proof_tests { ) .expect("expected the executed-create proof to verify"); assert_ne!(root_hash, [0u8; 32]); - assert_matches!( - &outcome, - dpp::state_transition::proof_result::StateTransitionProofOutcome::AffectedState(_) + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {outcome:?}" ); let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else { panic!("expected verified documents"); @@ -2837,9 +2858,9 @@ mod index_only_executed_proof_tests { ) .expect("expected the executed-delete proof to verify"); assert_ne!(root_hash, [0u8; 32]); - assert_matches!( - &outcome, - dpp::state_transition::proof_result::StateTransitionProofOutcome::AffectedState(_) + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {outcome:?}" ); let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else { panic!("expected verified documents"); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/lookup_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/lookup_reference.rs new file mode 100644 index 00000000000..e949836f875 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/lookup_reference.rs @@ -0,0 +1,732 @@ +//! Document references resolved through a unique index (`refersTo.lookup`, +//! protocol version 14) through the full ABCI pipeline. The fixture's +//! `joinRequest` is unique on (`submittedCharterId`, `$ownerId`), and the +//! `charter` type refers to it by the requester instead of by the request's +//! id: `memberId` must be the owner of a join request for the +//! charter's own `submittedCharterId`, and `members` is the moderation +//! charter's list of them, a typed array whose elements carry the same +//! declaration. `agreedMemberId` adds a `propertyAgreement` checked against the +//! request the lookup finds, `requestedCharterId` reads the writer as a key +//! part (which is why `charter` can be neither transferred nor traded) and +//! `metaMemberId` a nested one, `meta.charterId`. The second contract, +//! `vote`, refers to the same requests from another contract. +//! +//! A value the index finds no document for is refused, paid, with +//! `ReferencedEntityNotFoundError` (40120) naming the property, or the element +//! by its list path. + +use super::*; + +mod lookup_reference_tests { + use super::*; + use crate::execution::types::execution_operation::ValidationOperation; + use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, + }; + use crate::execution::validation::state_transition::batch::action_validation::document::document_reference_validation::DocumentReferenceValidation; + use crate::platform_types::platform::PlatformStateRef; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::data_contract::document_type::{DocumentPropertyReferenceTarget, DocumentTypeRef}; + use dpp::document::Document; + use dpp::identifier::Identifier; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::platform_value::platform_value; + use dpp::prelude::{DataContract, IdentityNonce}; + use dpp::state_transition::StateTransition; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use dpp::version::DefaultForPlatformVersion; + use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionV0}; + use simple_signer::signer::SimpleSigner; + use std::collections::BTreeMap; + + const CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup.json"; + + /// A `vote` type whose `voterId` looks join requests up in the contract + /// above, by its id: the lookup into another contract. + const VOTING_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-valid.json"; + + /// The identities of the fixture: the `Founder` writes charters and votes, + /// the `Member` and the `Stranger` join requests where a test needs them. + #[derive(Clone, Copy)] + enum Who { + Founder, + Member, + Stranger, + } + + struct Writer { + identity: Identity, + signer: SimpleSigner, + key: IdentityPublicKey, + /// The identity contract nonce the next transition uses, per contract; + /// every processed transition consumes one, a refused one included. + next_nonces: BTreeMap, + } + + impl Writer { + fn next_nonce(&mut self, contract_id: Identifier) -> IdentityNonce { + let nonce = self.next_nonces.entry(contract_id).or_insert(1); + let this_one = *nonce; + *nonce += 1; + this_one + } + } + + struct LookupFixture { + platform: TempPlatform, + contract: DataContract, + voting_contract: DataContract, + rng: StdRng, + founder: Writer, + member: Writer, + stranger: Writer, + } + + fn id_value(id: Identifier) -> Value { + Value::Identifier(id.to_buffer()) + } + + impl LookupFixture { + fn new() -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut writer = |seed| { + let (identity, signer, key) = + setup_identity(&mut platform, seed, dash_to_credits!(0.5)); + Writer { + identity, + signer, + key, + next_nonces: BTreeMap::new(), + } + }; + let founder = writer(958); + let member = writer(959); + let stranger = writer(960); + + // Parsed with full validation, so the contract-level lookup + // checks run on the fixtures too + let [contract, voting_contract] = [CONTRACT_PATH, VOTING_CONTRACT_PATH].map(|path| { + let contract = json_document_to_contract(path, true, platform_version) + .expect("expected to parse the contract"); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + contract + }); + + Self { + platform, + contract, + voting_contract, + rng: StdRng::seed_from_u64(4433), + founder, + member, + stranger, + } + } + + fn id(&self, who: Who) -> Identifier { + match who { + Who::Founder => self.founder.identity.id(), + Who::Member => self.member.identity.id(), + Who::Stranger => self.stranger.identity.id(), + } + } + + /// The document type named `type_name`, in whichever fixture contract + /// has it, with the contract's id, `who`'s writer and the random source: + /// the parts a transition is built from, borrowed at once. + fn parts( + &mut self, + who: Who, + type_name: &str, + ) -> (DocumentTypeRef<'_>, Identifier, &mut Writer, &mut StdRng) { + let (document_type, contract_id) = [&self.contract, &self.voting_contract] + .into_iter() + .find_map(|contract| { + contract + .document_type_optional_for_name(type_name) + .map(|document_type| (document_type, contract.id())) + }) + .expect("expected the document type"); + let writer = match who { + Who::Founder => &mut self.founder, + Who::Member => &mut self.member, + Who::Stranger => &mut self.stranger, + }; + (document_type, contract_id, writer, &mut self.rng) + } + + fn process(&self, transition: &StateTransition) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let platform_state = self.platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// Creates a `type_name` document owned by `who`, with `values` set + /// over the random required ones, and returns it with the result. + async fn create( + &mut self, + who: Who, + type_name: &str, + values: &[(&str, Value)], + ) -> (Document, StateTransitionExecutionResult) { + let platform_version = PlatformVersion::latest(); + let owner_id = self.id(who); + let (document_type, contract_id, writer, rng) = self.parts(who, type_name); + let entropy = Bytes32::random_with_rng(rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + rng, + owner_id, + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + for (property, value) in values { + document.set(property, value.clone()); + } + let nonce = writer.next_nonce(contract_id); + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected to set the document id"); + let transition = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + &writer.key, + nonce, + 0, + None, + &writer.signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + let result = self.process(&transition); + (document, result) + } + + /// Replaces `document`, as last accepted, with `change` applied, as + /// `who`, its current owner. + async fn replace( + &mut self, + who: Who, + type_name: &str, + document: &Document, + change: impl FnOnce(&mut Document), + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let mut replacement = document.clone(); + replacement + .increment_revision() + .expect("the revision increments"); + change(&mut replacement); + let (document_type, contract_id, writer, _) = self.parts(who, type_name); + let nonce = writer.next_nonce(contract_id); + let transition = BatchTransition::new_document_replacement_transition_from_document( + replacement, + document_type, + &writer.key, + nonce, + 0, + None, + &writer.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + self.process(&transition) + } + + /// A join request by `who` for the submitted charter `charter_id`. + async fn request_to_join( + &mut self, + who: Who, + charter_id: Identifier, + message: &str, + ) -> Document { + let (request, result) = self + .create( + who, + "joinRequest", + &[ + ("submittedCharterId", id_value(charter_id)), + ("message", message.into()), + ], + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + request + } + + /// A charter by the founder for `charter_id`, with `values` set. + async fn create_charter( + &mut self, + charter_id: Identifier, + values: &[(&str, Value)], + ) -> (Document, StateTransitionExecutionResult) { + let mut all = vec![ + ("submittedCharterId", id_value(charter_id)), + ("title", "charter".into()), + ]; + all.extend(values.iter().cloned()); + self.create(Who::Founder, "charter", &all).await + } + } + + fn charter_id(byte: u8) -> Identifier { + Identifier::from([byte; 32]) + } + + /// The refusal of a value the index found no document for, naming the + /// property and the value. + fn assert_not_found(result: StateTransitionExecutionResult, path: &str, value: Identifier) { + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(e)), + .. + } if e.path() == path + && *e.entity_id() == value + && matches!( + e.entity_type(), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } + ), + "expected 40120 at {path}" + ); + } + + #[tokio::test] + async fn should_create_a_document_whose_lookup_value_owns_a_request_for_its_target() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + fixture + .request_to_join(Who::Member, charter_id(1), "let me in") + .await; + + let (_, result) = fixture + .create_charter(charter_id(1), &[("memberId", id_value(member))]) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_refuse_a_lookup_value_that_owns_no_request_for_its_target() { + let mut fixture = LookupFixture::new(); + let stranger = fixture.id(Who::Stranger); + fixture + .request_to_join(Who::Member, charter_id(1), "let me in") + .await; + + let (_, result) = fixture + .create_charter(charter_id(1), &[("memberId", id_value(stranger))]) + .await; + + assert_not_found(result, "memberId", stranger); + } + + #[tokio::test] + async fn should_not_resolve_a_lookup_through_a_request_for_another_target() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + fixture + .request_to_join(Who::Member, charter_id(2), "let me in") + .await; + + // The member asked to join charter 2, not charter 1 + let (_, result) = fixture + .create_charter(charter_id(1), &[("memberId", id_value(member))]) + .await; + + assert_not_found(result, "memberId", member); + } + + #[tokio::test] + async fn should_refuse_a_replace_that_moves_the_target_to_one_with_no_request() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + fixture + .request_to_join(Who::Member, charter_id(1), "let me in") + .await; + let (charter, result) = fixture + .create_charter(charter_id(1), &[("memberId", id_value(member))]) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + // Touching neither the value nor a key part leaves the reference alone + let result = fixture + .replace(Who::Founder, "charter", &charter, |charter| { + charter.set("title", "renamed".into()); + }) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let mut charter = charter; + charter + .increment_revision() + .expect("the revision increments"); + charter.set("title", "renamed".into()); + + // Moving the key part re-validates it: the member never asked to join + // charter 2 + let result = fixture + .replace(Who::Founder, "charter", &charter, |charter| { + charter.set("submittedCharterId", id_value(charter_id(2))); + }) + .await; + assert_not_found(result, "memberId", member); + } + + #[tokio::test] + async fn should_check_a_property_agreement_beside_the_lookup_against_the_resolved_document() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + fixture + .request_to_join(Who::Member, charter_id(1), "welcome") + .await; + + let (_, agreeing) = fixture + .create_charter( + charter_id(1), + &[ + ("title", "welcome".into()), + ("agreedMemberId", id_value(member)), + ], + ) + .await; + assert_matches!( + agreeing, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let (_, disagreeing) = fixture + .create_charter( + charter_id(1), + &[ + ("title", "goodbye".into()), + ("agreedMemberId", id_value(member)), + ], + ) + .await; + assert_matches!( + disagreeing, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(e) + ), + .. + } if e.path() == "agreedMemberId" + && e.referring_property() == "title" + && e.referenced_property() == "message" + ); + } + + #[tokio::test] + async fn should_resolve_a_lookup_whose_key_reads_the_writer() { + let mut fixture = LookupFixture::new(); + // `requestedCharterId` must be a charter the writer itself asked to join + fixture + .request_to_join(Who::Founder, charter_id(1), "my own") + .await; + + let (_, asked) = fixture + .create_charter( + charter_id(9), + &[("requestedCharterId", id_value(charter_id(1)))], + ) + .await; + assert_matches!( + asked, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let (_, never_asked) = fixture + .create_charter( + charter_id(9), + &[("requestedCharterId", id_value(charter_id(2)))], + ) + .await; + assert_not_found(never_asked, "requestedCharterId", charter_id(2)); + } + + #[tokio::test] + async fn should_read_a_nested_key_part() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + fixture + .request_to_join(Who::Member, charter_id(2), "let me in") + .await; + + // `metaMemberId` is keyed by `meta.charterId`, not by the charter's + // own `submittedCharterId` + let (_, nested) = fixture + .create_charter( + charter_id(1), + &[ + ("meta", platform_value!({ "charterId": charter_id(2) })), + ("metaMemberId", id_value(member)), + ], + ) + .await; + assert_matches!( + nested, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let (_, elsewhere) = fixture + .create_charter( + charter_id(2), + &[ + ("meta", platform_value!({ "charterId": charter_id(1) })), + ("metaMemberId", id_value(member)), + ], + ) + .await; + assert_not_found(elsewhere, "metaMemberId", member); + } + + /// The charter contract's `members`: every element must be the owner of a + /// join request for the charter's own submitted charter. + #[tokio::test] + async fn should_look_up_every_element_of_a_members_list() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + fixture + .request_to_join(Who::Member, charter_id(1), "let me in") + .await; + + // One element without a request refuses the write, the error naming + // the element by its list path + let (_, one_missing) = fixture + .create_charter( + charter_id(1), + &[( + "members", + Value::Array(vec![id_value(member), id_value(stranger)]), + )], + ) + .await; + assert_not_found(one_missing, "members[1]", stranger); + + fixture + .request_to_join(Who::Stranger, charter_id(1), "me too") + .await; + let (charter, all_asked) = fixture + .create_charter( + charter_id(1), + &[( + "members", + Value::Array(vec![id_value(member), id_value(stranger)]), + )], + ) + .await; + assert_matches!( + all_asked, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + // Moving the charter's key part re-validates every element, not only + // the ones the stored list did not hold + let moved = fixture + .replace(Who::Founder, "charter", &charter, |charter| { + charter.set("submittedCharterId", id_value(charter_id(2))); + }) + .await; + assert_not_found(moved, "members[0]", member); + } + + /// A lookup into another contract resolves in that contract's documents. + #[tokio::test] + async fn should_look_up_documents_of_another_contract() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + fixture + .request_to_join(Who::Member, charter_id(1), "let me in") + .await; + + let (_, voted) = fixture + .create( + Who::Founder, + "vote", + &[ + ("submittedCharterId", id_value(charter_id(1))), + ("voterId", id_value(member)), + ], + ) + .await; + assert_matches!( + voted, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let (_, not_a_member) = fixture + .create( + Who::Founder, + "vote", + &[ + ("submittedCharterId", id_value(charter_id(1))), + ("voterId", id_value(stranger)), + ], + ) + .await; + assert_not_found(not_a_member, "voterId", stranger); + } + + /// The lookup is billed like the id fetch it replaces: one document query + /// whose processing cost lands in the execution context. + #[tokio::test] + async fn should_bill_the_lookup_as_a_document_fetch() { + let mut fixture = LookupFixture::new(); + let platform_version = PlatformVersion::latest(); + let member = fixture.id(Who::Member); + let founder = fixture.id(Who::Founder); + let stranger = fixture.id(Who::Stranger); + fixture + .request_to_join(Who::Member, charter_id(1), "let me in") + .await; + + let (_, contract_fetch_info) = fixture + .platform + .drive + .get_contract_with_fetch_info_and_fee( + fixture.contract.id().to_buffer(), + None, + false, + None, + platform_version, + ) + .expect("expected to fetch the contract"); + let base = DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([0xAA; 32]), + identity_contract_nonce: 1, + document_type_name: "charter".to_string(), + data_contract: contract_fetch_info.expect("the contract is in state"), + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::default(), + contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, + }); + let platform_state = fixture.platform.state.load(); + let platform_ref = PlatformStateRef { + drive: &fixture.platform.drive, + state: &platform_state, + config: &fixture.platform.config, + }; + let validate = |data: BTreeMap| { + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("expected an execution context"); + let result = base + .validate_document_references( + &data, + founder, + // The charter type records no creator ids + None, + None, + // A create: there is no stored document + None, + &platform_ref, + &BlockInfo::default(), + None, + &mut execution_context, + platform_version, + ) + .expect("expected the references to be validated"); + (result, execution_context) + }; + + let charter = |member_id: Option| { + let mut data = BTreeMap::from([ + ("submittedCharterId".to_string(), id_value(charter_id(1))), + ("title".to_string(), Value::Text("charter".to_string())), + ]); + if let Some(member_id) = member_id { + data.insert("memberId".to_string(), id_value(member_id)); + } + data + }; + + let (result, execution_context) = validate(charter(Some(member))); + assert!(result.is_valid(), "{:?}", result.errors); + assert_matches!( + execution_context.operations_slice(), + [ValidationOperation::PrecalculatedOperation(fee)] if fee.processing_fee > 0, + "the lookup query is the one billed operation" + ); + + // A refused lookup is billed the same way + let (result, execution_context) = validate(charter(Some(stranger))); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::ReferencedEntityNotFoundError(_) + )] + ); + assert_matches!( + execution_context.operations_slice(), + [ValidationOperation::PrecalculatedOperation(fee)] if fee.processing_fee > 0 + ); + + // Without the reference there is nothing to look up + let (result, execution_context) = validate(charter(None)); + assert!(result.is_valid()); + assert!(execution_context.operations_slice().is_empty()); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index 01101b14570..23e8c230af1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -2,19 +2,127 @@ mod action_fees; mod creation; mod deletable_document_reference; mod deletion; +mod distinct_from; mod dpns; +mod encrypted_for; mod gas_sponsorship; mod id_reuse; mod immutable; mod index_only; mod keep_history; +mod lookup_reference; mod nft; +mod owner_balance_proof; mod ranked_group_drain; +mod reference_test_setup; mod replacement; mod required_since; mod system_agreement; mod transfer; +mod typed_array_references; use super::*; use crate::execution::validation::state_transition::tests::create_card_game_internal_token_contract_with_owner_identity_burn_tokens; + +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::TempPlatform; +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::contract_bounds::ContractBounds; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use dpp::version::PlatformVersion; + +pub(super) const REFERENCE_VALIDATION_IDENTITY_KEY_REQUIREMENTS_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-requirements.json"; + +/// The keys of the test identity the key-requirement tests can point at. The fixture's +/// `message.recipientId` requires a decryption key bound to the fixture contract's +/// `inbox` document type. Both fixture types declare +/// `requiresIdentityEncryptionBoundedKey` and `requiresIdentityDecryptionBoundedKey`, +/// without which Drive registers no encryption or decryption key bound to them. +pub(super) struct IdentityKeyRequirementTargets { + pub(super) identity_id: Identifier, + /// The critical authentication key the identity registered with: the wrong purpose + pub(super) authentication_key_id: KeyID, + /// A decryption key bound to (fixture contract, `inbox`): meets both requirements + pub(super) decryption_key_bound_to_inbox_id: KeyID, + /// An encryption key bound to (fixture contract, `inbox`): the wrong purpose + pub(super) encryption_key_bound_to_inbox_id: KeyID, + /// A decryption key bound to (fixture contract, `message`): the wrong document type + pub(super) decryption_key_bound_to_message_id: KeyID, + /// A decryption key without contract bounds + pub(super) unbound_decryption_key_id: KeyID, +} + +/// Adds the four keys of [`IdentityKeyRequirementTargets`] to `identity` in state, bound +/// to `contract_id` where bound, and returns the targets. +pub(super) fn add_identity_key_requirement_targets( + platform: &mut TempPlatform, + identity: &Identity, + authentication_key_id: KeyID, + contract_id: Identifier, + platform_version: &PlatformVersion, +) -> IdentityKeyRequirementTargets { + let key = |id: KeyID, purpose: Purpose, contract_bounds: Option| { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level: SecurityLevel::HIGH, + contract_bounds, + key_type: KeyType::ECDSA_HASH160, + data: BinaryData::new(vec![0x70 + id as u8; 20]), + read_only: false, + disabled_at: None, + }) + }; + let bound_to = |document_type_name: &str| { + Some(ContractBounds::SingleContractDocumentType { + id: contract_id, + document_type_name: document_type_name.to_string(), + }) + }; + + let targets = IdentityKeyRequirementTargets { + identity_id: identity.id(), + authentication_key_id, + decryption_key_bound_to_inbox_id: 2, + encryption_key_bound_to_inbox_id: 3, + decryption_key_bound_to_message_id: 4, + unbound_decryption_key_id: 5, + }; + + platform + .drive + .add_new_non_unique_keys_to_identity( + identity.id().to_buffer(), + vec![ + key( + targets.decryption_key_bound_to_inbox_id, + Purpose::DECRYPTION, + bound_to("inbox"), + ), + key( + targets.encryption_key_bound_to_inbox_id, + Purpose::ENCRYPTION, + bound_to("inbox"), + ), + key( + targets.decryption_key_bound_to_message_id, + Purpose::DECRYPTION, + bound_to("message"), + ), + key(targets.unbound_decryption_key_id, Purpose::DECRYPTION, None), + ], + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add the keys to the identity"); + + targets +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs new file mode 100644 index 00000000000..50c8891a575 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_balance_proof.rs @@ -0,0 +1,639 @@ +//! The proof waitForStateTransitionResult returns for a document batch +//! carries the credit balance of the batch's owner next to the document: the +//! prover merges the owner's balance into the document proof and the verifier +//! reads both from one state, so a wallet learns what the write left it with +//! without a second query. + +use super::*; +use crate::platform_types::platform_state::PlatformState; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::TempPlatform; +use dpp::data_contract::accessors::v0::DataContractV0Setters; +use dpp::document::Document; +use dpp::identifier::Identifier; +use dpp::identity::SecurityLevel; +use dpp::prelude::DataContract; +use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; +use dpp::state_transition::batch_transition::document_create_transition::DocumentCreateTransitionV0; +use dpp::state_transition::batch_transition::{BatchTransitionV0, DocumentCreateTransition}; +use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::state_transition::StateTransition; +use dpp::util::hash::hash_double; +use dpp::util::strings::convert_to_homograph_safe_chars; +use drive::drive::Drive; +use drive::query::{SingleDocumentDriveQuery, SingleDocumentDriveQueryContestedStatus}; +use std::collections::BTreeMap; +use std::sync::Arc; + +fn process_and_commit( + platform: &TempPlatform, + platform_state: &PlatformState, + transition: &StateTransition, + platform_version: &PlatformVersion, +) { + process_and_commit_at( + platform, + platform_state, + transition, + &BlockInfo::default(), + platform_version, + ) +} + +fn process_and_commit_at( + platform: &TempPlatform, + platform_state: &PlatformState, + transition: &StateTransition, + block_info: &BlockInfo, + platform_version: &PlatformVersion, +) { + let serialized = transition + .serialize_to_bytes() + .expect("expected the batch transition to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + platform_state, + block_info, + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the batch"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the batch"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "the batch must execute: {:?}", + processing_result.execution_results() + ); +} + +fn stored_balance( + platform: &TempPlatform, + identity_id: Identifier, + platform_version: &PlatformVersion, +) -> Credits { + platform + .drive + .fetch_identity_balance(identity_id.to_buffer(), None, platform_version) + .expect("expected to fetch the owner's balance") + .expect("the owner has a balance") +} + +/// Proves the executed batch the way the node answers a wait, and verifies +/// the proof the way the SDK does: the executed document and the owner's +/// balance, execution-proved. The balance is checked against the stored one. +fn prove_and_verify( + platform: &TempPlatform, + transition: &StateTransition, + contract: &Arc, + platform_version: &PlatformVersion, +) -> (BTreeMap>, Credits) { + let proof = platform + .drive + .prove_state_transition(transition, None, platform_version) + .expect("expected to prove the executed batch") + .into_data() + .expect("expected proof bytes"); + let lookup = |_id: &Identifier| Ok(Some(Arc::clone(contract))); + let (root_hash, outcome) = Drive::verify_state_transition_was_executed_with_proof( + transition, + &BlockInfo::default(), + proof.as_slice(), + &lookup, + platform_version, + ) + .expect("expected the batch proof to verify"); + assert_ne!(root_hash, [0u8; 32]); + assert!( + outcome.is_execution_proved(), + "expected an execution-proved result, got {outcome:?}" + ); + let owner_balance = outcome.owner_balance(); + let StateTransitionProofResult::VerifiedDocuments(documents) = outcome.into_result() else { + panic!("expected a documents result"); + }; + let owner_balance = owner_balance.expect("a batch proof carries the owner's balance"); + let owner_id = transition + .owner_id() + .expect("a document write has an owner"); + assert_eq!( + owner_balance, + stored_balance(platform, owner_id, platform_version), + "the proved balance is the owner's stored balance" + ); + (documents, owner_balance) +} + +#[tokio::test] +async fn should_prove_the_owner_balance_next_to_a_created_replaced_and_deleted_document() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let mut rng = StdRng::seed_from_u64(4799); + + let initial_credits = dash_to_credits!(0.1); + let (identity, signer, key) = setup_identity(&mut platform, 958, initial_credits); + + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(platform_version) + .expect("expected the dashpay system contract"); + let profile = dashpay + .document_type_for_name("profile") + .expect("expected a profile document type"); + + // ── create ──────────────────────────────────────────────────────── + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = profile + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document + .set_id_for_creation(profile, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); + + let create = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + profile, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + process_and_commit(&platform, &platform_state, &create, platform_version); + + let (documents, owner_balance) = + prove_and_verify(&platform, &create, &dashpay, platform_version); + let (proved_id, proved_document) = documents.into_iter().next().expect("one document"); + assert_eq!(proved_id, document.id()); + assert_eq!( + proved_document + .expect("the created profile is present") + .id(), + document.id() + ); + assert!( + owner_balance < initial_credits, + "the create's fee left the owner with less than it started with" + ); + let balance_after_create = owner_balance; + + // ── replace ─────────────────────────────────────────────────────── + document + .increment_revision() + .expect("expected to bump the revision"); + document.set("avatarUrl", "http://test.com/alice.jpg".into()); + let replace = BatchTransition::new_document_replacement_transition_from_document( + document.clone(), + profile, + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + process_and_commit(&platform, &platform_state, &replace, platform_version); + + let (documents, owner_balance) = + prove_and_verify(&platform, &replace, &dashpay, platform_version); + let (_, proved_document) = documents.into_iter().next().expect("one document"); + assert_eq!( + proved_document + .expect("the replaced profile is present") + .revision(), + document.revision() + ); + assert!( + owner_balance < balance_after_create, + "the replace's fee left the owner with less than after the create" + ); + + // ── delete ──────────────────────────────────────────────────────── + let delete = BatchTransition::new_document_deletion_transition_from_document( + document.clone(), + profile, + &key, + 4, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected the delete transition"); + process_and_commit(&platform, &platform_state, &delete, platform_version); + + let (documents, owner_balance) = + prove_and_verify(&platform, &delete, &dashpay, platform_version); + let (proved_id, proved_document) = documents.into_iter().next().expect("one entry"); + assert_eq!(proved_id, document.id()); + assert!( + proved_document.is_none(), + "the deleted profile is proven absent" + ); + let _ = owner_balance; +} + +/// A batch proof names the balance of the batch's owner: a proof made for +/// another identity's batch carries a different balance entry and does not +/// verify against this batch, even though the document it shows is the same +/// (the document part of that proof verifies on its own). Depending on what +/// the other owner's balance proof happens to cover, the strict verification +/// of the merged query either finds no data for the owner's key or proves it +/// absent, which the verifier reports as a missing balance. +#[tokio::test] +async fn should_reject_a_document_proof_that_carries_another_identitys_balance() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let mut rng = StdRng::seed_from_u64(4800); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + let (other, _other_signer, _other_key) = + setup_identity(&mut platform, 959, dash_to_credits!(0.1)); + + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(platform_version) + .expect("expected the dashpay system contract"); + let profile = dashpay + .document_type_for_name("profile") + .expect("expected a profile document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = profile + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document + .set_id_for_creation(profile, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); + + let create = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + profile, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + process_and_commit(&platform, &platform_state, &create, platform_version); + + // The same document, claimed by a batch of another owner: the node proves + // that batch's owner balance, which the verifier of the real batch does + // not find. + let StateTransition::Batch(mut misattributed) = create.clone() else { + panic!("a document create is a batch"); + }; + match &mut misattributed { + BatchTransition::V0(batch) => batch.owner_id = other.id(), + BatchTransition::V1(batch) => batch.owner_id = other.id(), + } + let misattributed = StateTransition::Batch(misattributed); + let proof = platform + .drive + .prove_state_transition(&misattributed, None, platform_version) + .expect("expected to prove the misattributed batch") + .into_data() + .expect("expected proof bytes"); + + // The document part of that proof is sound on its own. + let (_, proved_document) = SingleDocumentDriveQuery { + contract_id: dashpay.id().to_buffer(), + document_type_name: "profile".to_string(), + document_type_keeps_history: profile.documents_keep_history(), + document_id: document.id().to_buffer(), + block_time_ms: None, + contested_status: SingleDocumentDriveQueryContestedStatus::NotContested, + } + .verify_proof(true, proof.as_slice(), profile, platform_version) + .expect("the document part of the proof verifies on its own"); + assert_eq!( + proved_document.map(|document| document.id()), + Some(document.id()) + ); + + let lookup = |_id: &Identifier| Ok(Some(Arc::clone(&dashpay))); + let error = Drive::verify_state_transition_was_executed_with_proof( + &create, + &BlockInfo::default(), + proof.as_slice(), + &lookup, + platform_version, + ) + .expect_err("a proof of another owner's balance is not a proof of this batch"); + assert!( + matches!( + error, + drive::error::Error::GroveDB(_) + | drive::error::Error::Proof(drive::error::proof::ProofError::IncorrectProof(_)) + ), + "expected the owner's balance to be reported as not proven, got: {error:?}" + ); +} + +/// A keeps-history document type stores each revision under the document's +/// key, so its single-document query carries a sub-query: the merged proof +/// must still prove the latest revision next to the owner's balance, on the +/// create and on the replace. +#[tokio::test] +async fn should_prove_the_owner_balance_next_to_a_keeps_history_document() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let mut rng = StdRng::seed_from_u64(4801); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + + // The fixture predates the v14 schema rules, so it is loaded without + // validation; Drive stores and serves it all the same. + let mut contract = json_document_to_contract( + "tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted.json", + false, + platform_version, + ) + .expect("expected the keeps-history note contract"); + contract.set_owner_id(identity.id()); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + let contract = Arc::new(contract); + let person = contract + .document_type_for_name("note") + .expect("expected a note document type"); + assert!(person.documents_keep_history()); + + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = person + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random note"); + document + .set_id_for_creation(person, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + let create = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + person, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + process_and_commit(&platform, &platform_state, &create, platform_version); + let (documents, _) = prove_and_verify(&platform, &create, &contract, platform_version); + let (_, proved) = documents.into_iter().next().expect("one document"); + assert_eq!( + proved.expect("the created note is present").revision(), + document.revision() + ); + + document + .increment_revision() + .expect("expected to bump the revision"); + document.set("message", "the second revision".into()); + let replace = BatchTransition::new_document_replacement_transition_from_document( + document.clone(), + person, + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + process_and_commit(&platform, &platform_state, &replace, platform_version); + let (documents, _) = prove_and_verify(&platform, &replace, &contract, platform_version); + let (_, proved) = documents.into_iter().next().expect("one document"); + let proved = proved.expect("the replaced note is present"); + assert_eq!(proved.revision(), document.revision()); + assert_eq!( + proved.properties().get("message"), + document.properties().get("message"), + "the latest revision is the proven one" + ); +} + +/// A contested create (a prefunded voting balance) stores the document under +/// the vote poll's tree, another path the merged proof must carry next to the +/// owner's balance. +#[tokio::test] +async fn should_prove_the_owner_balance_next_to_a_contested_document() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let mut rng = StdRng::seed_from_u64(4802); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.5)); + let dpns = platform + .drive + .cache + .system_data_contracts + .load_dpns(platform_version) + .expect("expected the dpns system contract"); + let domain = dpns + .document_type_for_name("domain") + .expect("expected a domain document type"); + let preorder = dpns + .document_type_for_name("preorder") + .expect("expected a preorder document type"); + let platform_state = platform.state.load(); + + // DPNS registers a name in two steps: the salted preorder, then the domain. + let name = "quantum"; + let salt: [u8; 32] = rng.gen(); + let mut salted_domain = salt.to_vec(); + salted_domain.extend((convert_to_homograph_safe_chars(name) + ".dash").as_bytes()); + let preorder_entropy = Bytes32::random_with_rng(&mut rng); + let mut preorder_document = preorder + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + preorder_entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random preorder document"); + preorder_document.set("saltedDomainHash", hash_double(salted_domain).into()); + preorder_document + .set_id_for_creation(preorder, &preorder_entropy.0, 2, platform_version) + .expect("expected to set the preorder id"); + let preorder_create = BatchTransition::new_document_creation_transition_from_document( + preorder_document, + preorder, + preorder_entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected the preorder create transition"); + process_and_commit( + &platform, + &platform_state, + &preorder_create, + platform_version, + ); + + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = domain + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random domain document"); + document.set("parentDomainName", "dash".into()); + document.set("normalizedParentDomainName", "dash".into()); + document.set("label", name.into()); + document.set( + "normalizedLabel", + convert_to_homograph_safe_chars(name).into(), + ); + document.set("records.identity", document.owner_id().into()); + document.set("subdomainRules.allowSubdomains", false.into()); + document.set("preorderSalt", salt.into()); + document + .set_id_for_creation(domain, &entropy.0, 3, platform_version) + .expect("expected to set the document id"); + + let create_transition: DocumentCreateTransition = DocumentCreateTransitionV0 { + base: DocumentBaseTransition::from_document( + &document, + domain, + None, + 3, + platform_version, + None, + ) + .expect("expected a base transition"), + entropy: entropy.0, + data: document.clone().properties_consumed(), + prefunded_voting_balance: Some(( + "parentNameAndLabel".to_string(), + platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_vote_resolution_fund_required_amount, + )), + } + .into(); + let batch: BatchTransition = BatchTransitionV0 { + owner_id: identity.id(), + transitions: vec![create_transition.into()], + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + let mut create: StateTransition = batch.into(); + create + .sign_external(&key, &signer, Some(|_, _| Ok(SecurityLevel::HIGH))) + .await + .expect("expected to sign the contested create"); + + process_and_commit_at( + &platform, + &platform_state, + &create, + &BlockInfo::default_with_time(3000), + platform_version, + ); + + let (documents, _) = prove_and_verify(&platform, &create, &dpns, platform_version); + let (proved_id, proved) = documents.into_iter().next().expect("one document"); + assert_eq!(proved_id, document.id()); + assert_eq!( + proved.expect("the contested domain is present").id(), + document.id() + ); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_test_setup.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_test_setup.rs new file mode 100644 index 00000000000..58f8d43fd23 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_test_setup.rs @@ -0,0 +1,319 @@ +//! The ABCI harness the `refersTo` suites share: a platform at the latest +//! protocol version with one funded identity and a fixture contract applied +//! directly, and create, replace and delete transitions processed and +//! committed one at a time. + +use super::*; +use crate::platform_types::platform_state::PlatformState; +use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::TempPlatform; +use dpp::data_contract::accessors::v0::DataContractV0Setters; +use dpp::document::Document; +use dpp::identifier::Identifier; +use dpp::identity::signer::Signer; +use dpp::identity::IdentityPublicKey; +use dpp::prelude::DataContract; +use dpp::state_transition::StateTransition; +use simple_signer::signer::SimpleSigner; +use std::sync::Arc; + +/// Applies the fixture contract at `path` owned by `owner_id`, parsed with +/// full validation when `validate` is set. +pub(super) fn register_contract_at( + platform: &TempPlatform, + path: &str, + owner_id: Identifier, + validate: bool, + platform_version: &PlatformVersion, +) -> DataContract { + let mut contract = json_document_to_contract(path, validate, platform_version) + .expect("expected to parse the fixture contract"); + contract.set_owner_id(owner_id); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the fixture contract"); + contract +} + +pub(super) fn process_and_commit( + platform: &TempPlatform, + platform_state: &PlatformState, + transition: &StateTransition, + platform_version: &PlatformVersion, +) -> StateTransitionsProcessingResult { + let serialized = transition + .serialize_to_bytes() + .expect("expected the batch transition to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + processing_result +} + +pub(super) fn assert_successful(result: &StateTransitionsProcessingResult, because: &str) { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "{because}" + ); +} + +/// Creates a document of `type_name` with exactly `properties` set and +/// returns it with the processing result. +#[allow(clippy::too_many_arguments)] +pub(super) async fn create_document>( + platform: &TempPlatform, + platform_state: &PlatformState, + contract: &DataContract, + type_name: &str, + properties: &[(&str, Value)], + owner: Identifier, + key: &IdentityPublicKey, + nonce: u64, + signer: &S, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> (Document, StateTransitionsProcessingResult) { + let document_type = contract + .document_type_for_name(type_name) + .expect("doctype exists"); + let entropy = Bytes32::random_with_rng(rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + rng, + owner, + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + for (property, value) in properties { + document.set(property, value.clone()); + } + // The id commits to the create transition's nonce: give the local + // copy the id the transition will carry, since the tests reference + // and act on it afterwards. + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected the creation id"); + let create = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + let result = process_and_commit(platform, platform_state, &create, platform_version); + (document, result) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn replace_document>( + platform: &TempPlatform, + platform_state: &PlatformState, + contract: &DataContract, + type_name: &str, + document: &Document, + key: &IdentityPublicKey, + nonce: u64, + signer: &S, + platform_version: &PlatformVersion, +) -> StateTransitionsProcessingResult { + let document_type = contract + .document_type_for_name(type_name) + .expect("doctype exists"); + let replace = BatchTransition::new_document_replacement_transition_from_document( + document.clone(), + document_type, + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + process_and_commit(platform, platform_state, &replace, platform_version) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn delete_document>( + platform: &TempPlatform, + platform_state: &PlatformState, + contract: &DataContract, + type_name: &str, + document: &Document, + key: &IdentityPublicKey, + nonce: u64, + signer: &S, + platform_version: &PlatformVersion, +) -> StateTransitionsProcessingResult { + let document_type = contract + .document_type_for_name(type_name) + .expect("doctype exists"); + let delete = BatchTransition::new_document_deletion_transition_from_document( + document.clone(), + document_type, + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected the delete transition"); + process_and_commit(platform, platform_state, &delete, platform_version) +} + +/// A fresh platform with one funded identity owning the fixture contract at +/// `contract_path`, and the nonce and randomness its transitions use. +pub(super) struct ReferenceTestSetup { + pub(super) platform: TempPlatform, + pub(super) platform_state: Arc, + pub(super) contract: DataContract, + pub(super) owner: Identifier, + pub(super) signer: SimpleSigner, + pub(super) key: IdentityPublicKey, + pub(super) rng: StdRng, + pub(super) nonce: u64, +} + +impl ReferenceTestSetup { + pub(super) fn new(contract_path: &str, seed: u64) -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load_full(); + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let contract = register_contract_at( + &platform, + contract_path, + identity.id(), + true, + platform_version, + ); + Self { + platform, + platform_state, + contract, + owner: identity.id(), + signer, + key, + rng: StdRng::seed_from_u64(seed), + nonce: 1, + } + } + + pub(super) fn next_nonce(&mut self) -> u64 { + self.nonce += 1; + self.nonce + } + + pub(super) async fn create( + &mut self, + type_name: &str, + properties: &[(&str, Value)], + ) -> (Document, StateTransitionsProcessingResult) { + let nonce = self.next_nonce(); + create_document( + &self.platform, + &self.platform_state, + &self.contract, + type_name, + properties, + self.owner, + &self.key, + nonce, + &self.signer, + &mut self.rng, + PlatformVersion::latest(), + ) + .await + } + + /// Bumps the revision and replaces `document` as it now stands. + pub(super) async fn replace( + &mut self, + type_name: &str, + document: &mut Document, + ) -> StateTransitionsProcessingResult { + document.increment_revision().expect("revision increments"); + let nonce = self.next_nonce(); + let result = replace_document( + &self.platform, + &self.platform_state, + &self.contract, + type_name, + document, + &self.key, + nonce, + &self.signer, + PlatformVersion::latest(), + ) + .await; + // A refused replace leaves the stored revision where it was. + if result.valid_count() == 0 { + let revision = document.revision().expect("revision set"); + document.set_revision(Some(revision - 1)); + } + result + } + + pub(super) async fn delete( + &mut self, + type_name: &str, + document: &Document, + ) -> StateTransitionsProcessingResult { + let nonce = self.next_nonce(); + delete_document( + &self.platform, + &self.platform_state, + &self.contract, + type_name, + document, + &self.key, + nonce, + &self.signer, + PlatformVersion::latest(), + ) + .await + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index 0bbf619b864..13c73623208 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -3417,6 +3417,230 @@ mod replacement_tests { .clone() } + /// Registers the key-requirements fixture contract, adds the keys of + /// [`IdentityKeyRequirementTargets`] to the test identity, creates a `message` + /// document referencing the key that meets the requirements (asserting success), + /// then replaces it shaped by `replace_mutator` and returns the replace execution + /// result. + async fn run_identity_key_requirement_create_then_replace( + replace_mutator: R, + ) -> StateTransitionExecutionResult + where + R: FnOnce(&mut Document, &IdentityKeyRequirementTargets), + { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(433); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_IDENTITY_KEY_REQUIREMENTS_CONTRACT_PATH, + None, + None, + None::, + None, + None, + ); + + let targets = add_identity_key_requirement_targets( + &mut platform, + &identity, + key.id(), + contract.id(), + platform_version, + ); + + let message = contract + .document_type_for_name("message") + .expect("expected a message document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = message + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + document + .set_id_for_creation(message, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + document.set("recipientId", targets.identity_id.into()); + document.set( + "recipientKeyId", + (targets.decryption_key_bound_to_inbox_id as i64).into(), + ); + document.set("note", "hello".into()); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document.clone(), + message, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_create_serialized_transition = documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_create_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + document.increment_revision().unwrap(); + replace_mutator(&mut document, &targets); + + let documents_batch_replace_transition = + BatchTransition::new_document_replacement_transition_from_document( + document, + message, + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_replace_serialized_transition = documents_batch_replace_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_replace_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone() + } + + /// keyRequirements on replace: repointing the reference at a key that + /// fails a requirement is refused, through the key id alone. + #[tokio::test] + async fn should_document_replace_fail_when_repointed_at_a_key_that_fails_the_requirement() { + let result = run_identity_key_requirement_create_then_replace(|document, targets| { + document.set( + "recipientKeyId", + (targets.encryption_key_bound_to_inbox_id as i64).into(), + ); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedIdentityKeyRequirementNotMetError(ref e) + ), + .. + } if e.document_type_name() == "message" + && e.path() == "recipientId" + && e.field() == "purpose" + && e.required() == "decryption" + && e.actual() == "encryption" + ); + } + + /// keyRequirements on replace: a replace that leaves the reference and its + /// key id alone is not re-checked, and one that repoints it at another key + /// meeting the requirements passes. + #[tokio::test] + async fn should_document_replace_succeed_when_the_reference_is_untouched_or_still_met() { + let result = run_identity_key_requirement_create_then_replace(|document, _| { + document.set("note", "changed".into()); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = run_identity_key_requirement_create_then_replace(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + document.set( + "recipientKeyId", + (targets.decryption_key_bound_to_inbox_id as i64).into(), + ); + document.set("note", "changed".into()); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + /// identityPublicKey on replace: changing only the key id property while /// leaving the identity id untouched must re-validate the reference: /// the referenced key is the (identity id, key id) pair, so the @@ -3488,4 +3712,698 @@ mod replacement_tests { StateTransitionExecutionResult::SuccessfulExecution { .. } ); } + + const REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key.json"; + + /// Committed state the key id reference replace tests can point at: the + /// writer and a second identity, each with an enabled critical key and a + /// master key (key 0) that the helper may disable between the create and + /// the replace. + struct KeyIdReferenceTargets { + writer_id: Identifier, + enabled_key_id: KeyID, + master_key_id: KeyID, + other_id: Identifier, + other_enabled_key_id: KeyID, + } + + /// Whose master key the helper disables between the create and the + /// replace, so that a replace refetching the key is observable. + enum DisableMasterKeyBetween { + Nobody, + Writer, + Other, + } + + /// Registers the key id reference fixture at `contract_path` (a `message` + /// type whose key id property carries `refersTo: identityPublicKey` with + /// an `identityProperty`), creates a `message` document shaped by + /// `create_mutator` (asserting success), disables the chosen master key + /// in state, replaces the document shaped by `replace_mutator` and + /// returns the replace execution result. Master keys are enabled at + /// create time, so a create may reference one. + async fn run_key_id_reference_create_then_replace( + contract_path: &str, + create_mutator: C, + disable_between: DisableMasterKeyBetween, + replace_mutator: R, + ) -> StateTransitionExecutionResult + where + C: FnOnce(&mut Document, &KeyIdReferenceTargets), + R: FnOnce(&mut Document, &KeyIdReferenceTargets), + { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(434); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 959, dash_to_credits!(0.1)); + let (other, _, other_key) = setup_identity(&mut platform, 452, dash_to_credits!(0.1)); + + let targets = KeyIdReferenceTargets { + writer_id: identity.id(), + enabled_key_id: key.id(), + master_key_id: 0, + other_id: other.id(), + other_enabled_key_id: other_key.id(), + }; + + let contract = setup_contract( + &platform.drive, + contract_path, + None, + None, + None::, + None, + None, + ); + + let message = contract + .document_type_for_name("message") + .expect("expected a message document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = message + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + // The key id property is optional; each test sets what it + // exercises + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + document + .set_id_for_creation(message, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + create_mutator(&mut document, &targets); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document.clone(), + message, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_create_serialized_transition = documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_create_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let disabled_identity = match disable_between { + DisableMasterKeyBetween::Nobody => None, + DisableMasterKeyBetween::Writer => Some(identity.id()), + DisableMasterKeyBetween::Other => Some(other.id()), + }; + if let Some(identity_id) = disabled_identity { + // Documents are signed with the critical key, so the replace + // below stays valid + platform + .drive + .disable_identity_keys( + identity_id.to_buffer(), + vec![0], + 1, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to disable the master key"); + } + + document.increment_revision().unwrap(); + replace_mutator(&mut document, &targets); + + let documents_batch_replace_transition = + BatchTransition::new_document_replacement_transition_from_document( + document, + message, + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_replace_serialized_transition = documents_batch_replace_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_replace_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone() + } + + /// The key id form on replace: a changed key id is re-validated against + /// the owner's keys, the identity being the writer by construction. + #[tokio::test] + async fn should_document_replace_fail_when_owner_key_id_changed_to_a_missing_key() { + let result = run_key_id_reference_create_then_replace( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.enabled_key_id as i64).into()); + }, + DisableMasterKeyBetween::Nobody, + |document, _| { + document.set("senderKeyId", 99i64.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + e + )), + .. + } if e.key_id() == 99 && e.path() == "senderKeyId" + ); + } + + #[tokio::test] + async fn should_document_replace_fail_when_owner_key_id_changed_to_a_disabled_key() { + let result = run_key_id_reference_create_then_replace( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.enabled_key_id as i64).into()); + }, + DisableMasterKeyBetween::Writer, + |document, targets| { + document.set("senderKeyId", (targets.master_key_id as i64).into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyDisabledError( + _ + )), + .. + } + ); + } + + /// An untouched key id is re-validated on every replace, as the writer + /// gate is: the identity is the writer, which is transition metadata and + /// never among the changed fields. The document was created naming the + /// master key while it was enabled, the key is disabled before the + /// replace, and a replace of another property is refused. + #[tokio::test] + async fn should_document_replace_fail_when_untouched_owner_key_id_names_a_now_disabled_key() { + let result = run_key_id_reference_create_then_replace( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.master_key_id as i64).into()); + }, + DisableMasterKeyBetween::Writer, + |document, _| { + document.set("note", "changed".into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyDisabledError( + _ + )), + .. + } + ); + } + + /// The same untouched replace passes while the key stays enabled: the + /// refetch is a check, not a change. + #[tokio::test] + async fn should_document_replace_succeed_when_untouched_owner_key_id_still_names_an_enabled_key( + ) { + let result = run_key_id_reference_create_then_replace( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.enabled_key_id as i64).into()); + }, + DisableMasterKeyBetween::Writer, + |document, _| { + document.set("note", "changed".into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + const REFERENCE_VALIDATION_OWNER_KEY_TRANSFERABLE_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-transferable.json"; + + /// The identities of a transfer: the writer (creator), whose master key + /// stays enabled, and the receiver, whose master key is disabled. + struct TransferTargets { + writer_id: Identifier, + receiver_id: Identifier, + receiver_key_id: KeyID, + } + + /// A transferable key id reference fixture at `contract_path`: the writer + /// creates a `message` naming its own master key (key 0, enabled) as + /// `senderKeyId`, transfers it to a receiver whose master key was + /// disabled in state beforehand, and the receiver replaces it shaped by + /// `replace_mutator`. Returns the replace execution result and the + /// identities. + async fn run_key_id_reference_create_transfer_then_replace( + contract_path: &str, + replace_mutator: R, + ) -> (StateTransitionExecutionResult, TransferTargets) + where + R: FnOnce(&mut Document, &TransferTargets), + { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(435); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 959, dash_to_credits!(0.1)); + let (receiver, receiver_signer, receiver_key) = + setup_identity(&mut platform, 451, dash_to_credits!(0.1)); + + // The receiver's master key is disabled, so a key id of 0 names an + // enabled key of the writer and a disabled key of the receiver + platform + .drive + .disable_identity_keys( + receiver.id().to_buffer(), + vec![0], + 1, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to disable the receiver's master key"); + + let contract = setup_contract( + &platform.drive, + contract_path, + None, + None, + None::, + None, + None, + ); + + let targets = TransferTargets { + writer_id: identity.id(), + receiver_id: receiver.id(), + receiver_key_id: receiver_key.id(), + }; + + let message = contract + .document_type_for_name("message") + .expect("expected a message document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = message + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + document + .set_id_for_creation(message, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + document.set("senderKeyId", 0i64.into()); + + let create_transition = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + message, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[create_transition + .serialize_to_bytes() + .expect("expected a serialized create transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // The transfer is not a reference check: the document changes hands + // with its key id as written + document.set_revision(Some(2)); + let transfer_transition = BatchTransition::new_document_transfer_transition_from_document( + document.clone(), + message, + receiver.id(), + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition for transfer"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[transfer_transition + .serialize_to_bytes() + .expect("expected a serialized transfer transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + document.set_owner_id(receiver.id()); + document.set_revision(Some(3)); + replace_mutator(&mut document, &targets); + + let replace_transition = + BatchTransition::new_document_replacement_transition_from_document( + document, + message, + &receiver_key, + 1, + 0, + None, + &receiver_signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[replace_transition + .serialize_to_bytes() + .expect("expected a serialized replace transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + ( + processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone(), + targets, + ) + } + + /// After a transfer the writer is the receiver, and the replace + /// re-validates the reference against it whether or not the key id + /// changed: the stored key id names the receiver's disabled master key, + /// and a replace of another property is refused. + #[tokio::test] + async fn should_document_replace_fail_after_transfer_when_untouched_owner_key_id_is_not_the_new_owners_key( + ) { + let (result, targets) = run_key_id_reference_create_transfer_then_replace( + REFERENCE_VALIDATION_OWNER_KEY_TRANSFERABLE_CONTRACT_PATH, + |document, _| { + document.set("note", "changed by the receiver".into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyDisabledError( + e + )), + .. + } if *e.identity_id() == targets.receiver_id + ); + } + + #[tokio::test] + async fn should_document_replace_succeed_after_transfer_when_owner_key_id_is_repointed_at_the_new_owners_key( + ) { + let (result, _) = run_key_id_reference_create_transfer_then_replace( + REFERENCE_VALIDATION_OWNER_KEY_TRANSFERABLE_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.receiver_key_id as i64).into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + const REFERENCE_VALIDATION_CREATOR_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key.json"; + const REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key.json"; + + /// `$creatorId` follows the creator through a transfer: the receiver may + /// repoint the key id at the creator's master key (enabled on the + /// creator, disabled on the receiver), which the owner form would refuse. + #[tokio::test] + async fn should_document_replace_succeed_after_transfer_when_creator_key_id_names_the_creators_key( + ) { + let (result, _) = run_key_id_reference_create_transfer_then_replace( + REFERENCE_VALIDATION_CREATOR_KEY_CONTRACT_PATH, + |document, _| { + document.set("senderKeyId", 0i64.into()); + document.set("note", "changed by the receiver".into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_replace_fail_after_transfer_when_creator_key_id_names_a_key_the_creator_lacks( + ) { + let (result, targets) = run_key_id_reference_create_transfer_then_replace( + REFERENCE_VALIDATION_CREATOR_KEY_CONTRACT_PATH, + |document, _| { + document.set("senderKeyId", 99i64.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + e + )), + .. + } if *e.identity_id() == targets.writer_id && e.key_id() == 99 + ); + } + + /// A property path binds both sides of the pair: changing only the + /// identity property re-validates the key id against the new identity. + #[tokio::test] + async fn should_document_replace_fail_when_only_the_identity_property_changed_to_an_identity_without_the_key( + ) { + let result = run_key_id_reference_create_then_replace( + REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH, + |document, targets| { + document.set("toUserId", targets.other_id.into()); + document.set( + "recipientKeyId", + (targets.other_enabled_key_id as i64).into(), + ); + }, + DisableMasterKeyBetween::Nobody, + |document, _| { + document.set("toUserId", Identifier::random().into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + _ + )), + .. + } + ); + } + + /// An untouched pair is not refetched: the named identity's key is + /// disabled between the create and a replace of another property, and + /// the replace still passes. + #[tokio::test] + async fn should_document_replace_succeed_without_refetching_an_untouched_identity_property_pair( + ) { + let result = run_key_id_reference_create_then_replace( + REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH, + |document, targets| { + document.set("toUserId", targets.other_id.into()); + document.set("recipientKeyId", (targets.master_key_id as i64).into()); + }, + DisableMasterKeyBetween::Other, + |document, _| { + document.set("note", "changed".into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/typed_array_references.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/typed_array_references.rs new file mode 100644 index 00000000000..87df7652ed8 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/typed_array_references.rs @@ -0,0 +1,572 @@ +//! `refersTo` on the elements of a typed array through the full ABCI +//! pipeline. The fixture's `submittedCharter.reasons` is a list of +//! `permanentDocument` references to `reason`, `gatedCharter.reasons` binds +//! the WRITER to every referenced reason's owner, `topicCharter.reasons` +//! binds the charter's `topic` to every reason's, `draftList.drafts` is a +//! list of `deletableDocument` references to `draft`, `nestedCharter` holds +//! its list inside the `team` object, `foreignCharter.reasons` refers to the +//! `note` documents of the permanent-document foreign fixture contract, +//! `contractList.contracts` to contracts the writer owns, and +//! `repeatCharter.reasons` may repeat an element. `plainCharter` and +//! `repeatPlainCh` have the shapes of `submittedCharter` and +//! `repeatCharter` without `refersTo`, as fee baselines. +//! +//! Each element is checked as a single reference is, in list order, and the +//! first one that fails refuses the write with the single reference's +//! error, naming the element by its list path. + +use super::*; + +mod typed_array_reference_tests { + use super::super::reference_test_setup::{ + assert_successful, create_document, register_contract_at, ReferenceTestSetup as Setup, + }; + use super::*; + use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; + use dpp::consensus::codes::ErrorWithCode; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::identifier::Identifier; + use dpp::prelude::DataContract; + + /// Shared with the contract-create registration test, which pins that + /// the declarations themselves register. + const CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json"; + + /// The contract `foreignCharter` refers into, applied with an owner + /// other than the writer's. It declares an empty `indices` list, which + /// only a non-validating load admits, as its own registration test loads + /// it. + const FOREIGN_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-permanent-doc-foreign.json"; + + /// The write was refused because the referenced entity `missing` named at + /// `path` does not exist (code 40120). + fn assert_element_not_found( + result: &StateTransitionsProcessingResult, + path: &str, + missing: Identifier, + because: &str, + ) { + let [StateTransitionExecutionResult::PaidConsensusError { error, .. }] = + result.execution_results().as_slice() + else { + panic!("{because}: expected one paid consensus error, got {result:?}"); + }; + assert_eq!(error.code(), 40120, "{because}: {error}"); + assert_matches!( + error, + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(not_found)) + if not_found.path() == path && *not_found.entity_id() == missing, + "{because}" + ); + } + + /// A list of identifiers as a document stores it. + fn identifier_list(ids: &[Identifier]) -> Value { + Value::Array( + ids.iter() + .map(|id| Value::Identifier(id.to_buffer())) + .collect(), + ) + } + + impl Setup { + /// A reason owned by the setup's identity. + async fn reason(&mut self, topic: &str) -> Identifier { + let (reason, result) = self.create("reason", &[("topic", topic.into())]).await; + assert_successful(&result, "the reason is created"); + reason.id() + } + + async fn reasons(&mut self, count: usize) -> Vec { + let mut reasons = Vec::with_capacity(count); + for _ in 0..count { + reasons.push(self.reason("dash").await); + } + reasons + } + + /// The foreign fixture contract, owned by an identity other than + /// the writer, with `count` notes the writer creates in it. + async fn foreign_notes(&mut self, count: usize) -> (DataContract, Vec) { + let platform_version = PlatformVersion::latest(); + let foreign = register_contract_at( + &self.platform, + FOREIGN_CONTRACT_PATH, + Identifier::new([7; 32]), + false, + platform_version, + ); + let mut notes = Vec::with_capacity(count); + // Identity contract nonces count per contract + for nonce in 2..2 + count as u64 { + let (note, result) = create_document( + &self.platform, + &self.platform_state, + &foreign, + "note", + &[("content", "dash".into())], + self.owner, + &self.key, + nonce, + &self.signer, + &mut self.rng, + platform_version, + ) + .await; + assert_successful(&result, "the foreign note is created"); + notes.push(note.id()); + } + (foreign, notes) + } + } + + #[tokio::test] + async fn should_accept_a_list_whose_every_element_exists() { + let mut setup = Setup::new(CONTRACT_PATH, 8101); + let reasons = setup.reasons(3).await; + + let (_, result) = setup + .create( + "submittedCharter", + &[ + ("reasons", identifier_list(&reasons)), + ("title", "a charter".into()), + ], + ) + .await; + assert_successful(&result, "every referenced reason exists"); + } + + #[tokio::test] + async fn should_refuse_a_list_whose_third_element_is_missing_naming_that_element() { + let mut setup = Setup::new(CONTRACT_PATH, 8102); + let reasons = setup.reasons(2).await; + let missing = Identifier::random_with_rng(&mut setup.rng); + + let (_, result) = setup + .create( + "submittedCharter", + &[( + "reasons", + identifier_list(&[reasons[0], reasons[1], missing]), + )], + ) + .await; + assert_element_not_found( + &result, + "reasons[2]", + missing, + "the third reason does not exist", + ); + } + + #[tokio::test] + async fn should_accept_an_empty_list() { + let mut setup = Setup::new(CONTRACT_PATH, 8103); + + let (_, result) = setup + .create("submittedCharter", &[("reasons", identifier_list(&[]))]) + .await; + assert_successful(&result, "an empty list checks nothing"); + } + + /// A writer gate on the elements holds for every element: the writer + /// must own each referenced reason. + #[tokio::test] + async fn should_refuse_an_element_writer_gate_when_the_writer_does_not_own_a_referenced_document( + ) { + let platform_version = PlatformVersion::latest(); + let mut setup = Setup::new(CONTRACT_PATH, 8104); + let own_reason = setup.reason("dash").await; + let (bob, bob_signer, bob_key) = + setup_identity(&mut setup.platform, 450, dash_to_credits!(1.0)); + let (bobs_reason, result) = create_document( + &setup.platform, + &setup.platform_state, + &setup.contract, + "reason", + &[("topic", "dash".into())], + bob.id(), + &bob_key, + 2, + &bob_signer, + &mut setup.rng, + platform_version, + ) + .await; + assert_successful(&result, "bob's reason is created"); + + let (_, result) = setup + .create( + "gatedCharter", + &[("reasons", identifier_list(&[own_reason]))], + ) + .await; + assert_successful(&result, "the writer owns every referenced reason"); + + let (_, result) = setup + .create( + "gatedCharter", + &[("reasons", identifier_list(&[own_reason, bobs_reason.id()]))], + ) + .await; + let [StateTransitionExecutionResult::PaidConsensusError { error, .. }] = + result.execution_results().as_slice() + else { + panic!("expected one paid consensus error, got {result:?}"); + }; + assert_eq!(error.code(), 40127, "{error}"); + assert_matches!( + error, + ConsensusError::StateError(StateError::ReferencedDocumentPropertyMismatchError( + mismatch + )) if mismatch.path() == "reasons[1]" + && mismatch.referring_property() == "$ownerId" + && mismatch.referenced_property() == "$ownerId", + "the second reason is bob's" + ); + } + + /// The referring side of an element agreement is a property of the + /// charter, the same for every element; the referenced side is that + /// element's reason's property. + #[tokio::test] + async fn should_hold_an_element_agreement_between_the_document_and_every_referenced_document() { + let mut setup = Setup::new(CONTRACT_PATH, 8105); + let dash = setup.reason("dash").await; + let other_dash = setup.reason("dash").await; + let btc = setup.reason("btc").await; + + let (mut charter, result) = setup + .create( + "topicCharter", + &[ + ("reasons", identifier_list(&[dash, other_dash])), + ("topic", "dash".into()), + ], + ) + .await; + assert_successful(&result, "every reason is about dash"); + + let (_, result) = setup + .create( + "topicCharter", + &[ + ("reasons", identifier_list(&[dash, btc])), + ("topic", "dash".into()), + ], + ) + .await; + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(mismatch) + ), + .. + }] if mismatch.path() == "reasons[1]", + "the second reason is about btc" + ); + + // Changing only the bound property re-validates every element + charter.set("topic", "btc".into()); + let result = setup.replace("topicCharter", &mut charter).await; + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(mismatch) + ), + .. + }] if mismatch.path() == "reasons[0]", + "the reasons stay about dash" + ); + } + + #[tokio::test] + async fn should_refuse_a_replace_that_adds_a_missing_element() { + let mut setup = Setup::new(CONTRACT_PATH, 8106); + let reasons = setup.reasons(2).await; + let missing = Identifier::random_with_rng(&mut setup.rng); + + let (mut charter, result) = setup + .create( + "submittedCharter", + &[("reasons", identifier_list(&reasons))], + ) + .await; + assert_successful(&result, "the charter is created"); + + charter.set( + "reasons", + identifier_list(&[reasons[0], reasons[1], missing]), + ); + let result = setup.replace("submittedCharter", &mut charter).await; + assert_element_not_found( + &result, + "reasons[2]", + missing, + "the added reason does not exist", + ); + + let another = setup.reason("dash").await; + charter.set( + "reasons", + identifier_list(&[reasons[0], reasons[1], another]), + ); + let result = setup.replace("submittedCharter", &mut charter).await; + assert_successful(&result, "adding a reason that exists is fine"); + } + + /// Every replace re-validates a list of deletableDocument references, + /// touched or not, as it does a single one: a dead element has to be + /// dropped or repointed before the document can be replaced. + #[tokio::test] + async fn should_refuse_an_unrelated_replace_while_a_deletable_element_is_dead() { + let mut setup = Setup::new(CONTRACT_PATH, 8107); + let (first, result) = setup.create("draft", &[("topic", "dash".into())]).await; + assert_successful(&result, "the first draft is created"); + let (second, result) = setup.create("draft", &[("topic", "dash".into())]).await; + assert_successful(&result, "the second draft is created"); + + let (mut list, result) = setup + .create( + "draftList", + &[ + ("drafts", identifier_list(&[first.id(), second.id()])), + ("body", "first".into()), + ], + ) + .await; + assert_successful(&result, "the list is created"); + + list.set("body", "second".into()); + let result = setup.replace("draftList", &mut list).await; + assert_successful(&result, "a replace passes while both drafts exist"); + + let result = setup.delete("draft", &second).await; + assert_successful(&result, "a referenced draft can still be deleted"); + + // Only `body` changes, but every element is re-validated anyway + list.set("body", "third".into()); + let result = setup.replace("draftList", &mut list).await; + assert_element_not_found( + &result, + "drafts[1]", + second.id(), + "a replace may not leave an element on the deleted draft", + ); + + list.set("drafts", identifier_list(&[first.id()])); + let result = setup.replace("draftList", &mut list).await; + assert_successful(&result, "dropping the dead element repairs the list"); + } + + /// The processing fee of creating a `type_name` document whose `reasons` + /// list holds `pick` of four own reasons, or of four foreign notes, on a + /// fresh platform with the same history every time (both sets are always + /// created), so that only the list differs between two runs. + async fn creation_fee(type_name: &str, pick: &[usize], foreign: bool) -> i128 { + let mut setup = Setup::new(CONTRACT_PATH, 8108); + let reasons = setup.reasons(4).await; + let (_, notes) = setup.foreign_notes(4).await; + let source = if foreign { ¬es } else { &reasons }; + let list: Vec = pick.iter().map(|index| source[*index]).collect(); + let (_, result) = setup + .create(type_name, &[("reasons", identifier_list(&list))]) + .await; + assert_successful(&result, "the list is created"); + i128::from(result.aggregated_fees().processing_fee) + } + + /// Each element is a billed read: what a list of references costs over + /// the same list without `refersTo` grows with the element count. + #[tokio::test] + async fn should_charge_more_for_more_referenced_elements() { + let mut surcharges = Vec::new(); + let picks: [&[usize]; 4] = [&[], &[0], &[0, 1], &[0, 1, 2, 3]]; + for pick in picks { + let referenced = creation_fee("submittedCharter", pick, false).await; + let plain = creation_fee("plainCharter", pick, false).await; + surcharges.push(referenced - plain); + } + // With no element there is nothing to read: what is left is the + // two document types' own difference + assert!( + surcharges.windows(2).all(|pair| pair[0] < pair[1]), + "the surcharge grows with every element: {surcharges:?}" + ); + } + + /// The elements of one list share their declaration, so a foreign + /// contract holding the referenced document type is fetched, and billed, + /// once per list: three foreign elements cost one contract fetch over + /// three own ones, as one does over one. + #[tokio::test] + async fn should_fetch_a_foreign_contract_once_per_list() { + let one = creation_fee("foreignCharter", &[0], true).await + - creation_fee("submittedCharter", &[0], false).await; + let three = creation_fee("foreignCharter", &[0, 1, 2], true).await + - creation_fee("submittedCharter", &[0, 1, 2], false).await; + + let platform_version = PlatformVersion::latest(); + let setup = Setup::new(CONTRACT_PATH, 8109); + let foreign = register_contract_at( + &setup.platform, + FOREIGN_CONTRACT_PATH, + Identifier::new([7; 32]), + false, + platform_version, + ); + let (fee, _) = setup + .platform + .drive + .get_contract_with_fetch_info_and_fee( + foreign.id().to_buffer(), + Some(&BlockInfo::default().epoch), + false, + None, + platform_version, + ) + .expect("expected to fetch the foreign contract"); + let contract_fetch = i128::from( + fee.expect("a fetch with an epoch carries its fee") + .processing_fee, + ); + + assert!( + (three - one).abs() < contract_fetch, + "two more foreign elements cost {} over their own counterparts, less than one \ + contract fetch ({contract_fetch})", + three - one + ); + } + + /// An element repeating an earlier one of the same list has that one's + /// outcome, so it is not fetched, or billed, again. + #[tokio::test] + async fn should_fetch_a_repeated_element_once() { + let mut surcharges = Vec::new(); + let picks: [&[usize]; 3] = [&[], &[0], &[0, 0]]; + for pick in picks { + surcharges.push( + creation_fee("repeatCharter", pick, false).await + - creation_fee("repeatPlainCh", pick, false).await, + ); + } + let one_fetch = surcharges[1] - surcharges[0]; + let second_copy = surcharges[2] - surcharges[1]; + assert!( + second_copy < one_fetch / 2, + "the repeated element costs {second_copy}, a fetch costs {one_fetch}" + ); + } + + /// A replace that only adds an element checks the new one: the elements + /// the stored list already held are unchanged references, and an + /// unchanged single reference is not re-validated either, so a reason + /// that stopped agreeing since it was written does not block the list. + #[tokio::test] + async fn should_leave_the_elements_the_stored_list_held_alone_when_a_replace_adds_one() { + let mut setup = Setup::new(CONTRACT_PATH, 8110); + let (mut drifting, result) = setup.create("reason", &[("topic", "dash".into())]).await; + assert_successful(&result, "the first reason is created"); + let (mut charter, result) = setup + .create( + "topicCharter", + &[ + ("reasons", identifier_list(&[drifting.id()])), + ("topic", "dash".into()), + ], + ) + .await; + assert_successful(&result, "the charter is created"); + + drifting.set("topic", "btc".into()); + let result = setup.replace("reason", &mut drifting).await; + assert_successful(&result, "the reason's owner changes its topic"); + + let added = setup.reason("dash").await; + charter.set("reasons", identifier_list(&[drifting.id(), added])); + let result = setup.replace("topicCharter", &mut charter).await; + assert_successful(&result, "only the added reason is checked, and it agrees"); + + // A missing addition is still refused, named at its new position + let missing = Identifier::random_with_rng(&mut setup.rng); + charter.set("reasons", identifier_list(&[missing, drifting.id(), added])); + let result = setup.replace("topicCharter", &mut charter).await; + assert_element_not_found(&result, "reasons[0]", missing, "the new element is checked"); + } + + /// A list inside an object is named by its dotted path, and replacing + /// the object re-validates it. + #[tokio::test] + async fn should_name_an_element_of_a_list_nested_in_an_object() { + let mut setup = Setup::new(CONTRACT_PATH, 8111); + let reasons = setup.reasons(2).await; + let missing = Identifier::random_with_rng(&mut setup.rng); + let team = |ids: &[Identifier]| { + Value::Map(vec![( + Value::Text("members".to_string()), + identifier_list(ids), + )]) + }; + + let (_, result) = setup + .create("nestedCharter", &[("team", team(&[reasons[0], missing]))]) + .await; + assert_element_not_found( + &result, + "team.members[1]", + missing, + "the nested list's second member does not exist", + ); + + let (mut charter, result) = setup + .create("nestedCharter", &[("team", team(&reasons))]) + .await; + assert_successful(&result, "every member exists"); + charter.set("team", team(&[reasons[0], reasons[1], missing])); + let result = setup.replace("nestedCharter", &mut charter).await; + assert_element_not_found( + &result, + "team.members[2]", + missing, + "replacing the object re-validates the list inside it", + ); + } + + /// A contract element's `contractRequirements` hold for every element + /// (40135), named at the element. + #[tokio::test] + async fn should_check_contract_requirements_on_every_element() { + let mut setup = Setup::new(CONTRACT_PATH, 8112); + let (foreign, _) = setup.foreign_notes(0).await; + let own = setup.contract.id(); + + let (_, result) = setup + .create("contractList", &[("contracts", identifier_list(&[own]))]) + .await; + assert_successful(&result, "the writer owns the contract"); + + let (_, result) = setup + .create( + "contractList", + &[("contracts", identifier_list(&[own, foreign.id()]))], + ) + .await; + let [StateTransitionExecutionResult::PaidConsensusError { error, .. }] = + result.execution_results().as_slice() + else { + panic!("expected one paid consensus error, got {result:?}"); + }; + assert_eq!(error.code(), 40135, "{error}"); + assert_matches!( + error, + ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError( + unmet + )) if unmet.path() == "contracts[1]", + "the second contract is owned by someone else" + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mint/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mint/mod.rs index 1be8e7c664f..35083228369 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mint/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/mint/mod.rs @@ -2282,7 +2282,15 @@ mod token_mint_tests { &|_| Ok(Some(contract.clone().into())), platform_version, ) - .map(|(root_hash, outcome)| (root_hash, outcome.into_result())) + .map(|(root_hash, outcome)| { + // From prover version 1 the proof carries the owner's balance. + assert_eq!( + outcome.owner_balance().is_some(), + platform_version.drive.methods.prove.prove_state_transition >= 1, + "the proof carries the owner's balance exactly from prover version 1" + ); + (root_hash, outcome.into_result()) + }) .unwrap_or_else(|_| { panic!( "expect to verify state transition proof {}", @@ -2399,7 +2407,15 @@ mod token_mint_tests { &|_| Ok(Some(contract.clone().into())), platform_version, ) - .map(|(root_hash, outcome)| (root_hash, outcome.into_result())) + .map(|(root_hash, outcome)| { + // From prover version 1 the proof carries the owner's balance. + assert_eq!( + outcome.owner_balance().is_some(), + platform_version.drive.methods.prove.prove_state_transition >= 1, + "the proof carries the owner's balance exactly from prover version 1" + ); + (root_hash, outcome.into_result()) + }) .unwrap_or_else(|_| { panic!( "expect to verify state transition proof {}", @@ -2564,7 +2580,15 @@ mod token_mint_tests { &|_| Ok(Some(contract.clone().into())), platform_version, ) - .map(|(root_hash, outcome)| (root_hash, outcome.into_result())) + .map(|(root_hash, outcome)| { + // From prover version 1 the proof carries the owner's balance. + assert_eq!( + outcome.owner_balance().is_some(), + platform_version.drive.methods.prove.prove_state_transition >= 1, + "the proof carries the owner's balance exactly from prover version 1" + ); + (root_hash, outcome.into_result()) + }) .unwrap_or_else(|_| { panic!( "expect to verify state transition proof {}", @@ -2736,7 +2760,15 @@ mod token_mint_tests { &|_| Ok(Some(contract.clone().into())), platform_version, ) - .map(|(root_hash, outcome)| (root_hash, outcome.into_result())) + .map(|(root_hash, outcome)| { + // From prover version 1 the proof carries the owner's balance. + assert_eq!( + outcome.owner_balance().is_some(), + platform_version.drive.methods.prove.prove_state_transition >= 1, + "the proof carries the owner's balance exactly from prover version 1" + ); + (root_hash, outcome.into_result()) + }) .unwrap_or_else(|e| { panic!( "expect to verify state transition proof {}, error is {}", @@ -2892,7 +2924,15 @@ mod token_mint_tests { &|_| Ok(Some(contract.clone().into())), platform_version, ) - .map(|(root_hash, outcome)| (root_hash, outcome.into_result())) + .map(|(root_hash, outcome)| { + // From prover version 1 the proof carries the owner's balance. + assert_eq!( + outcome.owner_balance().is_some(), + platform_version.drive.methods.prove.prove_state_transition >= 1, + "the proof carries the owner's balance exactly from prover version 1" + ); + (root_hash, outcome.into_result()) + }) .unwrap_or_else(|_| { panic!( "expect to verify state transition proof {}", diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs index 0598ee07e1a..f92d4ede714 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_fee_claim/tests.rs @@ -38,8 +38,7 @@ use dpp::state_transition::data_contract_create_transition::methods::DataContrac use dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; use dpp::state_transition::data_contract_update_transition::methods::DataContractUpdateTransitionMethodsV0; use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; -use dpp::state_transition::proof_result::{ - StateTransitionProofOutcome, StateTransitionProofResult, +use dpp::state_transition::proof_result::{StateTransitionProofResult, }; use dpp::state_transition::StateTransition; use dpp::tests::fixtures::get_data_contract_fixture; @@ -682,17 +681,21 @@ async fn should_prove_the_pot_and_the_balances_of_everyone_it_paid() { ) .expect("expected the proof to verify"); - let StateTransitionProofOutcome::AffectedState( - StateTransitionProofResult::VerifiedContractFeeClaim( - contract_id, - pot, - last_claim, - remaining, - balances, - ), - ) = outcome + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {:?}", + outcome + ); + + let StateTransitionProofResult::VerifiedContractFeeClaim( + contract_id, + pot, + last_claim, + remaining, + balances, + ) = outcome.into_result() else { - panic!("expected a contract fee claim result, got {outcome:?}"); + panic!("expected a contract fee claim result"); }; assert_eq!(contract_id, setup.contract.id()); assert_eq!(pot, ContractFeePot::Moderators); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs index 516572d3816..407323573e4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs @@ -52,9 +52,7 @@ use dpp::state_transition::data_contract_create_transition::methods::DataContrac use dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; use dpp::state_transition::data_contract_update_transition::methods::DataContractUpdateTransitionMethodsV0; use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; -use dpp::state_transition::proof_result::{ - StateTransitionProofOutcome, StateTransitionProofResult, -}; +use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::StateTransition; use dpp::tests::fixtures::get_data_contract_fixture; use dpp::tests::json_document::json_document_to_contract; @@ -501,14 +499,22 @@ impl Setup { platform_version, ) .expect("expected the proof to verify"); - match outcome { - StateTransitionProofOutcome::AffectedState( - StateTransitionProofResult::VerifiedContractDocumentRemoval( - contract_id, - document_type_name, - _, - removal, - ), + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {:?}", + outcome + ); + assert_eq!( + outcome.owner_balance().is_some(), + platform_version.drive.methods.prove.prove_state_transition >= 1, + "the proof carries the moderator's balance exactly from prover version 1" + ); + match outcome.into_result() { + StateTransitionProofResult::VerifiedContractDocumentRemoval( + contract_id, + document_type_name, + _, + removal, ) => { assert_eq!(contract_id, self.contract.id()); assert_eq!(document_type_name, POST); @@ -654,10 +660,20 @@ impl Setup { platform_version, ) .expect("expected the proof to verify"); - match outcome { - StateTransitionProofOutcome::AffectedState( - StateTransitionProofResult::VerifiedContractModerationListStatuses(_, _, status), - ) => status, + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {:?}", + outcome + ); + assert_eq!( + outcome.owner_balance().is_some(), + platform_version.drive.methods.prove.prove_state_transition >= 1, + "the proof carries the moderator's balance exactly from prover version 1" + ); + match outcome.into_result() { + StateTransitionProofResult::VerifiedContractModerationListStatuses(_, _, status) => { + status + } other => panic!("expected a moderation status, got {other:?}"), } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs index d64954e4e30..1a7ac58c775 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs @@ -3,10 +3,12 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, - DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, + DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, + DocumentReferenceDeclaration, KeyReferenceIdentityProperty, PropertyReference, }; use dpp::data_contract::DataContract; use dpp::document::property_names::CREATOR_ID; +use dpp::errors::consensus::state::document::referenced_document_lookup_invalid_error::ReferencedDocumentLookupInvalidError; use dpp::errors::consensus::state::document::referenced_document_property_agreement_invalid_error::ReferencedDocumentPropertyAgreementInvalidError; use dpp::errors::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use dpp::errors::consensus::state::document::referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError; @@ -30,16 +32,10 @@ use crate::execution::types::state_transition_execution_context::{ /// Whether two property types hold the same KIND of value for agreement /// purposes: sizes and other constraints may differ (both sides validated -/// their own documents already), and an identifier is one kind whether or -/// not it carries its own reference annotation. +/// their own documents already). The rule is `DocumentPropertyType::value_kind`, +/// shared with the key parts of a `refersTo` lookup. fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { - let normalized_kind = |property_type: &DocumentPropertyType| match property_type { - DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { - std::mem::discriminant(&DocumentPropertyType::Identifier) - } - other => std::mem::discriminant(other), - }; - normalized_kind(a) == normalized_kind(b) + a.value_kind() == b.value_kind() } /// Checks every reference declaration of the given contract that carries @@ -50,16 +46,26 @@ fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { /// including when it names its own id) and the referenced document type must /// exist in it; for `permanentDocument` that type must forbid deletion, for /// `deletableDocument` it must allow it. -/// Every `propertyAgreement` pair is checked for both. Self references are -/// checked against the in-flight -/// contract, so a contract may reference its own document types on creation; -/// foreign contract fetches are billed. +/// Every `propertyAgreement` pair is checked for both, and a `lookup` into +/// another contract's document type is checked against that type's indexes +/// (one into the declaring contract was checked by the contract parse). Self +/// references are checked against the in-flight contract, so a contract may +/// reference its own document types on creation; foreign contract fetches are +/// billed. /// /// `identityPublicKey`: the declared key id property must exist in the same /// document type and be an integer. /// +/// A declaration on the `items` of a typed array of identifiers holds for +/// every element and is checked once, exactly as a single reference's: the +/// referring side of an agreement is still a property of the declaring +/// document type (or the writer), the referenced side a property of the +/// referenced document type. `identityPublicKey` never reaches here on +/// elements: the parser refuses it there. +/// /// The error paths name the failing declaration as -/// `documentTypeName.propertyPath`. Validation stops at the first invalid +/// `documentTypeName.propertyPath`, and an element declaration by its list +/// path, `documentTypeName.propertyPath[]`. Validation stops at the first invalid /// declaration: this bounds the billed work an invalid contract can cause and /// matches document write-time reference validation. Foreign contract /// resolutions are memoized per contract id, so a contract declaring many @@ -79,18 +85,102 @@ pub(super) fn validate_data_contract_references_v0( for (declaring_type_name, document_type) in contract.document_types() { for (path, property) in document_type.as_ref().flattened_properties() { - let DocumentPropertyType::IdentifierWithReference(reference_target) = - &property.property_type - else { - continue; - }; - let declaration_path = format!("{declaring_type_name}.{path}"); + let (reference_target, declaration_path) = match property.property_type.reference() { + // A key reference on the key id property: what `identityProperty` + // names must fit the document type; nothing else about the + // declaration is state-dependent + Some(PropertyReference::KeyId(reference)) => { + let invalid = |message: &str| { + SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + path.to_string(), + declaration_path.clone(), + message.to_string(), + ) + .into(), + ) + }; + match &reference.identity_property { + KeyReferenceIdentityProperty::OwnerId => {} + KeyReferenceIdentityProperty::CreatorId => { + if !document_type + .as_ref() + .should_use_creator_id( + contract.system_version_type(), + contract.config().version(), + platform_version, + ) + .map_err(Error::Protocol)? + { + return Ok(invalid( + "identityProperty $creatorId needs a document type that \ + records creator ids: only transferable or tradeable \ + document types of a format-1 contract do", + )); + } + } + KeyReferenceIdentityProperty::Property(identity_path) => { + match document_type + .as_ref() + .flattened_properties() + .get(identity_path) + { + None => { + return Ok(invalid(&format!( + "the document type does not define the identity \ + property {identity_path}" + ))); + } + // The (identity, key id) pair is declared once + Some(DocumentProperty { + property_type: + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + .. + }, + ), + .. + }) => { + return Ok(invalid(&format!( + "the identity property {identity_path} carries its own \ + identityPublicKey reference" + ))); + } + Some(identity_property) + if !matches!( + identity_property.property_type, + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) + ) => + { + return Ok(invalid(&format!( + "the identity property {identity_path} must be an \ + identifier" + ))); + } + Some(_) => {} + } + } + } + continue; + } + Some(PropertyReference::Value(target)) => (target, declaration_path), + // A typed array only parses from protocol version 14, whose + // contract create and update state validation are the only + // callers, so this arm is never reached before it + Some(PropertyReference::Elements { target, .. }) => { + (target, format!("{declaring_type_name}.{path}[]")) + } + None => continue, + }; + // The key id property must exist in the same document type and be // an integer; nothing else about the declaration is state-dependent - if let DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } = - reference_target + if let DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, .. + } = reference_target { match document_type .as_ref() @@ -117,6 +207,22 @@ pub(super) fn validate_data_contract_references_v0( .into(), )); } + // A key id that already names whose key it is (the writer's) + // can not also be a key of the referenced identity + Some(DocumentProperty { + property_type: DocumentPropertyType::KeyIdWithReference(_), + .. + }) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), + declaration_path, + "the property carries its own identityPublicKey reference" + .to_string(), + ) + .into(), + )); + } Some(_) => continue, } } @@ -126,7 +232,8 @@ pub(super) fn validate_data_contract_references_v0( document_type_name, property_agreement, permanent, - }) = reference_target.as_document_reference() + lookup, + }) = reference_target.as_any_document_reference() else { continue; }; @@ -226,6 +333,30 @@ pub(super) fn validate_data_contract_references_v0( )); } + // A lookup, only ever on a permanentDocument reference, must + // resolve in the referenced document type: a unique index its keys + // cover exactly, filled from sources of the right kinds, with a key + // that stays with the document it found. The contract parse checks + // a lookup into the declaring contract under full validation, where + // it sees every document type; only here is another contract's + // document type in hand. + if let Some(lookup) = lookup { + if effective_contract_id != contract.id() { + if let Some(reason) = lookup + .referenced_side_error(document_type.as_ref(), referenced_document_type) + { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentLookupInvalidError::new( + declaration_path, + lookup.index.clone(), + reason, + ) + .into(), + )); + } + } + } + // propertyAgreement declarations: both sides must exist, be // plain values (not containers), and share one value kind — a // cross-kind equality could never be satisfied and would brick @@ -325,6 +456,18 @@ pub(super) fn validate_data_contract_references_v0( "agreement properties must be plain values, not object containers", )); } + // The write-time check compares index key encodings, which a + // list does not have, so an agreement on one would never hold + if matches!(referring_type, DocumentPropertyType::TypedArray(_)) + || matches!( + referenced.property_type, + DocumentPropertyType::TypedArray(_) + ) + { + return Ok(invalid( + "agreement properties must be single values, not typed arrays", + )); + } if !same_value_kind(referring_type, &referenced.property_type) { return Ok(invalid( "the two properties must share one value kind: a cross-kind \ diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs index 3721ab3896c..d9dc53b4d1d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs @@ -5555,6 +5555,7 @@ mod tests { mod permanent_document_reference_declarations { use super::*; use dpp::consensus::state::state_error::StateError; + use dpp::data_contract::errors::DataContractError; use drive::util::test_helpers::setup_contract; const FOREIGN_CONTRACT_PATH: &str = @@ -5564,6 +5565,15 @@ mod tests { /// fixture, with the foreign permanent-document fixture contract /// already in state, and returns the execution result. async fn run_contract_create(fixture_path: &str) -> StateTransitionExecutionResult { + run_contract_create_with_foreign(fixture_path, FOREIGN_CONTRACT_PATH).await + } + + /// [`run_contract_create`] with the contract at `foreign_contract_path` + /// in state instead. + async fn run_contract_create_with_foreign( + fixture_path: &str, + foreign_contract_path: &str, + ) -> StateTransitionExecutionResult { let platform_version = PlatformVersion::latest(); let mut platform = TestPlatformBuilder::new() .build_with_mock_rpc() @@ -5575,7 +5585,7 @@ mod tests { setup_contract( &platform.drive, - FOREIGN_CONTRACT_PATH, + foreign_contract_path, None, None, None::, @@ -5796,6 +5806,89 @@ mod tests { ); } + /// An agreement is checked at write time by comparing index key + /// encodings, which a typed array does not have, so one between two + /// typed arrays of the same element type is refused at registration + /// rather than refusing every write that carries them. + #[tokio::test] + async fn should_reject_agreement_on_typed_array_properties() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-typed-array.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(error) + ), + .. + } if error.reason().contains("not typed arrays") + ); + } + + /// `refersTo` on the items of a typed array registers with every target + /// the fixture uses: permanent and deletable document elements, an + /// agreement keyed by the writer and one on a schema property. + #[tokio::test] + async fn should_register_contract_with_typed_array_element_references() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// An element declaration is checked at registration as a single + /// reference is: the referring side of an agreement is a property of + /// the declaring document type, and the error names the declaration + /// by its list path. + #[tokio::test] + async fn should_reject_an_element_agreement_on_a_missing_referring_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referring.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(error) + ), + .. + } if error.path() == "topicCharter.reasons[]" + && error.referring_property() == "subject" + && error.reason().contains("does not define the referring property") + ); + } + + /// The referenced side is a property of the referenced document type. + #[tokio::test] + async fn should_reject_an_element_agreement_on_a_missing_referenced_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referenced.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(error) + ), + .. + } if error.path() == "topicCharter.reasons[]" + && error.referenced_property() == "subject" + && error.reason().contains("does not define the referenced property") + ); + } + /// `$ownerId` and `$creatorId` may sit on the referenced side of an /// agreement when the referring side is an identifier and, for /// `$creatorId`, the referenced type records creator ids @@ -5935,6 +6028,149 @@ mod tests { ); } + /// The key id form: `senderKeyId` names the writer's own key. Nothing + /// is checked at registration beyond the parse and the meta-schema, + /// which a real create runs, unlike the fixture insertion the + /// document tests use. + #[tokio::test] + async fn should_register_contract_with_owner_key_reference() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// A key id that already names whose key it is (the writer's) can not + /// also be named as a key of a referenced identity: one value would + /// have to be a key of two identities. + #[tokio::test] + async fn should_reject_contract_whose_key_id_property_carries_its_own_key_reference() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-key-id-with-reference.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedKeyIdPropertyInvalidError(e) + ), + .. + } if e.key_id_property() == "senderKeyId" + ); + } + + /// `$creatorId` needs a document type that records creator ids: a + /// transferable type of a format-1 contract does, a plain one does not. + #[tokio::test] + async fn should_register_contract_with_creator_key_reference_on_a_type_recording_creators() + { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_creator_key_reference_on_a_type_not_recording_creators() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key-registration-not-recorded.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedKeyIdPropertyInvalidError(e) + ), + .. + } if e.key_id_property() == "senderKeyId" && e.message().contains("$creatorId") + ); + } + + /// A property path names an identifier property of the same type that + /// carries the identity; it must exist, be an identifier, and not + /// declare the same (identity, key id) pair from its own side. + #[tokio::test] + async fn should_register_contract_with_key_reference_naming_an_identity_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_key_reference_naming_an_undefined_identity_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-missing.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedKeyIdPropertyInvalidError(e) + ), + .. + } if e.message().contains("does not define") + ); + } + + #[tokio::test] + async fn should_reject_key_reference_naming_a_non_identifier_identity_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-non-identifier.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedKeyIdPropertyInvalidError(e) + ), + .. + } if e.message().contains("must be an identifier") + ); + } + + #[tokio::test] + async fn should_reject_key_reference_naming_an_identity_property_with_its_own_key_reference( + ) { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-double-reference.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedKeyIdPropertyInvalidError(_) + ), + .. + } + ); + } + #[tokio::test] async fn should_reject_contract_referencing_missing_contract() { let result = run_contract_create( @@ -5952,5 +6188,69 @@ mod tests { } ); } + + /// The contract whose `joinRequest` type, unique on + /// (`submittedCharterId`, `$ownerId`), the lookup registration + /// fixtures reference from another contract. + const LOOKUP_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup.json"; + + #[tokio::test] + async fn should_register_a_lookup_into_a_unique_index_of_another_contract() { + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-valid.json", + LOOKUP_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_a_lookup_naming_an_index_another_contract_does_not_have() { + // Only registration sees the other contract's indexes: the contract + // parse cannot, so this is a state error + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-missing-index.json", + LOOKUP_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentLookupInvalidError(e) + ), + .. + } if e.path() == "vote.voterId" + && e.index() == "byMessage" + && e.reason().contains("has no index named \"byMessage\"") + ); + } + + #[tokio::test] + async fn should_reject_a_lookup_into_a_non_unique_index_of_the_same_contract() { + // The contract parse sees the referenced type of the same contract, + // and refuses the declaration before any state is read + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-own-not-unique.json", + LOOKUP_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message) + )), + .. + } if message.contains("index \"byCharter\" of \"ballot\" is not unique") + ); + } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index 9e004d3034c..da6ac9a75d6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -1689,7 +1689,15 @@ mod tests { &|_| Ok(None), platform_version, ) - .map(|(root_hash, outcome)| (root_hash, outcome.into_result())) + .map(|(root_hash, outcome)| { + // From prover version 1 the proof carries the owner's balance. + assert_eq!( + outcome.owner_balance().is_some(), + platform_version.drive.methods.prove.prove_state_transition >= 1, + "the proof carries the owner's balance exactly from prover version 1" + ); + (root_hash, outcome.into_result()) + }) .unwrap_or_else(|e| { panic!( "expect to verify state transition proof {}, error is {}", diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_key_limits_update/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_key_limits_update/tests.rs index 020abe70861..21ecd03fb1e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_key_limits_update/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_key_limits_update/tests.rs @@ -26,9 +26,7 @@ use dpp::state_transition::data_contract_create_transition::DataContractCreateTr use dpp::state_transition::identity_key_limits_update_transition::methods::IdentityKeyLimitsUpdateTransitionMethodsV0; use dpp::state_transition::identity_key_limits_update_transition::v0::IdentityKeyLimitsUpdateTransitionV0; use dpp::state_transition::identity_key_limits_update_transition::IdentityKeyLimitsUpdateTransition; -use dpp::state_transition::proof_result::{ - StateTransitionProofOutcome, StateTransitionProofResult, -}; +use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::{StateTransition, StateTransitionSingleSigned}; use dpp::tests::fixtures::get_data_contract_fixture; use dpp::version::PlatformVersion; @@ -705,11 +703,19 @@ async fn should_prove_the_rewritten_key() { ); // The proof shows the resulting key, not the nonce, so it authenticates the affected // state rather than this exact transition. - let StateTransitionProofOutcome::AffectedState( - StateTransitionProofResult::VerifiedPartialIdentity(identity), - ) = outcome + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {:?}", + outcome + ); + assert_eq!( + outcome.owner_balance().is_some(), + version.drive.methods.prove.prove_state_transition >= 1, + "the proof carries the owner's balance exactly from prover version 1" + ); + let StateTransitionProofResult::VerifiedPartialIdentity(identity) = outcome.into_result() else { - panic!("expected the affected state to be proved, got {outcome:?}"); + panic!("expected the affected state to be proved as a partial identity"); }; let key = &identity.loaded_public_keys[&LIMITED_KEY_ID]; assert_eq!(key.total_budget(), Some(BUDGET * 2)); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs index 56d6ca61c76..4d798d95822 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_shielded_pool/tests.rs @@ -26,9 +26,7 @@ mod tests { use dpp::shielded::SerializedAction; use dpp::state_transition::identity_top_up_from_shielded_pool_transition::v0::IdentityTopUpFromShieldedPoolTransitionV0; use dpp::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; - use dpp::state_transition::proof_result::{ - StateTransitionProofOutcome, StateTransitionProofResult, - }; + use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::StateTransition; use drive::drive::Drive; use grovedb_commitment_tree::{ @@ -490,7 +488,7 @@ mod tests { // spend of the same notes (see the competing-spend test below), so the // outcome is affected state and wallets use the affected-state wait. assert!( - matches!(outcome, StateTransitionProofOutcome::AffectedState(_)), + !outcome.is_execution_proved(), "a top-up proof must be classified as affected state, got {outcome:?}" ); @@ -612,7 +610,7 @@ mod tests { ) .expect("the losing top-up's proof verifies as a snapshot"); assert!( - matches!(outcome, StateTransitionProofOutcome::AffectedState(_)), + !outcome.is_execution_proved(), "a proof that cannot tell competing spends apart must not claim execution, got {outcome:?}" ); let result = outcome.into_result(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs index 10af27f7afe..96fddc39cd7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs @@ -373,7 +373,15 @@ mod tests { &|_id: &Identifier| Ok(None), platform_version, ) - .map(|(root_hash, outcome)| (root_hash, outcome.into_result())) + .map(|(root_hash, outcome)| { + // From prover version 1 the proof carries the owner's balance. + assert_eq!( + outcome.owner_balance().is_some(), + platform_version.drive.methods.prove.prove_state_transition >= 1, + "the proof carries the owner's balance exactly from prover version 1" + ); + (root_hash, outcome.into_result()) + }) .map_err(|e| e.to_string()) .expect("expected to verify state transition"); @@ -613,7 +621,15 @@ mod tests { &|_id: &Identifier| Ok(None), platform_version, ) - .map(|(root_hash, outcome)| (root_hash, outcome.into_result())) + .map(|(root_hash, outcome)| { + // From prover version 1 the proof carries the owner's balance. + assert_eq!( + outcome.owner_balance().is_some(), + platform_version.drive.methods.prove.prove_state_transition >= 1, + "the proof carries the owner's balance exactly from prover version 1" + ); + (root_hash, outcome.into_result()) + }) .map_err(|e| e.to_string()) .expect("expected to verify state transition"); let StateTransitionProofResult::VerifiedPartialIdentity(proven) = verification_result 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 5e0edc8c823..078fb372611 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 @@ -3,6 +3,8 @@ mod balance; mod nonce; mod state; mod transform_into_action; +#[cfg(test)] +mod yes_no_tests; use dpp::address_funds::PlatformAddress; use dpp::block::block_info::BlockInfo; @@ -24,6 +26,7 @@ use crate::rpc::core::CoreRPCLike; use crate::execution::validation::state_transition::masternode_vote::state::v0::MasternodeVoteStateTransitionStateValidationV0; use crate::execution::validation::state_transition::masternode_vote::state::v1::MasternodeVoteStateTransitionStateValidationV1; use crate::execution::validation::state_transition::masternode_vote::transform_into_action::v0::MasternodeVoteStateTransitionTransformIntoActionValidationV0; +use crate::execution::validation::state_transition::masternode_vote::transform_into_action::v1::MasternodeVoteStateTransitionTransformIntoActionValidationV1; use crate::execution::validation::state_transition::processor::state::StateTransitionStateValidation; use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; use crate::execution::validation::state_transition::ValidationMode; @@ -52,9 +55,12 @@ impl StateTransitionActionTransformer for MasternodeVoteTransition { 0 => self .transform_into_action_v0(platform, validation_mode, tx, platform_version) .map(|result| result.map(|action| action.into())), + 1 => self + .transform_into_action_v1(platform, validation_mode, tx, platform_version) + .map(|result| result.map(|action| action.into())), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "masternode votes state transition: transform_into_action".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v0/mod.rs index cde77903a56..129c7bc64b6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v0/mod.rs @@ -92,8 +92,22 @@ impl MasternodeVoteStateTransitionStateValidationV0 for MasternodeVoteTransition } } } + // Unreachable: this v0 is selected up to protocol version 13, where a + // masternode vote that names a yes/no poll does not decode + // (`StateTransition::active_version_range`). + ResolvedVotePoll::YesNoVotePoll(_) => { + Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "yes/no vote polls need validate_state version 1", + ))) + } } } + // Unreachable for the same reason: no yes/no vote decodes before 14. + ResolvedVote::YesNoVote(_) => { + Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "yes/no votes need validate_state version 1", + ))) + } } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs index 4ea6d6b31b4..b4340a9670a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs @@ -4,6 +4,7 @@ use dpp::consensus::state::state_error::StateError; use dpp::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; use dpp::consensus::state::voting::vote_poll_not_available_for_voting_error::VotePollNotAvailableForVotingError; use dpp::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; +use dpp::consensus::state::voting::yes_no_vote_poll_not_available_for_voting_error::YesNoVotePollNotAvailableForVotingError; use dpp::consensus::ConsensusError; use dpp::prelude::ConsensusValidationResult; @@ -16,6 +17,8 @@ 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_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStatus; +use dpp::voting::vote_polls::VotePoll; use drive::drive::votes::resolved::vote_polls::ResolvedVotePoll; use drive::drive::votes::resolved::votes::resolved_resource_vote::accessors::v0::ResolvedResourceVoteGettersV0; use drive::drive::votes::resolved::votes::ResolvedVote; @@ -113,6 +116,45 @@ impl MasternodeVoteStateTransitionStateValidationV1 for MasternodeVoteTransition } } } + ResolvedVotePoll::YesNoVotePoll(_) => { + // A resource vote choice answers no yes/no poll: no contested + // document resource poll named by this vote exists. + Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::VotePollNotFoundError( + VotePollNotFoundError::new(vote_poll.into()), + )), + )) + } + } + } + ResolvedVote::YesNoVote(yes_no_vote) => { + let vote_poll = &yes_no_vote.vote_poll; + let Some(stored_info) = platform.drive.fetch_yes_no_vote_poll_stored_info( + vote_poll, + tx, + platform_version, + )? + else { + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::VotePollNotFoundError( + VotePollNotFoundError::new(VotePoll::YesNoVotePoll(vote_poll.clone())), + )), + )); + }; + match stored_info.status() { + YesNoVotePollStatus::Started(_) => Ok( + ConsensusValidationResult::new_with_data(masternode_vote_action.into()), + ), + status @ YesNoVotePollStatus::Finished(_) => Ok( + ConsensusValidationResult::new_with_error(ConsensusError::StateError( + StateError::YesNoVotePollNotAvailableForVotingError( + YesNoVotePollNotAvailableForVotingError::new( + vote_poll.clone(), + *status, + ), + ), + )), + ), } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/mod.rs index 9a1925de7fc..008be12cc67 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/mod.rs @@ -1 +1,2 @@ pub(crate) mod v0; +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v0/mod.rs index e76fd0eb8b6..07a483fcf42 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v0/mod.rs @@ -1,3 +1,4 @@ +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::platform_types::platform::PlatformRef; use dpp::consensus::state::state_error::StateError; @@ -103,8 +104,22 @@ impl MasternodeVoteStateTransitionTransformIntoActionValidationV0 for Masternode } } } + // Unreachable: this v0 is selected up to protocol version 13, where a + // masternode vote that names a yes/no poll does not decode + // (`StateTransition::active_version_range`). + VotePoll::YesNoVotePoll(_) => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "yes/no vote polls need transform_into_action version 1", + ))) + } } } + // Unreachable for the same reason: no yes/no vote decodes before 14. + Vote::YesNoVote(_) => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "yes/no votes need transform_into_action version 1", + ))) + } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v1/mod.rs new file mode 100644 index 00000000000..4f5105423f2 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/transform_into_action/v1/mod.rs @@ -0,0 +1,232 @@ +use crate::error::Error; +use crate::platform_types::platform::PlatformRef; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::state::voting::masternode_not_found_error::MasternodeNotFoundError; +use dpp::consensus::state::voting::masternode_vote_already_present_error::MasternodeVoteAlreadyPresentError; +use dpp::consensus::state::voting::masternode_voted_too_many_times::MasternodeVotedTooManyTimesError; +use dpp::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; +use dpp::consensus::ConsensusError; +use dpp::dashcore::hashes::Hash; +use dpp::dashcore::ProTxHash; +use dpp::dashcore_rpc::dashcore_rpc_json::MasternodeType; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::masternode_vote_transition::accessors::MasternodeVoteTransitionAccessorsV0; +use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; +use drive::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; +use drive::drive::votes::resolved::votes::ResolvedVote; +use drive::state_transition_action::identity::masternode_vote::v0::MasternodeVoteTransitionActionV0; +use drive::state_transition_action::identity::masternode_vote::MasternodeVoteTransitionAction; + +use crate::execution::validation::state_transition::ValidationMode; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::VotePoll; +use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; +use dpp::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; +use dpp::voting::votes::Vote; +use drive::grovedb::TransactionArg; + +pub(in crate::execution::validation::state_transition::state_transitions::masternode_vote) trait MasternodeVoteStateTransitionTransformIntoActionValidationV1 +{ + fn transform_into_action_v1( + &self, + platform: &PlatformRef, + validation_mode: ValidationMode, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl MasternodeVoteStateTransitionTransformIntoActionValidationV1 for MasternodeVoteTransition { + /// Version 1 (protocol version 14) also transforms a vote on a yes/no poll. The same rules + /// hold for both kinds: the same answer cannot be given twice, and a masternode may vote on a + /// poll at most `votes_allowed_per_masternode` times, its first vote included. + fn transform_into_action_v1( + &self, + platform: &PlatformRef, + validation_mode: ValidationMode, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + // A resource vote choice (towards an identity, abstain, lock) answers a contested + // document resource poll. No such poll is named by a resource vote that carries a + // yes/no poll, whatever validation mode we are in. + if let Vote::ResourceVote(resource_vote) = self.vote() { + if let VotePoll::YesNoVotePoll(_) = resource_vote.vote_poll() { + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::VotePollNotFoundError( + VotePollNotFoundError::new(resource_vote.vote_poll().clone()), + )), + )); + } + } + + let mut previous_resource_vote_choice_to_remove = None; + let mut previous_yes_no_vote_choice_to_remove = None; + if validation_mode != ValidationMode::NoValidation { + // Before we transform into action we want to make sure that we have not yet voted + match self.vote() { + Vote::ResourceVote(resource_vote) => { + match resource_vote.vote_poll() { + VotePoll::ContestedDocumentResourceVotePoll(vote_poll) => { + let vote_id = vote_poll.unique_id()?; + let maybe_existing_resource_vote_choice = + platform.drive.fetch_identity_contested_resource_vote( + self.pro_tx_hash(), + vote_id, + tx, + &mut vec![], + platform_version, + )?; + if let Some((existing_resource_vote_choice, previous_vote_count)) = + maybe_existing_resource_vote_choice + { + if existing_resource_vote_choice + == resource_vote.resource_vote_choice() + { + // We are submitting a vote for something we already have + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError( + StateError::MasternodeVoteAlreadyPresentError( + MasternodeVoteAlreadyPresentError::new( + self.pro_tx_hash(), + resource_vote.vote_poll().clone(), + ), + ), + ), + )); + } else if previous_vote_count + >= platform_version + .dpp + .validation + .voting + .votes_allowed_per_masternode + { + // We are submitting a vote for something we already have + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError( + StateError::MasternodeVotedTooManyTimesError( + MasternodeVotedTooManyTimesError::new( + self.pro_tx_hash(), + previous_vote_count, + platform_version + .dpp + .validation + .voting + .votes_allowed_per_masternode, + ), + ), + ), + )); + } else { + previous_resource_vote_choice_to_remove = + Some((existing_resource_vote_choice, previous_vote_count)); + } + } + } + // Refused above. + VotePoll::YesNoVotePoll(_) => {} + } + } + Vote::YesNoVote(yes_no_vote) => { + let vote_poll = yes_no_vote.vote_poll(); + let vote_id = vote_poll.unique_id()?; + let maybe_existing_vote_choice = platform.drive.fetch_identity_yes_no_vote( + self.pro_tx_hash(), + vote_id, + tx, + &mut vec![], + platform_version, + )?; + if let Some((existing_vote_choice, previous_vote_count)) = + maybe_existing_vote_choice + { + if existing_vote_choice == yes_no_vote.vote_choice() { + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError( + StateError::MasternodeVoteAlreadyPresentError( + MasternodeVoteAlreadyPresentError::new( + self.pro_tx_hash(), + VotePoll::YesNoVotePoll(vote_poll.clone()), + ), + ), + ), + )); + } else if previous_vote_count + >= platform_version + .dpp + .validation + .voting + .votes_allowed_per_masternode + { + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError( + StateError::MasternodeVotedTooManyTimesError( + MasternodeVotedTooManyTimesError::new( + self.pro_tx_hash(), + previous_vote_count, + platform_version + .dpp + .validation + .voting + .votes_allowed_per_masternode, + ), + ), + ), + )); + } else { + previous_yes_no_vote_choice_to_remove = + Some((existing_vote_choice, previous_vote_count)); + } + } + } + } + } + + let Some(masternode) = platform + .state + .full_masternode_list() + .get(&ProTxHash::from_byte_array(self.pro_tx_hash().to_buffer())) + else { + return Ok(ConsensusValidationResult::new_with_error( + MasternodeNotFoundError::new(self.pro_tx_hash()).into(), + )); + }; + + let strength = match masternode.node_type { + MasternodeType::Regular => 1, + MasternodeType::Evo => 4, + }; + + let action = match self.vote() { + Vote::ResourceVote(_) => MasternodeVoteTransitionAction::transform_from_transition( + self, + masternode.state.voting_address, + strength, + previous_resource_vote_choice_to_remove, + platform.drive, + tx, + platform_version, + )?, + Vote::YesNoVote(yes_no_vote) => { + // A yes/no vote needs nothing from the state to be resolved; the previous + // answer found above rides with it. + let mut resolved_vote = ResolvedYesNoVote::from_vote(yes_no_vote); + resolved_vote.previous_vote_choice_to_remove = + previous_yes_no_vote_choice_to_remove; + MasternodeVoteTransitionActionV0 { + pro_tx_hash: self.pro_tx_hash(), + voter_identity_id: self.voter_identity_id(), + voting_address: masternode.state.voting_address, + vote_strength: strength, + vote: ResolvedVote::YesNoVote(resolved_vote), + previous_resource_vote_choice_to_remove: None, + nonce: self.nonce(), + } + .into() + } + }; + + Ok(ConsensusValidationResult::new_with_data(action)) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests.rs new file mode 100644 index 00000000000..e70813e8c89 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/yes_no_tests.rs @@ -0,0 +1,1347 @@ +//! Votes on yes/no vote polls: the poll's life from opening to the record of its decision. + +use crate::execution::check_tx::CheckTxLevel; +use crate::execution::validation::state_transition::state_transitions::tests::{ + process_test_state_transition, setup_masternode_voting_identity, + take_down_masternode_identities, +}; +use crate::platform_types::platform::PlatformRef; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; +use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::{ + InternalError, SuccessfulExecution, UnpaidConsensusError, +}; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::fast_forward_to_block::fast_forward_to_block; +use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels; +use assert_matches::assert_matches; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::ConsensusError; +use dpp::dashcore::hashes::Hash; +use dpp::dashcore::ProTxHash; +use dpp::dashcore_rpc::dashcore_rpc_json::MasternodeType; +use dpp::identifier::Identifier; +use dpp::identity::IdentityPublicKey; +use dpp::platform_value::BinaryData; +use dpp::prelude::IdentityNonce; +use dpp::serialization::PlatformSerializable; +use dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; +use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStatus; +use dpp::voting::vote_polls::yes_no_vote_poll::{ + VotingPowerRounding, YesNoMinimumVotingPower, YesNoVotePoll, +}; +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::yes_no_vote::v0::YesNoVoteV0; +use dpp::voting::votes::yes_no_vote::YesNoVote; +use dpp::voting::votes::Vote; +use drive::query::yes_no_vote_poll_state_query::YesNoVotePollStateDriveQuery; +use drive::query::VotePollsByEndDateDriveQuery; +use simple_signer::signer::SimpleSigner; +use std::ops::Deref; +use std::sync::Arc; + +/// Two weeks after genesis: the polls in these tests end here. +const END_DATE: u64 = 1_209_600_000; +/// Enough for many votes at the single vote cost. +const FUND: u64 = 100_000_000_000; + +fn two_thirds_poll(purpose: &[u8], minimum_voting_power: u32) -> YesNoVotePoll { + YesNoVotePoll { + resource_path: vec![ + BinaryData::new(vec![0xc1; 32]), + BinaryData::new(purpose.to_vec()), + ], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute(minimum_voting_power), + } +} + +fn open_poll( + platform: &TempPlatform, + vote_poll: &YesNoVotePoll, + fund: u64, + platform_version: &PlatformVersion, +) { + open_poll_ending_at(platform, vote_poll, END_DATE, fund, platform_version) +} + +fn open_poll_ending_at( + platform: &TempPlatform, + vote_poll: &YesNoVotePoll, + end_date: u64, + fund: u64, + platform_version: &PlatformVersion, +) { + platform + .drive + .open_yes_no_vote_poll( + vote_poll, + end_date, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected to open the yes/no vote poll"); + if fund > 0 { + platform + .drive + .add_prefunded_specialized_balance( + vote_poll + .specialized_balance_id() + .expect("expected a specialized balance id"), + fund, + None, + platform_version, + ) + .expect("expected to fund the poll"); + } +} + +/// A voting masternode, promoted to an evonode when asked, so its vote weighs 4. +fn setup_voter( + platform: &mut TempPlatform, + seed: u64, + evonode: bool, + platform_version: &PlatformVersion, +) -> (Identifier, SimpleSigner, IdentityPublicKey) { + let (pro_tx_hash, _identity, signer, voting_key) = + setup_masternode_voting_identity(platform, seed, platform_version); + if evonode { + let mut platform_state = platform.state.load().clone().deref().clone(); + platform_state + .full_masternode_list_mut() + .get_mut(&ProTxHash::from_byte_array(pro_tx_hash.to_buffer())) + .expect("expected the masternode") + .node_type = MasternodeType::Evo; + platform.state.store(Arc::new(platform_state)); + } + (pro_tx_hash, signer, voting_key) +} + +#[allow(clippy::too_many_arguments)] +async fn perform_yes_no_vote( + platform: &mut TempPlatform, + vote_poll: &YesNoVotePoll, + vote_choice: YesNoAbstainVoteChoice, + signer: &SimpleSigner, + pro_tx_hash: Identifier, + voting_key: &IdentityPublicKey, + nonce: IdentityNonce, + platform_version: &PlatformVersion, +) -> Result<(), StateTransitionExecutionResult> { + let vote = Vote::YesNoVote(YesNoVote::V0(YesNoVoteV0 { + vote_poll: vote_poll.clone(), + vote_choice, + })); + perform_vote_transition( + platform, + vote, + signer, + pro_tx_hash, + voting_key, + nonce, + platform_version, + ) + .await +} + +/// Whether CheckTx admits the serialized transition on its first check. A transition CheckTx +/// cannot take at all (one not active at the current protocol version fails to decode) is not +/// admitted either. +fn check_tx_admits(platform: &TempPlatform, serialized_transition: &[u8]) -> bool { + let platform_state = platform.state.load(); + let platform_version = platform_state + .current_platform_version() + .expect("expected the current platform version"); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + platform + .check_tx( + serialized_transition, + CheckTxLevel::FirstTimeCheck, + &platform_ref, + platform_version, + ) + .is_ok_and(|result| result.is_valid()) +} + +/// Processes one masternode vote transition in a block and reports how it went. +/// +/// Every vote first goes through CheckTx, which asserts under cfg(test) that it leaves committed +/// state untouched (the devnet paloma height 788 class); a vote CheckTx admits must then pass at +/// both levels, as the shared masternode vote helper requires of its valid votes. +async fn perform_vote_transition( + platform: &mut TempPlatform, + vote: Vote, + signer: &SimpleSigner, + pro_tx_hash: Identifier, + voting_key: &IdentityPublicKey, + nonce: IdentityNonce, + platform_version: &PlatformVersion, +) -> Result<(), StateTransitionExecutionResult> { + let masternode_vote_transition = MasternodeVoteTransition::try_from_vote_with_signer( + vote, + signer, + pro_tx_hash, + voting_key, + nonce, + platform_version, + None, + ) + .await + .expect("expected to make transition vote"); + let serialized_transition = masternode_vote_transition + .serialize_to_bytes() + .expect("expected to serialize the vote"); + if check_tx_admits(platform, &serialized_transition) { + assert_check_tx_valid_at_all_levels(platform, &serialized_transition, "yes/no vote"); + } + let platform_state = platform.state.load(); + let processing_result = process_test_state_transition( + platform, + masternode_vote_transition, + &platform_state, + platform_version, + ); + match processing_result.into_execution_results().remove(0) { + SuccessfulExecution { .. } => Ok(()), + other => Err(other), + } +} + +/// Casts one vote per masternode: `count` voters, each with the choice, at consecutive seeds. +async fn cast_votes( + platform: &mut TempPlatform, + vote_poll: &YesNoVotePoll, + vote_choice: YesNoAbstainVoteChoice, + count: u64, + start_seed: u64, + platform_version: &PlatformVersion, +) -> Vec { + let mut voters = vec![]; + for seed in start_seed..start_seed + count { + let (pro_tx_hash, signer, voting_key) = + setup_voter(platform, seed, false, platform_version); + perform_yes_no_vote( + platform, + vote_poll, + vote_choice, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect("expected the vote to be accepted"); + voters.push(pro_tx_hash); + } + voters +} + +/// Moves the chain past the polls' end date and closes the polls that ended. +fn close_ended_polls( + platform: &mut TempPlatform, + platform_version: &PlatformVersion, +) { + close_ended_polls_at(platform, END_DATE + 300_000, platform_version) +} + +/// Moves the chain to `time_ms` and closes the polls that ended by then. +fn close_ended_polls_at( + platform: &mut TempPlatform, + time_ms: u64, + platform_version: &PlatformVersion, +) { + fast_forward_to_block(platform, time_ms, 10000, 42, 0, false); + let block_info = BlockInfo { + time_ms, + height: 10000, + core_height: 42, + epoch: Default::default(), + }; + 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("expected to check for ended vote polls"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); +} + +fn poll_state( + platform: &TempPlatform, + vote_poll: &YesNoVotePoll, + platform_version: &PlatformVersion, +) -> drive::query::yes_no_vote_poll_state_query::YesNoVotePollState { + YesNoVotePollStateDriveQuery { + vote_poll: vote_poll.clone(), + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("expected the poll state") +} + +fn new_platform() -> TempPlatform { + TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state() +} + +#[tokio::test] +async fn should_pass_at_exactly_the_threshold_and_fail_one_power_below_it_with_abstains() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + // Six cast votes: two thirds is four, so five yes passes and four yes does not. + let passing = two_thirds_poll(b"passing", 6); + let failing = two_thirds_poll(b"failing", 6); + open_poll(&platform, &passing, FUND, platform_version); + open_poll(&platform, &failing, FUND, platform_version); + + cast_votes( + &mut platform, + &passing, + YesNoAbstainVoteChoice::Yes, + 5, + 100, + platform_version, + ) + .await; + cast_votes( + &mut platform, + &passing, + YesNoAbstainVoteChoice::No, + 1, + 200, + platform_version, + ) + .await; + cast_votes( + &mut platform, + &passing, + YesNoAbstainVoteChoice::Abstain, + 2, + 300, + platform_version, + ) + .await; + + cast_votes( + &mut platform, + &failing, + YesNoAbstainVoteChoice::Yes, + 4, + 400, + platform_version, + ) + .await; + cast_votes( + &mut platform, + &failing, + YesNoAbstainVoteChoice::No, + 2, + 500, + platform_version, + ) + .await; + cast_votes( + &mut platform, + &failing, + YesNoAbstainVoteChoice::Abstain, + 3, + 600, + platform_version, + ) + .await; + + let state = poll_state(&platform, &passing, platform_version); + assert_eq!( + ( + state.yes_voting_power, + state.no_voting_power, + state.abstain_voting_power + ), + (5, 1, 2) + ); + assert_matches!( + state.stored_info.expect("stored info").status(), + YesNoVotePollStatus::Started(_) + ); + + close_ended_polls(&mut platform, platform_version); + + let passing_result = *poll_state(&platform, &passing, platform_version) + .stored_info + .expect("stored info") + .result() + .expect("expected the poll to be finished"); + assert!(passing_result.passed); + assert_eq!(passing_result.yes_voting_power, 5); + assert_eq!(passing_result.no_voting_power, 1); + assert_eq!(passing_result.abstain_voting_power, 2); + assert_eq!( + passing_result.finalization_block.time_ms, + END_DATE + 300_000 + ); + + let failing_result = *poll_state(&platform, &failing, platform_version) + .stored_info + .expect("stored info") + .result() + .expect("expected the poll to be finished"); + assert!(!failing_result.passed); + assert_eq!(failing_result.yes_voting_power, 4); + assert_eq!(failing_result.no_voting_power, 2); + assert_eq!(failing_result.abstain_voting_power, 3); +} + +#[tokio::test] +async fn should_fail_below_the_minimum_voting_power_even_when_every_vote_is_yes() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"unanimous", 6); + open_poll(&platform, &vote_poll, FUND, platform_version); + cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + 5, + 100, + platform_version, + ) + .await; + // Abstaining power does not count towards the minimum. + cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Abstain, + 3, + 200, + platform_version, + ) + .await; + + close_ended_polls(&mut platform, platform_version); + + let result = *poll_state(&platform, &vote_poll, platform_version) + .stored_info + .expect("stored info") + .result() + .expect("expected the poll to be finished"); + assert!(!result.passed); + assert_eq!(result.yes_voting_power, 5); + assert_eq!(result.no_voting_power, 0); + assert_eq!(result.abstain_voting_power, 3); +} + +/// A minimum given as a share of the total is measured against the masternode list of the +/// closing block, evonodes weighing 4, and the bar it resolved to is kept in the result. +#[tokio::test] +async fn should_judge_a_share_minimum_against_the_masternode_list_at_close() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let half_rounded_up = |purpose: &[u8]| YesNoVotePoll { + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: 1, + denominator: 2, + rounding: VotingPowerRounding::Up, + }, + ..two_thirds_poll(purpose, 0) + }; + let evonode_alone = half_rounded_up(b"evonode alone"); + let evonode_and_one = half_rounded_up(b"evonode and one masternode"); + open_poll(&platform, &evonode_alone, FUND, platform_version); + open_poll(&platform, &evonode_and_one, FUND, platform_version); + + // One evonode and five masternodes: a total of 9, so half rounded up is 5. + let evonode = setup_voter(&mut platform, 100, true, platform_version); + let masternodes: Vec<_> = (101..106) + .map(|seed| setup_voter(&mut platform, seed, false, platform_version)) + .collect(); + let total: u32 = platform + .state + .load() + .full_masternode_list() + .values() + .map(|masternode| match masternode.node_type { + MasternodeType::Evo => 4, + MasternodeType::Regular => 1, + }) + .sum(); + assert_eq!(total, 9, "the list holds only the voters set up here"); + assert_eq!(masternodes.len(), 5); + + let (pro_tx_hash, signer, voting_key) = &evonode; + for (nonce, vote_poll) in [(1, &evonode_alone), (2, &evonode_and_one)] { + perform_yes_no_vote( + &mut platform, + vote_poll, + YesNoAbstainVoteChoice::Yes, + signer, + *pro_tx_hash, + voting_key, + nonce, + platform_version, + ) + .await + .expect("expected the evonode's vote to be accepted"); + } + let (pro_tx_hash, signer, voting_key) = &masternodes[0]; + perform_yes_no_vote( + &mut platform, + &evonode_and_one, + YesNoAbstainVoteChoice::Yes, + signer, + *pro_tx_hash, + voting_key, + 1, + platform_version, + ) + .await + .expect("expected the masternode's vote to be accepted"); + + close_ended_polls(&mut platform, platform_version); + + let result_of = |vote_poll: &YesNoVotePoll| { + *poll_state(&platform, vote_poll, platform_version) + .stored_info + .expect("stored info") + .result() + .expect("expected the poll to be finished") + }; + let alone = result_of(&evonode_alone); + assert_eq!(alone.yes_voting_power, 4); + assert_eq!(alone.required_voting_power, 5); + assert!(!alone.passed); + let with_one = result_of(&evonode_and_one); + assert_eq!(with_one.yes_voting_power, 5); + assert_eq!(with_one.required_voting_power, 5); + assert!(with_one.passed); +} + +#[tokio::test] +async fn should_count_an_evonode_vote_as_four() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"evonode", 5); + open_poll(&platform, &vote_poll, FUND, platform_version); + + let (evonode, signer, voting_key) = setup_voter(&mut platform, 100, true, platform_version); + perform_yes_no_vote( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + evonode, + &voting_key, + 1, + platform_version, + ) + .await + .expect("expected the evonode vote to be accepted"); + cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::No, + 1, + 200, + platform_version, + ) + .await; + + let state = poll_state(&platform, &vote_poll, platform_version); + assert_eq!((state.yes_voting_power, state.no_voting_power), (4, 1)); + + close_ended_polls(&mut platform, platform_version); + + // Five cast, minimum five: four is strictly more than two thirds of five. + let result = *poll_state(&platform, &vote_poll, platform_version) + .stored_info + .expect("stored info") + .result() + .expect("expected the poll to be finished"); + assert!(result.passed); + assert_eq!(result.yes_voting_power, 4); +} + +#[tokio::test] +async fn should_leave_only_the_record_behind_after_the_poll_ends() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"cleanup", 1); + open_poll(&platform, &vote_poll, FUND, platform_version); + let yes_voters = cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + 3, + 100, + platform_version, + ) + .await; + let no_voters = cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::No, + 1, + 200, + platform_version, + ) + .await; + let vote_poll_id = vote_poll.unique_id().expect("id"); + let vote_cost = platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_single_vote_cost; + + // Before the end: votes, index entries, end date entry and the fund minus four votes. + let voters = platform + .drive + .fetch_identities_voting_in_yes_no_vote_poll(&vote_poll, None, platform_version) + .expect("voters"); + assert_eq!(voters[&YesNoAbstainVoteChoice::Yes].len(), 3); + assert_eq!(voters[&YesNoAbstainVoteChoice::No].len(), 1); + assert!(voters[&YesNoAbstainVoteChoice::Abstain].is_empty()); + assert_eq!( + platform + .drive + .fetch_identity_yes_no_vote( + yes_voters[0], + vote_poll_id, + None, + &mut vec![], + platform_version + ) + .expect("vote"), + Some((YesNoAbstainVoteChoice::Yes, 1)) + ); + let balance = platform + .drive + .fetch_prefunded_specialized_balance(vote_poll_id.to_buffer(), None, platform_version) + .expect("balance"); + assert_eq!(balance, Some(FUND - 4 * vote_cost)); + let end_dates = VotePollsByEndDateDriveQuery { + start_time: None, + end_time: None, + limit: None, + offset: None, + order_ascending: true, + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("end dates"); + assert_eq!(end_dates.len(), 1); + + close_ended_polls(&mut platform, platform_version); + + // After the end: the record with the result, and nothing else. + let state = poll_state(&platform, &vote_poll, platform_version); + let result = *state + .stored_info + .expect("stored info") + .result() + .expect("expected the poll to be finished"); + assert!(result.passed); + assert_eq!((result.yes_voting_power, result.no_voting_power), (3, 1)); + assert_eq!( + ( + state.yes_voting_power, + state.no_voting_power, + state.abstain_voting_power + ), + (0, 0, 0) + ); + let voters = platform + .drive + .fetch_identities_voting_in_yes_no_vote_poll(&vote_poll, None, platform_version) + .expect("voters"); + assert!(voters.values().all(|voters| voters.is_empty())); + for voter in yes_voters.iter().chain(no_voters.iter()) { + assert_eq!( + platform + .drive + .fetch_identity_yes_no_vote( + *voter, + vote_poll_id, + None, + &mut vec![], + platform_version + ) + .expect("vote"), + None + ); + } + let balance = platform + .drive + .fetch_prefunded_specialized_balance(vote_poll_id.to_buffer(), None, platform_version) + .expect("balance"); + assert!(balance.is_none() || balance == Some(0)); + let end_dates = VotePollsByEndDateDriveQuery { + start_time: None, + end_time: None, + limit: None, + offset: None, + order_ascending: true, + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("end dates"); + assert!(end_dates.is_empty()); + + // A finished poll takes no more votes, and cannot be opened again under the same id. + let (pro_tx_hash, signer, voting_key) = + setup_voter(&mut platform, 300, false, platform_version); + let error = perform_yes_no_vote( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect_err("expected the vote to be refused"); + // Closing the poll emptied its fund, and the fund pre-check runs before state validation, + // so the missing fund is what refuses the vote. + assert_matches!( + &error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceNotFoundError(_) + )) + ); + // With a fund at its id again, the finished status refuses the vote. + platform + .drive + .add_prefunded_specialized_balance(vote_poll_id, FUND, None, platform_version) + .expect("expected to fund the finished poll"); + let error = perform_yes_no_vote( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect_err("expected the vote to be refused"); + assert_matches!( + &error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::YesNoVotePollNotAvailableForVotingError(_) + )) + ); + assert!(platform + .drive + .open_yes_no_vote_poll( + &vote_poll, + END_DATE, + &BlockInfo::default(), + None, + platform_version + ) + .is_err()); +} + +#[tokio::test] +async fn should_refuse_a_vote_on_an_unknown_poll_and_the_same_answer_twice() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"rules", 1); + let unknown_poll = two_thirds_poll(b"unknown", 1); + open_poll(&platform, &vote_poll, FUND, platform_version); + // The unknown poll's fund exists, the poll does not. + platform + .drive + .add_prefunded_specialized_balance( + unknown_poll.specialized_balance_id().expect("id"), + FUND, + None, + platform_version, + ) + .expect("fund"); + let (pro_tx_hash, signer, voting_key) = + setup_voter(&mut platform, 100, false, platform_version); + + let error = perform_yes_no_vote( + &mut platform, + &unknown_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect_err("expected the vote on an unknown poll to be refused"); + assert_matches!( + error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::VotePollNotFoundError(_) + )) + ); + + perform_yes_no_vote( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect("expected the first vote to be accepted"); + let error = perform_yes_no_vote( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + pro_tx_hash, + &voting_key, + 2, + platform_version, + ) + .await + .expect_err("expected the same answer to be refused"); + assert_matches!( + error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::MasternodeVoteAlreadyPresentError(_) + )) + ); +} + +#[tokio::test] +async fn should_refuse_a_resource_vote_that_names_a_yes_no_poll() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"cross kind", 1); + open_poll(&platform, &vote_poll, FUND, platform_version); + let (pro_tx_hash, signer, voting_key) = + setup_voter(&mut platform, 100, false, platform_version); + // A resource vote choice answers a contested document resource poll, not a yes/no one. + let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::YesNoVotePoll(vote_poll.clone()), + resource_vote_choice: ResourceVoteChoice::Abstain, + })); + let error = perform_vote_transition( + &mut platform, + vote, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect_err("expected the vote to be refused"); + assert_matches!( + error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::VotePollNotFoundError(_) + )) + ); + let state = poll_state(&platform, &vote_poll, platform_version); + assert_eq!(state.abstain_voting_power, 0); +} + +#[tokio::test] +async fn should_move_a_changed_vote_and_cap_the_changes() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"changes", 1); + open_poll(&platform, &vote_poll, FUND, platform_version); + let (pro_tx_hash, signer, voting_key) = setup_voter(&mut platform, 100, true, platform_version); + let vote_poll_id = vote_poll.unique_id().expect("id"); + let allowed = platform_version + .dpp + .validation + .voting + .votes_allowed_per_masternode; + + let choices = [ + YesNoAbstainVoteChoice::Yes, + YesNoAbstainVoteChoice::No, + YesNoAbstainVoteChoice::Abstain, + ]; + let mut nonce = 1; + for (vote_number, vote_choice) in choices.iter().cycle().take(allowed as usize).enumerate() { + perform_yes_no_vote( + &mut platform, + &vote_poll, + *vote_choice, + &signer, + pro_tx_hash, + &voting_key, + nonce, + platform_version, + ) + .await + .expect("expected the vote change to be accepted"); + nonce += 1; + // Only the latest answer holds the evonode's four. + let state = poll_state(&platform, &vote_poll, platform_version); + for choice in choices { + let expected = if choice == *vote_choice { 4 } else { 0 }; + assert_eq!(state.voting_power(choice), expected, "vote {}", vote_number); + } + assert_eq!( + platform + .drive + .fetch_identity_yes_no_vote( + pro_tx_hash, + vote_poll_id, + None, + &mut vec![], + platform_version + ) + .expect("vote"), + Some((*vote_choice, vote_number as u16 + 1)) + ); + } + + let next_choice = choices[allowed as usize % choices.len()]; + let error = perform_yes_no_vote( + &mut platform, + &vote_poll, + next_choice, + &signer, + pro_tx_hash, + &voting_key, + nonce, + platform_version, + ) + .await + .expect_err("expected one change too many to be refused"); + assert_matches!( + error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::MasternodeVotedTooManyTimesError(_) + )) + ); +} + +#[tokio::test] +async fn should_refuse_a_vote_when_the_poll_has_no_fund() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"unfunded", 1); + open_poll(&platform, &vote_poll, 0, platform_version); + let (pro_tx_hash, signer, voting_key) = + setup_voter(&mut platform, 100, false, platform_version); + // The prefunded balance pre-check runs before the vote is transformed, so the missing fund + // refuses it unpaid and nothing of the vote is recorded. + let error = perform_yes_no_vote( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect_err("expected the vote to be refused"); + assert_matches!( + &error, + UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceNotFoundError(_) + )) + ); + assert_eq!( + platform + .drive + .fetch_identity_yes_no_vote( + pro_tx_hash, + vote_poll.unique_id().expect("id"), + None, + &mut vec![], + platform_version + ) + .expect("vote"), + None + ); + let state = poll_state(&platform, &vote_poll, platform_version); + assert_eq!(state.yes_voting_power, 0); +} + +#[tokio::test] +async fn should_remove_the_votes_of_masternodes_that_left_the_list() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let vote_poll = two_thirds_poll(b"removals", 1); + open_poll(&platform, &vote_poll, FUND, platform_version); + let yes_voters = cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + 3, + 100, + platform_version, + ) + .await; + let no_voters = cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::No, + 2, + 200, + platform_version, + ) + .await; + let vote_poll_id = vote_poll.unique_id().expect("id"); + + let platform_state_before_removals = platform.state.load().deref().clone(); + let removed = vec![yes_voters[0], yes_voters[1], no_voters[0]]; + take_down_masternode_identities(&mut platform, &removed); + let block_platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + platform + .remove_votes_for_removed_masternodes( + &BlockInfo::default(), + &platform_state_before_removals, + &block_platform_state, + Some(&transaction), + platform_version, + ) + .expect("expected to remove votes for removed masternodes"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let state = poll_state(&platform, &vote_poll, platform_version); + assert_eq!((state.yes_voting_power, state.no_voting_power), (1, 1)); + let voters = platform + .drive + .fetch_identities_voting_in_yes_no_vote_poll(&vote_poll, None, platform_version) + .expect("voters"); + assert_eq!(voters[&YesNoAbstainVoteChoice::Yes], vec![yes_voters[2]]); + assert_eq!(voters[&YesNoAbstainVoteChoice::No], vec![no_voters[1]]); + for voter in &removed { + assert_eq!( + platform + .drive + .fetch_identity_yes_no_vote( + *voter, + vote_poll_id, + None, + &mut vec![], + platform_version + ) + .expect("vote"), + None + ); + } + assert_eq!( + platform + .drive + .fetch_identity_yes_no_vote( + yes_voters[2], + vote_poll_id, + None, + &mut vec![], + platform_version + ) + .expect("vote"), + Some((YesNoAbstainVoteChoice::Yes, 1)) + ); + + // The poll still closes cleanly with what is left. + close_ended_polls(&mut platform, platform_version); + let result = *poll_state(&platform, &vote_poll, platform_version) + .stored_info + .expect("stored info") + .result() + .expect("expected the poll to be finished"); + assert_eq!((result.yes_voting_power, result.no_voting_power), (1, 1)); + assert!(!result.passed); +} + +#[tokio::test] +async fn should_close_a_contested_poll_and_a_yes_no_poll_that_end_at_the_same_time() { + use crate::execution::validation::state_transition::state_transitions::tests::create_dpns_identity_name_contest; + + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let platform_state = platform.state.load(); + // The DPNS contest ends two weeks after its creation at the genesis block time. + let (_contender_1, _contender_2, _dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version, + ) + .await; + // The yes/no poll ends at the very millisecond the contest does. + let contest_end_date = *VotePollsByEndDateDriveQuery { + start_time: None, + end_time: None, + limit: None, + offset: None, + order_ascending: true, + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("end dates") + .keys() + .next() + .expect("expected the contest's end date"); + let vote_poll = two_thirds_poll(b"shared end date", 1); + open_poll_ending_at( + &platform, + &vote_poll, + contest_end_date, + FUND, + platform_version, + ); + cast_votes( + &mut platform, + &vote_poll, + YesNoAbstainVoteChoice::Yes, + 1, + 100, + platform_version, + ) + .await; + + let end_dates = VotePollsByEndDateDriveQuery { + start_time: None, + end_time: None, + limit: None, + offset: None, + order_ascending: true, + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("end dates"); + assert_eq!(end_dates.len(), 1, "both polls end at the same time"); + assert_eq!(end_dates.values().next().expect("polls").len(), 2); + + close_ended_polls_at(&mut platform, contest_end_date + 300_000, platform_version); + + let end_dates = VotePollsByEndDateDriveQuery { + start_time: None, + end_time: None, + limit: None, + offset: None, + order_ascending: true, + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("end dates"); + assert!(end_dates.is_empty()); + assert!( + poll_state(&platform, &vote_poll, platform_version) + .stored_info + .expect("stored info") + .result() + .expect("finished") + .passed + ); +} + +/// The block closes at most `maximum_vote_polls_to_process` polls, counted across every due end +/// date. When that cuts the last end date short, its tree must stay for the polls still under it: +/// deleting it in the same batch as the fetched poll's entry fails the whole block. +#[tokio::test] +async fn should_keep_an_end_date_the_block_only_partly_closed() { + let platform_version = PlatformVersion::latest(); + let mut platform = new_platform(); + let earlier = two_thirds_poll(b"earlier end date", 1); + let later_polls = [ + two_thirds_poll(b"later end date one", 1), + two_thirds_poll(b"later end date two", 1), + ]; + open_poll_ending_at(&platform, &earlier, END_DATE, FUND, platform_version); + for vote_poll in &later_polls { + open_poll_ending_at( + &platform, + vote_poll, + END_DATE + 1_000, + FUND, + platform_version, + ); + } + let end_dates = |platform: &TempPlatform| { + VotePollsByEndDateDriveQuery { + start_time: None, + end_time: None, + limit: None, + offset: None, + order_ascending: true, + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("end dates") + }; + let finished = |platform: &TempPlatform, vote_poll: &YesNoVotePoll| { + poll_state(platform, vote_poll, platform_version) + .stored_info + .expect("stored info") + .result() + .is_some() + }; + + // One block past both end dates closes the earlier poll and one of the later two. + close_ended_polls_at(&mut platform, END_DATE + 300_000, platform_version); + assert!(finished(&platform, &earlier)); + let finished_later = later_polls + .iter() + .filter(|vote_poll| finished(&platform, vote_poll)) + .count(); + assert_eq!(finished_later, 1); + let remaining = end_dates(&platform); + assert_eq!(remaining.len(), 1); + assert_eq!( + remaining.get(&(END_DATE + 1_000)).map(|polls| polls.len()), + Some(1) + ); + + // The next block closes the last one and the end date goes with it. + close_ended_polls_at(&mut platform, END_DATE + 301_000, platform_version); + assert!(later_polls + .iter() + .all(|vote_poll| finished(&platform, vote_poll))); + assert!(end_dates(&platform).is_empty()); +} + +/// Protocol version 13 selects the shipped (v0) masternode vote transform and state +/// validation, vote registration, end of poll check and clean-up, which this change gave +/// yes/no arms. A contest there runs as it did before: the vote counts, the poll ends, the +/// contender is awarded and the end date index empties. A masternode vote naming a yes/no +/// poll, of either kind, does not decode there, so it is stripped before any of them runs and +/// consumes nothing. +#[tokio::test] +async fn should_run_a_contest_unchanged_and_strip_yes_no_polls_at_protocol_version_13() { + use crate::execution::validation::state_transition::state_transitions::tests::{ + create_dpns_identity_name_contest, dpns_name_vote_poll, get_vote_states, + }; + 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_response::get_contested_resource_vote_state_response_v0::finished_vote_info::FinishedVoteOutcome; + use dpp::identity::accessors::IdentityGettersV0; + + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let (contender_1, _contender_2, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + "quantum", + platform_version, + ) + .await; + let (pro_tx_hash, signer, voting_key) = + setup_voter(&mut platform, 100, false, platform_version); + + let yes_no_poll = two_thirds_poll(b"before 14", 1); + let votes_naming_a_yes_no_poll = [ + Vote::YesNoVote(YesNoVote::V0(YesNoVoteV0 { + vote_poll: yes_no_poll.clone(), + vote_choice: YesNoAbstainVoteChoice::Yes, + })), + Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::YesNoVotePoll(yes_no_poll), + resource_vote_choice: ResourceVoteChoice::Abstain, + })), + ]; + for vote in votes_naming_a_yes_no_poll { + let result = perform_vote_transition( + &mut platform, + vote, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect_err("expected the vote to be stripped"); + assert_matches!(result, InternalError(message) if message.contains("is not active")); + } + + // Nonce 1 is still free: the stripped votes consumed nothing. + let contested_vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::ContestedDocumentResourceVotePoll(dpns_name_vote_poll( + &dpns_contract, + "quantum", + )), + resource_vote_choice: ResourceVoteChoice::TowardsIdentity(contender_1.id()), + })); + perform_vote_transition( + &mut platform, + contested_vote, + &signer, + pro_tx_hash, + &voting_key, + 1, + platform_version, + ) + .await + .expect("expected the contested vote to be accepted"); + + close_ended_polls(&mut platform, platform_version); + + let platform_state = platform.state.load(); + let (_contenders, _abstaining, _locking, finished_vote_info) = get_vote_states( + &platform, + &platform_state, + &dpns_contract, + "quantum", + None, + true, + None, + ResultType::DocumentsAndVoteTally, + platform_version, + ); + let finished_vote_info = finished_vote_info.expect("expected the contest to be finished"); + assert_eq!( + finished_vote_info.finished_vote_outcome, + FinishedVoteOutcome::TowardsIdentity as i32 + ); + assert_eq!( + finished_vote_info.won_by_identity_id, + Some(contender_1.id().to_vec()) + ); + let end_dates = VotePollsByEndDateDriveQuery { + start_time: None, + end_time: None, + limit: None, + offset: None, + order_ascending: true, + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("end dates"); + assert!(end_dates.is_empty()); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs index d0b03339f40..c4ac3e78ce4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -1380,12 +1380,7 @@ mod tests { .expect("shield affected-state verification should succeed"); assert!( - matches!( - outcome, - dpp::state_transition::proof_result::StateTransitionProofOutcome::AffectedState( - _ - ) - ), + !outcome.is_execution_proved(), "a shield state proof must not be treated as execution evidence, got {outcome:?}" ); } diff --git a/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_document_removals/v0/mod.rs b/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_document_removals/v0/mod.rs index bf67b4bc459..d6af4915296 100644 --- a/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_document_removals/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_document_removals/v0/mod.rs @@ -385,6 +385,7 @@ mod tests { &proof.grovedb_proof, contract.id(), &query, + false, version, ) .expect("expected the proof to verify"); diff --git a/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_moderation_status/v0/mod.rs b/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_moderation_status/v0/mod.rs index ff8d95390c3..87c38c7efe2 100644 --- a/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_moderation_status/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/contract_moderation_queries/contract_moderation_status/v0/mod.rs @@ -365,6 +365,7 @@ mod tests { contract.id(), identity_id, &lists, + false, version, ) .expect("expected the proof to verify"); @@ -440,6 +441,7 @@ mod tests { contract.id(), warned, &lists, + false, version, ) .expect("expected the proof to verify"); diff --git a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs index 5cda177a101..e3399b6e697 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/verify_state_transitions.rs @@ -9,7 +9,7 @@ use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::asset_lock::reduced_asset_lock_value::AssetLockValueGettersV0; use dpp::document::property_names::PRICE; -use dpp::state_transition::proof_result::{StateTransitionProofOutcome, StateTransitionProofResult}; +use dpp::state_transition::proof_result::{StateTransitionProofResult}; use dpp::state_transition::{StateTransition }; use dpp::state_transition::StateTransitionType; use dpp::state_transition::StateTransitionWitnessSigned; @@ -116,7 +116,7 @@ fn assert_transition_yields_affected_state_snapshot( // The post-state proof cannot be bound to this transition's execution, // so the outcome must stay tagged as an affected-state snapshot. assert!( - matches!(outcome, StateTransitionProofOutcome::AffectedState(_)), + !outcome.is_execution_proved(), "{operation}: post-state proof must not be treated as execution evidence, got {outcome:?}" ); } @@ -262,6 +262,10 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( expected_validation_errors: &[u32], platform_version: &PlatformVersion, ) -> bool { + // From prover version 1 every owned, fee-paying transition's proof also + // carries the owner's balance; its own part is then one subset of it. + let proof_carries_owner_balance = + platform_version.drive.methods.prove.prove_state_transition >= 1; let state = abci_app.platform.state.load(); let platform = PlatformRef { drive: &abci_app.platform.drive, @@ -389,7 +393,7 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( let (root_hash, contract) = Drive::verify_contract( &response_proof.grovedb_proof, Some(keeps_history), - false, + proof_carries_owner_balance, true, data_contract_create.data_contract_ref().id().into_buffer(), platform_version, @@ -422,7 +426,7 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( let (root_hash, contract) = Drive::verify_contract( &response_proof.grovedb_proof, Some(keeps_history), - false, + proof_carries_owner_balance, true, data_contract_update.data_contract_ref().id().into_buffer(), platform_version, @@ -507,9 +511,13 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( // .to_string(Encoding::Base58) // ); + // From prover version 1 the batch proof carries the + // owner's balance next to the document, so each is + // read as one subset of it. let (root_hash, document) = query .verify_proof( - false, + platform_version.drive.methods.prove.prove_state_transition + >= 1, &response_proof.grovedb_proof, document_type, platform_version, @@ -523,6 +531,34 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( platform.state.last_committed_block_info() ); + if proof_carries_owner_balance { + let owner_id = batch_transition.owner_id(); + let (balance_root_hash, proved_owner_balance) = + Drive::verify_identity_balance_for_identity_id( + &response_proof.grovedb_proof, + owner_id.into_buffer(), + true, + platform_version, + ) + .expect("expected to verify the owner's balance"); + assert_eq!( + balance_root_hash, root_hash, + "the document and the owner's balance come from one state" + ); + let stored_owner_balance = platform + .drive + .fetch_identity_balance( + owner_id.into_buffer(), + None, + platform_version, + ) + .expect("expected to fetch the owner's balance"); + assert_eq!( + proved_owner_balance, stored_owner_balance, + "the proved owner balance is the stored one" + ); + } + match document_action { DocumentTransitionAction::CreateAction(creation_action) => { if *was_executed { @@ -715,7 +751,8 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( let (root_hash, serialized_document) = query .verify_proof_keep_serialized( - false, + platform_version.drive.methods.prove.prove_state_transition + >= 1, &response_proof.grovedb_proof, platform_version, ) @@ -865,7 +902,7 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( ), false, false, - false, + proof_carries_owner_balance, platform_version, ) .expect("expected to verify identity keys"); @@ -933,28 +970,45 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( } } StateTransitionAction::MasternodeVoteAction(masternode_vote_action) => { - let data_contract = match masternode_vote_action.vote_ref() { - ResolvedVote::ResolvedResourceVote(resource_vote) => match resource_vote - .vote_poll() - { - ResolvedVotePoll::ContestedDocumentResourceVotePollWithContractInfo( - contested_document_resource_vote_poll, - ) => contested_document_resource_vote_poll.contract.as_ref(), - }, - }; - - let vote: Vote = masternode_vote_action.vote_ref().clone().into(); + let vote: Vote = masternode_vote_action + .vote_ref() + .clone() + .try_into() + .expect("expected a vote"); // we expect to get a vote that matches the state transition - let (root_hash_vote, maybe_vote) = Drive::verify_masternode_vote( - &response_proof.grovedb_proof, - masternode_vote_action.pro_tx_hash().into_buffer(), - &vote, - data_contract, - false, // we are not in a subset, we have just one vote - platform_version, - ) - .expect("expected to verify balance identity"); + let (root_hash_vote, maybe_vote) = match masternode_vote_action.vote_ref() { + ResolvedVote::ResolvedResourceVote(resource_vote) => { + let data_contract = match resource_vote.vote_poll() { + ResolvedVotePoll::ContestedDocumentResourceVotePollWithContractInfo( + contested_document_resource_vote_poll, + ) => contested_document_resource_vote_poll.contract.as_ref(), + ResolvedVotePoll::YesNoVotePoll(_) => { + panic!("a resource vote cannot answer a yes/no vote poll") + } + }; + Drive::verify_masternode_vote( + &response_proof.grovedb_proof, + masternode_vote_action.pro_tx_hash().into_buffer(), + &vote, + data_contract, + false, // we are not in a subset, we have just one vote + platform_version, + ) + .expect("expected to verify the masternode vote") + } + ResolvedVote::YesNoVote(yes_no_vote) => { + let (root_hash, maybe_vote) = Drive::verify_masternode_yes_no_vote( + &response_proof.grovedb_proof, + masternode_vote_action.pro_tx_hash().into_buffer(), + &yes_no_vote.clone().into(), + false, + platform_version, + ) + .expect("expected to verify the masternode yes/no vote"); + (root_hash, maybe_vote.map(Vote::YesNoVote)) + } + }; assert_eq!( &root_hash_vote, @@ -998,12 +1052,16 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( platform.state.last_committed_block_info() ); - let StateTransitionProofOutcome::ExecutionProved( - StateTransitionProofResult::VerifiedIdentityFullWithAddressInfos( - proved_identity, - proof_address_infos_map, - ), - ) = proof_result + assert!( + proof_result.is_execution_proved(), + "expected ExecutionProved, got {:?}", + proof_result + ); + + let StateTransitionProofResult::VerifiedIdentityFullWithAddressInfos( + proved_identity, + proof_address_infos_map, + ) = proof_result.into_result() else { panic!("expected identity/address infos for identity create from addresses proof"); }; @@ -1097,15 +1155,20 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( ) .expect("IdentityTopUpFromAddressesAction proof should verify"); - let StateTransitionProofOutcome::AffectedState( - StateTransitionProofResult::VerifiedIdentityWithAddressInfos( - identity, - proof_address_infos, - ), - ) = data + assert!( + !data.is_execution_proved(), + "expected AffectedState, got {:?}", + data + ); + + let StateTransitionProofResult::VerifiedIdentityWithAddressInfos( + identity, + proof_address_infos, + ) = data.into_result() else { - panic!("expected identity/address infos for top up from addresses proof, got {}", - std::any::type_name_of_val(&data)); + panic!( + "expected identity/address infos for top up from addresses proof" + ); }; assert!( @@ -1190,12 +1253,15 @@ pub(crate) fn verify_state_transitions_were_or_were_not_executed( ); if *was_executed { - let StateTransitionProofOutcome::AffectedState( - StateTransitionProofResult::VerifiedIdentityWithAddressInfos( - proved_identity, - proof_address_infos_map, - ), - ) = proof_result + assert!( + !proof_result.is_execution_proved(), + "expected AffectedState, got {:?}", + proof_result + ); + let StateTransitionProofResult::VerifiedIdentityWithAddressInfos( + proved_identity, + proof_address_infos_map, + ) = proof_result.into_result() else { panic!("expected identity/address infos for credit transfer proof"); }; diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-typed-array.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-typed-array.json new file mode 100644 index 00000000000..d90607de8aa --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-typed-array.json @@ -0,0 +1,62 @@ +{ + "$formatVersion": "1", + "id": "8Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVg", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "canBeDeleted": false, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 20 + }, + "maxItems": 8, + "position": 1 + } + }, + "required": [], + "additionalProperties": false + }, + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "noteId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "note", + "propertyAgreement": { + "tags": "tags" + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 20 + }, + "maxItems": 8, + "position": 1 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key-registration-not-recorded.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key-registration-not-recorded.json new file mode 100644 index 00000000000..60dd9eb2c2f --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key-registration-not-recorded.json @@ -0,0 +1,31 @@ +{ + "$formatVersion": "1", + "id": "8KfsEEAD7i7Whp6vg8sAPz5KcmhH4VaQcznmhZCXebCo", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$creatorId" + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key.json new file mode 100644 index 00000000000..f328f7c3d23 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key.json @@ -0,0 +1,32 @@ +{ + "$formatVersion": "1", + "id": "5qPsCGpmWxpaNubcjhSGTYGkyfrSGVTaxhjGAR3u7GiZ", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "transferable": 1, + "properties": { + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$creatorId" + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-key-id-with-reference.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-key-id-with-reference.json new file mode 100644 index 00000000000..88b96a2d7cc --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-key-id-with-reference.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "727KQg1FomW3hGE1jVSuerDrm3y9v3BFZ26fivL4AL9P", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "senderKeyId" + } + }, + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 1, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId" + } + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-requirements.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-requirements.json new file mode 100644 index 00000000000..de3f40e33dd --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-requirements.json @@ -0,0 +1,58 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "requiresIdentityEncryptionBoundedKey": 2, + "requiresIdentityDecryptionBoundedKey": 2, + "properties": { + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { + "purpose": "decryption", + "boundTo": "inbox" + } + } + }, + "recipientKeyId": { + "type": "integer", + "position": 1, + "minimum": 0 + }, + "note": { + "type": "string", + "position": 2, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + }, + "inbox": { + "type": "object", + "requiresIdentityEncryptionBoundedKey": 2, + "requiresIdentityDecryptionBoundedKey": 2, + "properties": { + "label": { + "type": "string", + "position": 0, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key.json new file mode 100644 index 00000000000..d182446c74a --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key.json @@ -0,0 +1,39 @@ +{ + "$formatVersion": "1", + "id": "F2rQbPN8ub3UN19qTQh86Wx8LWsAkHFJe39j4QJyxYVG", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 1, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "toUserId" + } + }, + "note": { + "type": "string", + "position": 2, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-double-reference.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-double-reference.json new file mode 100644 index 00000000000..52e0e66412d --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-double-reference.json @@ -0,0 +1,43 @@ +{ + "$formatVersion": "1", + "id": "6umgknTHwDxJ1PTdr1sYVdn48xgaFFLhC9f28f8izEFu", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId" + } + }, + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 1, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "toUserId" + } + }, + "note": { + "type": "string", + "position": 2, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-missing.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-missing.json new file mode 100644 index 00000000000..048b3b9fc34 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-missing.json @@ -0,0 +1,31 @@ +{ + "$formatVersion": "1", + "id": "E8etzQdQHwu9A8XHnBrxRLJaDiNLyZtdJ1fjZuYQngjN", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "toUserId" + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-non-identifier.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-non-identifier.json new file mode 100644 index 00000000000..e888743de8f --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-non-identifier.json @@ -0,0 +1,31 @@ +{ + "$formatVersion": "1", + "id": "HSpYHaHzFx9cKw7aHhUxUgHkDVDna28XurQtdxQm6qca", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "note" + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-missing-index.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-missing-index.json new file mode 100644 index 00000000000..bf560919d11 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-missing-index.json @@ -0,0 +1,44 @@ +{ + "$formatVersion": "1", + "id": "HBpE9BP7v9czi2hmsozHXSJdtVGcmykvAhBuYfm2r4Xi", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "vote": { + "type": "object", + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "voterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "permanentDocument", + "contractId": "4uAB6wAdt6FJ7djwjYrLnooVhYeQpzgkssBmgmvZ9WnM", + "documentType": "joinRequest", + "lookup": { + "index": "byMessage", + "keys": { + "message": "." + } + } + } + } + }, + "required": [ + "submittedCharterId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-valid.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-valid.json new file mode 100644 index 00000000000..939f51d0361 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-valid.json @@ -0,0 +1,45 @@ +{ + "$formatVersion": "1", + "id": "HBpE9BP7v9czi2hmsozHXSJdtVGcmykvAhBuYfm2r4Xi", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "vote": { + "type": "object", + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "voterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "permanentDocument", + "contractId": "4uAB6wAdt6FJ7djwjYrLnooVhYeQpzgkssBmgmvZ9WnM", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": "submittedCharterId", + "$ownerId": "." + } + } + } + } + }, + "required": [ + "submittedCharterId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-own-not-unique.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-own-not-unique.json new file mode 100644 index 00000000000..0c32c02dd0d --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-own-not-unique.json @@ -0,0 +1,71 @@ +{ + "$formatVersion": "1", + "id": "HBpE9BP7v9czi2hmsozHXSJdtVGcmykvAhBuYfm2r4Xi", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "ballot": { + "type": "object", + "canBeDeleted": false, + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "indices": [ + { + "name": "byCharter", + "properties": [ + { + "submittedCharterId": "asc" + } + ] + } + ], + "required": [ + "submittedCharterId" + ], + "additionalProperties": false + }, + "vote": { + "type": "object", + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "voterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "permanentDocument", + "documentType": "ballot", + "lookup": { + "index": "byCharter", + "keys": { + "submittedCharterId": "." + } + } + } + } + }, + "required": [ + "submittedCharterId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup.json new file mode 100644 index 00000000000..420dcf73039 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup.json @@ -0,0 +1,196 @@ +{ + "$formatVersion": "1", + "id": "4uAB6wAdt6FJ7djwjYrLnooVhYeQpzgkssBmgmvZ9WnM", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "joinRequest": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "message": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "indices": [ + { + "name": "bySubmittedCharter", + "properties": [ + { + "submittedCharterId": "asc" + }, + { + "$ownerId": "asc" + } + ], + "unique": true + } + ], + "required": [ + "submittedCharterId", + "message" + ], + "additionalProperties": false + }, + "charter": { + "type": "object", + "documentsMutable": true, + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 63 + }, + "memberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 2, + "distinctFrom": "$ownerId", + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": "submittedCharterId", + "$ownerId": "." + } + } + } + }, + "agreedMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 3, + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "propertyAgreement": { + "title": "message" + }, + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": "submittedCharterId", + "$ownerId": "." + } + } + } + }, + "requestedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 4, + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": ".", + "$ownerId": "$ownerId" + } + } + } + }, + "members": { + "type": "array", + "minItems": 0, + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId", + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": "submittedCharterId", + "$ownerId": "." + } + } + } + }, + "position": 5 + }, + "meta": { + "type": "object", + "position": 6, + "properties": { + "charterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "required": [ + "charterId" + ], + "additionalProperties": false + }, + "metaMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 7, + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": "meta.charterId", + "$ownerId": "." + } + } + } + } + }, + "required": [ + "submittedCharterId", + "title", + "meta" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-requirements.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-requirements.json new file mode 100644 index 00000000000..724b9475fa0 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-requirements.json @@ -0,0 +1,51 @@ +{ + "$formatVersion": "1", + "id": "EzvJ8tVV8evCUDLxzPxAbGe8aCj6eXQ64S43qoD7EKzi", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "requiresIdentityEncryptionBoundedKey": 2, + "requiresIdentityDecryptionBoundedKey": 2, + "properties": { + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyRequirements": { + "purpose": "decryption", + "boundTo": "inbox" + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + }, + "inbox": { + "type": "object", + "requiresIdentityEncryptionBoundedKey": 2, + "requiresIdentityDecryptionBoundedKey": 2, + "properties": { + "label": { + "type": "string", + "position": 0, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-transferable.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-transferable.json new file mode 100644 index 00000000000..31c5f9a9163 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-transferable.json @@ -0,0 +1,32 @@ +{ + "$formatVersion": "1", + "id": "GAhXtycMfHYYoXxreywKkNXbsMA4u8UaawZGyc3pBoMX", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "transferable": 1, + "properties": { + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId" + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key.json new file mode 100644 index 00000000000..334df9943ce --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key.json @@ -0,0 +1,48 @@ +{ + "$formatVersion": "1", + "id": "A55q7wQJcgwqMXJkEWdUuwhg4pffsxcYJFyBk1GSMwzU", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId" + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + }, + "meta": { + "type": "object", + "position": 2, + "properties": { + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId" + } + } + }, + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referenced.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referenced.json new file mode 100644 index 00000000000..34d495e8228 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referenced.json @@ -0,0 +1,54 @@ +{ + "$formatVersion": "1", + "id": "9ynYc5JBxVLyJ5G8PzGDcKWz2Pwu5s5CQVb4Kt3rRnTz", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "reason": { + "type": "object", + "canBeDeleted": false, + "properties": { + "topic": { + "type": "string", + "position": 0, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + }, + "topicCharter": { + "type": "object", + "documentsMutable": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { + "topic": "subject" + } + } + }, + "position": 0 + }, + "topic": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referring.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referring.json new file mode 100644 index 00000000000..9839aad3418 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referring.json @@ -0,0 +1,54 @@ +{ + "$formatVersion": "1", + "id": "8oxw8UU9C9b3UdPW3ygAeNXdcQ3S7WJwFe8GB4k6DfH4", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "reason": { + "type": "object", + "canBeDeleted": false, + "properties": { + "topic": { + "type": "string", + "position": 0, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + }, + "topicCharter": { + "type": "object", + "documentsMutable": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { + "subject": "topic" + } + } + }, + "position": 0 + }, + "topic": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json new file mode 100644 index 00000000000..069808bf43d --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json @@ -0,0 +1,356 @@ +{ + "$formatVersion": "1", + "id": "5v2TWJ1Mv6hQrN8mDRpJvSkqAqh3FzsoD1qXQwuYAbNk", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "reason": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": true, + "properties": { + "topic": { + "type": "string", + "position": 0, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + }, + "draft": { + "type": "object", + "canBeDeleted": true, + "documentsMutable": true, + "properties": { + "topic": { + "type": "string", + "position": 0, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + }, + "submittedCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason" + } + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "plainCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "gatedCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { + "$ownerId": "$ownerId" + } + } + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "topicCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { + "topic": "topic" + } + } + }, + "position": 0 + }, + "topic": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + }, + "draftList": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "drafts": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "deletableDocument", + "documentType": "draft" + } + }, + "position": 0 + }, + "body": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "nestedCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "team": { + "type": "object", + "position": 0, + "properties": { + "members": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason" + } + }, + "position": 0 + } + }, + "additionalProperties": false + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "foreignCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "contractId": "YMN9Qj5jPNp7j14VPcML1B6xGgcPWVZUGLFU3Mnyfaf", + "documentType": "note" + } + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "contractList": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "contracts": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "contract", + "contractRequirements": { + "owner": "self" + } + } + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "repeatCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason" + } + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "repeatPlainCh": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-proof-verifier/src/proof/contract_moderation.rs b/packages/rs-drive-proof-verifier/src/proof/contract_moderation.rs index b43d507728a..400f54b2402 100644 --- a/packages/rs-drive-proof-verifier/src/proof/contract_moderation.rs +++ b/packages/rs-drive-proof-verifier/src/proof/contract_moderation.rs @@ -55,6 +55,7 @@ impl FromProof for ContractModerationListSta contract_id, identity_id, &lists, + false, platform_version, ) .map_drive_error(&proof, &metadata)?; @@ -193,6 +194,7 @@ impl FromProof for ContractDocumentRemovals supported_grovedb_proof_bytes(&proof, platform_version)?, contract_id, &query, + false, platform_version, ) .map_drive_error(&proof, &metadata)?; diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 8a074d22cd9..f78ab73c211 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -3948,7 +3948,7 @@ "since": 1, "presence": "always", "source": "packages/rs-drive/src/drive/mod.rs", - "description": "Masternode votes, today on contested resources such as premium DPNS names.", + "description": "Masternode votes: on contested resources such as premium DPNS names, and on yes/no decisions.", "children": [ { "id": "votes.contested_resource", @@ -4473,8 +4473,251 @@ "since": 1, "presence": "always", "source": "packages/rs-drive/src/drive/votes/paths.rs", - "description": "Reserved for polls that decide something for the network. Nothing writes to it yet.", - "children": [] + "description": "Yes/no polls: decisions taken by a supermajority of the masternodes once enough voting power was cast.", + "children": [ + { + "id": "votes.decisions.identity_votes", + "key": { + "type": "fixed", + "hex": "69", + "label": "DecisionIdentityVotes", + "constant": "IDENTITY_VOTES_TREE_KEY", + "ascii": true + }, + "kinds": [ + "Tree" + ], + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The yes/no votes each masternode cast, so they can be changed and removed with the masternode.", + "children": [ + { + "id": "votes.decisions.identity_votes.voter", + "key": { + "type": "dynamic", + "name": "pro_tx_hash", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The voting masternode's pro tx hash" + }, + "kinds": [ + "Tree" + ], + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One masternode's yes/no votes. Stays once its votes are gone, like its contested counterpart.", + "children": [ + { + "id": "votes.decisions.identity_votes.voter.vote", + "key": { + "type": "dynamic", + "name": "vote_poll_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "hash32", + "description": "The double sha256 of the serialized vote poll" + }, + "kinds": [ + "Item" + ], + "value": "bincode YesNoVoteReferenceStorageForm: the choice and how many times the masternode voted on the poll", + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The masternode's current answer to one poll.", + "children": [] + } + ] + } + ] + }, + { + "id": "votes.decisions.active_polls", + "key": { + "type": "fixed", + "hex": "70", + "label": "DecisionActivePolls", + "constant": "ACTIVE_POLLS_TREE_KEY", + "ascii": true + }, + "kinds": [ + "Tree" + ], + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The yes/no polls, by their unique id.", + "children": [ + { + "id": "votes.decisions.active_polls.poll", + "key": { + "type": "dynamic", + "name": "vote_poll_id", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "hash32", + "description": "The double sha256 of the serialized vote poll" + }, + "kinds": [ + "Tree" + ], + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One yes/no poll: its stored info and its three vote sum trees. The vote trees go when the poll ends; the stored info stays as the record of the decision.", + "children": [ + { + "id": "votes.decisions.active_polls.poll.stored_info", + "key": { + "type": "fixed", + "hex": "00", + "label": "YesNoStoredInfo", + "constant": "YES_NO_VOTE_POLL_STORED_INFO_KEY" + }, + "kinds": [ + "Item" + ], + "value": "serialized YesNoVotePollStoredInfo", + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "The poll's status and, once it ends, its result.", + "children": [] + }, + { + "id": "votes.decisions.active_polls.poll.yes", + "key": { + "type": "fixed", + "hex": "01", + "label": "YesVotes", + "constant": "YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY" + }, + "kinds": [ + "SumTree" + ], + "since": 14, + "presence": "until_deleted", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "Votes for yes.", + "children": [ + { + "id": "votes.decisions.active_polls.poll.yes.voter", + "key": { + "type": "dynamic", + "name": "pro_tx_hash", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The voting masternode's pro tx hash" + }, + "kinds": [ + "SumItem" + ], + "value": "vote strength: 1 for a masternode, 4 for an evonode", + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One masternode's vote; the tree's sum is the tally.", + "children": [] + } + ] + }, + { + "id": "votes.decisions.active_polls.poll.no", + "key": { + "type": "fixed", + "hex": "02", + "label": "NoVotes", + "constant": "YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY" + }, + "kinds": [ + "SumTree" + ], + "since": 14, + "presence": "until_deleted", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "Votes for no.", + "children": [ + { + "id": "votes.decisions.active_polls.poll.no.voter", + "key": { + "type": "dynamic", + "name": "pro_tx_hash", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The voting masternode's pro tx hash" + }, + "kinds": [ + "SumItem" + ], + "value": "vote strength: 1 for a masternode, 4 for an evonode", + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One masternode's vote; the tree's sum is the tally.", + "children": [] + } + ] + }, + { + "id": "votes.decisions.active_polls.poll.abstain", + "key": { + "type": "fixed", + "hex": "03", + "label": "AbstainVotes", + "constant": "YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY" + }, + "kinds": [ + "SumTree" + ], + "since": 14, + "presence": "until_deleted", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "Votes to abstain, which count towards nothing.", + "children": [ + { + "id": "votes.decisions.active_polls.poll.abstain.voter", + "key": { + "type": "dynamic", + "name": "pro_tx_hash", + "matcher": { + "type": "len", + "len": 32 + }, + "encoding": "identifier32", + "description": "The voting masternode's pro tx hash" + }, + "kinds": [ + "SumItem" + ], + "value": "vote strength: 1 for a masternode, 4 for an evonode", + "since": 14, + "presence": "always", + "source": "packages/rs-drive/src/drive/votes/paths.rs", + "description": "One masternode's vote; the tree's sum is the tally.", + "children": [] + } + ] + } + ] + } + ] + } + ] }, { "id": "votes.end_date_queries", @@ -4509,9 +4752,10 @@ "Tree" ], "flags": [ - "EpochOwned" + "EpochOwned", + "None" ], - "flags_note": "The owner is the identity whose contested document started the poll.", + "flags_note": "The owner is the identity whose contested document started the poll. A yes/no poll is opened by the system and carries none.", "since": 1, "presence": "always", "source": "packages/rs-drive/src/drive/votes/paths.rs", @@ -4533,9 +4777,10 @@ "Item" ], "flags": [ - "EpochOwned" + "EpochOwned", + "None" ], - "flags_note": "The owner is the identity whose contested document started the poll.", + "flags_note": "The owner is the identity whose contested document started the poll. A yes/no poll is opened by the system and carries none.", "value": "serialized VotePoll", "since": 1, "presence": "always", @@ -5572,6 +5817,30 @@ } } }, + "votes.decisions": { + "origin": "genesis@14", + "tree": { + "hex": "70", + "left": { + "hex": "69" + } + } + }, + "votes.decisions.active_polls.poll": { + "origin": "fixture yes_no_vote_polls@14", + "tree": { + "hex": "02", + "left": { + "hex": "01", + "left": { + "hex": "00" + } + }, + "right": { + "hex": "03" + } + } + }, "withdrawals": { "origin": "genesis@14", "tree": { diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/lookup_reference_join_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/lookup_reference_join_tests.rs new file mode 100644 index 00000000000..3fe623a5d5e --- /dev/null +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/lookup_reference_join_tests.rs @@ -0,0 +1,144 @@ +//! Joins through a document reference resolved by a unique index +//! (`refersTo.lookup`, protocol version 14): such a reference's value is not +//! the referenced document's `$id`, so neither a chained query nor a composite +//! by-id join may take it as a join property. Both surfaces refuse it while +//! validating the shape, on the server and in the verifier alike. + +use crate::error::Error; +use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; +use dpp::platform_value::{platform_value, Identifier, Value}; +use dpp::prelude::DataContract; +use dpp::version::PlatformVersion; + +fn identifier(position: u32) -> Value { + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32u32, + "maxItems": 32u32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position + }) +} + +/// A permanent `profile` type, unique by owner, and two types whose `authorId` +/// names the owner of a profile through that index: the indexOnly `like` a +/// chained query starts from and the plain `note` a composite page starts from. +fn lookup_join_contract() -> DataContract { + let mut author_id = identifier(0); + author_id + .insert( + "refersTo".to_string(), + platform_value!({ + "type": "permanentDocument", + "documentType": "profile", + "lookup": { "index": "byOwner", "keys": { "$ownerId": "." } } + }), + ) + .expect("refersTo inserts"); + let referring_type = |index_only: bool| { + let mut schema = platform_value!({ + "type": "object", + "properties": { "authorId": author_id.clone() }, + "indices": [{ "name": "byAuthor", "properties": [{ "authorId": "asc" }] }], + "required": ["authorId"], + "additionalProperties": false + }); + if index_only { + schema + .insert("indexOnly".to_string(), Value::Bool(true)) + .expect("indexOnly inserts"); + schema + .insert("documentsMutable".to_string(), Value::Bool(false)) + .expect("documentsMutable inserts"); + } + schema + }; + DataContract::from_value( + platform_value!({ + "$formatVersion": "1", + "id": Identifier::from([0x5A; 32]), + "ownerId": Identifier::from([0x5B; 32]), + "version": 1u32, + "documentSchemas": { + "profile": { + "type": "object", + "canBeDeleted": false, + "properties": { + "name": { "type": "string", "maxLength": 32u32, "position": 0u32 } + }, + "indices": [ + { "name": "byOwner", "properties": [{ "$ownerId": "asc" }], "unique": true } + ], + "required": ["name"], + "additionalProperties": false + }, + "like": referring_type(true), + "note": referring_type(false) + } + }), + true, + PlatformVersion::latest(), + ) + .expect("the lookup join contract parses") +} + +fn by_author<'a>(contract: &'a DataContract, type_name: &str) -> DriveDocumentQuery<'a> { + DriveDocumentQuery { + contract, + document_type: contract + .document_type_for_name(type_name) + .expect("the document type exists"), + internal_clauses: InternalClauses::extract_from_clauses( + vec![WhereClause { + field: "authorId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier([0x11; 32]), + }], + PlatformVersion::latest(), + ) + .expect("the clauses extract"), + offset: None, + limit: Some(10), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + } + .with_by_id_join( + "authorId", + contract + .document_type_for_name("profile") + .expect("the profile type exists"), + ) +} + +fn assert_refused_as_a_lookup(result: Result<(), Error>) { + match result { + Err(Error::Query(error)) => assert!( + error.to_string().contains("unique index \"byOwner\""), + "the refusal should name the lookup's index: {error}" + ), + other => panic!("expected the join to be refused, got {other:?}"), + } +} + +#[test] +fn should_refuse_a_chained_join_through_a_lookup_reference() { + let contract = lookup_join_contract(); + assert_refused_as_a_lookup( + by_author(&contract, "like").validate_chained(PlatformVersion::latest()), + ); +} + +#[test] +fn should_refuse_a_composite_by_id_join_through_a_lookup_reference() { + let contract = lookup_join_contract(); + assert_refused_as_a_lookup( + by_author(&contract, "note").validate_composite(PlatformVersion::latest()), + ); +} diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs index f3e0c989771..5273b0846ab 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs @@ -31,6 +31,7 @@ mod composite_query_e2e_tests; mod countable_e2e_tests; mod index_only_e2e_tests; mod index_only_scalar_terminal_e2e_tests; +mod lookup_reference_join_tests; mod noncounted_sibling_e2e_tests; mod preallocated_index_e2e_tests; mod prefix_ranked_index_e2e_tests; diff --git a/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs b/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs index 2a07895f759..ed40f115612 100644 --- a/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs +++ b/packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs @@ -227,9 +227,14 @@ fn assert_removals( let proof = drive .prove_contract_document_removals(contract_id, query, None, platform_version) .expect("expected a removals proof"); - let (proved_root, proved) = - Drive::verify_contract_document_removals(&proof, contract_id, query, platform_version) - .expect("expected the proof to verify"); + let (proved_root, proved) = Drive::verify_contract_document_removals( + &proof, + contract_id, + query, + false, + platform_version, + ) + .expect("expected the proof to verify"); assert_eq!(proved, expected, "proved"); let root = drive .grove diff --git a/packages/rs-drive/src/drive/contract/moderation/tests.rs b/packages/rs-drive/src/drive/contract/moderation/tests.rs index ee76fe23dba..bad2a9a9dc4 100644 --- a/packages/rs-drive/src/drive/contract/moderation/tests.rs +++ b/packages/rs-drive/src/drive/contract/moderation/tests.rs @@ -133,6 +133,7 @@ fn assert_status( contract_id, identity_id, lists, + false, platform_version, ) .expect("expected to verify the status proof"); @@ -1018,6 +1019,7 @@ fn should_say_nothing_about_a_list_the_status_proof_does_not_cover() { contract.id(), target, &lists, + false, platform_version, ) .expect("expected to verify the status proof"); diff --git a/packages/rs-drive/src/drive/initialization/v4/mod.rs b/packages/rs-drive/src/drive/initialization/v4/mod.rs index c89df4de467..00eb17e945d 100644 --- a/packages/rs-drive/src/drive/initialization/v4/mod.rs +++ b/packages/rs-drive/src/drive/initialization/v4/mod.rs @@ -104,6 +104,13 @@ impl Drive { // sequence of inserts on both node populations. self.insert_contract_fee_pot_trees(transaction, platform_version)?; + // Vote decisions trees (protocol version 14): the active yes/no polls and the identity + // votes index under the votes tree's decisions branch, which the batch above creates. + // The upgrade path (`Platform::transition_to_version_14`) calls the same helper after + // the fee pot trees, so both node populations build the decisions Merk from the same + // sequence of inserts. + self.insert_vote_decisions_trees(transaction, platform_version)?; + Ok(()) } } diff --git a/packages/rs-drive/src/drive/votes/cleanup/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/mod.rs index e425c9a6c4e..488a517a2c0 100644 --- a/packages/rs-drive/src/drive/votes/cleanup/mod.rs +++ b/packages/rs-drive/src/drive/votes/cleanup/mod.rs @@ -6,3 +6,6 @@ mod remove_contested_resource_vote_poll_info_operations; mod remove_contested_resource_vote_poll_top_level_index_operations; mod remove_contested_resource_vote_poll_votes_operations; mod remove_specific_votes_given_by_identity; +mod remove_vote_poll_end_date_query_operations; +mod remove_yes_no_vote_poll_votes_operations; +mod remove_yes_no_vote_references_given_by_identity; diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/mod.rs index f93d92a424d..2f0afe81d3d 100644 --- a/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/mod.rs +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; @@ -27,7 +28,7 @@ impl Drive { .methods .vote .cleanup - .remove_specific_vote_references_given_by_identity + .remove_all_votes_given_by_identities { 0 => self.remove_all_votes_given_by_identities_v0( identity_ids_as_byte_arrays, @@ -37,9 +38,17 @@ impl Drive { transaction, platform_version, ), + 1 => self.remove_all_votes_given_by_identities_v1( + identity_ids_as_byte_arrays, + block_height, + network, + chain_id, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "remove_all_votes_given_by_identities".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/v1/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/v1/mod.rs new file mode 100644 index 00000000000..750ba852ad7 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_all_votes_given_by_identities/v1/mod.rs @@ -0,0 +1,234 @@ +use crate::drive::Drive; +use std::ops::RangeFull; + +use crate::error::drive::DriveError; +use crate::error::Error; + +use crate::drive::votes::paths::{ + vote_contested_resource_identity_votes_tree_path_for_identity, + vote_contested_resource_identity_votes_tree_path_vec, + vote_decisions_active_polls_poll_tree_path_vec, + vote_decisions_identity_votes_tree_path_for_identity, + vote_decisions_identity_votes_tree_path_vec, vote_decisions_tree_path, IDENTITY_VOTES_TREE_KEY, +}; +use crate::drive::votes::storage_form::contested_document_resource_reference_storage_form::ContestedDocumentResourceVoteReferenceStorageForm; +use crate::drive::votes::storage_form::yes_no_vote_reference_storage_form::YesNoVoteReferenceStorageForm; +use crate::drive::votes::YesNoAbstainVoteChoiceToKeyTrait; +use crate::query::QueryItem; +use crate::util::grove_operations::{BatchDeleteApplyType, DirectQueryType}; +use dpp::dashcore::Network; +use dpp::prelude::{BlockHeight, Identifier}; +use dpp::version::PlatformVersion; +use grovedb::query_result_type::QueryResultType::QueryPathKeyElementTrioResultType; +use grovedb::{Element, MaybeTree, PathQuery, Query, SizedQuery, TransactionArg}; +use std::collections::BTreeMap; + +impl Drive { + /// Version 1 (protocol version 14) removes the yes/no votes of the removed masternodes + /// too: the vote under the poll's choice sum tree and the entry in the decisions identity + /// votes index. + pub(super) fn remove_all_votes_given_by_identities_v1( + &self, + identity_ids_as_byte_arrays: Vec>, + block_height: BlockHeight, + network: Network, + chain_id: &str, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let mut deletion_batch = vec![]; + + // Contested resource votes, exactly as version 0. + let vote_path = vote_contested_resource_identity_votes_tree_path_vec(); + let mut query = Query::new_with_direction(true); + query.insert_keys(identity_ids_as_byte_arrays.clone()); + let subquery = Query::new_single_query_item(QueryItem::RangeFull(RangeFull)); + query.set_subquery(subquery); + let path_query = PathQuery::new(vote_path.clone(), SizedQuery::new(query, None, None)); + let votes_to_remove_by_identity_id = self + .grove_get_raw_path_query( + &path_query, + transaction, + QueryPathKeyElementTrioResultType, + &mut vec![], + &platform_version.drive, + )? + .0 + .to_last_path_to_key_elements_btree_map(); + + for (identifier_bytes, votes_to_remove) in votes_to_remove_by_identity_id { + let identity_id = Identifier::from_vec(identifier_bytes.clone())?; + let vote_path_ref = vote_contested_resource_identity_votes_tree_path_for_identity( + identity_id.as_bytes(), + ); + for (vote_id, vote_to_remove) in votes_to_remove { + // We delete the vote item as reference + self.batch_delete( + vote_path_ref.as_slice().into(), + vote_id.as_slice(), + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &mut deletion_batch, + &platform_version.drive, + )?; + + let serialized_reference = vote_to_remove.into_item_bytes()?; + let bincode_config = bincode::config::standard() + .with_big_endian() + .with_no_limit(); + let reference: ContestedDocumentResourceVoteReferenceStorageForm = + bincode::decode_from_slice(&serialized_reference, bincode_config) + .map_err(|e| { + Error::Drive(DriveError::CorruptedSerialization(format!( + "serialization of reference {} is corrupted: {}", + hex::encode(serialized_reference), + e + ))) + })? + .0; + + let mut absolute_path = reference + .reference_path_type + .absolute_path(vote_path_ref.as_slice(), Some(vote_id.as_slice()))?; + + // we then need to add to the batch the deletion + absolute_path.pop(); // we need to get rid of the key (which is the identifier bytes) + + let absolute_path_ref: Vec<_> = + absolute_path.iter().map(|a| a.as_slice()).collect(); + + self.batch_delete( + absolute_path_ref.as_slice().into(), + identifier_bytes.as_slice(), + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &mut deletion_batch, + &platform_version.drive, + )?; + } + } + + // Yes/no votes: the index item names the poll and the answer, so the vote sits under + // the poll's tree at the answer's sum tree. A chain that activated protocol version 14 + // before the decisions trees existed has no identity votes index, and no yes/no votes. + let has_decisions_index = self.grove_has_raw( + (&vote_decisions_tree_path()).into(), + &[IDENTITY_VOTES_TREE_KEY as u8], + DirectQueryType::StatefulDirectQuery, + transaction, + &mut vec![], + &platform_version.drive, + )?; + let yes_no_votes_to_remove_by_identity_id = if has_decisions_index { + self.yes_no_votes_of_identities( + identity_ids_as_byte_arrays, + transaction, + platform_version, + )? + } else { + BTreeMap::new() + }; + + for (identifier_bytes, votes_to_remove) in yes_no_votes_to_remove_by_identity_id { + let identity_id = Identifier::from_vec(identifier_bytes.clone())?; + let vote_path_ref = + vote_decisions_identity_votes_tree_path_for_identity(identity_id.as_bytes()); + for (vote_poll_id, vote_to_remove) in votes_to_remove { + self.batch_delete( + vote_path_ref.as_slice().into(), + vote_poll_id.as_slice(), + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &mut deletion_batch, + &platform_version.drive, + )?; + + let serialized_form = vote_to_remove.into_item_bytes()?; + let storage_form = YesNoVoteReferenceStorageForm::deserialize(&serialized_form) + .map_err(|e| { + Error::Drive(DriveError::CorruptedSerialization(format!( + "serialization of yes/no vote reference {} is corrupted: {}", + hex::encode(serialized_form), + e + ))) + })?; + let vote_poll_id = Identifier::from_vec(vote_poll_id)?; + let mut votes_path = + vote_decisions_active_polls_poll_tree_path_vec(vote_poll_id.as_bytes()); + votes_path.push(vec![storage_form.vote_choice.to_tree_key()]); + self.batch_delete( + votes_path.as_slice().into(), + identifier_bytes.as_slice(), + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &mut deletion_batch, + &platform_version.drive, + )?; + } + } + + if !deletion_batch.is_empty() { + // We had a sequence of errors on the mainnet started since block 32326. + // We got RocksDB's "transaction is busy" error because of a bug (https://github.com/dashpay/platform/pull/2309). + // Due to another bug in Tenderdash (https://github.com/dashpay/tenderdash/pull/966), + // validators just proceeded to the next block partially committing the state + // and updating the cache (https://github.com/dashpay/platform/pull/2305). + // Full nodes are stuck and proceeded after re-sync. + // For the mainnet chain, we enable this fix at the block when we consider the state is consistent. + let transaction = + if network == Network::Mainnet && chain_id == "evo1" && block_height < 32329 { + // Old behaviour on mainnet + None + } else { + // We should use transaction + transaction + }; + + self.apply_batch_low_level_drive_operations( + None, + transaction, + deletion_batch, + &mut vec![], + &platform_version.drive, + ) + .inspect_err(|err| tracing::error!(?err, "vote deletion batch failed"))?; + } + + Ok(()) + } + + /// The yes/no vote index entries of these masternodes, by masternode then poll id. + #[allow(clippy::type_complexity)] + fn yes_no_votes_of_identities( + &self, + identity_ids_as_byte_arrays: Vec>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, BTreeMap, Element>>, Error> { + let decisions_vote_path = vote_decisions_identity_votes_tree_path_vec(); + let mut query = Query::new_with_direction(true); + query.insert_keys(identity_ids_as_byte_arrays); + query.set_subquery(Query::new_single_query_item(QueryItem::RangeFull( + RangeFull, + ))); + let path_query = PathQuery::new(decisions_vote_path, SizedQuery::new(query, None, None)); + Ok(self + .grove_get_raw_path_query( + &path_query, + transaction, + QueryPathKeyElementTrioResultType, + &mut vec![], + &platform_version.drive, + )? + .0 + .to_last_path_to_key_elements_btree_map()) + } +} diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_vote_poll_end_date_query_operations/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_vote_poll_end_date_query_operations/mod.rs new file mode 100644 index 00000000000..5e34e3b9965 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_vote_poll_end_date_query_operations/mod.rs @@ -0,0 +1,44 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::identifier::Identifier; +use dpp::identity::TimestampMillis; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Removes finished polls of every kind from the end date index, and each end date's tree + /// once nothing is left under it. `vote_polls` holds every poll closed in this block with + /// its end date, so the count at one end date covers both kinds: a partial count would + /// delete a tree that still holds a poll of the other kind. + pub fn remove_vote_poll_end_date_query_operations( + &self, + vote_polls: &[(Identifier, TimestampMillis)], + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .cleanup + .remove_vote_poll_end_date_query_operations + { + 0 => self.remove_vote_poll_end_date_query_operations_v0( + vote_polls, + batch_operations, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "remove_vote_poll_end_date_query_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_vote_poll_end_date_query_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_vote_poll_end_date_query_operations/v0/mod.rs new file mode 100644 index 00000000000..5cec0e5a75e --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_vote_poll_end_date_query_operations/v0/mod.rs @@ -0,0 +1,92 @@ +use crate::drive::votes::paths::{ + vote_contested_resource_end_date_queries_at_time_tree_path_vec, + vote_end_date_queries_tree_path_vec, +}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::query::VotePollsByEndDateDriveQuery; +use crate::util::common::encode::encode_u64; +use crate::util::grove_operations::BatchDeleteApplyType; +use dpp::identifier::Identifier; +use dpp::identity::TimestampMillis; +use grovedb::{MaybeTree, TransactionArg}; +use platform_version::version::PlatformVersion; +use std::collections::BTreeMap; + +impl Drive { + pub(super) fn remove_vote_poll_end_date_query_operations_v0( + &self, + vote_polls: &[(Identifier, TimestampMillis)], + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + // This is a GroveDB Tree (Not Sub Tree Merk representation) + // End Date queries + // / \ + // 15/08/2025 5PM 15/08/2025 6PM + // / \ | + // VotePoll Info 1 VotePoll Info 2 VotePoll Info 3 + let delete_apply_type = BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }; + + let mut by_end_date: BTreeMap> = BTreeMap::new(); + for (unique_id, end_date) in vote_polls { + by_end_date.entry(*end_date).or_default().push(*unique_id); + } + + for (end_date, unique_ids) in by_end_date { + let time_path = + vote_contested_resource_end_date_queries_at_time_tree_path_vec(end_date); + let count = unique_ids.len(); + for unique_id in unique_ids { + self.batch_delete( + time_path.as_slice().into(), + unique_id.as_bytes(), + delete_apply_type, + transaction, + batch_operations, + &platform_version.drive, + )?; + } + + // The tree goes only once nothing of either kind is left under it. The block's + // fetch shares one limit across every due end date, so the last end date it reached + // can be cut short: count what the tree holds instead of inferring it from how many + // polls were processed at that date. + let limit = u16::try_from(count) + .ok() + .and_then(|count| count.checked_add(1)) + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "more polls closed at one end date than an end date query can count", + )))?; + let total_count = + VotePollsByEndDateDriveQuery::execute_no_proof_for_specialized_end_time_query_only_check_end_time( + end_date, + limit, + self, + transaction, + &mut vec![], + platform_version, + )? + .len(); + let should_delete_parent_time_tree = total_count <= count; + + if should_delete_parent_time_tree { + self.batch_delete( + vote_end_date_queries_tree_path_vec().as_slice().into(), + encode_u64(end_date).as_slice(), + delete_apply_type, + transaction, + batch_operations, + &platform_version.drive, + )?; + } + } + + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_poll_votes_operations/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_poll_votes_operations/mod.rs new file mode 100644 index 00000000000..5c0f72696ab --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_poll_votes_operations/mod.rs @@ -0,0 +1,47 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::TransactionArg; +use std::collections::BTreeMap; + +impl Drive { + /// Removes the votes of finished yes/no polls and the three sum trees that held them. The + /// polls' stored info stays as the record of the decision. + pub fn remove_yes_no_vote_poll_votes_operations( + &self, + vote_polls: &[( + &YesNoVotePoll, + &BTreeMap>, + )], + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .remove_yes_no_vote_poll_votes_operations + { + 0 => self.remove_yes_no_vote_poll_votes_operations_v0( + vote_polls, + batch_operations, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "remove_yes_no_vote_poll_votes_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_poll_votes_operations/v0/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_poll_votes_operations/v0/mod.rs new file mode 100644 index 00000000000..88059d6fdc3 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_poll_votes_operations/v0/mod.rs @@ -0,0 +1,68 @@ +use crate::drive::votes::paths::YesNoVotePollPaths; +use crate::drive::votes::YesNoAbstainVoteChoiceToKeyTrait; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchDeleteApplyType; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::{MaybeTree, TransactionArg}; +use std::collections::BTreeMap; + +impl Drive { + pub(super) fn remove_yes_no_vote_poll_votes_operations_v0( + &self, + vote_polls: &[( + &YesNoVotePoll, + &BTreeMap>, + )], + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + for (vote_poll, voters_by_choice) in vote_polls { + let poll_path = vote_poll.poll_path_vec()?; + for vote_choice in YesNoAbstainVoteChoice::ALL { + let votes_path = vote_poll.choice_votes_path_vec(vote_choice)?; + // Every delete here names its element type, so GroveDB never reads the pending + // batch for it. Each is built against an empty one: handing `batch_operations` + // to `batch_delete` would copy every operation already queued on each call and + // make the clean-up quadratic in the number of voters. + if let Some(voters) = voters_by_choice.get(&vote_choice) { + for voter in voters { + let mut delete_operations = vec![]; + self.batch_delete( + votes_path.as_slice().into(), + voter.as_slice(), + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &mut delete_operations, + &platform_version.drive, + )?; + batch_operations.append(&mut delete_operations); + } + } + // The sum tree itself, emptied by the deletes above. As for a contested poll's + // vote trees, it is not declared a tree: that would make GroveDB read every + // vote again to prove it empty. + let mut delete_operations = vec![]; + self.batch_delete( + poll_path.as_slice().into(), + &[vote_choice.to_tree_key()], + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &mut delete_operations, + &platform_version.drive, + )?; + batch_operations.append(&mut delete_operations); + } + } + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_references_given_by_identity/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_references_given_by_identity/mod.rs new file mode 100644 index 00000000000..5e869ff6bb5 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_references_given_by_identity/mod.rs @@ -0,0 +1,43 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Removes a masternode's entries for these yes/no polls from the identity votes index of + /// the decisions branch. + pub fn remove_yes_no_vote_references_given_by_identity_operations( + &self, + masternode_pro_tx_hash: &Identifier, + vote_poll_ids: &[&Identifier], + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .remove_yes_no_vote_references_given_by_identity + { + 0 => self.remove_yes_no_vote_references_given_by_identity_operations_v0( + masternode_pro_tx_hash, + vote_poll_ids, + batch_operations, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "remove_yes_no_vote_references_given_by_identity_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_references_given_by_identity/v0/mod.rs b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_references_given_by_identity/v0/mod.rs new file mode 100644 index 00000000000..5a5ebe0a411 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/cleanup/remove_yes_no_vote_references_given_by_identity/v0/mod.rs @@ -0,0 +1,39 @@ +use crate::drive::votes::paths::vote_decisions_identity_votes_tree_path_for_identity; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchDeleteApplyType; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::{MaybeTree, TransactionArg}; + +impl Drive { + pub(super) fn remove_yes_no_vote_references_given_by_identity_operations_v0( + &self, + masternode_pro_tx_hash: &Identifier, + vote_poll_ids: &[&Identifier], + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let path = + vote_decisions_identity_votes_tree_path_for_identity(masternode_pro_tx_hash.as_bytes()); + for vote_poll_id in vote_poll_ids { + // An item delete never reads the pending batch, so it is built against an empty + // one instead of copying everything a poll's clean-up has queued so far. + let mut delete_operations = vec![]; + self.batch_delete( + path.as_slice().into(), + vote_poll_id.as_slice(), + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &mut delete_operations, + &platform_version.drive, + )?; + batch_operations.append(&mut delete_operations); + } + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_identities_voting_in_yes_no_vote_poll/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_identities_voting_in_yes_no_vote_poll/mod.rs new file mode 100644 index 00000000000..4cf401ad926 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_identities_voting_in_yes_no_vote_poll/mod.rs @@ -0,0 +1,41 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::TransactionArg; +use std::collections::BTreeMap; + +impl Drive { + /// The masternodes that voted on a yes/no poll, by their answer. Every choice is present, + /// with an empty list when nobody chose it. + pub fn fetch_identities_voting_in_yes_no_vote_poll( + &self, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .fetch_identities_voting_in_yes_no_vote_poll + { + 0 => self.fetch_identities_voting_in_yes_no_vote_poll_v0( + vote_poll, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_identities_voting_in_yes_no_vote_poll".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_identities_voting_in_yes_no_vote_poll/v0/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_identities_voting_in_yes_no_vote_poll/v0/mod.rs new file mode 100644 index 00000000000..788e6e9a976 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_identities_voting_in_yes_no_vote_poll/v0/mod.rs @@ -0,0 +1,75 @@ +use crate::drive::votes::paths::YesNoVotePollPaths; +use crate::drive::votes::YesNoAbstainVoteChoiceToKeyTrait; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::query_result_type::QueryResultType; +use grovedb::{PathQuery, Query, QueryItem, SizedQuery, TransactionArg}; +use std::collections::BTreeMap; +use std::ops::RangeFull; + +impl Drive { + pub(super) fn fetch_identities_voting_in_yes_no_vote_poll_v0( + &self, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + let path = vote_poll.poll_path_vec()?; + let mut query = Query::new_with_direction(true); + query.insert_keys( + YesNoAbstainVoteChoice::ALL + .iter() + .map(|vote_choice| vec![vote_choice.to_tree_key()]) + .collect(), + ); + query.set_subquery(Query::new_single_query_item(QueryItem::RangeFull( + RangeFull, + ))); + let path_query = PathQuery { + path, + query: SizedQuery { + query, + limit: None, + offset: None, + }, + }; + let voters_by_choice_key = self + .grove_get_path_query( + &path_query, + transaction, + QueryResultType::QueryPathKeyElementTrioResultType, + &mut vec![], + &platform_version.drive, + )? + .0 + .to_last_path_to_key_elements_btree_map(); + let mut voters_by_choice: BTreeMap> = + YesNoAbstainVoteChoice::ALL + .iter() + .map(|vote_choice| (*vote_choice, vec![])) + .collect(); + for (choice_key, voters) in voters_by_choice_key { + let vote_choice = choice_key + .first() + .filter(|_| choice_key.len() == 1) + .and_then(|key| YesNoAbstainVoteChoice::from_tree_key(*key)) + .ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState(format!( + "unexpected key {} in yes/no vote poll tree", + hex::encode(&choice_key) + ))) + })?; + let voters = voters + .into_keys() + .map(|voter| voter.try_into()) + .collect::, dpp::platform_value::Error>>()?; + voters_by_choice.insert(vote_choice, voters); + } + Ok(voters_by_choice) + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_identity_yes_no_vote/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_identity_yes_no_vote/mod.rs new file mode 100644 index 00000000000..3c4324c266f --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_identity_yes_no_vote/mod.rs @@ -0,0 +1,45 @@ +mod v0; + +use crate::drive::votes::resolved::votes::resolved_yes_no_vote::PreviousYesNoVoteCount; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use grovedb::TransactionArg; + +impl Drive { + /// A masternode's current answer to a yes/no poll and how many times it voted on it, or + /// `None` when it never did. + pub fn fetch_identity_yes_no_vote( + &self, + masternode_pro_tx_hash: Identifier, + vote_poll_id: Identifier, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .fetch_identity_yes_no_vote + { + 0 => self.fetch_identity_yes_no_vote_v0( + masternode_pro_tx_hash, + vote_poll_id, + transaction, + drive_operations, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_identity_yes_no_vote".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_identity_yes_no_vote/v0/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_identity_yes_no_vote/v0/mod.rs new file mode 100644 index 00000000000..7c9252f85cc --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_identity_yes_no_vote/v0/mod.rs @@ -0,0 +1,48 @@ +use crate::drive::votes::paths::vote_decisions_identity_votes_tree_path_for_identity; +use crate::drive::votes::resolved::votes::resolved_yes_no_vote::PreviousYesNoVoteCount; +use crate::drive::votes::storage_form::yes_no_vote_reference_storage_form::YesNoVoteReferenceStorageForm; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::DirectQueryType; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use grovedb::TransactionArg; + +impl Drive { + pub(super) fn fetch_identity_yes_no_vote_v0( + &self, + masternode_pro_tx_hash: Identifier, + vote_poll_id: Identifier, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path = + vote_decisions_identity_votes_tree_path_for_identity(masternode_pro_tx_hash.as_bytes()); + let maybe_element = self.grove_get_raw_optional( + (&path).into(), + vote_poll_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + )?; + maybe_element + .map(|element| { + let bytes = element.into_item_bytes()?; + let storage_form = + YesNoVoteReferenceStorageForm::deserialize(&bytes).map_err(|e| { + Error::Drive(DriveError::CorruptedSerialization(format!( + "serialization of yes/no vote reference {} is corrupted: {}", + hex::encode(bytes), + e + ))) + })?; + Ok((storage_form.vote_choice, storage_form.identity_vote_times)) + }) + .transpose() + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_yes_no_vote_poll_stored_info/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_yes_no_vote_poll_stored_info/mod.rs new file mode 100644 index 00000000000..32f6aec9697 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_yes_no_vote_poll_stored_info/mod.rs @@ -0,0 +1,36 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::TransactionArg; + +impl Drive { + /// The stored info of a yes/no vote poll, `None` when the poll was never opened. + pub fn fetch_yes_no_vote_poll_stored_info( + &self, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .fetch_yes_no_vote_poll_stored_info + { + 0 => { + self.fetch_yes_no_vote_poll_stored_info_v0(vote_poll, transaction, platform_version) + } + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "fetch_yes_no_vote_poll_stored_info".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/fetch_yes_no_vote_poll_stored_info/v0/mod.rs b/packages/rs-drive/src/drive/votes/fetch/fetch_yes_no_vote_poll_stored_info/v0/mod.rs new file mode 100644 index 00000000000..654fe0d5386 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/fetch/fetch_yes_no_vote_poll_stored_info/v0/mod.rs @@ -0,0 +1,35 @@ +use crate::drive::votes::paths::{YesNoVotePollPaths, YES_NO_VOTE_POLL_STORED_INFO_KEY}; +use crate::drive::Drive; +use crate::error::Error; +use crate::util::grove_operations::DirectQueryType; +use dpp::serialization::PlatformDeserializableTrusted; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::TransactionArg; + +impl Drive { + pub(super) fn fetch_yes_no_vote_poll_stored_info_v0( + &self, + vote_poll: &YesNoVotePoll, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path = vote_poll.poll_path_vec()?; + let maybe_element = self.grove_get_raw_optional( + path.as_slice().into(), + &[YES_NO_VOTE_POLL_STORED_INFO_KEY], + DirectQueryType::StatefulDirectQuery, + transaction, + &mut vec![], + &platform_version.drive, + )?; + let Some(element) = maybe_element else { + return Ok(None); + }; + let bytes = element.into_item_bytes()?; + Ok(Some( + YesNoVotePollStoredInfo::deserialize_from_bytes_trusted(&bytes)?, + )) + } +} diff --git a/packages/rs-drive/src/drive/votes/fetch/mod.rs b/packages/rs-drive/src/drive/votes/fetch/mod.rs index 74748b04f0c..982be172512 100644 --- a/packages/rs-drive/src/drive/votes/fetch/mod.rs +++ b/packages/rs-drive/src/drive/votes/fetch/mod.rs @@ -1,3 +1,6 @@ mod fetch_contested_document_vote_poll_stored_info; mod fetch_identities_voting_for_contenders; +mod fetch_identities_voting_in_yes_no_vote_poll; mod fetch_identity_contested_resource_vote; +mod fetch_identity_yes_no_vote; +mod fetch_yes_no_vote_poll_stored_info; diff --git a/packages/rs-drive/src/drive/votes/insert/mod.rs b/packages/rs-drive/src/drive/votes/insert/mod.rs index 67ea229fe2a..02c9ff21d61 100644 --- a/packages/rs-drive/src/drive/votes/insert/mod.rs +++ b/packages/rs-drive/src/drive/votes/insert/mod.rs @@ -1,3 +1,4 @@ mod contested_resource; mod register_identity_vote; mod vote_poll; +mod yes_no; diff --git a/packages/rs-drive/src/drive/votes/insert/register_identity_vote/mod.rs b/packages/rs-drive/src/drive/votes/insert/register_identity_vote/mod.rs index 1060ea88dc1..d1460122dc9 100644 --- a/packages/rs-drive/src/drive/votes/insert/register_identity_vote/mod.rs +++ b/packages/rs-drive/src/drive/votes/insert/register_identity_vote/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; @@ -66,9 +67,18 @@ impl Drive { transaction, platform_version, ), + 1 => self.register_identity_vote_v1( + voter_pro_tx_hash, + strength, + vote, + previous_resource_vote_choice_to_remove, + block_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "register_identity_vote".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -123,9 +133,17 @@ impl Drive { transaction, platform_version, ), + 1 => self.register_identity_vote_operations_v1( + voter_pro_tx_hash, + strength, + vote, + previous_resource_vote_choice_to_remove, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "register_identity_vote_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v0/mod.rs b/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v0/mod.rs index e5cd5a52fd0..659998b55f1 100644 --- a/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v0/mod.rs +++ b/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v0/mod.rs @@ -2,6 +2,7 @@ use crate::drive::votes::resolved::vote_polls::ResolvedVotePoll; use crate::drive::votes::resolved::votes::resolved_resource_vote::accessors::v0::ResolvedResourceVoteGettersV0; use crate::drive::votes::resolved::votes::ResolvedVote; use crate::drive::Drive; +use crate::error::drive::DriveError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; use crate::state_transition_action::identity::masternode_vote::v0::PreviousVoteCount; @@ -39,8 +40,18 @@ impl Drive { transaction, platform_version, ), + // Unreachable: this v0 is selected up to protocol version 13, its vote + // comes from a masternode vote action, and a masternode vote that names a + // yes/no poll does not decode there (`StateTransition::active_version_range`). + ResolvedVotePoll::YesNoVotePoll(_) => Err(Error::Drive( + DriveError::CorruptedCodeExecution("yes/no vote polls need version 1"), + )), } } + // Unreachable for the same reason: no yes/no vote decodes before 14. + ResolvedVote::YesNoVote(_) => Err(Error::Drive(DriveError::CorruptedCodeExecution( + "yes/no votes need version 1", + ))), } } @@ -68,8 +79,18 @@ impl Drive { transaction, platform_version, ), + // Unreachable: this v0 is selected up to protocol version 13, its vote + // comes from a masternode vote action, and a masternode vote that names a + // yes/no poll does not decode there (`StateTransition::active_version_range`). + ResolvedVotePoll::YesNoVotePoll(_) => Err(Error::Drive( + DriveError::CorruptedCodeExecution("yes/no vote polls need version 1"), + )), } } + // Unreachable for the same reason: no yes/no vote decodes before 14. + ResolvedVote::YesNoVote(_) => Err(Error::Drive(DriveError::CorruptedCodeExecution( + "yes/no votes need version 1", + ))), } } } diff --git a/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v1/mod.rs b/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v1/mod.rs new file mode 100644 index 00000000000..5c98e918067 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/register_identity_vote/v1/mod.rs @@ -0,0 +1,103 @@ +use crate::drive::votes::resolved::vote_polls::ResolvedVotePoll; +use crate::drive::votes::resolved::votes::resolved_resource_vote::accessors::v0::ResolvedResourceVoteGettersV0; +use crate::drive::votes::resolved::votes::ResolvedVote; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::state_transition_action::identity::masternode_vote::v0::PreviousVoteCount; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use grovedb::TransactionArg; +use platform_version::version::PlatformVersion; + +impl Drive { + /// Version 1 (protocol version 14) registers a vote of either kind: a resource vote as + /// before, and a yes/no vote under its poll in the decisions branch. + #[allow(clippy::too_many_arguments)] + pub(super) fn register_identity_vote_v1( + &self, + voter_pro_tx_hash: [u8; 32], + strength: u8, + vote: ResolvedVote, + previous_resource_vote_choice_to_remove: Option<(ResourceVoteChoice, PreviousVoteCount)>, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match vote { + ResolvedVote::ResolvedResourceVote(resource_vote) => { + let vote_choice = resource_vote.resource_vote_choice(); + match resource_vote.vote_poll_owned() { + ResolvedVotePoll::ContestedDocumentResourceVotePollWithContractInfo( + contested_document_resource_vote_poll, + ) => self.register_contested_resource_identity_vote( + voter_pro_tx_hash, + strength, + contested_document_resource_vote_poll, + vote_choice, + previous_resource_vote_choice_to_remove, + block_info, + transaction, + platform_version, + ), + ResolvedVotePoll::YesNoVotePoll(_) => { + Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a resource vote cannot answer a yes/no vote poll", + ))) + } + } + } + ResolvedVote::YesNoVote(yes_no_vote) => self.register_yes_no_identity_vote( + voter_pro_tx_hash, + strength, + yes_no_vote, + block_info, + transaction, + platform_version, + ), + } + } + + pub(super) fn register_identity_vote_operations_v1( + &self, + voter_pro_tx_hash: [u8; 32], + strength: u8, + vote: ResolvedVote, + previous_resource_vote_choice_to_remove: Option<(ResourceVoteChoice, PreviousVoteCount)>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match vote { + ResolvedVote::ResolvedResourceVote(resource_vote) => { + let vote_choice = resource_vote.resource_vote_choice(); + match resource_vote.vote_poll_owned() { + ResolvedVotePoll::ContestedDocumentResourceVotePollWithContractInfo( + contested_document_resource_vote_poll, + ) => self.register_contested_resource_identity_vote_operations( + voter_pro_tx_hash, + strength, + contested_document_resource_vote_poll, + vote_choice, + previous_resource_vote_choice_to_remove, + transaction, + platform_version, + ), + ResolvedVotePoll::YesNoVotePoll(_) => { + Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a resource vote cannot answer a yes/no vote poll", + ))) + } + } + } + ResolvedVote::YesNoVote(yes_no_vote) => self.register_yes_no_identity_vote_operations( + voter_pro_tx_hash, + strength, + yes_no_vote, + transaction, + platform_version, + ), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/insert_stored_info_for_yes_no_vote_poll/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/insert_stored_info_for_yes_no_vote_poll/mod.rs new file mode 100644 index 00000000000..4ddddcd1ef2 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/insert_stored_info_for_yes_no_vote_poll/mod.rs @@ -0,0 +1,68 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::TransactionArg; + +impl Drive { + /// Writes the stored info of a yes/no vote poll, replacing what was there. + pub fn insert_stored_info_for_yes_no_vote_poll( + &self, + vote_poll: &YesNoVotePoll, + stored_info: YesNoVotePollStoredInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .insert_stored_info_for_yes_no_vote_poll + { + 0 => self.insert_stored_info_for_yes_no_vote_poll_v0( + vote_poll, + stored_info, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "insert_stored_info_for_yes_no_vote_poll".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// The operations of [`Self::insert_stored_info_for_yes_no_vote_poll`]. + pub fn insert_stored_info_for_yes_no_vote_poll_operations( + &self, + vote_poll: &YesNoVotePoll, + stored_info: YesNoVotePollStoredInfo, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .insert_stored_info_for_yes_no_vote_poll + { + 0 => self.insert_stored_info_for_yes_no_vote_poll_operations_v0( + vote_poll, + stored_info, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "insert_stored_info_for_yes_no_vote_poll_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/insert_stored_info_for_yes_no_vote_poll/v0/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/insert_stored_info_for_yes_no_vote_poll/v0/mod.rs new file mode 100644 index 00000000000..c976a961014 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/insert_stored_info_for_yes_no_vote_poll/v0/mod.rs @@ -0,0 +1,53 @@ +use crate::drive::votes::paths::{YesNoVotePollPaths, YES_NO_VOTE_POLL_STORED_INFO_KEY}; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::object_size_info::PathKeyElementInfo::PathKeyElement; +use dpp::serialization::PlatformSerializable; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::{Element, TransactionArg}; + +impl Drive { + pub(super) fn insert_stored_info_for_yes_no_vote_poll_v0( + &self, + vote_poll: &YesNoVotePoll, + stored_info: YesNoVotePollStoredInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let batch_operations = self.insert_stored_info_for_yes_no_vote_poll_operations_v0( + vote_poll, + stored_info, + platform_version, + )?; + self.apply_batch_low_level_drive_operations( + None, + transaction, + batch_operations, + &mut vec![], + &platform_version.drive, + ) + } + + pub(super) fn insert_stored_info_for_yes_no_vote_poll_operations_v0( + &self, + vote_poll: &YesNoVotePoll, + stored_info: YesNoVotePollStoredInfo, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut drive_operations: Vec = vec![]; + let serialization = stored_info.serialize_consume_to_bytes()?; + self.batch_insert::<0>( + PathKeyElement(( + vote_poll.poll_path_vec()?, + vec![YES_NO_VOTE_POLL_STORED_INFO_KEY], + Element::new_item(serialization), + )), + &mut drive_operations, + &platform_version.drive, + )?; + Ok(drive_operations) + } +} diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/mod.rs new file mode 100644 index 00000000000..54327dd002c --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/mod.rs @@ -0,0 +1,3 @@ +mod insert_stored_info_for_yes_no_vote_poll; +mod open_yes_no_vote_poll; +mod register_yes_no_identity_vote; diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/mod.rs new file mode 100644 index 00000000000..ba99d118201 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/mod.rs @@ -0,0 +1,85 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use grovedb::TransactionArg; + +impl Drive { + /// Opens a yes/no vote poll that takes votes until `end_date`: its tree with the stored + /// info and the three vote sum trees, and its entry in the end date index. The prefunded + /// balance the votes are paid from is the caller's to fund, under the poll's + /// `specialized_balance_id`. + /// + /// Refuses parameters no poll can run with, and a poll that already exists. + pub fn open_yes_no_vote_poll( + &self, + vote_poll: &YesNoVotePoll, + end_date: TimestampMillis, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .open_yes_no_vote_poll + { + 0 => self.open_yes_no_vote_poll_v0( + vote_poll, + end_date, + block_info, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "open_yes_no_vote_poll".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// The operations of [`Self::open_yes_no_vote_poll`], added to `batch_operations`. + #[allow(clippy::too_many_arguments)] + pub fn open_yes_no_vote_poll_operations( + &self, + vote_poll: &YesNoVotePoll, + end_date: TimestampMillis, + block_info: &BlockInfo, + previous_batch_operations: &mut Option<&mut Vec>, + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .open_yes_no_vote_poll + { + 0 => self.open_yes_no_vote_poll_operations_v0( + vote_poll, + end_date, + block_info, + previous_batch_operations, + batch_operations, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "open_yes_no_vote_poll_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/v0/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/v0/mod.rs new file mode 100644 index 00000000000..8e20244aa1a --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/open_yes_no_vote_poll/v0/mod.rs @@ -0,0 +1,118 @@ +use crate::drive::votes::paths::{vote_decisions_active_polls_tree_path_vec, YesNoVotePollPaths}; +use crate::drive::votes::YesNoAbstainVoteChoiceToKeyTrait; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchInsertTreeApplyType; +use crate::util::object_size_info::{DriveKeyInfo, PathKeyInfo}; +use dpp::block::block_info::BlockInfo; +use dpp::prelude::TimestampMillis; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use dpp::voting::vote_polls::VotePoll; +use dpp::ProtocolError; +use grovedb::{TransactionArg, TreeType}; + +impl Drive { + pub(super) fn open_yes_no_vote_poll_v0( + &self, + vote_poll: &YesNoVotePoll, + end_date: TimestampMillis, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let mut batch_operations = vec![]; + self.open_yes_no_vote_poll_operations_v0( + vote_poll, + end_date, + block_info, + &mut None, + &mut batch_operations, + transaction, + platform_version, + )?; + self.apply_batch_low_level_drive_operations( + None, + transaction, + batch_operations, + &mut vec![], + &platform_version.drive, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn open_yes_no_vote_poll_operations_v0( + &self, + vote_poll: &YesNoVotePoll, + end_date: TimestampMillis, + block_info: &BlockInfo, + previous_batch_operations: &mut Option<&mut Vec>, + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + vote_poll.validate_parameters(platform_version)?; + let poll_id = vote_poll.unique_id()?; + + // The poll's own tree. A poll that already has one was opened before. + let inserted = self.batch_insert_empty_tree_if_not_exists( + PathKeyInfo::PathKey::<0>(( + vote_decisions_active_polls_tree_path_vec(), + poll_id.to_vec(), + )), + TreeType::NormalTree, + None, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + batch_operations, + &platform_version.drive, + )?; + if !inserted { + return Err(Error::Protocol(Box::new(ProtocolError::VoteError( + format!( + "yes/no vote poll {} is already open or was already decided", + vote_poll + ), + )))); + } + + // Its stored info: started in this block. + let stored_info = YesNoVotePollStoredInfo::new(*block_info, platform_version)?; + let mut stored_info_operations = self.insert_stored_info_for_yes_no_vote_poll_operations( + vote_poll, + stored_info, + platform_version, + )?; + batch_operations.append(&mut stored_info_operations); + + // One sum tree per choice; the sums are the tallies. + let poll_path = vote_poll.poll_path_vec()?; + for vote_choice in YesNoAbstainVoteChoice::ALL { + self.batch_insert_empty_sum_tree( + poll_path.iter().map(|segment| segment.as_slice()), + DriveKeyInfo::Key(vec![vote_choice.to_tree_key()]), + None, + batch_operations, + &platform_version.drive, + )?; + } + + // The end date index closes the poll when its time comes. Nobody is refunded for the + // entry: the poll is a system action, so it carries no storage flags. + self.add_vote_poll_end_date_query_operations( + None, + VotePoll::YesNoVotePoll(vote_poll.clone()), + end_date, + block_info, + &mut None, + previous_batch_operations, + batch_operations, + transaction, + platform_version, + ) + } +} diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/register_yes_no_identity_vote/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/register_yes_no_identity_vote/mod.rs new file mode 100644 index 00000000000..3b9d63ec89a --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/register_yes_no_identity_vote/mod.rs @@ -0,0 +1,79 @@ +mod v0; + +use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Registers a masternode's vote on a yes/no poll: its strength under the chosen answer's + /// sum tree, the previous answer removed if the vote changed, and the identity votes index + /// updated. + pub fn register_yes_no_identity_vote( + &self, + voter_pro_tx_hash: [u8; 32], + strength: u8, + vote: ResolvedYesNoVote, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .vote + .yes_no + .register_yes_no_identity_vote + { + 0 => self.register_yes_no_identity_vote_v0( + voter_pro_tx_hash, + strength, + vote, + block_info, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "register_yes_no_identity_vote".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// The operations of [`Self::register_yes_no_identity_vote`]. + pub fn register_yes_no_identity_vote_operations( + &self, + voter_pro_tx_hash: [u8; 32], + strength: u8, + vote: ResolvedYesNoVote, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive + .methods + .vote + .yes_no + .register_yes_no_identity_vote + { + 0 => self.register_yes_no_identity_vote_operations_v0( + voter_pro_tx_hash, + strength, + vote, + transaction, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "register_yes_no_identity_vote_operations".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/votes/insert/yes_no/register_yes_no_identity_vote/v0/mod.rs b/packages/rs-drive/src/drive/votes/insert/yes_no/register_yes_no_identity_vote/v0/mod.rs new file mode 100644 index 00000000000..4b7beedaf1b --- /dev/null +++ b/packages/rs-drive/src/drive/votes/insert/yes_no/register_yes_no_identity_vote/v0/mod.rs @@ -0,0 +1,135 @@ +use crate::drive::votes::paths::{ + vote_decisions_identity_votes_tree_path_for_identity_vec, + vote_decisions_identity_votes_tree_path_vec, YesNoVotePollPaths, +}; +use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; +use crate::drive::votes::storage_form::yes_no_vote_reference_storage_form::YesNoVoteReferenceStorageForm; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::{BatchDeleteApplyType, BatchInsertTreeApplyType}; +use crate::util::object_size_info::PathKeyElementInfo::PathKeyElement; +use crate::util::object_size_info::PathKeyInfo; +use dpp::block::block_info::BlockInfo; +use dpp::fee::fee_result::FeeResult; +use dpp::version::PlatformVersion; +use grovedb::{Element, MaybeTree, TransactionArg, TreeType}; + +impl Drive { + pub(super) fn register_yes_no_identity_vote_v0( + &self, + voter_pro_tx_hash: [u8; 32], + strength: u8, + vote: ResolvedYesNoVote, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let batch_operations = self.register_yes_no_identity_vote_operations_v0( + voter_pro_tx_hash, + strength, + vote, + transaction, + platform_version, + )?; + let mut drive_operations: Vec = vec![]; + self.apply_batch_low_level_drive_operations( + None, + transaction, + batch_operations, + &mut drive_operations, + &platform_version.drive, + )?; + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, + )?; + Ok(fees) + } + + pub(super) fn register_yes_no_identity_vote_operations_v0( + &self, + voter_pro_tx_hash: [u8; 32], + strength: u8, + vote: ResolvedYesNoVote, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + // Voting has a fixed cost, so there are no estimated costs to keep. + let mut drive_operations: Vec = vec![]; + let ResolvedYesNoVote { + vote_poll, + vote_choice, + previous_vote_choice_to_remove, + } = vote; + + // The vote itself: the masternode's strength under its answer's sum tree. + let voting_path = vote_poll.choice_votes_path_vec(vote_choice)?; + self.batch_insert::<0>( + PathKeyElement(( + voting_path, + voter_pro_tx_hash.to_vec(), + Element::new_sum_item(strength as i64), + )), + &mut drive_operations, + &platform_version.drive, + )?; + + // A changed vote leaves its previous answer. + let mut identity_vote_times = 1; + if let Some((previous_vote_choice, previous_vote_count)) = previous_vote_choice_to_remove { + let previous_voting_path = vote_poll.choice_votes_path_vec(previous_vote_choice)?; + self.batch_delete( + previous_voting_path.as_slice().into(), + voter_pro_tx_hash.as_slice(), + BatchDeleteApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &mut drive_operations, + &platform_version.drive, + )?; + identity_vote_times += previous_vote_count; + } + + // The identity votes index: the masternode's tree, then its answer to this poll. + self.batch_insert_empty_tree_if_not_exists( + PathKeyInfo::PathKey::<0>(( + vote_decisions_identity_votes_tree_path_vec(), + voter_pro_tx_hash.to_vec(), + )), + TreeType::NormalTree, + None, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + &mut None, + &mut drive_operations, + &platform_version.drive, + )?; + let storage_form = YesNoVoteReferenceStorageForm { + vote_choice, + identity_vote_times, + }; + let encoded = storage_form.serialize().map_err(|e| { + Error::Drive(DriveError::CorruptedSerialization(format!( + "can not encode yes/no vote reference: {}", + e + ))) + })?; + self.batch_insert::<0>( + PathKeyElement(( + vote_decisions_identity_votes_tree_path_for_identity_vec(&voter_pro_tx_hash), + vote_poll.unique_id()?.to_vec(), + Element::new_item(encoded), + )), + &mut drive_operations, + &platform_version.drive, + )?; + Ok(drive_operations) + } +} diff --git a/packages/rs-drive/src/drive/votes/mod.rs b/packages/rs-drive/src/drive/votes/mod.rs index 83078aab11b..7b1cf13217e 100644 --- a/packages/rs-drive/src/drive/votes/mod.rs +++ b/packages/rs-drive/src/drive/votes/mod.rs @@ -1,11 +1,14 @@ use crate::drive::document::paths::contract_document_type_path; use crate::drive::votes::paths::{ RESOURCE_ABSTAIN_VOTE_TREE_KEY_U8_32, RESOURCE_LOCK_VOTE_TREE_KEY_U8_32, + YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY, YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY, + YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY, }; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::DataContract; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; use dpp::voting::vote_polls::VotePoll; use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; use dpp::voting::votes::resource_vote::ResourceVote; @@ -50,6 +53,11 @@ impl TreePath for Vote { fn tree_path<'a>(&'a self, contract: &'a DataContract) -> Result, ProtocolError> { match self { Vote::ResourceVote(resource_vote) => resource_vote.tree_path(contract), + // A yes/no poll lives under the decisions branch by its unique id, not under a + // contract's index. + Vote::YesNoVote(_) => Err(ProtocolError::VoteError( + "a yes/no vote has no tree path under a contract".to_string(), + )), } } } @@ -94,6 +102,9 @@ impl TreePath for ResourceVote { } Ok(path) } + VotePoll::YesNoVotePoll(_) => Err(ProtocolError::VoteError( + "a resource vote cannot answer a yes/no vote poll".to_string(), + )), } } } @@ -114,6 +125,34 @@ impl ResourceVoteChoiceToKeyTrait for ResourceVoteChoice { } } +/// The key of the sum tree holding the votes for a yes/no choice, and back. +pub trait YesNoAbstainVoteChoiceToKeyTrait: Sized { + /// The key of the choice's sum tree inside the poll's tree + fn to_tree_key(&self) -> u8; + + /// The choice a sum tree key stands for, if it is one of the three + fn from_tree_key(key: u8) -> Option; +} + +impl YesNoAbstainVoteChoiceToKeyTrait for YesNoAbstainVoteChoice { + fn to_tree_key(&self) -> u8 { + match self { + YesNoAbstainVoteChoice::Yes => YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY, + YesNoAbstainVoteChoice::No => YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY, + YesNoAbstainVoteChoice::Abstain => YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY, + } + } + + fn from_tree_key(key: u8) -> Option { + match key { + YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY => Some(YesNoAbstainVoteChoice::Yes), + YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY => Some(YesNoAbstainVoteChoice::No), + YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY => Some(YesNoAbstainVoteChoice::Abstain), + _ => None, + } + } +} + #[cfg(test)] #[cfg(feature = "server")] mod tests { diff --git a/packages/rs-drive/src/drive/votes/paths.rs b/packages/rs-drive/src/drive/votes/paths.rs index 1b35eecbc58..a466460cf5f 100644 --- a/packages/rs-drive/src/drive/votes/paths.rs +++ b/packages/rs-drive/src/drive/votes/paths.rs @@ -5,15 +5,37 @@ /// /// |- End date Queries [key: "e"] /// |- Decisions [key: "d"] +/// |- Active polls [key: "p"] +/// |- Identifier Votes Query [key: "i"] /// |- Contested Resource [key: "c"] /// |- Active polls [key: "p"] /// |- Identifier Votes Query [key: "i"] /// ``` +/// +/// Decisions are yes/no vote polls (protocol version 14). Each active poll is a tree keyed by +/// the poll's unique id under the active polls tree: +/// +/// ```text +/// Active polls [key: "p"] +/// |- +/// |- Stored info [key: 0]: serialized YesNoVotePollStoredInfo +/// |- Yes votes [key: 1]: sum tree of pro_tx_hash -> vote strength +/// |- No votes [key: 2]: sum tree of pro_tx_hash -> vote strength +/// |- Abstain votes [key: 3]: sum tree of pro_tx_hash -> vote strength +/// ``` +/// +/// and each masternode's votes are indexed by its pro tx hash under the identity votes tree: +/// +/// ```text +/// Identifier Votes Query [key: "i"] +/// |- +/// |- : serialized YesNoVoteReferenceStorageForm (choice + times voted) +/// ``` use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::{ ContestedDocumentResourceVotePollWithContractInfo, ContestedDocumentResourceVotePollWithContractInfoAllowBorrowed, }; -use crate::drive::votes::ResourceVoteChoiceToKeyTrait; +use crate::drive::votes::{ResourceVoteChoiceToKeyTrait, YesNoAbstainVoteChoiceToKeyTrait}; use crate::drive::RootTree; use crate::error::query::QuerySyntaxError; use crate::error::Error; @@ -23,6 +45,8 @@ use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::IndexProperty; use dpp::identity::TimestampMillis; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; use platform_version::version::PlatformVersion; /// A subtree made for polls to the network that represent decisions. @@ -64,6 +88,18 @@ pub const CONTESTED_DOCUMENT_INDEXES_TREE_KEY: u8 = 1; /// The tree key for storage pub const VOTING_STORAGE_TREE_KEY: u8 = 1; +/// In a yes/no vote poll tree, the key of the item holding the poll's stored info +pub const YES_NO_VOTE_POLL_STORED_INFO_KEY: u8 = 0; + +/// In a yes/no vote poll tree, the key of the sum tree of yes votes +pub const YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY: u8 = 1; + +/// In a yes/no vote poll tree, the key of the sum tree of no votes +pub const YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY: u8 = 2; + +/// In a yes/no vote poll tree, the key of the sum tree of abstaining votes +pub const YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY: u8 = 3; + /// Convenience methods to be easily able to get a path when we know the vote poll pub trait VotePollPaths { /// The root path, under this there should be the documents area and the contract itself @@ -304,6 +340,35 @@ impl VotePollPaths for ContestedDocumentResourceVotePollWithContractInfoAllowBor } } +/// The paths of a yes/no vote poll's storage, derived from its unique id. +pub trait YesNoVotePollPaths { + /// The poll's tree, holding its stored info and its three vote sum trees. + fn poll_path_vec(&self) -> Result>, Error>; + + /// The sum tree holding the votes for one choice. + fn choice_votes_path_vec( + &self, + vote_choice: YesNoAbstainVoteChoice, + ) -> Result>, Error>; +} + +impl YesNoVotePollPaths for YesNoVotePoll { + fn poll_path_vec(&self) -> Result>, Error> { + Ok(vote_decisions_active_polls_poll_tree_path_vec( + self.unique_id()?.as_bytes(), + )) + } + + fn choice_votes_path_vec( + &self, + vote_choice: YesNoAbstainVoteChoice, + ) -> Result>, Error> { + let mut path = self.poll_path_vec()?; + path.push(vec![vote_choice.to_tree_key()]); + Ok(path) + } +} + /// the root path of the voting branch pub fn vote_root_path<'a>() -> [&'a [u8]; 1] { [Into::<&[u8; 1]>::into(RootTree::Votes)] @@ -546,6 +611,84 @@ pub fn vote_contested_resource_identity_votes_tree_path_for_identity_vec( ] } +/// the active yes/no vote polls of the decisions branch +pub fn vote_decisions_active_polls_tree_path<'a>() -> [&'a [u8]; 3] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[VOTE_DECISIONS_TREE_KEY as u8], + &[ACTIVE_POLLS_TREE_KEY as u8], + ] +} + +/// the active yes/no vote polls of the decisions branch as a vec +pub fn vote_decisions_active_polls_tree_path_vec() -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![VOTE_DECISIONS_TREE_KEY as u8], + vec![ACTIVE_POLLS_TREE_KEY as u8], + ] +} + +/// the tree of one yes/no vote poll, keyed by its unique id +pub fn vote_decisions_active_polls_poll_tree_path(poll_id: &[u8; 32]) -> [&[u8]; 4] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[VOTE_DECISIONS_TREE_KEY as u8], + &[ACTIVE_POLLS_TREE_KEY as u8], + poll_id, + ] +} + +/// the tree of one yes/no vote poll, keyed by its unique id, as a vec +pub fn vote_decisions_active_polls_poll_tree_path_vec(poll_id: &[u8; 32]) -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![VOTE_DECISIONS_TREE_KEY as u8], + vec![ACTIVE_POLLS_TREE_KEY as u8], + poll_id.to_vec(), + ] +} + +/// the identity votes of the decisions branch +pub fn vote_decisions_identity_votes_tree_path<'a>() -> [&'a [u8]; 3] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[VOTE_DECISIONS_TREE_KEY as u8], + &[IDENTITY_VOTES_TREE_KEY as u8], + ] +} + +/// the identity votes of the decisions branch as a vec +pub fn vote_decisions_identity_votes_tree_path_vec() -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![VOTE_DECISIONS_TREE_KEY as u8], + vec![IDENTITY_VOTES_TREE_KEY as u8], + ] +} + +/// the yes/no votes of one masternode, by its pro tx hash +pub fn vote_decisions_identity_votes_tree_path_for_identity(pro_tx_hash: &[u8; 32]) -> [&[u8]; 4] { + [ + Into::<&[u8; 1]>::into(RootTree::Votes), + &[VOTE_DECISIONS_TREE_KEY as u8], + &[IDENTITY_VOTES_TREE_KEY as u8], + pro_tx_hash, + ] +} + +/// the yes/no votes of one masternode, by its pro tx hash, as a vec +pub fn vote_decisions_identity_votes_tree_path_for_identity_vec( + pro_tx_hash: &[u8; 32], +) -> Vec> { + vec![ + vec![RootTree::Votes as u8], + vec![VOTE_DECISIONS_TREE_KEY as u8], + vec![IDENTITY_VOTES_TREE_KEY as u8], + pro_tx_hash.to_vec(), + ] +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-drive/src/drive/votes/resolved/vote_polls/mod.rs b/packages/rs-drive/src/drive/votes/resolved/vote_polls/mod.rs index 643af76e8a1..31a3cc20617 100644 --- a/packages/rs-drive/src/drive/votes/resolved/vote_polls/mod.rs +++ b/packages/rs-drive/src/drive/votes/resolved/vote_polls/mod.rs @@ -2,6 +2,8 @@ use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_ use derive_more::From; use dpp::identifier::Identifier; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; use dpp::voting::vote_polls::VotePoll; use dpp::ProtocolError; use std::collections::BTreeMap; @@ -14,12 +16,16 @@ pub mod contested_document_resource_vote_poll; pub mod resolve; /// Represents a resolved vote poll in the system. +// The contested variant carries the resolved contract; a yes/no poll is a few bytes. +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq, From)] pub enum ResolvedVotePoll { /// A resolved vote poll with contract information for a contested document resource. ContestedDocumentResourceVotePollWithContractInfo( ContestedDocumentResourceVotePollWithContractInfo, ), + /// A yes/no vote poll. It needs nothing from the state to be resolved. + YesNoVotePoll(YesNoVotePoll), } impl From<&ResolvedVotePoll> for VotePoll { @@ -30,11 +36,16 @@ impl From<&ResolvedVotePoll> for VotePoll { ) => VotePoll::ContestedDocumentResourceVotePoll( contested_document_resource_vote_poll.into(), ), + ResolvedVotePoll::YesNoVotePoll(yes_no_vote_poll) => { + VotePoll::YesNoVotePoll(yes_no_vote_poll.clone()) + } } } } /// Represents a resolved vote poll in the system that also contains votes. +// The contested variant carries the resolved contract; a yes/no poll is a few bytes. +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq, From)] pub enum ResolvedVotePollWithVotes { /// A resolved vote poll with contract information for a contested document resource. @@ -42,6 +53,11 @@ pub enum ResolvedVotePollWithVotes { ContestedDocumentResourceVotePollWithContractInfo, BTreeMap>, ), + /// A yes/no vote poll and the masternodes that voted for each choice. + YesNoVotePollWithVotes( + YesNoVotePoll, + BTreeMap>, + ), } impl ResolvedVotePoll { @@ -63,6 +79,9 @@ impl ResolvedVotePoll { ) => Ok(Some( contested_document_resource_vote_poll.specialized_balance_id()?, )), + ResolvedVotePoll::YesNoVotePoll(yes_no_vote_poll) => { + Ok(Some(yes_no_vote_poll.specialized_balance_id()?)) + } } } @@ -81,6 +100,7 @@ impl ResolvedVotePoll { ResolvedVotePoll::ContestedDocumentResourceVotePollWithContractInfo( contested_document_resource_vote_poll, ) => contested_document_resource_vote_poll.unique_id(), + ResolvedVotePoll::YesNoVotePoll(yes_no_vote_poll) => yes_no_vote_poll.unique_id(), } } } diff --git a/packages/rs-drive/src/drive/votes/resolved/vote_polls/resolve.rs b/packages/rs-drive/src/drive/votes/resolved/vote_polls/resolve.rs index eecb852f1b1..40f4b474fa8 100644 --- a/packages/rs-drive/src/drive/votes/resolved/vote_polls/resolve.rs +++ b/packages/rs-drive/src/drive/votes/resolved/vote_polls/resolve.rs @@ -63,6 +63,9 @@ impl VotePollResolver for VotePoll { ), ) } + VotePoll::YesNoVotePoll(yes_no_vote_poll) => { + Ok(ResolvedVotePoll::YesNoVotePoll(yes_no_vote_poll.clone())) + } } } @@ -84,6 +87,9 @@ impl VotePollResolver for VotePoll { ), ) } + VotePoll::YesNoVotePoll(yes_no_vote_poll) => { + Ok(ResolvedVotePoll::YesNoVotePoll(yes_no_vote_poll)) + } } } } diff --git a/packages/rs-drive/src/drive/votes/resolved/votes/mod.rs b/packages/rs-drive/src/drive/votes/resolved/votes/mod.rs index 99a31f50a69..ca1fdfd0957 100644 --- a/packages/rs-drive/src/drive/votes/resolved/votes/mod.rs +++ b/packages/rs-drive/src/drive/votes/resolved/votes/mod.rs @@ -2,10 +2,13 @@ pub(crate) mod resolve; /// Resolved resource vote module pub mod resolved_resource_vote; +/// Resolved yes/no vote module +pub mod resolved_yes_no_vote; use crate::drive::votes::resolved::vote_polls::ResolvedVotePoll; use crate::drive::votes::resolved::votes::resolved_resource_vote::accessors::v0::ResolvedResourceVoteGettersV0; use crate::drive::votes::resolved::votes::resolved_resource_vote::ResolvedResourceVote; +use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; use dpp::identifier::Identifier; use dpp::voting::vote_polls::VotePoll; use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; @@ -14,10 +17,14 @@ use dpp::voting::votes::Vote; use dpp::ProtocolError; /// Represents the different types of resolved votes within the system. +// A resource vote carries the resolved contract of its poll; a yes/no vote is a few bytes. +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)] pub enum ResolvedVote { /// A resolved vote for a specific resource. ResolvedResourceVote(ResolvedResourceVote), + /// A vote on a yes/no poll. + YesNoVote(ResolvedYesNoVote), } impl ResolvedVote { @@ -37,12 +44,20 @@ impl ResolvedVote { ResolvedVote::ResolvedResourceVote(resource_vote) => { resource_vote.vote_poll().specialized_balance_id() } + ResolvedVote::YesNoVote(yes_no_vote) => { + Ok(Some(yes_no_vote.vote_poll.specialized_balance_id()?)) + } } } } -impl From for Vote { - fn from(value: ResolvedVote) -> Self { +impl TryFrom for Vote { + type Error = ProtocolError; + + /// The vote as it was cast. A resource vote resolves to the contested document resource + /// poll it carried, so a resolved resource vote holding a yes/no poll is a programming + /// error, not a vote. + fn try_from(value: ResolvedVote) -> Result { match value { ResolvedVote::ResolvedResourceVote(resolved_resource_vote) => { match resolved_resource_vote { @@ -51,16 +66,23 @@ impl From for Vote { match v0.resolved_vote_poll { ResolvedVotePoll::ContestedDocumentResourceVotePollWithContractInfo( contested_document_resource, - ) => Self::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + ) => Ok(Self::ResourceVote(ResourceVote::V0(ResourceVoteV0 { vote_poll: VotePoll::ContestedDocumentResourceVotePoll( contested_document_resource.into(), ), resource_vote_choice: vote_choice, - })), + }))), + ResolvedVotePoll::YesNoVotePoll(_) => { + Err(ProtocolError::CorruptedCodeExecution( + "a resolved resource vote cannot hold a yes/no vote poll" + .to_string(), + )) + } } } } } + ResolvedVote::YesNoVote(yes_no_vote) => Ok(Self::YesNoVote(yes_no_vote.into())), } } } diff --git a/packages/rs-drive/src/drive/votes/resolved/votes/resolve.rs b/packages/rs-drive/src/drive/votes/resolved/votes/resolve.rs index 363a29da07f..053a181a82e 100644 --- a/packages/rs-drive/src/drive/votes/resolved/votes/resolve.rs +++ b/packages/rs-drive/src/drive/votes/resolved/votes/resolve.rs @@ -1,4 +1,5 @@ use crate::drive::votes::resolved::votes::resolved_resource_vote::resolve::ResourceVoteResolver; +use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; use crate::drive::votes::resolved::votes::ResolvedVote; use crate::drive::Drive; use crate::error::Error; @@ -53,6 +54,9 @@ impl VoteResolver for Vote { Vote::ResourceVote(resource_vote) => Ok(ResolvedVote::ResolvedResourceVote( resource_vote.resolve(drive, transaction, platform_version)?, )), + Vote::YesNoVote(yes_no_vote) => Ok(ResolvedVote::YesNoVote( + ResolvedYesNoVote::from_vote(yes_no_vote), + )), } } @@ -66,6 +70,9 @@ impl VoteResolver for Vote { Vote::ResourceVote(resource_vote) => Ok(ResolvedVote::ResolvedResourceVote( resource_vote.resolve_owned(drive, transaction, platform_version)?, )), + Vote::YesNoVote(yes_no_vote) => Ok(ResolvedVote::YesNoVote( + ResolvedYesNoVote::from_vote_owned(yes_no_vote), + )), } } } diff --git a/packages/rs-drive/src/drive/votes/resolved/votes/resolved_yes_no_vote/mod.rs b/packages/rs-drive/src/drive/votes/resolved/votes/resolved_yes_no_vote/mod.rs new file mode 100644 index 00000000000..425d51fd56c --- /dev/null +++ b/packages/rs-drive/src/drive/votes/resolved/votes/resolved_yes_no_vote/mod.rs @@ -0,0 +1,52 @@ +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePoll; +use dpp::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; +use dpp::voting::votes::yes_no_vote::v0::YesNoVoteV0; +use dpp::voting::votes::yes_no_vote::YesNoVote; + +/// How many times a masternode already voted on a poll, before the vote being cast. +pub type PreviousYesNoVoteCount = u16; + +/// A yes/no vote as the execution layer handles it. A yes/no poll needs no contract to be +/// resolved, so this is the vote itself plus what validation learned from the state: the +/// masternode's previous answer to the same poll, which registering the vote removes. +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedYesNoVote { + /// The poll answered. + pub vote_poll: YesNoVotePoll, + /// The answer. + pub vote_choice: YesNoAbstainVoteChoice, + /// The masternode's previous answer to this poll and how many times it had voted, when it + /// changes its vote. `None` for a first vote, and whenever validation was skipped. + pub previous_vote_choice_to_remove: Option<(YesNoAbstainVoteChoice, PreviousYesNoVoteCount)>, +} + +impl ResolvedYesNoVote { + /// The vote as it came in, with no previous answer attached. + pub fn from_vote(vote: &YesNoVote) -> Self { + ResolvedYesNoVote { + vote_poll: vote.vote_poll().clone(), + vote_choice: vote.vote_choice(), + previous_vote_choice_to_remove: None, + } + } + + /// The vote as it came in, owned, with no previous answer attached. + pub fn from_vote_owned(vote: YesNoVote) -> Self { + let vote_choice = vote.vote_choice(); + ResolvedYesNoVote { + vote_poll: vote.vote_poll_owned(), + vote_choice, + previous_vote_choice_to_remove: None, + } + } +} + +impl From for YesNoVote { + fn from(value: ResolvedYesNoVote) -> Self { + YesNoVote::V0(YesNoVoteV0 { + vote_poll: value.vote_poll, + vote_choice: value.vote_choice, + }) + } +} diff --git a/packages/rs-drive/src/drive/votes/setup/insert_vote_decisions_trees/mod.rs b/packages/rs-drive/src/drive/votes/setup/insert_vote_decisions_trees/mod.rs new file mode 100644 index 00000000000..c555686edb8 --- /dev/null +++ b/packages/rs-drive/src/drive/votes/setup/insert_vote_decisions_trees/mod.rs @@ -0,0 +1,79 @@ +use crate::drive::votes::paths::{ + vote_decisions_tree_path, ACTIVE_POLLS_TREE_KEY, IDENTITY_VOTES_TREE_KEY, +}; +use crate::drive::Drive; +use crate::error::Error; +use dpp::version::PlatformVersion; +use grovedb::{Element, TransactionArg}; + +impl Drive { + /// Creates the active polls and identity votes trees of the decisions branch when they are + /// missing. A chain upgrading to protocol version 14 calls this on its first block and a + /// chain born at 14 calls it from `create_initial_state_structure` v4, so both populations + /// build the same votes Merk. + pub fn insert_vote_decisions_trees( + &self, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + for subtree_key in [ACTIVE_POLLS_TREE_KEY, IDENTITY_VOTES_TREE_KEY] { + self.grove_insert_if_not_exists( + vote_decisions_tree_path().as_slice().into(), + &[subtree_key as u8], + Element::empty_tree(), + transaction, + None, + &platform_version.drive, + )?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::drive::votes::paths::{ + vote_decisions_active_polls_tree_path, vote_decisions_identity_votes_tree_path, + }; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::version::PlatformVersion; + + #[test] + fn should_create_the_decisions_trees_once_and_keep_them() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + // Genesis at the latest version already made them; a second run changes nothing. + let root_hash_before = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("root hash"); + drive + .insert_vote_decisions_trees(None, platform_version) + .expect("expected to insert the decisions trees"); + let root_hash_after = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("root hash"); + assert_eq!(root_hash_before, root_hash_after); + for path in [ + vote_decisions_active_polls_tree_path(), + vote_decisions_identity_votes_tree_path(), + ] { + let (parent, key) = path.split_at(3); + let _ = key; + let element = drive + .grove + .get( + &parent[..2], + path[2], + None, + &platform_version.drive.grove_version, + ) + .unwrap() + .expect("expected the tree"); + assert!(element.is_any_tree()); + } + } +} diff --git a/packages/rs-drive/src/drive/votes/setup/mod.rs b/packages/rs-drive/src/drive/votes/setup/mod.rs index 00eb6e7ec30..b3391f792d9 100644 --- a/packages/rs-drive/src/drive/votes/setup/mod.rs +++ b/packages/rs-drive/src/drive/votes/setup/mod.rs @@ -1 +1,2 @@ +mod insert_vote_decisions_trees; mod setup_initial_vote_tree_main_structure; diff --git a/packages/rs-drive/src/drive/votes/storage_form/mod.rs b/packages/rs-drive/src/drive/votes/storage_form/mod.rs index a797ee8c5ba..c450b46062a 100644 --- a/packages/rs-drive/src/drive/votes/storage_form/mod.rs +++ b/packages/rs-drive/src/drive/votes/storage_form/mod.rs @@ -6,3 +6,6 @@ pub mod vote_storage_form; /// Module for handling the storage form of the reference of the contested document resources. pub mod contested_document_resource_reference_storage_form; + +/// Module for handling the storage form of a masternode's vote on a yes/no poll. +pub mod yes_no_vote_reference_storage_form; diff --git a/packages/rs-drive/src/drive/votes/storage_form/vote_storage_form/mod.rs b/packages/rs-drive/src/drive/votes/storage_form/vote_storage_form/mod.rs index 2d75ebe90ce..ab8453d88db 100644 --- a/packages/rs-drive/src/drive/votes/storage_form/vote_storage_form/mod.rs +++ b/packages/rs-drive/src/drive/votes/storage_form/vote_storage_form/mod.rs @@ -60,8 +60,11 @@ impl TreePathStorageForm for VoteStorageForm { CONTESTED_RESOURCE_TREE_KEY => Ok(VoteStorageForm::ContestedDocumentResource( ContestedDocumentResourceVoteStorageForm::try_from_tree_path(path)?, )), + // A yes/no vote is keyed by the hash of its poll, which a tree path cannot be + // turned back into. The identity votes index of the decisions branch stores the + // choice itself instead of a reference. VOTE_DECISIONS_TREE_KEY => Err(ProtocolError::NotSupported( - "decision votes not supported yet".to_string(), + "yes/no votes are not addressed by tree path".to_string(), )), _ => Err(ProtocolError::VoteError(format!( "path {} second element must be a byte for CONTESTED_RESOURCE_TREE_KEY {}, got {}", @@ -175,7 +178,7 @@ mod tests { }; match err { ProtocolError::NotSupported(msg) => { - assert!(msg.contains("decision votes"), "msg: {msg}"); + assert!(msg.contains("yes/no votes"), "msg: {msg}"); } other => panic!("expected NotSupported, got {other:?}"), } diff --git a/packages/rs-drive/src/drive/votes/storage_form/yes_no_vote_reference_storage_form/mod.rs b/packages/rs-drive/src/drive/votes/storage_form/yes_no_vote_reference_storage_form/mod.rs new file mode 100644 index 00000000000..82fba796a9d --- /dev/null +++ b/packages/rs-drive/src/drive/votes/storage_form/yes_no_vote_reference_storage_form/mod.rs @@ -0,0 +1,43 @@ +use bincode::{Decode, DecodeUntrusted, Encode}; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; + +/// What the identity votes index of the decisions branch stores for one masternode's vote on +/// one yes/no poll: the answer, and how many times the masternode voted on the poll. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] +pub struct YesNoVoteReferenceStorageForm { + /// The masternode's current answer + pub vote_choice: YesNoAbstainVoteChoice, + /// How many times the masternode voted on the poll, counting this vote + pub identity_vote_times: u16, +} + +impl YesNoVoteReferenceStorageForm { + /// Encodes the form for storage. + pub fn serialize(&self) -> Result, bincode::error::EncodeError> { + bincode::encode_to_vec(self, bincode::config::standard().with_big_endian()) + } + + /// Decodes a stored form. + pub fn deserialize(bytes: &[u8]) -> Result { + bincode::decode_from_slice(bytes, bincode::config::standard().with_big_endian()) + .map(|(form, _)| form) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_round_trip() { + let form = YesNoVoteReferenceStorageForm { + vote_choice: YesNoAbstainVoteChoice::No, + identity_vote_times: 3, + }; + let bytes = form.serialize().expect("serialize"); + assert_eq!( + YesNoVoteReferenceStorageForm::deserialize(&bytes).expect("deserialize"), + form + ); + } +} diff --git a/packages/rs-drive/src/drive/votes/structure.rs b/packages/rs-drive/src/drive/votes/structure.rs index 42ef6631b51..c328bee1755 100644 --- a/packages/rs-drive/src/drive/votes/structure.rs +++ b/packages/rs-drive/src/drive/votes/structure.rs @@ -3,7 +3,9 @@ use crate::drive::votes::paths::{ CONTESTED_DOCUMENT_STORAGE_TREE_KEY, CONTESTED_RESOURCE_TREE_KEY, END_DATE_QUERIES_TREE_KEY, IDENTITY_VOTES_TREE_KEY, RESOURCE_ABSTAIN_VOTE_TREE_KEY_U8_32, RESOURCE_LOCK_VOTE_TREE_KEY_U8_32, RESOURCE_STORED_INFO_KEY_U8_32, VOTE_DECISIONS_TREE_KEY, - VOTING_STORAGE_TREE_KEY, + VOTING_STORAGE_TREE_KEY, YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY, + YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY, YES_NO_VOTE_POLL_STORED_INFO_KEY, + YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY, }; use crate::drive::RootTree; use crate::structure::{ElementKind, FlagsKind, KeyEncoding, KeyMatcher, StructureNode}; @@ -12,7 +14,9 @@ const SOURCE: &str = "packages/rs-drive/src/drive/votes/paths.rs"; const CONTENDER_FLAGS: &str = "The owner is the contender whose document created the level, who is \ refunded when the poll is cleaned up. Written without storage flags, it carries none."; -const POLL_FLAGS: &str = "The owner is the identity whose contested document started the poll."; +const POLL_FLAGS: &str = "The owner is the identity whose contested document started the \ + poll. A yes/no poll is opened by the system and carries none."; +const POLL: [FlagsKind; 2] = [FlagsKind::EpochOwned, FlagsKind::None]; const OWNED: [FlagsKind; 2] = [FlagsKind::EpochOwned, FlagsKind::None]; const CONTESTED_DOCUMENT: &str = "votes.contested_resource.active_polls.contract.document_type.storage.document"; @@ -55,8 +59,8 @@ pub(crate) fn structure() -> StructureNode { .kind(ElementKind::Tree) .source("packages/rs-drive/src/drive/mod.rs") .describe( - "Masternode votes, today on contested resources \ - such as premium DPNS names.", + "Masternode votes: on contested resources such as \ + premium DPNS names, and on yes/no decisions.", ) .children(vec![ StructureNode::fixed( @@ -83,9 +87,11 @@ pub(crate) fn structure() -> StructureNode { .kind(ElementKind::Tree) .source(SOURCE) .describe( - "Reserved for polls that decide something for the \ - network. Nothing writes to it yet.", - ), + "Yes/no polls: decisions taken by a supermajority \ + of the masternodes once enough voting power was \ + cast.", + ) + .children(vec![decision_identity_votes(), decision_active_polls()]), StructureNode::fixed( "end_date_queries", &[END_DATE_QUERIES_TREE_KEY as u8], @@ -109,7 +115,7 @@ pub(crate) fn structure() -> StructureNode { with the sign bit flipped", ) .kind(ElementKind::Tree) - .flags(&[FlagsKind::EpochOwned], POLL_FLAGS) + .flags(&POLL, POLL_FLAGS) .describe("The polls ending at this time.") .child( StructureNode::dynamic( @@ -120,7 +126,7 @@ pub(crate) fn structure() -> StructureNode { "The double sha256 of the serialized vote poll", ) .kind(ElementKind::Item) - .flags(&[FlagsKind::EpochOwned], POLL_FLAGS) + .flags(&POLL, POLL_FLAGS) .value("serialized VotePoll") .describe("One poll ending at this time."), ), @@ -322,3 +328,133 @@ fn index_value() -> StructureNode { ), ]) } + +fn decision_vote_choice( + segment: &str, + key: u8, + label: &str, + constant: &str, + what: &str, +) -> StructureNode { + StructureNode::fixed(segment, &[key], label, constant) + .kind(ElementKind::SumTree) + .until_deleted() + .describe(what) + .child( + StructureNode::identifier( + "voter", + "pro_tx_hash", + "The voting masternode's pro tx hash", + ) + .kind(ElementKind::SumItem) + .value("vote strength: 1 for a masternode, 4 for an evonode") + .describe("One masternode's vote; the tree's sum is the tally."), + ) +} + +fn decision_active_polls() -> StructureNode { + StructureNode::fixed( + "active_polls", + &[ACTIVE_POLLS_TREE_KEY as u8], + "DecisionActivePolls", + "ACTIVE_POLLS_TREE_KEY", + ) + .ascii() + .kind(ElementKind::Tree) + .since(14) + .source(SOURCE) + .describe("The yes/no polls, by their unique id.") + .child( + StructureNode::dynamic( + "poll", + "vote_poll_id", + KeyMatcher::Len(32), + KeyEncoding::Hash32, + "The double sha256 of the serialized vote poll", + ) + .kind(ElementKind::Tree) + .describe( + "One yes/no poll: its stored info and its three \ + vote sum trees. The vote trees go when the poll \ + ends; the stored info stays as the record of the \ + decision.", + ) + .children(vec![ + StructureNode::fixed( + "stored_info", + &[YES_NO_VOTE_POLL_STORED_INFO_KEY], + "YesNoStoredInfo", + "YES_NO_VOTE_POLL_STORED_INFO_KEY", + ) + .kind(ElementKind::Item) + .value("serialized YesNoVotePollStoredInfo") + .describe("The poll's status and, once it ends, its result."), + decision_vote_choice( + "yes", + YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY, + "YesVotes", + "YES_NO_VOTE_POLL_YES_VOTES_TREE_KEY", + "Votes for yes.", + ), + decision_vote_choice( + "no", + YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY, + "NoVotes", + "YES_NO_VOTE_POLL_NO_VOTES_TREE_KEY", + "Votes for no.", + ), + decision_vote_choice( + "abstain", + YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY, + "AbstainVotes", + "YES_NO_VOTE_POLL_ABSTAIN_VOTES_TREE_KEY", + "Votes to abstain, which count towards nothing.", + ), + ]), + ) +} + +fn decision_identity_votes() -> StructureNode { + StructureNode::fixed( + "identity_votes", + &[IDENTITY_VOTES_TREE_KEY as u8], + "DecisionIdentityVotes", + "IDENTITY_VOTES_TREE_KEY", + ) + .ascii() + .kind(ElementKind::Tree) + .since(14) + .source(SOURCE) + .describe( + "The yes/no votes each masternode cast, so they can \ + be changed and removed with the masternode.", + ) + .child( + StructureNode::identifier( + "voter", + "pro_tx_hash", + "The voting masternode's pro tx hash", + ) + .kind(ElementKind::Tree) + .describe( + "One masternode's yes/no votes. Stays once its \ + votes are gone, like its contested counterpart.", + ) + .child( + StructureNode::dynamic( + "vote", + "vote_poll_id", + KeyMatcher::Len(32), + KeyEncoding::Hash32, + "The double sha256 of the serialized vote poll", + ) + .kind(ElementKind::Item) + .value( + "bincode YesNoVoteReferenceStorageForm: the \ + choice and how many times the masternode voted \ + on the poll", + ) + .describe("The masternode's current answer to one poll."), + ), + ) +} diff --git a/packages/rs-drive/src/prove/prove_state_transition/mod.rs b/packages/rs-drive/src/prove/prove_state_transition/mod.rs index 707bf78cbf0..18274c076b7 100644 --- a/packages/rs-drive/src/prove/prove_state_transition/mod.rs +++ b/packages/rs-drive/src/prove/prove_state_transition/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -34,9 +35,10 @@ impl Drive { ) -> Result>, Error> { match platform_version.drive.methods.prove.prove_state_transition { 0 => self.prove_state_transition_v0(state_transition, transaction, platform_version), + 1 => self.prove_state_transition_v1(state_transition, transaction, platform_version), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "prove_state_transition".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs index 731e8376cb2..3ad8bf6a908 100644 --- a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs +++ b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs @@ -48,7 +48,9 @@ use dpp::state_transition::masternode_vote_transition::accessors::MasternodeVote use dpp::state_transition::StateTransitionIdentityIdFromInputs; use dpp::state_transition::StateTransitionWitnessSigned; use dpp::state_transition::{StateTransition, StateTransitionLike, StateTransitionOwned}; +use dpp::voting::vote_polls::VotePoll; use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; +use dpp::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; use dpp::voting::votes::Vote; use grovedb::{PathQuery, TransactionArg}; use platform_version::version::PlatformVersion; @@ -75,6 +77,20 @@ impl Drive { state_transition: &StateTransition, transaction: TransactionArg, platform_version: &PlatformVersion, + ) -> Result>, Error> { + self.prove_state_transition_internal(state_transition, transaction, false, platform_version) + } + + /// The proof of a state transition's execution, shared by every version of + /// `prove_state_transition`. With `carries_owner_balance` (from version 1) the + /// proof of an owned, fee-paying transition also carries the owner's credit + /// balance. + pub(in crate::prove::prove_state_transition) fn prove_state_transition_internal( + &self, + state_transition: &StateTransition, + transaction: TransactionArg, + carries_owner_balance: bool, + platform_version: &PlatformVersion, ) -> Result>, Error> { let path_query = match state_transition { StateTransition::DataContractCreate(st) => { @@ -145,7 +161,7 @@ impl Drive { // entry its values produce under the proof index — // the same single-entry path query the verifier // rebuilds from the transition. - { + let document_path_query = { use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; use dpp::state_transition::batch_transition::batched_transition::document_index_only_delete_transition::v0::v0_methods::DocumentIndexOnlyDeleteTransitionV0Methods; if document_type.index_only() { @@ -208,7 +224,9 @@ impl Drive { path_query.query.limit = None; path_query } - } + }; + + document_path_query } BatchedTransitionRef::Token(token_transition) => { let data_contract_id = token_transition.data_contract_id(); @@ -414,20 +432,25 @@ impl Drive { StateTransition::MasternodeVote(st) => { let pro_tx_hash = st.pro_tx_hash(); - match st.vote() { - Vote::ResourceVote(resource_vote) => { - let query = IdentityBasedVoteDriveQuery { - identity_id: pro_tx_hash, - vote_poll: resource_vote.vote_poll().clone(), - }; - - // The path query construction can only fail if the serialization fails. - // Because the serialization will pretty much never fail, we can do this. - let mut path_query = query.construct_path_query()?; - path_query.query.limit = None; - path_query + // Shared by versions 0 and 1, and off the block execution path (it answers + // proof queries). A resource vote on a contested poll gets the same path query + // as before; a vote naming a yes/no poll decodes only from protocol version 14. + let vote_poll = match st.vote() { + Vote::ResourceVote(resource_vote) => resource_vote.vote_poll().clone(), + Vote::YesNoVote(yes_no_vote) => { + VotePoll::YesNoVotePoll(yes_no_vote.vote_poll().clone()) } - } + }; + let query = IdentityBasedVoteDriveQuery { + identity_id: pro_tx_hash, + vote_poll, + }; + + // The path query construction can only fail if the serialization fails. + // Because the serialization will pretty much never fail, we can do this. + let mut path_query = query.construct_path_query()?; + path_query.query.limit = None; + path_query } StateTransition::IdentityCreditTransferToAddresses(st) => { let identity_query = Drive::revision_and_balance_path_query( @@ -699,6 +722,30 @@ impl Drive { } }; + // From version 1 the proof of an owned, fee-paying transition carries + // the owner's credit balance next to its result, so a wallet learns + // what the write left it with without a second query. The verifier + // rebuilds a document batch's merged query and verifies it strictly, + // and reads the other kinds' result and balance as subsets of the + // merged proof. + let path_query = + if carries_owner_balance && Self::proof_merges_owner_balance_after(state_transition) { + let owner_id = state_transition.owner_id().ok_or(Error::Proof( + ProofError::InvalidTransition( + "an owned transition names its owner".to_string(), + ), + ))?; + let mut path_query = path_query; + path_query.query.limit = None; + let owner_balance_query = Drive::identity_balance_query(&owner_id.to_buffer()); + PathQuery::merge( + vec![&path_query, &owner_balance_query], + &platform_version.drive.grove_version, + )? + } else { + path_query + }; + let proof = self.grove_get_proved_path_query( &path_query, transaction, @@ -708,4 +755,20 @@ impl Drive { Ok(ProofCreationResult::new_with_data(proof)) } + + /// The owned, fee-paying transitions whose version 1 proof gains the owner's + /// balance by a merge after their own path query is built: document and token + /// batches, contract creates and updates, identity updates and key limit + /// updates, and contract moderation. + fn proof_merges_owner_balance_after(state_transition: &StateTransition) -> bool { + matches!( + state_transition, + StateTransition::Batch(_) + | StateTransition::DataContractCreate(_) + | StateTransition::DataContractUpdate(_) + | StateTransition::IdentityUpdate(_) + | StateTransition::IdentityKeyLimitsUpdate(_) + | StateTransition::ContractUserModeration(_) + ) + } } diff --git a/packages/rs-drive/src/prove/prove_state_transition/v1/mod.rs b/packages/rs-drive/src/prove/prove_state_transition/v1/mod.rs new file mode 100644 index 00000000000..e9932b7b9af --- /dev/null +++ b/packages/rs-drive/src/prove/prove_state_transition/v1/mod.rs @@ -0,0 +1,22 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::prove::prove_state_transition::ProofCreationResult; +use dpp::state_transition::StateTransition; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +impl Drive { + /// Version 1: the proof of an owned, fee-paying transition (document and + /// token batches, contract creates and updates, identity updates and key + /// limit updates, contract moderation) carries the owner's credit balance + /// next to its result, in one merged query. Every other transition proves + /// as in version 0. + pub(super) fn prove_state_transition_v1( + &self, + state_transition: &StateTransition, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + self.prove_state_transition_internal(state_transition, transaction, true, platform_version) + } +} diff --git a/packages/rs-drive/src/query/chained_document_query/mod.rs b/packages/rs-drive/src/query/chained_document_query/mod.rs index 42080512394..a421cc52287 100644 --- a/packages/rs-drive/src/query/chained_document_query/mod.rs +++ b/packages/rs-drive/src/query/chained_document_query/mod.rs @@ -60,7 +60,8 @@ use crate::query::{ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::{ - DocumentPropertyType, DocumentReferenceDeclaration, DocumentTypeRef, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, + DocumentTypeRef, }; use dpp::data_contract::DataContract; use dpp::document::{Document, DocumentV0Getters}; @@ -222,12 +223,28 @@ impl<'a> DriveDocumentQuery<'a> { self.document_type.name(), ))); }; + // A typed array of references is no join property: it is not + // indexable, and a join value is one identifier let document_reference = match &join_document_property.property_type { DocumentPropertyType::IdentifierWithReference(reference_target) => { reference_target.as_document_reference() } _ => None, }; + // A lookup reference is a document reference whose value is not the + // outer document's id, so `as_document_reference` leaves it out; it + // is named here so the refusal says why + if let DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. }, + ) = &join_document_property.property_type + { + return Err(unsupported(format!( + "chained query join property \"{}\" refers to its document through the \ + unique index \"{}\", so its value is not the outer document's id: a join \ + needs a reference whose value is the referenced document's $id", + join_property, lookup.index, + ))); + } match document_reference { Some(DocumentReferenceDeclaration { contract_id, diff --git a/packages/rs-drive/src/query/composite_document_query/mod.rs b/packages/rs-drive/src/query/composite_document_query/mod.rs index c3ee3fabdff..88df71e80b9 100644 --- a/packages/rs-drive/src/query/composite_document_query/mod.rs +++ b/packages/rs-drive/src/query/composite_document_query/mod.rs @@ -99,7 +99,8 @@ use crate::query::{ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::{ - DocumentPropertyType, DocumentReferenceDeclaration, DocumentTypeRef, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, + DocumentTypeRef, }; use dpp::data_contract::DataContract; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; @@ -305,7 +306,10 @@ fn sorted_values(values: &[Identifier]) -> Vec { sorted } -/// The document reference a property type declares, of either kind. +/// The document reference a property type declares, of either kind. Only +/// a scalar reference counts: a binding reads one identifier out of the +/// property, and a typed array whose elements are references holds many, +/// is no index property and so is never a join field. fn document_reference_of( property_type: &DocumentPropertyType, ) -> Option> { @@ -613,6 +617,20 @@ impl<'a> DriveDocumentQuery<'a> { // proof; a deletableDocument one does not, and a missing // document is then a proven absence (see // `assemble_documents`). + // A lookup reference's values are not document ids, so + // `document_reference_of` leaves it out; it is named here so + // the refusal says why + if let Some(DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. }, + )) = source_property_type + { + return Err(label(&format!( + "the source property's refersTo finds its document through the unique \ + index \"{}\", so its values are not document ids: a by-id join needs \ + a reference whose value is the referenced document's $id", + lookup.index, + ))); + } match source_property_type.and_then(document_reference_of) { Some(DocumentReferenceDeclaration { contract_id, diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 7d4dfd71591..0d231e38e71 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -1302,16 +1302,22 @@ impl<'a> WhereClause { | Value::U8(_) | Value::I8(_) ), - T::U8 | T::U16 | T::U32 | T::U64 | T::U128 => matches!( - v, - Value::U8(_) | Value::U16(_) | Value::U32(_) | Value::U64(_) | Value::U128(_) - ), + T::U8 | T::U16 | T::U32 | T::KeyIdWithReference(_) | T::U64 | T::U128 => { + matches!( + v, + Value::U8(_) + | Value::U16(_) + | Value::U32(_) + | Value::U64(_) + | Value::U128(_) + ) + } T::I8 | T::I16 | T::I32 | T::I64 | T::I128 => matches!( v, Value::I8(_) | Value::I16(_) | Value::I32(_) | Value::I64(_) | Value::I128(_) ), // No validation for object/array types as operators are disallowed - T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => false, + T::Object(_) | T::Array(_) | T::VariableTypeArray(_) | T::TypedArray(_) => false, } }; @@ -1324,6 +1330,7 @@ impl<'a> WhereClause { T::U8 | T::U16 | T::U32 + | T::KeyIdWithReference(_) | T::U64 | T::U128 | T::I8 @@ -1364,7 +1371,9 @@ impl<'a> WhereClause { T::ByteArray(_) => matches!(self.value, Value::Bytes(_)), T::Boolean => matches!(self.value, Value::Bool(_)), // Not applicable for object/array/variable arrays - T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => false, + T::Object(_) | T::Array(_) | T::VariableTypeArray(_) | T::TypedArray(_) => { + false + } }; if !ok { return QuerySyntaxSimpleValidationResult::new_with_error( @@ -1424,6 +1433,7 @@ pub fn allowed_ops_for_type(property_type: &DocumentPropertyType) -> &'static [W | DocumentPropertyType::U16 | DocumentPropertyType::I16 | DocumentPropertyType::U32 + | DocumentPropertyType::KeyIdWithReference(_) | DocumentPropertyType::I32 | DocumentPropertyType::U64 | DocumentPropertyType::I64 @@ -1462,7 +1472,8 @@ pub fn allowed_ops_for_type(property_type: &DocumentPropertyType) -> &'static [W DocumentPropertyType::Boolean => &[Equal], DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) - | DocumentPropertyType::VariableTypeArray(_) => &[], + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => &[], } } diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 1a5175a2f21..a9d0a7e2342 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -198,6 +198,10 @@ pub mod vote_polls_by_end_date_query; /// Vote polls by document type query pub mod vote_polls_by_document_type_query; +/// Yes/no vote poll state query +#[cfg(any(feature = "server", feature = "verify"))] +pub mod yes_no_vote_poll_state_query; + /// Function type for looking up a contract by identifier /// /// This function is used to look up a contract by its identifier. diff --git a/packages/rs-drive/src/query/vote_query.rs b/packages/rs-drive/src/query/vote_query.rs index c49927ae108..eab05afec88 100644 --- a/packages/rs-drive/src/query/vote_query.rs +++ b/packages/rs-drive/src/query/vote_query.rs @@ -1,4 +1,7 @@ -use crate::drive::votes::paths::vote_contested_resource_identity_votes_tree_path_for_identity_vec; +use crate::drive::votes::paths::{ + vote_contested_resource_identity_votes_tree_path_for_identity_vec, + vote_decisions_identity_votes_tree_path_for_identity_vec, +}; use crate::error::Error; use crate::query::Query; use bincode::{Decode, Encode}; @@ -18,10 +21,17 @@ pub struct IdentityBasedVoteDriveQuery { impl IdentityBasedVoteDriveQuery { /// Operations to construct a path query. pub fn construct_path_query(&self) -> Result { - // First we should get the overall document_type_path - let path = vote_contested_resource_identity_votes_tree_path_for_identity_vec( - self.identity_id.as_bytes(), - ); + // Each poll kind indexes the votes of a masternode under its own branch + let path = match &self.vote_poll { + VotePoll::ContestedDocumentResourceVotePoll(_) => { + vote_contested_resource_identity_votes_tree_path_for_identity_vec( + self.identity_id.as_bytes(), + ) + } + VotePoll::YesNoVotePoll(_) => vote_decisions_identity_votes_tree_path_for_identity_vec( + self.identity_id.as_bytes(), + ), + }; let vote_id = self.vote_poll.unique_id()?; diff --git a/packages/rs-drive/src/query/yes_no_vote_poll_state_query.rs b/packages/rs-drive/src/query/yes_no_vote_poll_state_query.rs new file mode 100644 index 00000000000..b50fbb850f7 --- /dev/null +++ b/packages/rs-drive/src/query/yes_no_vote_poll_state_query.rs @@ -0,0 +1,287 @@ +#[cfg(feature = "server")] +use crate::drive::votes::paths::vote_decisions_active_polls_tree_path; +use crate::drive::votes::paths::{YesNoVotePollPaths, YES_NO_VOTE_POLL_STORED_INFO_KEY}; +use crate::drive::votes::YesNoAbstainVoteChoiceToKeyTrait; +#[cfg(feature = "server")] +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +#[cfg(feature = "server")] +use crate::fees::op::LowLevelDriveOperation; +use crate::query::Query; +#[cfg(feature = "server")] +use crate::util::grove_operations::DirectQueryType; +use dpp::serialization::PlatformDeserializableUntrusted; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; +use dpp::voting::vote_polls::yes_no_vote_poll::{VotingPower, YesNoVotePoll}; +#[cfg(feature = "server")] +use grovedb::query_result_type::QueryResultType; +#[cfg(feature = "server")] +use grovedb::TransactionArg; +use grovedb::{Element, PathQuery, SizedQuery}; +#[cfg(feature = "server")] +use platform_version::version::PlatformVersion; + +/// The state of one yes/no vote poll: its stored info and its tallies. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct YesNoVotePollState { + /// The poll's status and, once finished, its result. `None` when the poll was never opened. + pub stored_info: Option, + /// The yes voting power cast so far. Zero once the poll is finished and its votes removed. + pub yes_voting_power: VotingPower, + /// The no voting power cast so far. Zero once the poll is finished and its votes removed. + pub no_voting_power: VotingPower, + /// The abstaining voting power cast so far. Zero once the poll is finished and its votes + /// removed. + pub abstain_voting_power: VotingPower, +} + +impl YesNoVotePollState { + /// The voting power cast for a choice. + pub fn voting_power(&self, vote_choice: YesNoAbstainVoteChoice) -> VotingPower { + match vote_choice { + YesNoAbstainVoteChoice::Yes => self.yes_voting_power, + YesNoAbstainVoteChoice::No => self.no_voting_power, + YesNoAbstainVoteChoice::Abstain => self.abstain_voting_power, + } + } +} + +/// The query for the state of a yes/no vote poll: its stored info item and its three vote sum +/// trees, whose sums are the tallies. +#[derive(Debug, PartialEq, Clone)] +pub struct YesNoVotePollStateDriveQuery { + /// The poll asked about + pub vote_poll: YesNoVotePoll, +} + +impl YesNoVotePollStateDriveQuery { + /// The path query: the four keys of the poll's tree. + pub fn construct_path_query(&self) -> Result { + let path = self.vote_poll.poll_path_vec()?; + let mut query = Query::new_with_direction(true); + query.insert_key(vec![YES_NO_VOTE_POLL_STORED_INFO_KEY]); + for vote_choice in YesNoAbstainVoteChoice::ALL { + query.insert_key(vec![vote_choice.to_tree_key()]); + } + // Four keys at most; absence proofs need the limit stated. + Ok(PathQuery::new( + path, + SizedQuery::new( + query, + Some(1 + YesNoAbstainVoteChoice::ALL.len() as u16), + None, + ), + )) + } + + /// Reads the state out of the elements of the poll's tree, by key. Keys that are absent + /// leave their part of the state empty: a poll that was never opened has no stored info, + /// and a finished poll has no vote trees. + pub(crate) fn state_from_key_elements( + key_elements: impl IntoIterator, Element)>, + ) -> Result { + let mut state = YesNoVotePollState::default(); + for (key, element) in key_elements { + let [key_byte] = key.as_slice() else { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "unexpected key {} in yes/no vote poll tree", + hex::encode(&key) + )))); + }; + if *key_byte == YES_NO_VOTE_POLL_STORED_INFO_KEY { + let bytes = element.into_item_bytes()?; + state.stored_info = Some( + YesNoVotePollStoredInfo::deserialize_from_bytes_untrusted(&bytes)?, + ); + continue; + } + let Some(vote_choice) = YesNoAbstainVoteChoice::from_tree_key(*key_byte) else { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "unexpected key {} in yes/no vote poll tree", + hex::encode(&key) + )))); + }; + let Element::SumTree(_, sum, _) = element else { + return Err(Error::Drive(DriveError::CorruptedDriveState(format!( + "the {} votes of a yes/no vote poll must be a sum tree", + vote_choice + )))); + }; + let voting_power: VotingPower = sum.try_into().map_err(|_| { + Error::Drive(DriveError::CorruptedDriveState(format!( + "the {} voting power of a yes/no vote poll must be between 0 and u32::MAX, got {}", + vote_choice, sum + ))) + })?; + match vote_choice { + YesNoAbstainVoteChoice::Yes => state.yes_voting_power = voting_power, + YesNoAbstainVoteChoice::No => state.no_voting_power = voting_power, + YesNoAbstainVoteChoice::Abstain => state.abstain_voting_power = voting_power, + } + } + Ok(state) + } + + /// Executes the query and returns the proof. + #[cfg(feature = "server")] + pub fn execute_with_proof( + &self, + drive: &Drive, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let path_query = self.construct_path_query()?; + drive.grove_get_proved_path_query( + &path_query, + transaction, + drive_operations, + &platform_version.drive, + ) + } + + /// Executes the query and returns the state. + #[cfg(feature = "server")] + pub fn execute_no_proof( + &self, + drive: &Drive, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + // A poll that was never opened has no tree; the proof path reports the same as an + // empty state, so does this one. + let poll_id = self.vote_poll.unique_id()?; + let exists = drive.grove_has_raw( + (&vote_decisions_active_polls_tree_path()).into(), + poll_id.as_bytes(), + DirectQueryType::StatefulDirectQuery, + transaction, + drive_operations, + &platform_version.drive, + )?; + if !exists { + return Ok(YesNoVotePollState::default()); + } + let path_query = self.construct_path_query()?; + let key_elements = drive + .grove_get_raw_path_query( + &path_query, + transaction, + QueryResultType::QueryKeyElementPairResultType, + drive_operations, + &platform_version.drive, + )? + .0 + .to_key_elements(); + Self::state_from_key_elements(key_elements) + } +} + +#[cfg(all(test, feature = "server", feature = "verify"))] +mod tests { + use super::*; + use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::platform_value::BinaryData; + use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStatus; + use dpp::voting::vote_polls::yes_no_vote_poll::YesNoMinimumVotingPower; + + fn poll() -> YesNoVotePoll { + YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![0xc1; 32])], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute(400), + } + } + + #[test] + fn should_prove_and_verify_the_state_of_a_poll_with_votes() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let vote_poll = poll(); + drive + .open_yes_no_vote_poll( + &vote_poll, + 1_000, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected to open the poll"); + for (voter, strength, vote_choice) in [ + ([1u8; 32], 4, YesNoAbstainVoteChoice::Yes), + ([2u8; 32], 1, YesNoAbstainVoteChoice::Yes), + ([3u8; 32], 1, YesNoAbstainVoteChoice::No), + ] { + drive + .register_yes_no_identity_vote( + voter, + strength, + ResolvedYesNoVote { + vote_poll: vote_poll.clone(), + vote_choice, + previous_vote_choice_to_remove: None, + }, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected to register the vote"); + } + let query = YesNoVotePollStateDriveQuery { vote_poll }; + let state = query + .execute_no_proof(&drive, None, &mut vec![], platform_version) + .expect("expected the state"); + assert_eq!( + ( + state.yes_voting_power, + state.no_voting_power, + state.abstain_voting_power + ), + (5, 1, 0) + ); + assert!(matches!( + state.stored_info.as_ref().expect("stored info").status(), + YesNoVotePollStatus::Started(_) + )); + + let proof = query + .execute_with_proof(&drive, None, &mut vec![], platform_version) + .expect("expected a proof"); + let (root_hash, proved_state) = query + .verify_yes_no_vote_poll_state_proof(&proof, platform_version) + .expect("expected the proof to verify"); + assert_eq!( + root_hash, + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("root hash") + ); + assert_eq!(proved_state, state); + } + + #[test] + fn should_prove_and_verify_the_absence_of_a_poll() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let query = YesNoVotePollStateDriveQuery { vote_poll: poll() }; + let proof = query + .execute_with_proof(&drive, None, &mut vec![], platform_version) + .expect("expected a proof"); + let (_, proved_state) = query + .verify_yes_no_vote_poll_state_proof(&proof, platform_version) + .expect("expected the proof to verify"); + assert_eq!(proved_state, YesNoVotePollState::default()); + let state = query + .execute_no_proof(&drive, None, &mut vec![], platform_version) + .expect("expected the state of an absent poll"); + assert_eq!(state, YesNoVotePollState::default()); + } +} diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/masternode_vote_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/masternode_vote_transition.rs index 37740ef7900..317215f624f 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/masternode_vote_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/masternode_vote_transition.rs @@ -183,6 +183,7 @@ mod tests { assert_eq!(v0.resource_vote_choice, ResourceVoteChoice::Abstain); } }, + ResolvedVote::YesNoVote(_) => panic!("expected a resource vote"), } } other => panic!("expected MasternodeCastVote, got {:?}", other), @@ -291,6 +292,7 @@ mod tests { assert_eq!(v0.resource_vote_choice, ResourceVoteChoice::Abstain); } }, + ResolvedVote::YesNoVote(_) => panic!("expected a resource vote"), } } other => panic!("expected MasternodeCastVote, got {:?}", other), diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs index 893c5cc21ab..d3f8c122502 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs @@ -127,6 +127,12 @@ impl DocumentReplaceTransitionActionAccessorsV0 for DocumentReplaceTransitionAct } } + fn stored_changed_values(&self) -> &BTreeMap { + match self { + DocumentReplaceTransitionAction::V0(v0) => &v0.stored_changed_values, + } + } + fn data_owned(self) -> BTreeMap { match self { DocumentReplaceTransitionAction::V0(v0) => v0.data, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs index 1391beb7aba..92e2f917f54 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs @@ -52,6 +52,13 @@ pub struct DocumentReplaceTransitionActionV0 { /// pointed at is gone: the stored value is the only record of what /// that was. pub removed_identifier_fields: BTreeMap, + /// The stored value of each `changed_data_fields` property the stored + /// document held (the changed and the removed ones, not the added ones). + /// Read by the reference validation, which re-validates only the + /// elements of a changed typed array of references that the stored list + /// did not already hold, as an unchanged single reference is not + /// re-validated either. + pub stored_changed_values: BTreeMap, /// Creator id pub creator_id: Option, } @@ -99,6 +106,8 @@ pub trait DocumentReplaceTransitionActionAccessorsV0 { /// The identifier each removed top-level property held in the stored /// document fn removed_identifier_fields(&self) -> &BTreeMap; + /// The stored value of each changed property the stored document held + fn stored_changed_values(&self) -> &BTreeMap; /// data owned fn data_owned(self) -> BTreeMap; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs index 5a5a361fd9a..89e6b4f31c5 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs @@ -1,6 +1,6 @@ use dpp::block::block_info::BlockInfo; use dpp::document::{property_names, Document, DocumentV0Getters}; -use dpp::platform_value::Identifier; +use dpp::platform_value::{Identifier, Value}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use dpp::data_contract::document_type::accessors::DocumentTypeV1Getters; @@ -163,6 +163,19 @@ impl DocumentReplaceTransitionActionV0 { ) .collect(); + // What the stored document held for each changed property, so the + // reference validation can tell a list's new elements from the ones + // it already held + let stored_changed_values: BTreeMap = changed_fields + .iter() + .filter_map(|key| { + original_document + .properties() + .get(key) + .map(|value| (key.clone(), value.clone())) + }) + .collect(); + Ok(( BatchedTransitionAction::DocumentAction(DocumentTransitionAction::ReplaceAction( DocumentReplaceTransitionActionV0 { @@ -182,6 +195,7 @@ impl DocumentReplaceTransitionActionV0 { changed_data_fields: changed_fields, added_data_fields: added_fields, removed_identifier_fields, + stored_changed_values, creator_id: original_creator_id, } .into(), diff --git a/packages/rs-drive/src/state_transition_action/batch/tests.rs b/packages/rs-drive/src/state_transition_action/batch/tests.rs index cf44f276ab7..a4ec5149de0 100644 --- a/packages/rs-drive/src/state_transition_action/batch/tests.rs +++ b/packages/rs-drive/src/state_transition_action/batch/tests.rs @@ -402,6 +402,7 @@ fn make_replace_v0() -> DocumentReplaceTransitionActionV0 { changed_data_fields: BTreeSet::from(["field".to_string()]), added_data_fields: BTreeSet::new(), removed_identifier_fields: BTreeMap::new(), + stored_changed_values: BTreeMap::new(), creator_id: Some(Identifier::from([0xCC; 32])), } } @@ -2977,6 +2978,7 @@ fn stamp_test_replace_action(protocol_version: u32) -> DocumentReplaceTransition changed_data_fields: BTreeSet::from(["field".to_string()]), added_data_fields: BTreeSet::new(), removed_identifier_fields: BTreeMap::new(), + stored_changed_values: BTreeMap::new(), creator_id: Some(Identifier::from([0xCC; 32])), }) } diff --git a/packages/rs-drive/src/structure/tests.rs b/packages/rs-drive/src/structure/tests.rs index 8babc356afe..02de0e36579 100644 --- a/packages/rs-drive/src/structure/tests.rs +++ b/packages/rs-drive/src/structure/tests.rs @@ -273,6 +273,7 @@ mod fixtures { use super::*; use crate::drive::credit_pools::epochs::operations_factory::EpochOperations; use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; + use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; use crate::drive::Drive; use crate::fees::op::LowLevelDriveOperation; use crate::structure::export::{LayerShape, ShapeNode, StateShape}; @@ -352,7 +353,9 @@ mod fixtures { use dpp::tokens::status::TokenStatus; use dpp::tokens::token_event::TokenEvent; use dpp::tokens::token_pricing_schedule::TokenPricingSchedule; + use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfo; + use dpp::voting::vote_polls::yes_no_vote_poll::{YesNoMinimumVotingPower, YesNoVotePoll}; use rand::rngs::StdRng; use rand::SeedableRng; use std::collections::{BTreeMap, BTreeSet}; @@ -1194,6 +1197,49 @@ mod fixtures { conformance_of(&drive, "contested_documents", run); } + /// A yes/no vote poll with one vote per choice, so every node of the decisions branch is + /// reached: the poll's stored info and vote trees, and the voters' index entries. + fn yes_no_vote_polls(run: &mut FixtureRun) { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let vote_poll = YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![0xc1; 32])], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute(400), + }; + drive + .open_yes_no_vote_poll( + &vote_poll, + 1_000, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected to open the yes/no vote poll"); + for (voter, strength, vote_choice) in [ + ([1; 32], 4, YesNoAbstainVoteChoice::Yes), + ([2; 32], 1, YesNoAbstainVoteChoice::No), + ([3; 32], 1, YesNoAbstainVoteChoice::Abstain), + ] { + drive + .register_yes_no_identity_vote( + voter, + strength, + ResolvedYesNoVote { + vote_poll: vote_poll.clone(), + vote_choice, + previous_vote_choice_to_remove: None, + }, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected to register the vote"); + } + conformance_of(&drive, "yes_no_vote_polls", run); + } + fn apply_operations(drive: &Drive, operations: Vec) { drive .apply_batch_low_level_drive_operations( @@ -1510,6 +1556,7 @@ mod fixtures { address_balances(&mut run); current_then_paid_epoch(&mut run); contested_documents(&mut run); + yes_no_vote_polls(&mut run); token_distributions(&mut run); contract_groups_and_bound_keys(&mut run); spent_nullifiers(&mut run); diff --git a/packages/rs-drive/src/verify/bounded_decode.rs b/packages/rs-drive/src/verify/bounded_decode.rs index 7b324a89904..5bf76649c87 100644 --- a/packages/rs-drive/src/verify/bounded_decode.rs +++ b/packages/rs-drive/src/verify/bounded_decode.rs @@ -1,4 +1,5 @@ use crate::drive::votes::storage_form::contested_document_resource_reference_storage_form::ContestedDocumentResourceVoteReferenceStorageForm; +use crate::drive::votes::storage_form::yes_no_vote_reference_storage_form::YesNoVoteReferenceStorageForm; use crate::error::drive::DriveError; use crate::error::Error; use dpp::bincode; @@ -69,6 +70,20 @@ pub(super) fn decode_proof_data_contract( pub(super) fn decode_vote_reference( serialized_reference: &[u8], ) -> Result { + decode_bounded_vote_reference(serialized_reference) +} + +/// A masternode's proved yes/no vote, decoded under the same budget and exact-length rule as a +/// contested vote reference. +pub(super) fn decode_yes_no_vote_reference( + serialized_reference: &[u8], +) -> Result { + decode_bounded_vote_reference(serialized_reference) +} + +fn decode_bounded_vote_reference>( + serialized_reference: &[u8], +) -> Result { if serialized_reference.len() > MAX_VOTE_REFERENCE_DECODE_BYTES { return Err(Error::Drive(DriveError::CorruptedSerialization( "serialized vote reference exceeds the proof decoding limit".to_string(), diff --git a/packages/rs-drive/src/verify/contract_moderation/verify_contract_document_removals/mod.rs b/packages/rs-drive/src/verify/contract_moderation/verify_contract_document_removals/mod.rs index 91639e6e963..1fe039e4d40 100644 --- a/packages/rs-drive/src/verify/contract_moderation/verify_contract_document_removals/mod.rs +++ b/packages/rs-drive/src/verify/contract_moderation/verify_contract_document_removals/mod.rs @@ -22,6 +22,7 @@ impl Drive { proof: &[u8], contract_id: Identifier, query: &ContractDocumentRemovalsQuery, + verify_subset_of_proof: bool, platform_version: &PlatformVersion, ) -> Result<(RootHash, Vec), Error> { match platform_version @@ -35,6 +36,7 @@ impl Drive { proof, contract_id, query, + verify_subset_of_proof, platform_version, ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { diff --git a/packages/rs-drive/src/verify/contract_moderation/verify_contract_document_removals/v0/mod.rs b/packages/rs-drive/src/verify/contract_moderation/verify_contract_document_removals/v0/mod.rs index b65fc36d9ea..f5f1ec6d898 100644 --- a/packages/rs-drive/src/verify/contract_moderation/verify_contract_document_removals/v0/mod.rs +++ b/packages/rs-drive/src/verify/contract_moderation/verify_contract_document_removals/v0/mod.rs @@ -14,11 +14,15 @@ impl Drive { proof: &[u8], contract_id: Identifier, query: &ContractDocumentRemovalsQuery, + verify_subset_of_proof: bool, platform_version: &PlatformVersion, ) -> Result<(RootHash, Vec), Error> { let path_query = Self::contract_document_removals_query(contract_id.to_buffer(), query); - let (root_hash, proved_key_values) = - GroveDb::verify_query(proof, &path_query, &platform_version.drive.grove_version)?; + let (root_hash, proved_key_values) = if verify_subset_of_proof { + GroveDb::verify_subset_query(proof, &path_query, &platform_version.drive.grove_version)? + } else { + GroveDb::verify_query(proof, &path_query, &platform_version.drive.grove_version)? + }; let entries = proved_key_values .into_iter() diff --git a/packages/rs-drive/src/verify/contract_moderation/verify_contract_moderation_status/mod.rs b/packages/rs-drive/src/verify/contract_moderation/verify_contract_moderation_status/mod.rs index 0056c26ace9..ae0f3e055b7 100644 --- a/packages/rs-drive/src/verify/contract_moderation/verify_contract_moderation_status/mod.rs +++ b/packages/rs-drive/src/verify/contract_moderation/verify_contract_moderation_status/mod.rs @@ -32,6 +32,7 @@ impl Drive { contract_id: Identifier, identity_id: Identifier, lists: &[ContractModerationList], + verify_subset_of_proof: bool, platform_version: &PlatformVersion, ) -> Result<(RootHash, ContractModerationListStatuses), Error> { match platform_version @@ -46,6 +47,7 @@ impl Drive { contract_id, identity_id, lists, + verify_subset_of_proof, platform_version, ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { diff --git a/packages/rs-drive/src/verify/contract_moderation/verify_contract_moderation_status/v0/mod.rs b/packages/rs-drive/src/verify/contract_moderation/verify_contract_moderation_status/v0/mod.rs index e45afbfb5f5..a965a717430 100644 --- a/packages/rs-drive/src/verify/contract_moderation/verify_contract_moderation_status/v0/mod.rs +++ b/packages/rs-drive/src/verify/contract_moderation/verify_contract_moderation_status/v0/mod.rs @@ -19,6 +19,7 @@ impl Drive { contract_id: Identifier, identity_id: Identifier, lists: &[ContractModerationList], + verify_subset_of_proof: bool, platform_version: &PlatformVersion, ) -> Result<(RootHash, ContractModerationListStatuses), Error> { if lists.is_empty() { @@ -32,8 +33,11 @@ impl Drive { lists, &platform_version.drive.grove_version, )?; - let (root_hash, proved_key_values) = - GroveDb::verify_query(proof, &path_query, &platform_version.drive.grove_version)?; + let (root_hash, proved_key_values) = if verify_subset_of_proof { + GroveDb::verify_subset_query(proof, &path_query, &platform_version.drive.grove_version)? + } else { + GroveDb::verify_query(proof, &path_query, &platform_version.drive.grove_version)? + }; let mut status = ContractModerationStatus::default(); for (path, key, element) in proved_key_values { diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/mod.rs index 3863413a0e2..bd3195d7e43 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/mod.rs @@ -11,6 +11,7 @@ use dpp::state_transition::StateTransition; use dpp::version::PlatformVersion; mod v0; +mod v1; impl Drive { /// Verifies a state transition against a provided proof, returning the @@ -28,6 +29,11 @@ impl Drive { /// snapshot (keys derived from the transition, values as of the proof's /// block), **not** evidence that the transition executed. /// + /// From protocol version 14 a document batch's proof also carries the + /// credit balance of the batch's owner, read from the same state as the + /// document; it is a snapshot at the proof's block whatever the outcome's + /// guarantee, and `None` for a proof made at an earlier version. + /// /// # Parameters /// /// - `state_transition`: A reference to the `StateTransition` that needs to be verified. @@ -70,9 +76,16 @@ impl Drive { known_contracts_provider_fn, platform_version, ), + 1 => Drive::verify_state_transition_was_executed_with_proof_v1( + state_transition, + block_info, + proof, + known_contracts_provider_fn, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "verify_state_transition_was_executed_with_proof".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index a3958140802..86bc3853cec 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -1,4 +1,7 @@ use std::collections::BTreeMap; +use crate::drive::balances::balance_path_vec; +use grovedb::query_result_type::PathKeyOptionalElementTrio; +use grovedb::PathQuery; use dpp::address_funds::PlatformAddress; use dpp::balances::credits::TokenAmount; use dpp::block::block_info::BlockInfo; @@ -86,6 +89,33 @@ impl Drive { known_contracts_provider_fn: &ContractLookupFn, platform_version: &PlatformVersion, ) -> Result<(RootHash, StateTransitionProofOutcome), Error> { + Self::verify_state_transition_was_executed_with_proof_internal( + state_transition, + block_info, + proof, + known_contracts_provider_fn, + false, + platform_version, + ) + } + + /// The verification shared by every version of + /// `verify_state_transition_was_executed_with_proof`. With + /// `carries_owner_balance` (from version 1) the proof of an owned, fee-paying + /// transition also carries the owner's credit balance: a document batch's + /// proof is verified strictly as one merged query, an identity update's + /// through the identity keys verifier's own composition, and the others as + /// subsets of the merged proof; the outcome carries the balance. + pub(in crate::verify::state_transition::verify_state_transition_was_executed_with_proof) fn verify_state_transition_was_executed_with_proof_internal( + state_transition: &StateTransition, + block_info: &BlockInfo, + proof: &[u8], + known_contracts_provider_fn: &ContractLookupFn, + carries_owner_balance: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, StateTransitionProofOutcome), Error> { + // The balance a document batch's proof carried, set by its arm. + let mut document_owner_balance: Option = None; let (root_hash, result) = match state_transition { StateTransition::DataContractCreate(data_contract_create) => { // we expect to get a contract that matches the state transition @@ -96,7 +126,7 @@ impl Drive { let (root_hash, contract) = Drive::verify_contract( proof, Some(keeps_history), - false, + carries_owner_balance, true, data_contract_create.data_contract().id().into_buffer(), platform_version, @@ -123,7 +153,7 @@ impl Drive { let (root_hash, contract) = Drive::verify_contract( proof, Some(keeps_history), - false, + carries_owner_balance, true, data_contract_update.data_contract().id().into_buffer(), platform_version, @@ -206,15 +236,48 @@ impl Drive { documents_batch_transition.owner_id(), platform_version, )?; - let (root_hash, mut proved) = grovedb::GroveDb::verify_query( - proof, - &path_query, - &platform_version.drive.grove_version, - )?; - let entry_element = - proved.pop().and_then(|(_path, _key, element)| element); + let (root_hash, entry_element, owner_balance) = + if carries_owner_balance { + // One strict verification of the prover's + // merged query: the entry and the owner's + // balance, and nothing else. + let owner_balance_query = + Drive::identity_balance_query(&owner_id.to_buffer()); + let merged_query = PathQuery::merge( + vec![&path_query, &owner_balance_query], + &platform_version.drive.grove_version, + )?; + let (root_hash, proved) = grovedb::GroveDb::verify_query( + proof, + &merged_query, + &platform_version.drive.grove_version, + )?; + let (mut entries, owner_balance) = + Self::split_document_batch_proof_entries( + proved, owner_id, + )?; + ( + root_hash, + entries + .pop() + .and_then(|(_path, _key, element)| element), + Some(owner_balance), + ) + } else { + let (root_hash, mut proved) = + grovedb::GroveDb::verify_query( + proof, + &path_query, + &platform_version.drive.grove_version, + )?; + ( + root_hash, + proved.pop().and_then(|(_path, _key, element)| element), + None, + ) + }; - let (root_hash, result) = match document_transition { + let (root_hash, documents) = match document_transition { DocumentTransition::Create(create_transition) => { let expected_document = Document::try_from_create_transition( @@ -306,10 +369,10 @@ impl Drive { } ( root_hash, - VerifiedDocuments(BTreeMap::from([( + BTreeMap::from([( expected_document.id(), Some(expected_document), - )])), + )]), ) } DocumentTransition::IndexOnlyDelete(delete_transition) => { @@ -321,10 +384,7 @@ impl Drive { } ( root_hash, - VerifiedDocuments(BTreeMap::from([( - delete_transition.base().id(), - None, - )])), + BTreeMap::from([(delete_transition.base().id(), None)]), ) } _ => { @@ -346,14 +406,17 @@ impl Drive { // that was already absent — the proof attests // the resulting STATE (`AffectedState`), not // the execution. + let result = VerifiedDocuments(documents); + let outcome = if Self::state_transition_proof_binds_execution( state_transition, known_contracts_provider_fn, )? { - StateTransitionProofOutcome::ExecutionProved(result) + StateTransitionProofOutcome::execution_proved(result) } else { - StateTransitionProofOutcome::AffectedState(result) - }; + StateTransitionProofOutcome::affected_state(result) + } + .with_owner_balance(owner_balance); return Ok((root_hash, outcome)); } } @@ -379,10 +442,63 @@ impl Drive { block_time_ms: None, //None because we want latest contested_status, }; - let (root_hash, document) = - query.verify_proof(false, proof, document_type, platform_version)?; + let (root_hash, document, owner_balance) = if carries_owner_balance { + // One strict verification of the prover's merged query: + // the document (present or proven absent) and the + // owner's balance, and nothing else. + let mut document_path_query = + query.construct_path_query(platform_version)?; + document_path_query.query.limit = None; + let owner_balance_query = + Drive::identity_balance_query(&owner_id.to_buffer()); + let mut merged_query = PathQuery::merge( + vec![&document_path_query, &owner_balance_query], + &platform_version.drive.grove_version, + )?; + // An absence proof needs a bound: the document + // (one element, a keeps-history type keeps its + // latest revision under one key) and the balance. + merged_query.query.limit = Some(2); + let (root_hash, proved) = + grovedb::GroveDb::verify_query_with_absence_proof( + proof, + &merged_query, + &platform_version.drive.grove_version, + )?; + let (mut entries, owner_balance) = + Self::split_document_batch_proof_entries(proved, owner_id)?; + if entries.len() != 1 { + return Err(Error::Proof(ProofError::CorruptedProof(format!( + "we should always get back one document element, we got {}", + entries.len() + )))); + } + let document = entries + .remove(0) + .2 + .map(|element| element.into_item_bytes().map_err(Error::from)) + .transpose()? + .map(|serialized| { + Document::from_bytes( + serialized.as_slice(), + document_type, + platform_version, + ) + .map_err(Error::from) + }) + .transpose()?; + (root_hash, document, Some(owner_balance)) + } else { + let (root_hash, document) = query.verify_proof( + false, + proof, + document_type, + platform_version, + )?; + (root_hash, document, None) + }; - match document_transition { + let (root_hash, documents) = match document_transition { DocumentTransition::Create(create_transition) => { let document = document.ok_or(Error::Proof(ProofError::IncorrectProof(format!("proof did not contain document with id {} expected to exist because of state transition (create)", create_transition.base().id()))))?; let expected_document = Document::try_from_create_transition( @@ -407,13 +523,7 @@ impl Drive { )? { return Err(Error::Proof(ProofError::IncorrectProof(format!("proof of state transition execution did not contain expected document (time fields were not checked) after create, got: [{}] vs expected: [{}], state transition is [{}]", document, expected_document, create_transition)))); } - Ok(( - root_hash, - VerifiedDocuments(BTreeMap::from([( - document.id(), - Some(document), - )])), - )) + Ok((root_hash, BTreeMap::from([(document.id(), Some(document))]))) } DocumentTransition::Replace(replace_transition) => { let document = document.ok_or(Error::Proof(ProofError::IncorrectProof(format!("proof did not contain document with id {} expected to exist because of state transition (replace)", replace_transition.base().id()))))?; @@ -446,13 +556,7 @@ impl Drive { return Err(Error::Proof(ProofError::IncorrectProof(format!("proof of state transition execution did not contain expected document (time fields were not checked) after replace, got: [{}] vs expected: [{}], state transition is [{}]", document, expected_document, replace_transition)))); } - Ok(( - root_hash, - VerifiedDocuments(BTreeMap::from([( - document.id(), - Some(document), - )])), - )) + Ok((root_hash, BTreeMap::from([(document.id(), Some(document))]))) } DocumentTransition::Transfer(transfer_transition) => { let document = document.ok_or(Error::Proof(ProofError::IncorrectProof(format!("proof did not contain document with id {} expected to exist because of state transition (transfer)", transfer_transition.base().id()))))?; @@ -462,13 +566,7 @@ impl Drive { return Err(Error::Proof(ProofError::IncorrectProof(format!("proof of state transition execution did not have the transfer executed after expected transfer with id {}", transfer_transition.base().id())))); } - Ok(( - root_hash, - VerifiedDocuments(BTreeMap::from([( - document.id(), - Some(document), - )])), - )) + Ok((root_hash, BTreeMap::from([(document.id(), Some(document))]))) } DocumentTransition::Delete(delete_transition) => { if document.is_some() { @@ -476,10 +574,7 @@ impl Drive { } Ok(( root_hash, - VerifiedDocuments(BTreeMap::from([( - delete_transition.base().id(), - None, - )])), + BTreeMap::from([(delete_transition.base().id(), None)]), )) } DocumentTransition::UpdatePrice(update_price_transition) => { @@ -488,13 +583,7 @@ impl Drive { if new_document_price != update_price_transition.price() { return Err(Error::Proof(ProofError::IncorrectProof(format!("proof of state transition execution did not contain expected document update of price after price update with id {}", update_price_transition.base().id())))); } - Ok(( - root_hash, - VerifiedDocuments(BTreeMap::from([( - document.id(), - Some(document), - )])), - )) + Ok((root_hash, BTreeMap::from([(document.id(), Some(document))]))) } DocumentTransition::Purchase(purchase_transition) => { let document = document.ok_or(Error::Proof(ProofError::IncorrectProof(format!("proof did not contain document with id {} expected to exist because of state transition (purchase)", purchase_transition.base().id()))))?; @@ -503,13 +592,7 @@ impl Drive { return Err(Error::Proof(ProofError::IncorrectProof(format!("proof of state transition execution did not have the transfer executed after expected transfer with id {}", purchase_transition.base().id())))); } - Ok(( - root_hash, - VerifiedDocuments(BTreeMap::from([( - document.id(), - Some(document), - )])), - )) + Ok((root_hash, BTreeMap::from([(document.id(), Some(document))]))) } DocumentTransition::IndexOnlyDelete(_) => { // Only reachable when the doctype is NOT @@ -523,7 +606,9 @@ impl Drive { .to_string(), ))) } - } + }?; + document_owner_balance = owner_balance; + Ok((root_hash, VerifiedDocuments(documents))) } BatchedTransitionRef::Token(token_transition) => { let data_contract_id = token_transition.data_contract_id(); @@ -572,7 +657,8 @@ impl Drive { token_transition.base().using_group_info().is_some(); let (root_hash, document) = query.verify_proof( - is_group_action, // it will be a subset if it is a group action + // a subset if it is a group action or the owner's balance rides along + is_group_action || carries_owner_balance, proof, token_history_document_type, platform_version, @@ -705,7 +791,7 @@ impl Drive { proof, token_id.into_buffer(), owner_id.into_buffer(), - false, + carries_owner_balance, platform_version, )? else { @@ -767,7 +853,7 @@ impl Drive { proof, token_id.into_buffer(), recipient_id.into_buffer(), - false, + carries_owner_balance, platform_version, )? else { @@ -793,7 +879,7 @@ impl Drive { proof, token_id.into_buffer(), &identity_ids, - false, + carries_owner_balance, platform_version, )?; @@ -858,7 +944,7 @@ impl Drive { token_freeze_transition .frozen_identity_id() .into_buffer(), - false, + carries_owner_balance, platform_version, )? else { @@ -926,7 +1012,7 @@ impl Drive { token_unfreeze_transition .frozen_identity_id() .into_buffer(), - false, + carries_owner_balance, platform_version, )? else { @@ -952,7 +1038,7 @@ impl Drive { proof, token_id.into_buffer(), owner_id.into_buffer(), - false, + carries_owner_balance, platform_version, )? else { @@ -1007,7 +1093,7 @@ impl Drive { Drive::verify_token_direct_selling_price( proof, token_id.into_buffer(), - false, + carries_owner_balance, platform_version, )?; Ok(( @@ -1116,7 +1202,7 @@ impl Drive { ), true, false, - false, + carries_owner_balance, platform_version, )?; let identity = identity.ok_or(Error::Proof(ProofError::IncorrectProof(format!("proof did not contain update for identity {} expected to exist because of state transition (update)", identity_update_transition.identity_id()))))?; @@ -1165,7 +1251,12 @@ impl Drive { StateTransition::ContractUserModeration(transition) if transition.action().document().is_some() => { - verify_contract_document_deletion_execution(proof, transition, platform_version) + verify_contract_document_deletion_execution( + proof, + transition, + carries_owner_balance, + platform_version, + ) } StateTransition::ContractUserModeration(transition) if transition.action().restored_document().is_some() => @@ -1174,6 +1265,7 @@ impl Drive { proof, transition, known_contracts_provider_fn, + carries_owner_balance, platform_version, ) } @@ -1226,6 +1318,7 @@ impl Drive { contract_id, identity_id, &lists, + carries_owner_balance, platform_version, )?; let as_expected = match transition.action() { @@ -1358,7 +1451,7 @@ impl Drive { ), false, false, - false, + carries_owner_balance, platform_version, )?; let identity = identity.ok_or(Error::Proof(ProofError::IncorrectProof(format!( @@ -1445,31 +1538,50 @@ impl Drive { StateTransition::MasternodeVote(masternode_vote) => { let pro_tx_hash = masternode_vote.pro_tx_hash(); let vote = masternode_vote.vote(); - let contract = match vote { - Vote::ResourceVote(resource_vote) => match resource_vote.vote_poll() { - VotePoll::ContestedDocumentResourceVotePoll( - contested_document_resource_vote_poll, - ) => known_contracts_provider_fn( - &contested_document_resource_vote_poll.contract_id, + // Shared by versions 0 and 1, and client side only. A resource vote on a + // contested poll verifies exactly as before; a vote naming a yes/no poll exists + // only from protocol version 14. + let (root_hash, vote) = match vote { + Vote::ResourceVote(resource_vote) => { + let contract = match resource_vote.vote_poll() { + VotePoll::ContestedDocumentResourceVotePoll( + contested_document_resource_vote_poll, + ) => known_contracts_provider_fn( + &contested_document_resource_vote_poll.contract_id, + )? + .ok_or(Error::Proof( + ProofError::UnknownContract(format!( + "unknown contract with id {} in resource vote verification", + contested_document_resource_vote_poll.contract_id + )), + ))?, + VotePoll::YesNoVotePoll(_) => { + return Err(Error::Proof(ProofError::InvalidTransition( + "a resource vote cannot answer a yes/no vote poll".to_string(), + ))) + } + }; + // we expect to get a vote that matches the state transition + Drive::verify_masternode_vote( + proof, + pro_tx_hash.to_buffer(), + vote, + &contract, + false, + platform_version, )? - .ok_or(Error::Proof( - ProofError::UnknownContract(format!( - "unknown contract with id {} in resource vote verification", - contested_document_resource_vote_poll.contract_id - )), - ))?, - }, + } + Vote::YesNoVote(yes_no_vote) => { + let (root_hash, proved_vote) = Drive::verify_masternode_yes_no_vote( + proof, + pro_tx_hash.to_buffer(), + yes_no_vote, + false, + platform_version, + )?; + (root_hash, proved_vote.map(Vote::YesNoVote)) + } }; - - // we expect to get a vote that matches the state transition - let (root_hash, vote) = Drive::verify_masternode_vote( - proof, - pro_tx_hash.to_buffer(), - vote, - &contract, - false, - platform_version, - )?; let vote = vote.ok_or(Error::Proof(ProofError::IncorrectProof(format!("proof did not contain actual vote for masternode {} expected to exist because of state transition (masternode vote)", masternode_vote.pro_tx_hash()))))?; Ok((root_hash, VerifiedMasternodeVote(vote))) } @@ -2540,18 +2652,114 @@ impl Drive { } }?; + let owner_balance = if carries_owner_balance { + Self::owner_balance_of_verified_transition( + state_transition, + document_owner_balance, + proof, + root_hash, + platform_version, + )? + } else { + None + }; + let outcome = if Self::state_transition_proof_binds_execution( state_transition, known_contracts_provider_fn, )? { - StateTransitionProofOutcome::ExecutionProved(result) + StateTransitionProofOutcome::execution_proved(result) } else { - StateTransitionProofOutcome::AffectedState(result) - }; + StateTransitionProofOutcome::affected_state(result) + } + .with_owner_balance(owner_balance); Ok((root_hash, outcome)) } + /// The owner's credit balance a version 1 proof carries for an owned, + /// fee-paying transition: a document batch's came out of its strict merged + /// verification, and the others (contract creates and updates, identity + /// updates and key limit updates, contract moderation, token batches) are + /// read as a subset of the merged proof and must come from the same state + /// as the result. `None` for a transition whose proof does not carry it. + fn owner_balance_of_verified_transition( + state_transition: &StateTransition, + document_owner_balance: Option, + proof: &[u8], + root_hash: RootHash, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let subset_owner = match state_transition { + StateTransition::Batch(batch) => match batch.first_transition() { + Some(BatchedTransitionRef::Document(_)) => return Ok(document_owner_balance), + Some(BatchedTransitionRef::Token(_)) => state_transition.owner_id(), + None => None, + }, + StateTransition::DataContractCreate(_) + | StateTransition::DataContractUpdate(_) + | StateTransition::IdentityUpdate(_) + | StateTransition::IdentityKeyLimitsUpdate(_) + | StateTransition::ContractUserModeration(_) => state_transition.owner_id(), + _ => None, + }; + let Some(owner_id) = subset_owner else { + return Ok(None); + }; + let (balance_root_hash, balance) = Drive::verify_identity_balance_for_identity_id( + proof, + owner_id.into_buffer(), + true, + platform_version, + )?; + if balance_root_hash != root_hash { + return Err(Error::Proof(ProofError::CorruptedProof( + "the result and the owner balance of a proof are read from different states" + .to_string(), + ))); + } + balance + .ok_or(Error::Proof(ProofError::IncorrectProof(format!( + "proof did not contain the balance of the transition owner {}", + owner_id + )))) + .map(Some) + } + + /// Splits the entries one strict verification of a document batch's merged + /// query returned into the document's entries and the owner's credit + /// balance. Every identity has a balance entry, so a proof that shows none + /// for the owner is not a proof of this batch. + fn split_document_batch_proof_entries( + proved: Vec, + owner_id: Identifier, + ) -> Result<(Vec, Credits), Error> { + let balances_path = balance_path_vec(); + let mut owner_balance = None; + let mut document_entries = Vec::with_capacity(proved.len()); + for (path, key, element) in proved { + if path == balances_path && key == owner_id.as_slice() { + owner_balance = element; + } else if path == balances_path { + return Err(Error::Proof(ProofError::CorruptedProof( + "a document batch proof carries only the owner's balance".to_string(), + ))); + } else { + document_entries.push((path, key, element)); + } + } + let owner_balance = owner_balance + .ok_or(Error::Proof(ProofError::IncorrectProof(format!( + "proof did not contain the balance of the document batch owner {}", + owner_id + ))))? + .as_sum_item_value() + .map_err(Error::from)? + .try_into() + .map_err(|_| Error::Proof(ProofError::IncorrectValueSize("value size is incorrect")))?; + Ok((document_entries, owner_balance)) + } + /// Whether a valid proof for this state transition binds the execution of /// this specific transition (`ExecutionProved`) or can only authenticate /// the state the transition affects at the committed block @@ -2753,6 +2961,7 @@ impl Drive { fn verify_contract_document_deletion_execution( proof: &[u8], transition: &ContractUserModerationTransition, + verify_subset_of_proof: bool, platform_version: &PlatformVersion, ) -> Result<(RootHash, StateTransitionProofResult), Error> { let contract_id = transition.data_contract_id(); @@ -2773,6 +2982,7 @@ fn verify_contract_document_deletion_execution( document_type_name: document_type_name.clone(), selection: ContractDocumentRemovalsSelection::DocumentIds(vec![*document_id]), }, + verify_subset_of_proof, platform_version, )?; match (entries.pop(), entries.is_empty()) { @@ -2809,6 +3019,7 @@ fn verify_contract_document_restore_execution( proof: &[u8], transition: &ContractUserModerationTransition, known_contracts_provider_fn: &ContractLookupFn, + verify_subset_of_proof: bool, platform_version: &PlatformVersion, ) -> Result<(RootHash, StateTransitionProofResult), Error> { let contract_id = transition.data_contract_id(); @@ -2834,6 +3045,7 @@ fn verify_contract_document_restore_execution( document_type_name: document_type_name.to_string(), selection: ContractDocumentRemovalsSelection::DocumentIds(vec![document_id]), }, + verify_subset_of_proof, platform_version, )?; match (entries.pop(), entries.is_empty()) { @@ -2880,14 +3092,12 @@ mod tests { use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::random_document::CreateRandomDocument; use dpp::data_contract::serialized_version::DataContractInSerializationFormat; - use dpp::document::DocumentV0Getters; + use dpp::document::{DocumentV0Getters, DocumentV0Setters}; use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dpp::identity::Identity; use dpp::prelude::DataContract; use dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; - use dpp::state_transition::proof_result::{ - StateTransitionProofOutcome, StateTransitionProofResult, - }; + use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::StateTransition; use dpp::tests::fixtures::get_dpns_data_contract_fixture; use dpp::version::PlatformVersion; @@ -2940,12 +3150,22 @@ mod tests { fn verify_data_contract_create_happy_path() { let (drive, contract) = setup_drive_and_contract(); let platform_version = PlatformVersion::latest(); - let contract_id = contract.id().to_buffer(); - // Generate a proof for this contract - let proof = drive - .prove_contract(contract_id, None, platform_version) - .expect("expected to prove contract"); + // The contract's owner, with the balance every identity has: the version 1 + // proof carries it next to the contract. + let mut owner = Identity::random_identity(2, Some(21), platform_version) + .expect("expected a random identity"); + owner.set_id(contract.owner_id()); + drive + .add_new_identity( + owner.clone(), + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add the contract owner"); // Build the DataContractCreate state transition from the contract let data_contract_serialized: DataContractInSerializationFormat = contract @@ -2965,6 +3185,13 @@ mod tests { }, )); + // The prover's proof: the contract and the owner's balance + let proof = drive + .prove_state_transition(&st, None, platform_version) + .expect("expected to prove the transition") + .into_data() + .expect("expected proof bytes"); + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(None); let result = Drive::verify_state_transition_was_executed_with_proof( @@ -2982,10 +3209,14 @@ mod tests { ); let (root_hash, proof_result) = result.unwrap(); assert_ne!(root_hash, [0u8; 32], "root hash should not be all zeros"); - match proof_result { - StateTransitionProofOutcome::ExecutionProved( - StateTransitionProofResult::VerifiedDataContract(verified_contract), - ) => { + assert!( + proof_result.is_execution_proved(), + "expected ExecutionProved, got {:?}", + proof_result + ); + assert_eq!(proof_result.owner_balance(), Some(owner.balance())); + match proof_result.into_result() { + StateTransitionProofResult::VerifiedDataContract(verified_contract) => { assert_eq!( verified_contract.id(), contract.id(), @@ -3004,13 +3235,24 @@ mod tests { fn verify_data_contract_update_happy_path() { let (drive, contract) = setup_drive_and_contract(); let platform_version = PlatformVersion::latest(); - let contract_id = contract.id().to_buffer(); // For the update transition, we use the same contract (version hasn't changed // in the fixture, but the proof verifies the contract is as expected). - let proof = drive - .prove_contract(contract_id, None, platform_version) - .expect("expected to prove contract"); + // The contract's owner, with the balance every identity has: the version 1 + // proof carries it next to the contract. + let mut owner = Identity::random_identity(2, Some(21), platform_version) + .expect("expected a random identity"); + owner.set_id(contract.owner_id()); + drive + .add_new_identity( + owner.clone(), + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add the contract owner"); let data_contract_serialized: DataContractInSerializationFormat = contract .clone() @@ -3028,6 +3270,13 @@ mod tests { }, )); + // The prover's proof: the contract and the owner's balance + let proof = drive + .prove_state_transition(&st, None, platform_version) + .expect("expected to prove the transition") + .into_data() + .expect("expected proof bytes"); + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(None); let result = Drive::verify_state_transition_was_executed_with_proof( @@ -3047,10 +3296,14 @@ mod tests { // The proven contract body equaling the update's target does not // prove that THIS update executed (the version may predate the // request), so the outcome is a snapshot, not execution evidence. - match proof_result { - StateTransitionProofOutcome::AffectedState( - StateTransitionProofResult::VerifiedDataContract(verified_contract), - ) => { + assert!( + !proof_result.is_execution_proved(), + "expected AffectedState, got {:?}", + proof_result + ); + assert_eq!(proof_result.owner_balance(), Some(owner.balance())); + match proof_result.into_result() { + StateTransitionProofResult::VerifiedDataContract(verified_contract) => { assert_eq!(verified_contract.id(), contract.id()); } other => panic!( @@ -3100,10 +3353,13 @@ mod tests { result.err() ); let (_root_hash, proof_result) = result.unwrap(); - match proof_result { - StateTransitionProofOutcome::ExecutionProved( - StateTransitionProofResult::VerifiedIdentity(verified_identity), - ) => { + assert!( + proof_result.is_execution_proved(), + "expected ExecutionProved, got {:?}", + proof_result + ); + match proof_result.into_result() { + StateTransitionProofResult::VerifiedIdentity(verified_identity) => { assert_eq!(verified_identity.id(), identity.id()); } other => panic!("expected VerifiedIdentity, got {:?}", other), @@ -3179,10 +3435,14 @@ mod tests { ) .expect("expected verification to succeed"); - match outcome { - StateTransitionProofOutcome::AffectedState( - StateTransitionProofResult::VerifiedPartialIdentity(partial_identity), - ) => { + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {:?}", + outcome + ); + + match outcome.into_result() { + StateTransitionProofResult::VerifiedPartialIdentity(partial_identity) => { assert_eq!(partial_identity.id, identity.id()); assert!(partial_identity.balance.is_some()); assert!(partial_identity.revision.is_some()); @@ -3229,10 +3489,14 @@ mod tests { ) .expect("expected verification to succeed"); - match outcome { - StateTransitionProofOutcome::AffectedState( - StateTransitionProofResult::VerifiedPartialIdentity(partial_identity), - ) => { + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {:?}", + outcome + ); + + match outcome.into_result() { + StateTransitionProofResult::VerifiedPartialIdentity(partial_identity) => { assert_eq!(partial_identity.id, identity.id()); assert!(partial_identity.balance.is_some()); } @@ -3260,8 +3524,10 @@ mod tests { let key_request = IdentityKeysRequest::new_all_keys_query(&identity_id, None); let keys_path_query = key_request.into_path_query(); let revision_path_query = Drive::identity_revision_query(&identity_id); + let balance_path_query = Drive::balance_for_identity_id_query(identity_id); + // The keys, the balance and the revision, as the version 1 prover composes them. let merged = grovedb::PathQuery::merge( - vec![&keys_path_query, &revision_path_query], + vec![&keys_path_query, &balance_path_query, &revision_path_query], &platform_version.drive.grove_version, ) .expect("expected to merge path queries"); @@ -3295,10 +3561,14 @@ mod tests { result.err() ); let (_root_hash, proof_result) = result.unwrap(); - match proof_result { - StateTransitionProofOutcome::ExecutionProved( - StateTransitionProofResult::VerifiedPartialIdentity(partial_identity), - ) => { + assert!( + proof_result.is_execution_proved(), + "expected ExecutionProved, got {:?}", + proof_result + ); + assert_eq!(proof_result.owner_balance(), Some(identity.balance())); + match proof_result.into_result() { + StateTransitionProofResult::VerifiedPartialIdentity(partial_identity) => { assert_eq!(partial_identity.id, identity.id()); assert!( !partial_identity.loaded_public_keys.is_empty(), @@ -3317,8 +3587,10 @@ mod tests { let key_request = IdentityKeysRequest::new_all_keys_query(&identity_id, None); let keys_path_query = key_request.into_path_query(); let revision_path_query = Drive::identity_revision_query(&identity_id); + let balance_path_query = Drive::balance_for_identity_id_query(identity_id); + // The keys, the balance and the revision, as the version 1 prover composes them. let merged = grovedb::PathQuery::merge( - vec![&keys_path_query, &revision_path_query], + vec![&keys_path_query, &balance_path_query, &revision_path_query], &platform_version.drive.grove_version, ) .expect("expected to merge path queries"); @@ -3358,8 +3630,10 @@ mod tests { let key_request = IdentityKeysRequest::new_all_keys_query(&identity_id, None); let keys_path_query = key_request.into_path_query(); let revision_path_query = Drive::identity_revision_query(&identity_id); + let balance_path_query = Drive::balance_for_identity_id_query(identity_id); + // The keys, the balance and the revision, as the version 1 prover composes them. let merged = grovedb::PathQuery::merge( - vec![&keys_path_query, &revision_path_query], + vec![&keys_path_query, &balance_path_query, &revision_path_query], &platform_version.drive.grove_version, ) .expect("expected to merge path queries"); @@ -3427,8 +3701,10 @@ mod tests { let key_request = IdentityKeysRequest::new_all_keys_query(&identity_id, None); let keys_path_query = key_request.into_path_query(); let revision_path_query = Drive::identity_revision_query(&identity_id); + let balance_path_query = Drive::balance_for_identity_id_query(identity_id); + // The keys, the balance and the revision, as the version 1 prover composes them. let merged = grovedb::PathQuery::merge( - vec![&keys_path_query, &revision_path_query], + vec![&keys_path_query, &balance_path_query, &revision_path_query], &platform_version.drive.grove_version, ) .expect("expected to merge path queries"); @@ -3460,10 +3736,15 @@ mod tests { ) .expect("expected verification to accept a key disabled before the proof block"); - match result { - StateTransitionProofOutcome::ExecutionProved( - StateTransitionProofResult::VerifiedPartialIdentity(partial_identity), - ) => { + assert!( + result.is_execution_proved(), + "expected ExecutionProved, got {:?}", + result + ); + assert_eq!(result.owner_balance(), Some(identity.balance())); + + match result.into_result() { + StateTransitionProofResult::VerifiedPartialIdentity(partial_identity) => { assert_eq!(partial_identity.id, identity.id()); } other => panic!( @@ -3539,12 +3820,16 @@ mod tests { ) .expect("expected verification to succeed"); - match outcome { - StateTransitionProofOutcome::AffectedState( - StateTransitionProofResult::VerifiedBalanceTransfer( - sender_identity, - recipient_identity, - ), + assert!( + !outcome.is_execution_proved(), + "expected AffectedState, got {:?}", + outcome + ); + + match outcome.into_result() { + StateTransitionProofResult::VerifiedBalanceTransfer( + sender_identity, + recipient_identity, ) => { assert_eq!(sender_identity.id, sender.id()); assert_eq!(recipient_identity.id, recipient.id()); @@ -3682,10 +3967,29 @@ mod tests { // Batch: document delete happy path // ----------------------------------------------------------------------- + /// The batch's owner, with the balance every identity has, so the batch + /// proof can carry it next to the document. + fn add_batch_owner(drive: &Drive, seed: u64, platform_version: &PlatformVersion) -> Identity { + let identity = Identity::random_identity(2, Some(seed), platform_version) + .expect("expected a random identity"); + drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add the batch owner"); + identity + } + #[test] fn verify_batch_document_delete_happy_path() { let (drive, contract) = setup_drive_and_contract(); let platform_version = PlatformVersion::latest(); + let owner = add_batch_owner(&drive, 7, platform_version); let document_type = contract .document_type_for_name("preorder") @@ -3730,23 +4034,6 @@ mod tests { ) .expect("expected to delete document"); - // Generate a proof for the now-absent document - use crate::query::{SingleDocumentDriveQuery, SingleDocumentDriveQueryContestedStatus}; - let single_query = SingleDocumentDriveQuery { - contract_id: contract.id().to_buffer(), - document_type_name: "preorder".to_string(), - document_type_keeps_history: document_type.documents_keep_history(), - document_id: doc_id.to_buffer(), - block_time_ms: None, - contested_status: SingleDocumentDriveQueryContestedStatus::NotContested, - }; - let path_query = single_query - .construct_path_query(platform_version) - .expect("expected to build path query"); - let proof = drive - .grove_get_proved_path_query(&path_query, None, &mut vec![], &platform_version.drive) - .expect("expected to get proof"); - // Build a document delete batch transition use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; use dpp::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; @@ -3764,13 +4051,20 @@ mod tests { }); let st = StateTransition::Batch(BatchTransition::V0(BatchTransitionV0 { - owner_id: Default::default(), + owner_id: owner.id(), transitions: vec![DocumentTransition::Delete(DocumentDeleteTransition::V0( DocumentDeleteTransitionV0 { base }, ))], ..Default::default() })); + // The prover's proof: the now-absent document and the owner's balance + let proof = drive + .prove_state_transition(&st, None, platform_version) + .expect("expected to prove the delete") + .into_data() + .expect("expected proof bytes"); + let contract_arc = Arc::new(contract.clone()); let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(Some(contract_arc.clone())); @@ -3788,10 +4082,14 @@ mod tests { result.err() ); let (_root_hash, proof_result) = result.unwrap(); - match proof_result { - StateTransitionProofOutcome::ExecutionProved( - StateTransitionProofResult::VerifiedDocuments(docs), - ) => { + assert!( + proof_result.is_execution_proved(), + "expected ExecutionProved, got {:?}", + proof_result + ); + let owner_balance = proof_result.owner_balance(); + match proof_result.into_result() { + StateTransitionProofResult::VerifiedDocuments(docs) => { assert_eq!(docs.len(), 1, "expected exactly one document entry"); let (returned_id, maybe_doc) = docs.into_iter().next().unwrap(); assert_eq!(returned_id, doc_id); @@ -3799,45 +4097,166 @@ mod tests { maybe_doc.is_none(), "document should be None after deletion" ); + assert_eq!(owner_balance, Some(owner.balance())); } other => panic!("expected VerifiedDocuments, got {:?}", other), } } // ----------------------------------------------------------------------- - // Batch: document create happy path + // Batch: before protocol version 14 the proof carries no balance // ----------------------------------------------------------------------- + /// Version 0 of the prover and the verifier (every protocol version before + /// 14) keep the document-only proof: it verifies, carries no balance, and is + /// exactly the single-document proof a pre-14 client rebuilds strictly. #[test] - fn verify_batch_document_create_happy_path() { + fn verify_batch_document_proof_before_protocol_version_14_carries_no_balance() { + let (drive, contract) = setup_drive_and_contract(); + let latest = PlatformVersion::latest(); + let before_balances = PlatformVersion::get(13).expect("protocol version 13 exists"); + assert_eq!( + before_balances.drive.methods.prove.prove_state_transition, + 0 + ); + assert_eq!( + before_balances + .drive + .methods + .verify + .state_transition + .verify_state_transition_was_executed_with_proof, + 0 + ); + let owner = add_batch_owner(&drive, 17, latest); + + let document_type = contract + .document_type_for_name("preorder") + .expect("expected preorder document type"); + let mut document = document_type + .random_document(Some(99), latest) + .expect("expected a random document"); + document.set_owner_id(owner.id()); + let doc_id = document.id(); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&document, None)), + owner_id: Some(owner.id().to_buffer()), + }, + contract: &contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + latest, + None, + ) + .expect("expected to insert document"); + + use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; + use dpp::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; + use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; + use dpp::state_transition::batch_transition::document_create_transition::DocumentCreateTransition; + use dpp::state_transition::batch_transition::document_create_transition::DocumentCreateTransitionV0; + use dpp::state_transition::batch_transition::BatchTransition; + use dpp::state_transition::batch_transition::BatchTransitionV0; + let base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: doc_id, + identity_contract_nonce: 1, + document_type_name: "preorder".to_string(), + data_contract_id: contract.id(), + }); + let st = StateTransition::Batch(BatchTransition::V0(BatchTransitionV0 { + owner_id: owner.id(), + transitions: vec![DocumentTransition::Create(DocumentCreateTransition::V0( + DocumentCreateTransitionV0 { + base, + entropy: [100u8; 32], + data: document.properties().clone(), + prefunded_voting_balance: None, + }, + ))], + ..Default::default() + })); + + let proof = drive + .prove_state_transition(&st, None, before_balances) + .expect("expected to prove the create at protocol version 13") + .into_data() + .expect("expected proof bytes"); + + // The old shape: the document alone verifies strictly. + use crate::query::{SingleDocumentDriveQuery, SingleDocumentDriveQueryContestedStatus}; + let (_, proved_document) = SingleDocumentDriveQuery { + contract_id: contract.id().to_buffer(), + document_type_name: "preorder".to_string(), + document_type_keeps_history: document_type.documents_keep_history(), + document_id: doc_id.to_buffer(), + block_time_ms: None, + contested_status: SingleDocumentDriveQueryContestedStatus::NotContested, + } + .verify_proof(false, &proof, document_type, before_balances) + .expect("a version 0 proof is the document alone"); + assert_eq!(proved_document.map(|document| document.id()), Some(doc_id)); + + let contract_arc = Arc::new(contract.clone()); + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(Some(contract_arc.clone())); + let (_, outcome) = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &proof, + known_contracts_provider_fn, + before_balances, + ) + .expect("the version 0 proof verifies at protocol version 13"); + assert!(outcome.is_execution_proved()); + assert_eq!(outcome.owner_balance(), None); + assert!(matches!( + outcome.into_result(), + StateTransitionProofResult::VerifiedDocuments(_) + )); + } + + // ----------------------------------------------------------------------- + // Batch: a proof of the document alone is not a batch proof + // ----------------------------------------------------------------------- + + /// A document batch proof carries the owner's balance next to the + /// document. A proof of only the document (the shape before the balance + /// joined it) verifies the document alone but not the batch: the strict + /// verification of the merged query finds no data for the balance in it. + #[test] + fn verify_batch_document_proof_without_owner_balance_is_rejected() { let (drive, contract) = setup_drive_and_contract(); let platform_version = PlatformVersion::latest(); + let owner = add_batch_owner(&drive, 11, platform_version); let document_type = contract .document_type_for_name("preorder") .expect("expected preorder document type"); - let document = document_type + let mut document = document_type .random_document(Some(99), platform_version) .expect("expected a random document"); + document.set_owner_id(owner.id()); let doc_id = document.id(); - let owner_id = document.owner_id(); - let block_info = BlockInfo::default(); - - // Insert the document drive .add_document_for_contract( DocumentAndContractInfo { owned_document_info: OwnedDocumentInfo { document_info: DocumentRefInfo((&document, None)), - owner_id: Some(owner_id.to_buffer()), + owner_id: Some(owner.id().to_buffer()), }, contract: &contract, document_type, }, false, - block_info, + BlockInfo::default(), true, None, platform_version, @@ -3845,7 +4264,7 @@ mod tests { ) .expect("expected to insert document"); - // Generate a proof for the existing document + // The document alone use crate::query::{SingleDocumentDriveQuery, SingleDocumentDriveQueryContestedStatus}; let single_query = SingleDocumentDriveQuery { contract_id: contract.id().to_buffer(), @@ -3862,6 +4281,121 @@ mod tests { .grove_get_proved_path_query(&path_query, None, &mut vec![], &platform_version.drive) .expect("expected to get proof"); + // The proof does prove the document: the rejection below is for the + // missing balance alone. + let (_, proved_document) = single_query + .verify_proof(false, &proof, document_type, platform_version) + .expect("the document alone verifies"); + assert_eq!(proved_document.map(|document| document.id()), Some(doc_id)); + + use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; + use dpp::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; + use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; + use dpp::state_transition::batch_transition::document_create_transition::DocumentCreateTransition; + use dpp::state_transition::batch_transition::document_create_transition::DocumentCreateTransitionV0; + use dpp::state_transition::batch_transition::BatchTransition; + use dpp::state_transition::batch_transition::BatchTransitionV0; + + let base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: doc_id, + identity_contract_nonce: 1, + document_type_name: "preorder".to_string(), + data_contract_id: contract.id(), + }); + let create_transition = DocumentCreateTransition::V0(DocumentCreateTransitionV0 { + base, + entropy: [100u8; 32], + data: document.properties().clone(), + prefunded_voting_balance: None, + }); + let st = StateTransition::Batch(BatchTransition::V0(BatchTransitionV0 { + owner_id: owner.id(), + transitions: vec![DocumentTransition::Create(create_transition)], + ..Default::default() + })); + + let contract_arc = Arc::new(contract.clone()); + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(Some(contract_arc.clone())); + + let result = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &proof, + known_contracts_provider_fn, + platform_version, + ); + + // GroveDB reports the merged query's balance part as not covered. + assert!( + matches!(result, Err(Error::GroveDB(_))), + "expected the document-only proof to be rejected for its missing balance, got {:?}", + result + ); + + // The same transition verifies against the prover's proof, with the balance. + let proof = drive + .prove_state_transition(&st, None, platform_version) + .expect("expected to prove the create") + .into_data() + .expect("expected proof bytes"); + let (_, outcome) = Drive::verify_state_transition_was_executed_with_proof( + &st, + &BlockInfo::default(), + &proof, + known_contracts_provider_fn, + platform_version, + ) + .expect("the prover's proof verifies"); + assert!(outcome.owner_balance().is_some()); + assert!(matches!( + outcome.into_result(), + StateTransitionProofResult::VerifiedDocuments(_) + )); + } + + // ----------------------------------------------------------------------- + // Batch: document create happy path + // ----------------------------------------------------------------------- + + #[test] + fn verify_batch_document_create_happy_path() { + let (drive, contract) = setup_drive_and_contract(); + let platform_version = PlatformVersion::latest(); + let owner = add_batch_owner(&drive, 5, platform_version); + + let document_type = contract + .document_type_for_name("preorder") + .expect("expected preorder document type"); + + let mut document = document_type + .random_document(Some(99), platform_version) + .expect("expected a random document"); + document.set_owner_id(owner.id()); + let doc_id = document.id(); + let owner_id = document.owner_id(); + + let block_info = BlockInfo::default(); + + // Insert the document + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&document, None)), + owner_id: Some(owner_id.to_buffer()), + }, + contract: &contract, + document_type, + }, + false, + block_info, + true, + None, + platform_version, + None, + ) + .expect("expected to insert document"); + // Build a document create batch transition use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; use dpp::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; @@ -3892,6 +4426,13 @@ mod tests { ..Default::default() })); + // The prover's proof: the created document and the owner's balance + let proof = drive + .prove_state_transition(&st, None, platform_version) + .expect("expected to prove the create") + .into_data() + .expect("expected proof bytes"); + let contract_arc = Arc::new(contract.clone()); let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(Some(contract_arc.clone())); @@ -3909,14 +4450,19 @@ mod tests { result.err() ); let (_root_hash, proof_result) = result.unwrap(); - match proof_result { - StateTransitionProofOutcome::ExecutionProved( - StateTransitionProofResult::VerifiedDocuments(docs), - ) => { + assert!( + proof_result.is_execution_proved(), + "expected ExecutionProved, got {:?}", + proof_result + ); + let owner_balance = proof_result.owner_balance(); + match proof_result.into_result() { + StateTransitionProofResult::VerifiedDocuments(docs) => { assert_eq!(docs.len(), 1, "expected exactly one document entry"); let (returned_id, maybe_doc) = docs.into_iter().next().unwrap(); assert_eq!(returned_id, doc_id); assert!(maybe_doc.is_some(), "document should exist after creation"); + assert_eq!(owner_balance, Some(owner.balance())); } other => panic!("expected VerifiedDocuments, got {:?}", other), } @@ -4276,6 +4822,125 @@ mod tests { ); } + // --- MasternodeVote on a yes/no poll: the prover's path query and the verifier agree, + // so a registered vote is proved executed and an unregistered one is refused. + #[test] + fn verify_masternode_yes_no_vote_executed_round_trip() { + use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::platform_value::BinaryData; + use dpp::state_transition::masternode_vote_transition::v0::MasternodeVoteTransitionV0; + use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; + use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; + use dpp::voting::vote_polls::yes_no_vote_poll::{YesNoMinimumVotingPower, YesNoVotePoll}; + use dpp::voting::votes::yes_no_vote::v0::YesNoVoteV0; + use dpp::voting::votes::yes_no_vote::YesNoVote; + use dpp::voting::votes::Vote; + + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let vote_poll = YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![0xc1; 32])], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute(400), + }; + drive + .open_yes_no_vote_poll( + &vote_poll, + 1_000, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected to open the poll"); + let voter = [1u8; 32]; + drive + .register_yes_no_identity_vote( + voter, + 4, + ResolvedYesNoVote { + vote_poll: vote_poll.clone(), + vote_choice: YesNoAbstainVoteChoice::Yes, + previous_vote_choice_to_remove: None, + }, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("expected to register the vote"); + + let transition_for = |voter: [u8; 32], vote_choice: YesNoAbstainVoteChoice| { + StateTransition::MasternodeVote(MasternodeVoteTransition::V0( + MasternodeVoteTransitionV0 { + pro_tx_hash: dpp::prelude::Identifier::from(voter), + voter_identity_id: dpp::prelude::Identifier::from([2u8; 32]), + vote: Vote::YesNoVote(YesNoVote::V0(YesNoVoteV0 { + vote_poll: vote_poll.clone(), + vote_choice, + })), + nonce: 1, + signature_public_key_id: 0, + signature: Default::default(), + }, + )) + }; + let known_contracts_provider_fn: &ContractLookupFn = &|_id| Ok(None); + + let executed = transition_for(voter, YesNoAbstainVoteChoice::Yes); + let proof = drive + .prove_state_transition(&executed, None, platform_version) + .expect("expected to prove the vote") + .into_data() + .expect("expected proof bytes"); + let (root_hash, verified) = Drive::verify_state_transition_was_executed_with_proof( + &executed, + &BlockInfo::default(), + &proof, + known_contracts_provider_fn, + platform_version, + ) + .expect("expected the executed vote to verify"); + assert_eq!( + root_hash, + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("root hash") + ); + let StateTransition::MasternodeVote(masternode_vote) = &executed else { + unreachable!() + }; + assert_eq!( + verified.into_result(), + VerifiedMasternodeVote(masternode_vote.vote().clone()) + ); + + // A vote the state does not hold is not proved executed. + let not_executed = transition_for([9u8; 32], YesNoAbstainVoteChoice::No); + let proof = drive + .prove_state_transition(¬_executed, None, platform_version) + .expect("expected to prove the absence") + .into_data() + .expect("expected proof bytes"); + let result = Drive::verify_state_transition_was_executed_with_proof( + ¬_executed, + &BlockInfo::default(), + &proof, + known_contracts_provider_fn, + platform_version, + ); + assert!( + matches!( + result, + Err(crate::error::Error::Proof(ProofError::IncorrectProof(_))) + ), + "expected IncorrectProof, got {:?}", + result + ); + } + // --- MasternodeVote: unknown contract returns a specific // `ProofError::UnknownContract` because the provider returns None. #[test] diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v1/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v1/mod.rs new file mode 100644 index 00000000000..3763746d7eb --- /dev/null +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v1/mod.rs @@ -0,0 +1,30 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::query::ContractLookupFn; +use crate::verify::RootHash; +use dpp::block::block_info::BlockInfo; +use dpp::state_transition::proof_result::StateTransitionProofOutcome; +use dpp::state_transition::StateTransition; +use dpp::version::PlatformVersion; + +impl Drive { + /// Version 1: the proof of an owned, fee-paying transition carries the + /// owner's credit balance next to its result, and the outcome carries it. + /// Every other transition verifies as in version 0. + pub(super) fn verify_state_transition_was_executed_with_proof_v1( + state_transition: &StateTransition, + block_info: &BlockInfo, + proof: &[u8], + known_contracts_provider_fn: &ContractLookupFn, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, StateTransitionProofOutcome), Error> { + Self::verify_state_transition_was_executed_with_proof_internal( + state_transition, + block_info, + proof, + known_contracts_provider_fn, + true, + platform_version, + ) + } +} diff --git a/packages/rs-drive/src/verify/voting/mod.rs b/packages/rs-drive/src/verify/voting/mod.rs index 28169ef1537..edfab9061b9 100644 --- a/packages/rs-drive/src/verify/voting/mod.rs +++ b/packages/rs-drive/src/verify/voting/mod.rs @@ -3,7 +3,9 @@ mod verify_contests_proof; mod verify_identity_votes_given_proof; mod verify_masternode_vote; +mod verify_masternode_yes_no_vote; mod verify_specialized_balance; mod verify_vote_poll_vote_state_proof; mod verify_vote_poll_votes_proof; mod verify_vote_polls_end_date_query; +mod verify_yes_no_vote_poll_state_proof; diff --git a/packages/rs-drive/src/verify/voting/verify_masternode_vote/mod.rs b/packages/rs-drive/src/verify/voting/verify_masternode_vote/mod.rs index 4c5669a67ec..52303ad7580 100644 --- a/packages/rs-drive/src/verify/voting/verify_masternode_vote/mod.rs +++ b/packages/rs-drive/src/verify/voting/verify_masternode_vote/mod.rs @@ -4,10 +4,13 @@ mod v0; use crate::drive::Drive; use crate::error::drive::DriveError; +use crate::error::proof::ProofError; use crate::error::Error; use crate::verify::RootHash; use dpp::prelude::DataContract; use dpp::version::PlatformVersion; +use dpp::voting::vote_polls::VotePoll; +use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; use dpp::voting::votes::Vote; impl Drive { @@ -44,6 +47,29 @@ impl Drive { verify_subset_of_proof: bool, platform_version: &PlatformVersion, ) -> Result<(RootHash, Option), Error> { + // The versions below prove the contested resource votes index. A yes/no vote lives in the + // decisions branch, so it goes to its own verifier: querying the contested index for it + // would accept a genuine absence proof from the wrong branch as "not voted". A resource + // vote cannot name a yes/no poll at all. + match vote { + Vote::YesNoVote(yes_no_vote) => { + return Self::verify_masternode_yes_no_vote( + proof, + masternode_pro_tx_hash, + yes_no_vote, + verify_subset_of_proof, + platform_version, + ) + .map(|(root_hash, proved_vote)| (root_hash, proved_vote.map(Vote::YesNoVote))); + } + Vote::ResourceVote(resource_vote) => { + if let VotePoll::YesNoVotePoll(_) = resource_vote.vote_poll() { + return Err(Error::Proof(ProofError::InvalidTransition( + "a resource vote cannot answer a yes/no vote poll".to_string(), + ))); + } + } + } match platform_version .drive .methods diff --git a/packages/rs-drive/src/verify/voting/verify_masternode_yes_no_vote/mod.rs b/packages/rs-drive/src/verify/voting/verify_masternode_yes_no_vote/mod.rs new file mode 100644 index 00000000000..ab9e09ccf36 --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_masternode_yes_no_vote/mod.rs @@ -0,0 +1,252 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::verify::RootHash; +use dpp::version::PlatformVersion; +use dpp::voting::votes::yes_no_vote::YesNoVote; + +impl Drive { + /// Verifies a proof of a masternode's vote on a yes/no poll. + /// + /// Returns the root hash and the proved vote, `None` when the proof shows the masternode + /// has not voted on the poll. A proved vote whose answer differs from `vote` is an error. + pub fn verify_masternode_yes_no_vote( + proof: &[u8], + masternode_pro_tx_hash: [u8; 32], + vote: &YesNoVote, + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, Option), Error> { + match platform_version + .drive + .methods + .verify + .voting + .verify_masternode_yes_no_vote + { + 0 => Drive::verify_masternode_yes_no_vote_v0( + proof, + masternode_pro_tx_hash, + vote, + verify_subset_of_proof, + platform_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "verify_masternode_yes_no_vote".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + use crate::drive::votes::resolved::votes::resolved_yes_no_vote::ResolvedYesNoVote; + use crate::error::proof::ProofError; + use crate::query::IdentityBasedVoteDriveQuery; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::block::block_info::BlockInfo; + use dpp::platform_value::{BinaryData, Identifier}; + use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; + use dpp::voting::vote_polls::yes_no_vote_poll::{YesNoMinimumVotingPower, YesNoVotePoll}; + use dpp::voting::vote_polls::VotePoll; + use dpp::voting::votes::yes_no_vote::v0::YesNoVoteV0; + + fn poll() -> YesNoVotePoll { + YesNoVotePoll { + resource_path: vec![BinaryData::new(vec![0xc1; 32])], + supermajority_numerator: 2, + supermajority_denominator: 3, + minimum_voting_power: YesNoMinimumVotingPower::Absolute(400), + } + } + + fn vote(vote_choice: YesNoAbstainVoteChoice) -> YesNoVote { + YesNoVote::V0(YesNoVoteV0 { + vote_poll: poll(), + vote_choice, + }) + } + + fn proof_of_vote(drive: &Drive, voter: [u8; 32]) -> Vec { + let platform_version = PlatformVersion::latest(); + let mut path_query = IdentityBasedVoteDriveQuery { + identity_id: Identifier::new(voter), + vote_poll: VotePoll::YesNoVotePoll(poll()), + } + .construct_path_query() + .expect("path query"); + path_query.query.limit = None; + drive + .grove_get_proved_path_query(&path_query, None, &mut vec![], &platform_version.drive) + .expect("proof") + } + + #[test] + fn should_prove_a_vote_and_its_absence() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + drive + .open_yes_no_vote_poll( + &poll(), + 1_000, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("open"); + let voter = [7u8; 32]; + drive + .register_yes_no_identity_vote( + voter, + 1, + ResolvedYesNoVote { + vote_poll: poll(), + vote_choice: YesNoAbstainVoteChoice::No, + previous_vote_choice_to_remove: None, + }, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("register"); + + let proof = proof_of_vote(&drive, voter); + let (_, proved) = Drive::verify_masternode_yes_no_vote( + &proof, + voter, + &vote(YesNoAbstainVoteChoice::No), + false, + platform_version, + ) + .expect("verify"); + assert_eq!(proved, Some(vote(YesNoAbstainVoteChoice::No))); + + // The proof shows the answer; claiming another one is caught. + assert!(matches!( + Drive::verify_masternode_yes_no_vote( + &proof, + voter, + &vote(YesNoAbstainVoteChoice::Yes), + false, + platform_version, + ), + Err(Error::Proof(ProofError::IncorrectProof(_))) + )); + + // A masternode that has not voted proves absent. + let other = [8u8; 32]; + let proof = proof_of_vote(&drive, other); + let (_, proved) = Drive::verify_masternode_yes_no_vote( + &proof, + other, + &vote(YesNoAbstainVoteChoice::No), + false, + platform_version, + ) + .expect("verify"); + assert_eq!(proved, None); + } + + /// The generic masternode vote verifier routes a yes/no vote to this one, so a genuine + /// absence proof taken from the contested resource votes index (where a yes/no vote never + /// lives) cannot pass as "the masternode did not vote". + #[test] + fn should_route_a_yes_no_vote_through_the_generic_verifier() { + use crate::drive::votes::paths::vote_contested_resource_identity_votes_tree_path_for_identity_vec; + use crate::query::Query; + use dpp::tests::json_document::json_document_to_contract; + use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; + use dpp::voting::votes::resource_vote::ResourceVote; + use dpp::voting::votes::Vote; + use grovedb::{PathQuery, SizedQuery}; + + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let data_contract = json_document_to_contract( + "tests/supporting_files/contract/dpns/dpns-contract.json", + false, + platform_version, + ) + .expect("expected a data contract"); + drive + .open_yes_no_vote_poll( + &poll(), + 1_000, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("open"); + let voter = [7u8; 32]; + drive + .register_yes_no_identity_vote( + voter, + 1, + ResolvedYesNoVote { + vote_poll: poll(), + vote_choice: YesNoAbstainVoteChoice::No, + previous_vote_choice_to_remove: None, + }, + &BlockInfo::default(), + None, + platform_version, + ) + .expect("register"); + let yes_no_vote = Vote::YesNoVote(vote(YesNoAbstainVoteChoice::No)); + + // The right proof verifies through the generic entry point. + let (_, proved) = Drive::verify_masternode_vote( + &proof_of_vote(&drive, voter), + voter, + &yes_no_vote, + &data_contract, + false, + platform_version, + ) + .expect("verify"); + assert_eq!(proved, Some(yes_no_vote.clone())); + + // An absence proof of the poll id under the contested index is refused. + let mut query = Query::new(); + query.insert_key(poll().unique_id().expect("id").to_vec()); + let wrong_branch = PathQuery::new( + vote_contested_resource_identity_votes_tree_path_for_identity_vec(&voter), + SizedQuery::new(query, None, None), + ); + let wrong_branch_proof = drive + .grove_get_proved_path_query(&wrong_branch, None, &mut vec![], &platform_version.drive) + .expect("proof"); + assert!(Drive::verify_masternode_vote( + &wrong_branch_proof, + voter, + &yes_no_vote, + &data_contract, + false, + platform_version, + ) + .is_err()); + + // A resource vote naming a yes/no poll is refused outright. + let resource_vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::YesNoVotePoll(poll()), + resource_vote_choice: ResourceVoteChoice::Abstain, + })); + assert!(matches!( + Drive::verify_masternode_vote( + &wrong_branch_proof, + voter, + &resource_vote, + &data_contract, + false, + platform_version, + ), + Err(Error::Proof(ProofError::InvalidTransition(_))) + )); + } +} diff --git a/packages/rs-drive/src/verify/voting/verify_masternode_yes_no_vote/v0/mod.rs b/packages/rs-drive/src/verify/voting/verify_masternode_yes_no_vote/v0/mod.rs new file mode 100644 index 00000000000..6b2779f9b21 --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_masternode_yes_no_vote/v0/mod.rs @@ -0,0 +1,69 @@ +use crate::drive::Drive; +use crate::error::proof::ProofError; +use crate::error::Error; +use crate::query::IdentityBasedVoteDriveQuery; +use crate::verify::bounded_decode::decode_yes_no_vote_reference; +use crate::verify::RootHash; +use dpp::identifier::Identifier; +use dpp::voting::vote_polls::VotePoll; +use dpp::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; +use dpp::voting::votes::yes_no_vote::v0::YesNoVoteV0; +use dpp::voting::votes::yes_no_vote::YesNoVote; +use grovedb::GroveDb; +use platform_version::version::PlatformVersion; + +impl Drive { + #[inline(always)] + pub(super) fn verify_masternode_yes_no_vote_v0( + proof: &[u8], + masternode_pro_tx_hash: [u8; 32], + vote: &YesNoVote, + verify_subset_of_proof: bool, + platform_version: &PlatformVersion, + ) -> Result<(RootHash, Option), Error> { + // The prover's own query, so where a masternode's yes/no vote lives is written once. + let path_query = IdentityBasedVoteDriveQuery { + identity_id: Identifier::new(masternode_pro_tx_hash), + vote_poll: VotePoll::YesNoVotePoll(vote.vote_poll().clone()), + } + .construct_path_query()?; + let (root_hash, mut proved_key_values) = if verify_subset_of_proof { + GroveDb::verify_subset_query_with_absence_proof( + proof, + &path_query, + &platform_version.drive.grove_version, + )? + } else { + GroveDb::verify_query_with_absence_proof( + proof, + &path_query, + &platform_version.drive.grove_version, + )? + }; + if proved_key_values.len() != 1 { + return Err(Error::Proof(ProofError::TooManyElements( + "expected one masternode yes/no vote", + ))); + } + let (_, _, maybe_element) = proved_key_values.remove(0); + let maybe_vote = maybe_element + .map(|element| { + let bytes = element.into_item_bytes()?; + let storage_form = decode_yes_no_vote_reference(&bytes)?; + let proved_vote = YesNoVote::V0(YesNoVoteV0 { + vote_poll: vote.vote_poll().clone(), + vote_choice: storage_form.vote_choice, + }); + if &proved_vote != vote { + Err(Error::Proof(ProofError::IncorrectProof(format!( + "returned vote {:?} does not match the vote that was sent {:?}", + proved_vote, vote + )))) + } else { + Ok::(proved_vote) + } + }) + .transpose()?; + Ok((root_hash, maybe_vote)) + } +} diff --git a/packages/rs-drive/src/verify/voting/verify_yes_no_vote_poll_state_proof/mod.rs b/packages/rs-drive/src/verify/voting/verify_yes_no_vote_poll_state_proof/mod.rs new file mode 100644 index 00000000000..19cb04ef544 --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_yes_no_vote_poll_state_proof/mod.rs @@ -0,0 +1,33 @@ +mod v0; + +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::query::yes_no_vote_poll_state_query::{ + YesNoVotePollState, YesNoVotePollStateDriveQuery, +}; +use crate::verify::RootHash; +use dpp::version::PlatformVersion; + +impl YesNoVotePollStateDriveQuery { + /// Verifies a proof of the state of a yes/no vote poll: its stored info and its tallies. + pub fn verify_yes_no_vote_poll_state_proof( + &self, + proof: &[u8], + platform_version: &PlatformVersion, + ) -> Result<(RootHash, YesNoVotePollState), Error> { + match platform_version + .drive + .methods + .verify + .voting + .verify_yes_no_vote_poll_state_proof + { + 0 => self.verify_yes_no_vote_poll_state_proof_v0(proof, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "verify_yes_no_vote_poll_state_proof".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/verify/voting/verify_yes_no_vote_poll_state_proof/v0/mod.rs b/packages/rs-drive/src/verify/voting/verify_yes_no_vote_poll_state_proof/v0/mod.rs new file mode 100644 index 00000000000..3da2b6e040e --- /dev/null +++ b/packages/rs-drive/src/verify/voting/verify_yes_no_vote_poll_state_proof/v0/mod.rs @@ -0,0 +1,28 @@ +use crate::error::Error; +use crate::query::yes_no_vote_poll_state_query::{ + YesNoVotePollState, YesNoVotePollStateDriveQuery, +}; +use crate::verify::RootHash; +use grovedb::GroveDb; +use platform_version::version::PlatformVersion; + +impl YesNoVotePollStateDriveQuery { + #[inline(always)] + pub(super) fn verify_yes_no_vote_poll_state_proof_v0( + &self, + proof: &[u8], + platform_version: &PlatformVersion, + ) -> Result<(RootHash, YesNoVotePollState), Error> { + let path_query = self.construct_path_query()?; + let (root_hash, proved_key_values) = GroveDb::verify_query_with_absence_proof( + proof, + &path_query, + &platform_version.drive.grove_version, + )?; + let key_elements = proved_key_values + .into_iter() + .filter_map(|(_, key, maybe_element)| maybe_element.map(|element| (key, element))); + let state = Self::state_from_key_elements(key_elements)?; + Ok((root_hash, state)) + } +} diff --git a/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs b/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs index 3984cfb261e..10965652c00 100644 --- a/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs +++ b/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs @@ -1199,6 +1199,48 @@ pub static KEYWORD_COMPATIBILITY_RULES: Lazy = Laz ], }, ), + ( + // How a byte array property's ciphertext was produced: documents written + // under one recipe could not be read under another, so nothing about it + // may change. + "encryptedFor", + CompatibilityRules { + allow_addition: false, + allow_removal: false, + allow_replacement_callback: FALSE_CALLBACK.clone(), + subschema_levels_depth: None, + inner: None, + #[cfg(any(test, feature = "examples"))] + examples: vec![ + ( + json!({}), + json!({ "encryptedFor": { "recipient": "recipientId", "recipientKey": "recipientKeyId", "senderKey": "senderKeyId", "scheme": "ecdh-secp256k1-aes256-cbc" } }), + Some(JsonSchemaChange::Add(AddOperation { + path: "/encryptedFor".to_string(), + value: json!({ "recipient": "recipientId", "recipientKey": "recipientKeyId", "senderKey": "senderKeyId", "scheme": "ecdh-secp256k1-aes256-cbc" }), + })), + ) + .into(), + ( + json!({ "encryptedFor": { "recipient": "recipientId", "recipientKey": "recipientKeyId", "senderKey": "senderKeyId", "scheme": "ecdh-secp256k1-aes256-cbc" } }), + json!({}), + Some(JsonSchemaChange::Remove(RemoveOperation { + path: "/encryptedFor".to_string(), + })), + ) + .into(), + ( + json!({ "encryptedFor": { "recipient": "recipientId", "recipientKey": "recipientKeyId", "senderKey": "senderKeyId", "scheme": "ecdh-secp256k1-aes256-cbc" } }), + json!({ "encryptedFor": { "recipient": "$ownerId", "recipientKey": "recipientKeyId", "senderKey": "senderKeyId", "scheme": "ecdh-secp256k1-aes256-cbc" } }), + Some(JsonSchemaChange::Replace(ReplaceOperation { + path: "/encryptedFor/recipient".to_string(), + value: json!("$ownerId"), + })), + ) + .into(), + ], + }, + ), ( "byteArray", CompatibilityRules { @@ -1417,6 +1459,49 @@ pub static KEYWORD_COMPATIBILITY_RULES: Lazy = Laz ], }, ), + // `distinctFrom` (what an identifier property's value must differ + // from: `$ownerId` or another property of the same document type) is + // frozen like `refersTo`: adding, removing or changing it changes + // which documents the type accepts. + ( + "distinctFrom", + CompatibilityRules { + allow_addition: false, + allow_removal: false, + allow_replacement_callback: FALSE_CALLBACK.clone(), + subschema_levels_depth: None, + inner: None, + #[cfg(any(test, feature = "examples"))] + examples: vec![ + ( + json!({}), + json!({ "distinctFrom": "$ownerId" }), + Some(JsonSchemaChange::Add(AddOperation { + path: "/distinctFrom".to_string(), + value: json!("$ownerId"), + })), + ) + .into(), + ( + json!({ "distinctFrom": "$ownerId" }), + json!({}), + Some(JsonSchemaChange::Remove(RemoveOperation { + path: "/distinctFrom".to_string(), + })), + ) + .into(), + ( + json!({ "distinctFrom": "$ownerId" }), + json!({ "distinctFrom": "delegateId" }), + Some(JsonSchemaChange::Replace(ReplaceOperation { + path: "/distinctFrom".to_string(), + value: json!("delegateId"), + })), + ) + .into(), + ], + }, + ), ( "$defs", CompatibilityRules { diff --git a/packages/rs-platform-serialization-derive/src/deserialize_enum.rs b/packages/rs-platform-serialization-derive/src/deserialize_enum.rs index 25829d37753..c177bd83b85 100644 --- a/packages/rs-platform-serialization-derive/src/deserialize_enum.rs +++ b/packages/rs-platform-serialization-derive/src/deserialize_enum.rs @@ -25,8 +25,8 @@ pub(super) fn derive_platform_deserialize_enum( let TrustNames { decode_from_slice, deserializable, - deserialize, - deserialize_no_limit, + deserialize_with_bytes_len, + deserialize_no_limit_with_bytes_len, from_versioned_structure, versioned_deserialize, limit_from_versioned_structure, @@ -217,44 +217,44 @@ pub(super) fn derive_platform_deserialize_enum( }; quote! { impl #impl_generics #crate_name::serialization::#deserializable for #name #ty_generics #where_clause { - fn #deserialize( + fn #deserialize_with_bytes_len( data: &[u8] - ) -> Result + ) -> Result<(Self, usize), ProtocolError> where Self: Sized { let config = bincode::config::standard().with_big_endian().with_limit::<{ #limit }>(); - #decode_from_slice(&data, config).map(|(a,_)| a)#limit_map_err + #decode_from_slice(&data, config)#limit_map_err } - fn #deserialize_no_limit( + fn #deserialize_no_limit_with_bytes_len( data: &[u8] - ) -> Result + ) -> Result<(Self, usize), ProtocolError> where Self: Sized { let config = bincode::config::standard().with_big_endian().with_no_limit(); - #decode_from_slice(&data, config).map(|(a,_)| a)#map_err + #decode_from_slice(&data, config)#map_err } } } } else { quote! { impl #impl_generics #crate_name::serialization::#deserializable for #name #ty_generics #where_clause { - fn #deserialize( + fn #deserialize_with_bytes_len( data: &[u8] - ) -> Result + ) -> Result<(Self, usize), ProtocolError> where Self: Sized { let config = bincode::config::standard().with_big_endian().with_no_limit(); - #decode_from_slice(&data, config).map(|(a,_)| a)#map_err + #decode_from_slice(&data, config)#map_err } - fn #deserialize_no_limit( + fn #deserialize_no_limit_with_bytes_len( data: &[u8] - ) -> Result + ) -> Result<(Self, usize), ProtocolError> where Self: Sized { let config = bincode::config::standard().with_big_endian().with_no_limit(); - #decode_from_slice(&data, config).map(|(a,_)| a)#map_err + #decode_from_slice(&data, config)#map_err } } } diff --git a/packages/rs-platform-serialization-derive/src/deserialize_struct.rs b/packages/rs-platform-serialization-derive/src/deserialize_struct.rs index 9a53965cbcc..5cdf435abbf 100644 --- a/packages/rs-platform-serialization-derive/src/deserialize_struct.rs +++ b/packages/rs-platform-serialization-derive/src/deserialize_struct.rs @@ -24,8 +24,8 @@ pub(super) fn derive_platform_deserialize_struct( let TrustNames { decode_from_slice, deserializable, - deserialize, - deserialize_no_limit, + deserialize_with_bytes_len, + deserialize_no_limit_with_bytes_len, from_versioned_structure, versioned_deserialize, limit_from_versioned_structure, @@ -70,28 +70,33 @@ pub(super) fn derive_platform_deserialize_struct( ) }; - let deserialize_into = match platform_serialize_into { + // Every body yields the value together with the number of bytes it took. + let decode_with_len = match platform_serialize_into { Some(inner) => quote! { #config - let inner: #inner = #decode_from_slice(bytes, config).map(|(a, _)| a)?; - Ok(inner.into()) + let (inner, consumed): (#inner, usize) = #decode_from_slice(bytes, config)?; + Ok((inner.into(), consumed)) }, None => { if !unversioned { quote! { #config - platform_serialization::platform_versioned_decode_from_slice(&bytes, config, platform_version).map(|(a, _)| a)#limit_err + platform_serialization::platform_versioned_decode_from_slice(&bytes, config, platform_version)#limit_err } } else { quote! { #config - #decode_from_slice(bytes, config).map(|(a,_)| a) - #limit_err + #decode_from_slice(bytes, config)#limit_err } } } }; + // The versioned entry points hand back the value alone. + let decode = quote! { + { #decode_with_len }.map(|(value, _)| value) + }; + // if we have passthrough or untagged we can't decode directly // Only the trusted derive emits the ordinary `PlatformVersionedDecode` body, @@ -108,12 +113,12 @@ pub(super) fn derive_platform_deserialize_struct( quote! { impl #impl_generics #crate_name::serialization::#deserializable for #name #ty_generics #where_clause { - fn #deserialize(bytes: &[u8]) -> Result { - #deserialize_into + fn #deserialize_with_bytes_len(bytes: &[u8]) -> Result<(Self, usize), #error_type> { + #decode_with_len } - fn #deserialize_no_limit(bytes: &[u8]) -> Result { - #deserialize_into + fn #deserialize_no_limit_with_bytes_len(bytes: &[u8]) -> Result<(Self, usize), #error_type> { + #decode_with_len } } @@ -124,14 +129,14 @@ pub(super) fn derive_platform_deserialize_struct( impl #impl_generics #crate_name::serialization::#from_versioned_structure for #name #ty_generics #where_clause { fn #versioned_deserialize(bytes: &[u8], platform_version: &#crate_name::version::PlatformVersion) -> Result { - #deserialize_into + #decode } } impl #impl_generics #crate_name::serialization::#limit_from_versioned_structure for #name #ty_generics #where_clause { fn #versioned_limit_deserialize(bytes: &[u8], platform_version: &#crate_name::version::PlatformVersion) -> Result { - #deserialize_into + #decode } } diff --git a/packages/rs-platform-serialization-derive/src/lib.rs b/packages/rs-platform-serialization-derive/src/lib.rs index cd746053f0c..41e6aac8a46 100644 --- a/packages/rs-platform-serialization-derive/src/lib.rs +++ b/packages/rs-platform-serialization-derive/src/lib.rs @@ -50,8 +50,8 @@ enum DecodeTrust { struct TrustNames { decode_from_slice: proc_macro2::TokenStream, deserializable: Ident, - deserialize: Ident, - deserialize_no_limit: Ident, + deserialize_with_bytes_len: Ident, + deserialize_no_limit_with_bytes_len: Ident, from_versioned_structure: Ident, versioned_deserialize: Ident, limit_from_versioned_structure: Ident, @@ -71,8 +71,14 @@ impl DecodeTrust { TrustNames { decode_from_slice, deserializable: format_ident!("PlatformDeserializable{}", trait_suffix), - deserialize: format_ident!("deserialize_from_bytes_{}", fn_suffix), - deserialize_no_limit: format_ident!("deserialize_from_bytes_{}_no_limit", fn_suffix), + deserialize_with_bytes_len: format_ident!( + "deserialize_from_bytes_{}_with_bytes_len", + fn_suffix + ), + deserialize_no_limit_with_bytes_len: format_ident!( + "deserialize_from_bytes_{}_no_limit_with_bytes_len", + fn_suffix + ), from_versioned_structure: format_ident!( "PlatformDeserializableFromVersionedStructure{}", trait_suffix @@ -277,7 +283,9 @@ pub fn derive_platform_serialize(input: TokenStream) -> TokenStream { /// Derive Platform deserialization for bytes this node wrote itself. /// -/// Implements `PlatformDeserializableTrusted` (`deserialize_from_bytes_trusted`) +/// Implements `PlatformDeserializableTrusted` (`deserialize_from_bytes_trusted` +/// and the rest of the trait, through its two derived `_with_bytes_len` +/// methods, which report how many bytes the value took) /// and, for versioned structures, the `...FromVersionedStructureTrusted` twins, /// all running bincode's ordinary decoder, which reserves each collection from /// its length prefix. Use the trusted entry points for Drive state read back @@ -297,7 +305,9 @@ pub fn derive_platform_deserialize_trusted(input: TokenStream) -> TokenStream { /// Derive Platform deserialization for bytes from outside this node. /// -/// Implements `PlatformDeserializableUntrusted` (`deserialize_from_bytes_untrusted`) +/// Implements `PlatformDeserializableUntrusted` (`deserialize_from_bytes_untrusted` +/// and the rest of the trait, through its two derived `_with_bytes_len` +/// methods, which report how many bytes the value took) /// running bincode's untrusted decoder, so a short input whose length prefix /// claims a huge collection fails before anything is reserved for it. The type /// and every field need `DecodeUntrusted`. Existing configured budgets and diff --git a/packages/rs-platform-value/src/btreemap_extensions/btreemap_field_replacement.rs b/packages/rs-platform-value/src/btreemap_extensions/btreemap_field_replacement.rs index f93a1b79832..1be83e5cc69 100644 --- a/packages/rs-platform-value/src/btreemap_extensions/btreemap_field_replacement.rs +++ b/packages/rs-platform-value/src/btreemap_extensions/btreemap_field_replacement.rs @@ -1,3 +1,4 @@ +use crate::inner_value_at_path::is_array_path; use crate::value_map::ValueMapHelper; use crate::{Error, Value}; use std::collections::BTreeMap; @@ -118,43 +119,88 @@ pub trait BTreeValueMapReplacementPathHelper { ) -> Result<(), Error>; } +/// Replaces one value in place with its `replacement_type` form. The +/// fixed-size byte values keep their width; anything else is read as the +/// bytes it holds in whatever encoding it currently has. Shared by the +/// `Value` and the `BTreeMap` path replacers, so both give +/// the same value kind for the same input. +pub(crate) fn replace_leaf( + value: &mut Value, + replacement_type: ReplacementType, +) -> Result<(), Error> { + match value { + Value::Bytes20(bytes) => { + *value = replacement_type.replace_for_bytes_20(*bytes)?; + } + Value::Bytes32(bytes) => { + *value = replacement_type.replace_for_bytes_32(*bytes)?; + } + Value::Bytes36(bytes) => { + *value = replacement_type.replace_for_bytes_36(*bytes)?; + } + _ => { + let bytes = match replacement_type { + ReplacementType::Identifier | ReplacementType::TextBase58 => { + value.to_identifier_bytes() + } + ReplacementType::BinaryBytes | ReplacementType::TextBase64 => { + value.to_binary_bytes() + } + }?; + *value = replacement_type.replace_for_bytes(bytes)?; + } + } + Ok(()) +} + +/// The members a `list[]` or `list[3]` component addresses in a list value: +/// every element, or the one at the index. +fn list_members(list: &mut Value, index: Option) -> Result, Error> { + let list = list.to_array_mut()?; + match index { + Some(index) => match list.get_mut(index) { + Some(member) => Ok(vec![member]), + None => Err(Error::StructureError(format!( + "element at position {index} in array does not exist" + ))), + }, + None => Ok(list.iter_mut().collect()), + } +} + fn replace_down( mut current_values: Vec<&mut Value>, mut split: Peekable>, replacement_type: ReplacementType, ) -> Result<(), Error> { if let Some(path_component) = split.next() { + let is_last_component = split.peek().is_none(); let next_values = current_values .iter_mut() .map(|current_value| { if current_value.is_map() { let map = current_value.as_map_mut_ref()?; + // `list[]` names the members of a list: the values to + // replace when it ends the path, the values to descend + // into otherwise. An absent list is nothing to replace. + if let Some((list_name, index)) = is_array_path(path_component)? { + let Some(list) = map.get_optional_key_mut(list_name) else { + return Ok(None); + }; + let members = list_members(list, index)?; + if is_last_component { + for member in members { + replace_leaf(member, replacement_type)?; + } + return Ok(None); + } + return Ok(Some(members)); + } let Some(new_value) = map.get_optional_key_mut(path_component) else { return Ok(None); }; - if split.peek().is_none() { - match new_value { - Value::Bytes20(bytes) => { - *new_value = replacement_type.replace_for_bytes_20(*bytes)?; - } - Value::Bytes32(bytes) => { - *new_value = replacement_type.replace_for_bytes_32(*bytes)?; - } - Value::Bytes36(bytes) => { - *new_value = replacement_type.replace_for_bytes_36(*bytes)?; - } - _ => { - let bytes = match replacement_type { - ReplacementType::Identifier | ReplacementType::TextBase58 => { - new_value.to_identifier_bytes() - } - ReplacementType::BinaryBytes | ReplacementType::TextBase64 => { - new_value.to_binary_bytes() - } - }?; - *new_value = replacement_type.replace_for_bytes(bytes)?; - } - } + if is_last_component { + replace_leaf(new_value, replacement_type)?; Ok(None) } else { Ok(Some(vec![new_value])) @@ -189,33 +235,26 @@ impl BTreeValueMapReplacementPathHelper for BTreeMap { let Some(first_path_component) = first else { return Err(Error::PathError("path was empty".to_string())); }; + // `list[]` first: the members of a top-level list + if let Some((list_name, index)) = is_array_path(first_path_component)? { + let Some(list) = self.get_mut(list_name) else { + return Ok(()); + }; + let members = list_members(list, index)?; + if split.len() == 1 { + for member in members { + replace_leaf(member, replacement_type)?; + } + return Ok(()); + } + split.remove(0); + return replace_down(members, split.into_iter().peekable(), replacement_type); + } let Some(current_value) = self.get_mut(first_path_component.to_owned()) else { return Ok(()); }; if split.len() == 1 { - match current_value { - Value::Bytes20(bytes) => { - *current_value = replacement_type.replace_for_bytes_20(*bytes)?; - } - Value::Bytes32(bytes) => { - *current_value = replacement_type.replace_for_bytes_32(*bytes)?; - } - Value::Bytes36(bytes) => { - *current_value = replacement_type.replace_for_bytes_36(*bytes)?; - } - _ => { - let bytes = match replacement_type { - ReplacementType::Identifier | ReplacementType::TextBase58 => { - current_value.to_identifier_bytes() - } - ReplacementType::BinaryBytes | ReplacementType::TextBase64 => { - current_value.to_binary_bytes() - } - }?; - *current_value = replacement_type.replace_for_bytes(bytes)?; - } - } - Ok(()) + replace_leaf(current_value, replacement_type) } else { split.remove(0); let current_values = vec![current_value]; @@ -242,8 +281,9 @@ impl BTreeValueMapReplacementPathHelper for BTreeMap { #[cfg(test)] mod tests { use super::*; + use crate::string_encoding::Encoding; use crate::value_map::ValueMapHelper; - use crate::{Error, Value}; + use crate::{Error, Identifier, Value}; use base64::prelude::BASE64_STANDARD; use base64::Engine; use std::collections::BTreeMap; @@ -676,6 +716,97 @@ mod tests { } } + // ----------------------------------------------------------------------- + // replace_at_path: `list[]` names the members of a list + // ----------------------------------------------------------------------- + + fn base58_id(seed: u8) -> Value { + Value::Text(Identifier::from([seed; 32]).to_string(Encoding::Base58)) + } + + #[test] + fn should_replace_the_members_of_a_top_level_list() { + let mut map = BTreeMap::new(); + map.insert( + "ids".to_string(), + Value::Array(vec![base58_id(1), Value::Bytes32([2u8; 32])]), + ); + map.replace_at_path("ids[]", ReplacementType::Identifier) + .unwrap(); + assert_eq!( + map.get("ids").unwrap(), + &Value::Array(vec![ + Value::Identifier([1u8; 32]), + Value::Identifier([2u8; 32]) + ]) + ); + + // One member by index; an index past the end is an error + map.insert( + "more".to_string(), + Value::Array(vec![base58_id(3), Value::Text("keep".into())]), + ); + map.replace_at_path("more[0]", ReplacementType::Identifier) + .unwrap(); + assert_eq!( + map.get("more").unwrap(), + &Value::Array(vec![ + Value::Identifier([3u8; 32]), + Value::Text("keep".into()) + ]) + ); + assert!(map + .replace_at_path("more[2]", ReplacementType::Identifier) + .is_err()); + } + + #[test] + fn should_replace_the_members_of_a_nested_list_and_descend_into_a_list_of_maps() { + let inner = Value::Map(vec![( + Value::Text("ids".into()), + Value::Array(vec![base58_id(4)]), + )]); + let mut map = BTreeMap::new(); + map.insert("meta".to_string(), inner); + map.replace_at_path("meta.ids[]", ReplacementType::Identifier) + .unwrap(); + let Some(Value::Map(meta)) = map.get("meta") else { + panic!("expected the map"); + }; + assert_eq!( + meta.get_optional_key("ids").unwrap(), + &Value::Array(vec![Value::Identifier([4u8; 32])]) + ); + + let item = Value::Map(vec![(Value::Text("id".into()), base58_id(5))]); + map.insert("items".to_string(), Value::Array(vec![item])); + map.replace_at_path("items[].id", ReplacementType::Identifier) + .unwrap(); + let Some(Value::Array(items)) = map.get("items") else { + panic!("expected the list"); + }; + let Value::Map(item) = &items[0] else { + panic!("expected a map member"); + }; + assert_eq!( + item.get_optional_key("id").unwrap(), + &Value::Identifier([5u8; 32]) + ); + } + + #[test] + fn should_treat_an_absent_top_level_list_as_nothing_to_replace() { + let mut map = BTreeMap::new(); + map.insert("a".to_string(), Value::U32(1)); + assert!(map + .replace_at_path("ids[]", ReplacementType::Identifier) + .is_ok()); + // Below a scalar there is no list to look in + assert!(map + .replace_at_path("a.ids[]", ReplacementType::Identifier) + .is_err()); + } + // ----------------------------------------------------------------------- // Error paths // ----------------------------------------------------------------------- diff --git a/packages/rs-platform-value/src/replace.rs b/packages/rs-platform-value/src/replace.rs index c3088f1d470..aef12e2066d 100644 --- a/packages/rs-platform-value/src/replace.rs +++ b/packages/rs-platform-value/src/replace.rs @@ -1,3 +1,4 @@ +use crate::btreemap_extensions::btreemap_field_replacement::replace_leaf; use crate::btreemap_extensions::btreemap_field_replacement::IntegerReplacementType; use crate::inner_value_at_path::is_array_path; use crate::{Error, ReplacementType, Value, ValueMapHelper}; @@ -64,30 +65,44 @@ impl Value { let mut current_values = vec![self]; while let Some(path_component) = split.next() { if let Some((string_part, number_part)) = is_array_path(path_component)? { + let is_last_component = split.peek().is_none(); current_values = current_values .into_iter() - .map(|current_value| { - let map = current_value.to_map_mut()?; - let array_value = map.get_key_mut(string_part)?; - let array = array_value.to_array_mut()?; - if let Some(number_part) = number_part { - if array.len() < number_part { - //this already exists - Ok(vec![array.get_mut(number_part).unwrap()]) - } else { - Err(Error::StructureError(format!( + .filter_map(|current_value| { + let map = match current_value.to_map_mut() { + Ok(map) => map, + Err(err) => return Some(Err(err)), + }; + // An absent list is an absent optional property, as an + // absent key is below: nothing to replace + let array_value = map.get_optional_key_mut(string_part)?; + let array = match array_value.to_array_mut() { + Ok(array) => array, + Err(err) => return Some(Err(err)), + }; + match number_part { + Some(number_part) => match array.get_mut(number_part) { + Some(member) => Some(Ok(vec![member])), + None => Some(Err(Error::StructureError(format!( "element at position {number_part} in array does not exist" - ))) - } - } else { + )))), + }, // we are replacing all members in array - Ok(array.iter_mut().collect()) + None => Some(Ok(array.iter_mut().collect())), } }) .collect::>, Error>>()? .into_iter() .flatten() - .collect() + .collect(); + if is_last_component { + // `list[]` or `list[3]` ends the path: the members are the + // values to replace + for member in current_values { + replace_leaf(member, replacement_type)?; + } + return Ok(()); + } } else { current_values = current_values .into_iter() @@ -100,23 +115,7 @@ impl Value { let new_value = map.get_optional_key_mut(path_component)?; if split.peek().is_none() { - let bytes_result = match replacement_type { - ReplacementType::Identifier | ReplacementType::TextBase58 => { - new_value.to_identifier_bytes() - } - ReplacementType::BinaryBytes | ReplacementType::TextBase64 => { - new_value.to_binary_bytes() - } - }; - let bytes = match bytes_result { - Ok(bytes) => bytes, - Err(err) => return Some(Err(err)), - }; - *new_value = match replacement_type.replace_for_bytes(bytes) { - Ok(value) => value, - Err(err) => return Some(Err(err)), - }; - return None; + return replace_leaf(new_value, replacement_type).err().map(Err); } Some(Ok(new_value)) }) @@ -645,6 +644,77 @@ mod tests { ); } + // =============================================================== + // replace_at_path: a path ending in a list names its members + // =============================================================== + + #[test] + fn should_replace_every_member_when_a_list_ends_the_path() { + let b58 = base58_of_32_bytes(7); + let list = Value::Array(vec![Value::Text(b58), Value::Bytes32([8u8; 32])]); + let mut value = Value::Map(vec![(Value::Text("ids".into()), list)]); + + value + .replace_at_path("ids[]", ReplacementType::Identifier) + .unwrap(); + assert_eq!( + value.get_value_at_path("ids").unwrap(), + &Value::Array(vec![ + Value::Identifier([7u8; 32]), + Value::Identifier([8u8; 32]) + ]) + ); + } + + #[test] + fn should_replace_one_member_by_index_and_refuse_an_index_past_the_end() { + let b58 = base58_of_32_bytes(9); + let list = Value::Array(vec![Value::Text("keep".into()), Value::Text(b58)]); + let mut value = Value::Map(vec![(Value::Text("ids".into()), list)]); + + value + .replace_at_path("ids[1]", ReplacementType::Identifier) + .unwrap(); + assert_eq!( + value.get_value_at_path("ids").unwrap(), + &Value::Array(vec![ + Value::Text("keep".into()), + Value::Identifier([9u8; 32]) + ]) + ); + // An error, not the panic the inverted bounds check used to reach + assert!(value + .replace_at_path("ids[2]", ReplacementType::Identifier) + .is_err()); + } + + #[test] + fn should_keep_the_fixed_size_kind_of_a_replaced_member() { + // The same helper serves the map replacer: a 32-byte value replaced + // as binary bytes stays `Bytes32` on both paths + let list = Value::Array(vec![Value::Bytes32([8u8; 32])]); + let mut value = Value::Map(vec![(Value::Text("digests".into()), list)]); + + value + .replace_at_path("digests[]", ReplacementType::BinaryBytes) + .unwrap(); + assert_eq!( + value.get_value_at_path("digests").unwrap(), + &Value::Array(vec![Value::Bytes32([8u8; 32])]) + ); + } + + #[test] + fn should_treat_an_absent_list_as_nothing_to_replace() { + let mut value = Value::Map(vec![(Value::Text("a".into()), Value::U32(42))]); + assert!(value + .replace_at_path("ids[]", ReplacementType::Identifier) + .is_ok()); + assert!(value + .replace_at_path("ids[].inner", ReplacementType::Identifier) + .is_ok()); + } + // =============================================================== // replace_at_path — optional key missing returns Ok // =============================================================== 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 ce45a9b8f5e..2f55e2dd4c6 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs @@ -75,6 +75,15 @@ pub struct DocumentTypeMethodVersions { pub max_size: FeatureVersion, pub serialize_value_for_key: FeatureVersion, pub deserialize_value_for_key: FeatureVersion, + /// `validate_distinct_from_properties`: refuses a document whose `distinctFrom` + /// property equals the value it must differ from. `None` on versions that + /// predate the keyword, where no parsed property carries it. + pub validate_distinct_from: OptionalFeatureVersion, + /// `validate_encrypted_property_shapes`: refuses a document whose `encryptedFor` + /// property does not have the shape its scheme produces. `None` on versions + /// that predate the keyword: the method returns an empty result there, so the + /// shipped create and replace structure validations that call it are inert. + pub validate_encrypted_property_shapes: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] @@ -92,6 +101,24 @@ pub struct DocumentTypeSchemaVersions { /// keyword: they ignore it entirely, exactly as they parsed before it /// existed. pub apply_required_since: OptionalFeatureVersion, + /// Parses the `distinctFrom` property keyword (an identifier property whose + /// value must differ from a named property of the same document, or from + /// the document's `$ownerId`) onto the property. `None` on versions that + /// predate the keyword: they ignore it entirely, exactly as they parsed + /// before it existed. + pub apply_distinct_from: OptionalFeatureVersion, + /// Parses the `encryptedFor` property keyword (how a byte array property's + /// ciphertext was produced: recipient, key ids and scheme) onto the + /// property, and checks the properties it names at contract registration. + /// `None` on versions that predate the keyword: they ignore it entirely, + /// exactly as they parsed before it existed. + pub apply_encrypted_for: OptionalFeatureVersion, + /// Parses a typed array property (`type: "array"` with an `items` + /// element schema instead of `byteArray`). `None` on versions that + /// predate typed arrays: they leave such a property to the scalar + /// parser, which refuses an array that is not a byte array, exactly as + /// they parsed before typed arrays existed. + pub parse_typed_array: OptionalFeatureVersion, pub validate_max_depth: FeatureVersion, pub max_depth: u16, pub recursive_schema_validator_versions: RecursiveSchemaValidatorVersions, 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 5d8268e5c02..a8d3161f6c4 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs @@ -45,6 +45,9 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + apply_distinct_from: None, + apply_encrypted_for: None, + parse_typed_array: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -62,6 +65,8 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { max_size: 0, serialize_value_for_key: 0, deserialize_value_for_key: 0, + validate_distinct_from: None, + validate_encrypted_property_shapes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs index 0adcf1900fa..5f35727e72f 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs @@ -45,6 +45,9 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + apply_distinct_from: None, + apply_encrypted_for: None, + parse_typed_array: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -62,6 +65,8 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { max_size: 0, serialize_value_for_key: 0, deserialize_value_for_key: 0, + validate_distinct_from: None, + validate_encrypted_property_shapes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs index 8aa7bcfd4ff..0c21c7188d4 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs @@ -47,6 +47,9 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + apply_distinct_from: None, + apply_encrypted_for: None, + parse_typed_array: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -64,6 +67,8 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { max_size: 0, serialize_value_for_key: 0, deserialize_value_for_key: 0, + validate_distinct_from: None, + validate_encrypted_property_shapes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs index c7b75d7bd50..4e381e26d00 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs @@ -47,6 +47,9 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + apply_distinct_from: None, + apply_encrypted_for: None, + parse_typed_array: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -64,6 +67,8 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { max_size: 0, serialize_value_for_key: 0, deserialize_value_for_key: 0, + validate_distinct_from: None, + validate_encrypted_property_shapes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs index e192b402bdc..afc06a4c41d 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs @@ -49,6 +49,9 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + apply_distinct_from: None, + apply_encrypted_for: None, + parse_typed_array: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -66,6 +69,8 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { max_size: 0, serialize_value_for_key: 0, deserialize_value_for_key: 0, + validate_distinct_from: None, + validate_encrypted_property_shapes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs index a5ec478500c..a43ce0d7464 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 @@ -79,9 +79,12 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { document_type_schema: 3, // changed: v3 document meta-schema — v2 plus the ranked index keywords, and the gate the index parser reads should_add_creator_id: 1, enrich_with_base_schema: 1, - find_identifier_and_binary_paths: 0, + find_identifier_and_binary_paths: 1, // changed: a typed array's identifier and byte array elements are registered as `path[]` conversion paths apply_property_reference: Some(0), // changed: the meta-schema v3 `refersTo` keyword is folded into the parsed property type; None before this version means the keyword is ignored, as it was before it existed apply_required_since: Some(0), // changed: the meta-schema v3 `requiredSince` keyword (contract version a property is required from) is parsed onto the property; None before this version means the keyword is ignored, as it was before it existed + apply_distinct_from: Some(0), // changed: the meta-schema v3 `distinctFrom` keyword (an identifier property whose value must differ from a named sibling property or the document's `$ownerId`) is parsed onto the property; None before this version means the keyword is ignored, as it was before it existed + apply_encrypted_for: Some(0), // changed: the meta-schema v3 `encryptedFor` keyword (recipient, key ids and scheme of a byte array property's ciphertext) is parsed onto the property and its named properties are checked at registration; None before this version means the keyword is ignored, as it was before it existed + parse_typed_array: Some(0), // changed: a meta-schema v3 typed array (`type: "array"` with an `items` element schema) parses to `DocumentPropertyType::TypedArray`; None before this version leaves it to the scalar parser, which refuses an array that is not a byte array validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -110,6 +113,8 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { max_size: 0, serialize_value_for_key: 0, deserialize_value_for_key: 0, + validate_distinct_from: Some(0), // changed: `validate_distinct_from_properties` refuses a document whose `distinctFrom` property equals what it must differ from (DocumentPropertyNotDistinctError, 10419); None before this version, where no property can carry the keyword + validate_encrypted_property_shapes: Some(0), // changed: refuses an `encryptedFor` property whose bytes are not the shape its scheme produces; None before this version returns an empty result }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs index 900fab5a43c..0318614f8e7 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/mod.rs @@ -8,4 +8,6 @@ pub struct DPPVotingVersions { pub default_vote_poll_time_duration_mainnet_ms: u64, pub default_vote_poll_time_duration_test_network_ms: u64, pub contested_document_vote_poll_stored_info_version: FeatureVersion, + /// The stored info of a yes/no poll (protocol version 14). + pub yes_no_vote_poll_stored_info_version: FeatureVersion, } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs index 944a295d1fe..4e19daaf563 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v1.rs @@ -4,4 +4,5 @@ pub const VOTING_VERSION_V1: DPPVotingVersions = DPPVotingVersions { default_vote_poll_time_duration_mainnet_ms: 1_209_600_000, //2 weeks default_vote_poll_time_duration_test_network_ms: 1_209_600_000, //2 weeks contested_document_vote_poll_stored_info_version: 0, + yes_no_vote_poll_stored_info_version: 0, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs index de072e87837..409b7bec462 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_voting_versions/v2.rs @@ -4,4 +4,5 @@ pub const VOTING_VERSION_V2: DPPVotingVersions = DPPVotingVersions { default_vote_poll_time_duration_mainnet_ms: 1_209_600_000, //2 weeks default_vote_poll_time_duration_test_network_ms: 5_400_000, //90 minutes contested_document_vote_poll_stored_info_version: 0, + yes_no_vote_poll_stored_info_version: 0, }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs index 3b1499e29cd..f9615f8af65 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs @@ -152,6 +152,12 @@ pub struct DriveAbciVotingMethodVersions { pub delay_vote_poll: FeatureVersion, pub run_dao_platform_events: FeatureVersion, pub remove_votes_for_removed_masternodes: FeatureVersion, + /// The yes/no poll kind (protocol version 14): its tally, its record, its clean-up and the + /// hook a feature acts on when one of its polls finishes. + pub tally_votes_for_yes_no_vote_poll: FeatureVersion, + pub keep_record_of_finished_yes_no_vote_poll: FeatureVersion, + pub clean_up_after_yes_no_vote_polls_end: FeatureVersion, + pub on_yes_no_vote_poll_finished: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs index 1356dbdaac1..4429368e9ba 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs @@ -102,6 +102,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V1: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: 0, + keep_record_of_finished_yes_no_vote_poll: 0, + clean_up_after_yes_no_vote_polls_end: 0, + on_yes_no_vote_poll_finished: 0, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs index 49de2a0083f..9853bd36095 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs @@ -102,14 +102,18 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet }, voting: DriveAbciVotingMethodVersions { keep_record_of_finished_contested_resource_vote_poll: 0, - clean_up_after_vote_poll_end: 0, - clean_up_after_contested_resources_vote_poll_end: 1, - check_for_ended_vote_polls: 1, // changed in v14: a tie goes to the earliest contender + clean_up_after_vote_poll_end: 1, // changed in v14: also cleans up finished yes/no polls, and removes every finished poll from the end date index itself + clean_up_after_contested_resources_vote_poll_end: 2, // changed in v14: leaves the end date index to clean_up_after_vote_poll_end + check_for_ended_vote_polls: 1, // changed in v14: a tie goes to the earliest contender, and yes/no polls are closed too tally_votes_for_contested_document_resource_vote_poll: 0, award_document_to_winner: 0, delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: 0, // new in v14 + keep_record_of_finished_yes_no_vote_poll: 0, // new in v14 + clean_up_after_yes_no_vote_polls_end: 0, // new in v14 + on_yes_no_vote_poll_finished: 0, // new in v14 }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 1, // changed: deducts what a budgeted signing key spent from its remaining budget, and charges a document batch's gas sponsor instead of its signer diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs index a648c5553d5..0dd9de5dd0f 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs @@ -103,6 +103,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V2: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: 0, + keep_record_of_finished_yes_no_vote_poll: 0, + clean_up_after_yes_no_vote_polls_end: 0, + on_yes_no_vote_poll_finished: 0, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs index dc4873efd51..90b93945fd4 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs @@ -102,6 +102,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V3: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: 0, + keep_record_of_finished_yes_no_vote_poll: 0, + clean_up_after_yes_no_vote_polls_end: 0, + on_yes_no_vote_poll_finished: 0, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs index 6aa6c34ac4c..e06b75faf02 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs @@ -102,6 +102,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V4: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: 0, + keep_record_of_finished_yes_no_vote_poll: 0, + clean_up_after_yes_no_vote_polls_end: 0, + on_yes_no_vote_poll_finished: 0, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs index 8772809b224..b3ca7d4dd2a 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs @@ -106,6 +106,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V5: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: 0, + keep_record_of_finished_yes_no_vote_poll: 0, + clean_up_after_yes_no_vote_polls_end: 0, + on_yes_no_vote_poll_finished: 0, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs index 71f03c433f5..16c1a7bb0e5 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs @@ -104,6 +104,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V6: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: 0, + keep_record_of_finished_yes_no_vote_poll: 0, + clean_up_after_yes_no_vote_polls_end: 0, + on_yes_no_vote_poll_finished: 0, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs index a03a16bb9eb..a8283840fd5 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs @@ -111,6 +111,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V7: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: 0, + keep_record_of_finished_yes_no_vote_poll: 0, + clean_up_after_yes_no_vote_polls_end: 0, + on_yes_no_vote_poll_finished: 0, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs index a89543c3f84..5e7ed132811 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs @@ -113,6 +113,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V8: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: 0, + keep_record_of_finished_yes_no_vote_poll: 0, + clean_up_after_yes_no_vote_polls_end: 0, + on_yes_no_vote_poll_finished: 0, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs index 47e32f39da8..469197058c9 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs @@ -122,6 +122,10 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V9: DriveAbciMethodVersions = DriveAbciMeth delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: 0, + keep_record_of_finished_yes_no_vote_poll: 0, + clean_up_after_yes_no_vote_polls_end: 0, + on_yes_no_vote_poll_finished: 0, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index bf3417cbf7b..5d4e5ec1f13 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -9,7 +9,9 @@ use crate::version::drive_abci_versions::drive_abci_validation_versions::{ // PROTOCOL_VERSION_14: bump `document_create_transition_structure_validation` to // 1, which cross-checks the index named by a document create transition's // prefunded voting balance against the contested index the document itself -// resolves to. Also bump document create state validation to 2, adding +// resolves to, and checks the ciphertext shape of every `encryptedFor` +// property (replace structure validation 0 gained the same shape check in +// place, inert before this version). Also bump document create state validation to 2, adding // `refersTo` document reference validation (referenced identities and // contracts must exist) and rejecting a non-contested create whose id is // already held by a live contested document. Document replace state @@ -17,6 +19,13 @@ use crate::version::drive_abci_versions::drive_abci_validation_versions::{ // the `document_reference_validation` feature version. Also bump // `delete_withdrawal_data_trigger` to 2 so owners can delete withdrawals in the // terminal FAILED status the withdrawals contract v2 admits. +// Document create structure validation 1 also refuses a `distinctFrom` +// identifier property whose value equals the named sibling property or the +// writer's `$ownerId` (DocumentPropertyNotDistinctError, 10419). Document +// replace, transfer and purchase structure validation stay at 0: their v0 +// gained the same judgement in place (replace on the transition's data, +// transfer and purchase on the stored document and its new owner), which is +// inert before this version, where no property carries the keyword. // v9 remains unchanged for PROTOCOL_VERSION_13 chain replay. pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = DriveAbciValidationVersions { @@ -103,8 +112,8 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: Some(0), identity_signatures: None, nonce: Some(1), - state: 1, // changed in v14: refuses a Lock vote on a contested index resolved without locking - transform_into_action: 0, + state: 1, // changed in v14: refuses a Lock vote on a contested index resolved without locking; a vote on a yes/no poll checks that poll's stored info + transform_into_action: 1, // changed in v14: a vote on a yes/no poll looks up the masternode's previous yes/no vote }, masternode_vote_state_transition_balance_pre_check: 0, contract_create_state_transition: DriveAbciStateTransitionValidationVersion { @@ -219,14 +228,14 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = // PROTOCOL_VERSION_14: a batch that asks the contract owner to pay its gas // only has to fund its principal (purchases, contest collateral) itself. identity_minimum_balance_pre_check: 1, - document_create_transition_structure_validation: 1, + document_create_transition_structure_validation: 1, // changed: v1 also cross-checks the prefunded voting balance against the contested index and refuses a `distinctFrom` identifier property equal to the value it must differ from // Reject deletes on legacy keep-history types as paid consensus errors. // Protocols through 13 retain the original internal-error outcome. document_delete_transition_structure_validation: 1, document_index_only_delete_transition_structure_validation: 0, - document_replace_transition_structure_validation: 0, - document_transfer_transition_structure_validation: 0, - document_purchase_transition_structure_validation: 0, + document_replace_transition_structure_validation: 0, // unchanged: v0 gained the `distinctFrom` refusal in place, inert before this version + document_transfer_transition_structure_validation: 0, // unchanged: v0 gained the `distinctFrom: $ownerId` judgement in place, inert before this version + document_purchase_transition_structure_validation: 0, // unchanged: v0 gained the `distinctFrom: $ownerId` judgement in place, inert before this version document_update_price_transition_structure_validation: 0, document_base_transition_state_validation: 0, document_create_transition_state_validation: 2, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs index 06bc07e5544..3e7714d6a68 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs @@ -2,6 +2,7 @@ use versioned_feature_core::FeatureVersion; pub mod v1; pub mod v2; +pub mod v3; #[derive(Clone, Debug, Default)] pub struct DriveVerifyMethodVersions { @@ -181,6 +182,10 @@ pub struct DriveVerifyVoteMethodVersions { pub verify_contests_proof: FeatureVersion, pub verify_vote_polls_by_end_date_proof: FeatureVersion, pub verify_specialized_balance: FeatureVersion, + /// The stored info and tallies of a yes/no poll (protocol version 14). + pub verify_yes_no_vote_poll_state_proof: FeatureVersion, + /// A masternode's vote on a yes/no poll (protocol version 14). + pub verify_masternode_yes_no_vote: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs index 312f4c22275..eff0506447d 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs @@ -126,6 +126,8 @@ pub const DRIVE_VERIFY_METHOD_VERSIONS_V1: DriveVerifyMethodVersions = DriveVeri verify_contests_proof: 0, verify_vote_polls_by_end_date_proof: 0, verify_specialized_balance: 0, + verify_yes_no_vote_poll_state_proof: 0, + verify_masternode_yes_no_vote: 0, }, address_funds: DriveVerifyAddressFundsMethodVersions { verify_address_info: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v3.rs new file mode 100644 index 00000000000..473a0578f31 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v3.rs @@ -0,0 +1,19 @@ +use crate::version::drive_versions::drive_verify_method_versions::v2::DRIVE_VERIFY_METHOD_VERSIONS_V2; +use crate::version::drive_versions::drive_verify_method_versions::{ + DriveVerifyMethodVersions, DriveVerifyStateTransitionMethodVersions, +}; + +/// Version 3 of the Drive verify method versions. +/// +/// Changed from v2: `verify_state_transition_was_executed_with_proof` 0 → 1. Feature +/// version 1 verifies a document batch proof that carries the owner's credit balance next +/// to the document (one strict verification of the merged query); feature version 0 +/// verifies the document alone. Must move in lockstep with +/// `DriveProveMethodVersions::prove_state_transition`, which selects the matching prover +/// on the server side. +pub const DRIVE_VERIFY_METHOD_VERSIONS_V3: DriveVerifyMethodVersions = DriveVerifyMethodVersions { + state_transition: DriveVerifyStateTransitionMethodVersions { + verify_state_transition_was_executed_with_proof: 1, + }, + ..DRIVE_VERIFY_METHOD_VERSIONS_V2 +}; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/mod.rs index 825b0629051..609f52b10ab 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/mod.rs @@ -2,6 +2,7 @@ use versioned_feature_core::FeatureVersion; pub mod v1; pub mod v2; +pub mod v3; #[derive(Clone, Debug, Default)] pub struct DriveVoteMethodVersions { @@ -11,6 +12,22 @@ pub struct DriveVoteMethodVersions { pub setup: DriveVoteSetupMethodVersions, pub storage_form: DriveVoteStorageFormMethodVersions, pub fetch: DriveVoteFetchMethodVersions, + /// Yes/no polls: the decision polls under the votes tree's `d` branch (protocol version 14). + pub yes_no: DriveVoteYesNoMethodVersions, +} + +/// The methods of the yes/no poll kind. All of them are new in protocol version 14 and +/// unreachable before it: no yes/no poll can be opened or voted on earlier. +#[derive(Clone, Debug, Default)] +pub struct DriveVoteYesNoMethodVersions { + pub open_yes_no_vote_poll: FeatureVersion, + pub register_yes_no_identity_vote: FeatureVersion, + pub insert_stored_info_for_yes_no_vote_poll: FeatureVersion, + pub fetch_yes_no_vote_poll_stored_info: FeatureVersion, + pub fetch_identity_yes_no_vote: FeatureVersion, + pub fetch_identities_voting_in_yes_no_vote_poll: FeatureVersion, + pub remove_yes_no_vote_poll_votes_operations: FeatureVersion, + pub remove_yes_no_vote_references_given_by_identity: FeatureVersion, } #[derive(Clone, Debug, Default)] @@ -40,6 +57,11 @@ pub struct DriveVoteCleanupMethodVersions { pub remove_contested_resource_vote_poll_contenders_operations: FeatureVersion, pub remove_contested_resource_top_level_index_operations: FeatureVersion, pub remove_contested_resource_info_operations: FeatureVersion, + /// Removes every vote a removed masternode cast. Version 1 (protocol version 14) also + /// sweeps its yes/no votes. + pub remove_all_votes_given_by_identities: FeatureVersion, + /// Removes finished polls of every kind from the end date index (protocol version 14). + pub remove_vote_poll_end_date_query_operations: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v1.rs index 1e7a59dd95e..9d46bdfad07 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v1.rs @@ -1,7 +1,7 @@ use crate::version::drive_versions::drive_vote_method_versions::{ DriveVoteCleanupMethodVersions, DriveVoteContestedResourceInsertMethodVersions, DriveVoteFetchMethodVersions, DriveVoteInsertMethodVersions, DriveVoteMethodVersions, - DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, + DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, DriveVoteYesNoMethodVersions, }; pub const DRIVE_VOTE_METHOD_VERSIONS_V1: DriveVoteMethodVersions = DriveVoteMethodVersions { @@ -23,6 +23,8 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V1: DriveVoteMethodVersions = DriveVoteMeth remove_contested_resource_vote_poll_contenders_operations: 0, remove_contested_resource_top_level_index_operations: 0, remove_contested_resource_info_operations: 0, + remove_all_votes_given_by_identities: 0, + remove_vote_poll_end_date_query_operations: 0, }, setup: DriveVoteSetupMethodVersions { add_initial_vote_tree_main_structure_operations: 0, @@ -35,4 +37,14 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V1: DriveVoteMethodVersions = DriveVoteMeth fetch_contested_document_vote_poll_stored_info: 0, fetch_identity_contested_resource_vote: 0, }, + yes_no: DriveVoteYesNoMethodVersions { + open_yes_no_vote_poll: 0, + register_yes_no_identity_vote: 0, + insert_stored_info_for_yes_no_vote_poll: 0, + fetch_yes_no_vote_poll_stored_info: 0, + fetch_identity_yes_no_vote: 0, + fetch_identities_voting_in_yes_no_vote_poll: 0, + remove_yes_no_vote_poll_votes_operations: 0, + remove_yes_no_vote_references_given_by_identity: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v2.rs index ef99612f58d..cbe2331c0ab 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v2.rs @@ -1,7 +1,7 @@ use crate::version::drive_versions::drive_vote_method_versions::{ DriveVoteCleanupMethodVersions, DriveVoteContestedResourceInsertMethodVersions, DriveVoteFetchMethodVersions, DriveVoteInsertMethodVersions, DriveVoteMethodVersions, - DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, + DriveVoteSetupMethodVersions, DriveVoteStorageFormMethodVersions, DriveVoteYesNoMethodVersions, }; pub const DRIVE_VOTE_METHOD_VERSIONS_V2: DriveVoteMethodVersions = DriveVoteMethodVersions { @@ -23,6 +23,8 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V2: DriveVoteMethodVersions = DriveVoteMeth remove_contested_resource_vote_poll_contenders_operations: 1, remove_contested_resource_top_level_index_operations: 0, remove_contested_resource_info_operations: 0, + remove_all_votes_given_by_identities: 0, + remove_vote_poll_end_date_query_operations: 0, }, setup: DriveVoteSetupMethodVersions { add_initial_vote_tree_main_structure_operations: 0, @@ -35,4 +37,14 @@ pub const DRIVE_VOTE_METHOD_VERSIONS_V2: DriveVoteMethodVersions = DriveVoteMeth fetch_contested_document_vote_poll_stored_info: 0, fetch_identity_contested_resource_vote: 0, }, + yes_no: DriveVoteYesNoMethodVersions { + open_yes_no_vote_poll: 0, + register_yes_no_identity_vote: 0, + insert_stored_info_for_yes_no_vote_poll: 0, + fetch_yes_no_vote_poll_stored_info: 0, + fetch_identity_yes_no_vote: 0, + fetch_identities_voting_in_yes_no_vote_poll: 0, + remove_yes_no_vote_poll_votes_operations: 0, + remove_yes_no_vote_references_given_by_identity: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v3.rs new file mode 100644 index 00000000000..7bbdf5d0059 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_vote_method_versions/v3.rs @@ -0,0 +1,27 @@ +use crate::version::drive_versions::drive_vote_method_versions::v2::DRIVE_VOTE_METHOD_VERSIONS_V2; +use crate::version::drive_versions::drive_vote_method_versions::{ + DriveVoteCleanupMethodVersions, DriveVoteContestedResourceInsertMethodVersions, + DriveVoteMethodVersions, +}; + +/// Drive vote method versions 3. Introduced in protocol version 14 with the yes/no poll +/// kind: +/// +/// * `contested_resource_insert.register_identity_vote` 1 (the slot `register_identity_vote` +/// dispatches on; `insert.register_identity_vote` is read by nothing) dispatches a resolved +/// vote of either kind (v0 knows only contested resource votes). +/// * `remove_all_votes_given_by_identities` 1 also removes the yes/no votes of a masternode +/// that left the list. +/// +/// Everything else matches `DRIVE_VOTE_METHOD_VERSIONS_V2`. +pub const DRIVE_VOTE_METHOD_VERSIONS_V3: DriveVoteMethodVersions = DriveVoteMethodVersions { + contested_resource_insert: DriveVoteContestedResourceInsertMethodVersions { + register_identity_vote: 1, + ..DRIVE_VOTE_METHOD_VERSIONS_V2.contested_resource_insert + }, + cleanup: DriveVoteCleanupMethodVersions { + remove_all_votes_given_by_identities: 1, + ..DRIVE_VOTE_METHOD_VERSIONS_V2.cleanup + }, + ..DRIVE_VOTE_METHOD_VERSIONS_V2 +}; diff --git a/packages/rs-platform-version/src/version/drive_versions/v9.rs b/packages/rs-platform-version/src/version/drive_versions/v9.rs index db9faa198ef..9ac79f1acbe 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v9.rs @@ -10,8 +10,8 @@ use crate::version::drive_versions::drive_identity_method_versions::v2::DRIVE_ID use crate::version::drive_versions::drive_state_transition_method_versions::v4::DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4; use crate::version::drive_versions::drive_structure_version::v1::DRIVE_STRUCTURE_V1; use crate::version::drive_versions::drive_token_method_versions::v2::DRIVE_TOKEN_METHOD_VERSIONS_V2; -use crate::version::drive_versions::drive_verify_method_versions::v2::DRIVE_VERIFY_METHOD_VERSIONS_V2; -use crate::version::drive_versions::drive_vote_method_versions::v2::DRIVE_VOTE_METHOD_VERSIONS_V2; +use crate::version::drive_versions::drive_verify_method_versions::v3::DRIVE_VERIFY_METHOD_VERSIONS_V3; +use crate::version::drive_versions::drive_vote_method_versions::v3::DRIVE_VOTE_METHOD_VERSIONS_V3; use crate::version::drive_versions::{ DriveAssetLockMethodVersions, DriveBalancesMethodVersions, DriveBatchOperationsMethodVersion, DriveEstimatedCostsMethodVersions, DriveFeesMethodVersions, DriveFetchMethodVersions, @@ -75,7 +75,7 @@ pub const DRIVE_VERSION_V9: DriveVersion = DriveVersion { prove: DriveProveMethodVersions { prove_elements: 0, prove_multiple_state_transition_results: 0, - prove_state_transition: 0, + prove_state_transition: 1, // changed in v9: a document batch proof carries the owner's balance (verify v1) }, balances: DriveBalancesMethodVersions { add_to_system_credits: 0, @@ -85,7 +85,7 @@ pub const DRIVE_VERSION_V9: DriveVersion = DriveVersion { calculate_total_credits_balance: 2, // ShieldedBalances root tree adds a fifth term to the equation }, document: DRIVE_DOCUMENT_METHOD_VERSIONS_V4, // changed in v9: v2 index walkers + v1 update walker (shared-prefix aggregate indexes become insertable) and the detect_ranked_mode slot - vote: DRIVE_VOTE_METHOD_VERSIONS_V2, + vote: DRIVE_VOTE_METHOD_VERSIONS_V3, // changed in v14: the yes/no poll kind (register_identity_vote 1, masternode removal sweep 1) contract: DRIVE_CONTRACT_METHOD_VERSIONS_V4, // changed in v9: add_contract_to_storage v1 writes the contract version item beside the contract; update_contract v2 creates the distribution storage and mints the base supply of tokens added by an update fees: DriveFeesMethodVersions { calculate_fee: 0 }, estimated_costs: DriveEstimatedCostsMethodVersions { @@ -99,7 +99,7 @@ pub const DRIVE_VERSION_V9: DriveVersion = DriveVersion { add_estimation_costs_for_adding_asset_lock: 0, fetch_asset_lock_outpoint_info: 0, }, - verify: DRIVE_VERIFY_METHOD_VERSIONS_V2, // changed in v8: compacted address-balance proof envelope (verify v1) + verify: DRIVE_VERIFY_METHOD_VERSIONS_V3, // changed in v9: a document batch proof carries the owner's balance (verify state transition v1) identity: DRIVE_IDENTITY_METHOD_VERSIONS_V2, // changed in v9: v1 withdrawal-by-transaction-index query builder (structural, identical lowering) token: DRIVE_TOKEN_METHOD_VERSIONS_V2, // changed in v9: add_pre_programmed_distributions v1 queues the release-time tree shared by a contract's tokens once platform_system: DrivePlatformSystemMethodVersions { diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index 6ffed9849cf..1c97dde0eef 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -567,6 +567,8 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5000, max_document_value_depth: None, + max_typed_array_items: 1024, + max_references_per_document: 256, max_state_transition_size: 20000, // Is different in this test version, not sure if this was a mistake // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch. Raising it here @@ -588,6 +590,10 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_yes_no_vote_poll_resource_path_segments: 16, + max_yes_no_vote_poll_resource_path_bytes: 1024, + max_yes_no_vote_poll_minimum_voting_power: 2000, + max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, min_contract_moderation_election_window_seconds: 86_400, diff --git a/packages/rs-platform-version/src/version/mocks/v3_test.rs b/packages/rs-platform-version/src/version/mocks/v3_test.rs index 12d62c79dc3..834c7baa37c 100644 --- a/packages/rs-platform-version/src/version/mocks/v3_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v3_test.rs @@ -137,6 +137,10 @@ pub const TEST_PLATFORM_V3: PlatformVersion = PlatformVersion { delay_vote_poll: 0, run_dao_platform_events: 0, remove_votes_for_removed_masternodes: 0, + tally_votes_for_yes_no_vote_poll: 0, + keep_record_of_finished_yes_no_vote_poll: 0, + clean_up_after_yes_no_vote_polls_end: 0, + on_yes_no_vote_poll_finished: 0, }, state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { execute_event: 0, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index aa11841d772..68e8ffd261c 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -11,6 +11,23 @@ pub struct SystemLimits { /// /// `None` preserves the behavior of protocol versions that predate this limit. pub max_document_value_depth: Option, + /// Maximum `maxItems` a typed array document property (`type: "array"` with an `items` + /// element schema) may declare, enforced when a contract is registered or updated (every + /// parse requires `maxItems`; full validation refuses one above this). The bound keeps an + /// array's worst-case encoded size, which fee estimation charges by, small. Read by + /// document type parser generation 3 (protocol version 14), the only generation that + /// parses typed arrays, and never reached before. + pub max_typed_array_items: u16, + /// Maximum number of references one document of a document type may carry, counted at + /// contract registration or update from the type's `refersTo` declarations: one for each + /// property that declares one (an identifier, or a key id carrying a key reference), and + /// `maxItems` for each typed array whose identifier elements declare one. Every reference is checked against state when the + /// document is created or replaced, each check a billed read, so this bounds the reads one + /// document write can cause; without it a type could declare many typed arrays of + /// `max_typed_array_items` references each. Refused under full validation only, like + /// `max_typed_array_items`. Read by document type parser generation 3 (protocol version + /// 14), the only generation that parses `refersTo`, and never reached before. + pub max_references_per_document: u16, /// Max size of a state transition in bytes. /// /// NOTE: This must be equal to the `max-tx-bytes` in the Tenderdash config @@ -119,6 +136,18 @@ pub struct SystemLimits { /// warning or a moderator's document deletion carries (`ContractModerationReason::text`). Read by the `ContractUserModeration` basic /// structure validation v0 (protocol version 14) and never reached before. pub max_contract_moderation_reason_length: u16, + /// The most segments a yes/no vote poll's resource path may have, read by + /// `YesNoVotePoll::validate_parameters` (protocol version 14). + pub max_yes_no_vote_poll_resource_path_segments: u8, + /// The most bytes a yes/no vote poll's resource path may hold across its segments, read by + /// `YesNoVotePoll::validate_parameters` (protocol version 14). Every vote carries the poll. + pub max_yes_no_vote_poll_resource_path_bytes: u16, + /// The largest fixed minimum voting power a yes/no vote poll may require, read by + /// `YesNoVotePoll::validate_parameters` (protocol version 14). + pub max_yes_no_vote_poll_minimum_voting_power: u32, + /// The largest share of the total voting power, in percent, a yes/no vote poll's minimum may + /// require, read by `YesNoVotePoll::validate_parameters` (protocol version 14). + pub max_yes_no_vote_poll_minimum_voting_power_percent_of_total: u8, /// Maximum number of warnings one identity may carry on a contract's warning list at a /// time: a warn that would exceed it is refused until the warnings are cleared. Read by /// the `ContractUserModeration` state validation v0 (protocol version 14) and never diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 510c1426a69..26aee941d9c 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -4,6 +4,8 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB max_document_value_depth: None, + max_typed_array_items: 1024, + max_references_per_document: 256, max_state_transition_size: 20480, //20 KiB // TODO: this is currently capped at 1 because the batch state-transition // pipeline has known correctness issues with multi-transition batches: @@ -49,6 +51,10 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_yes_no_vote_poll_resource_path_segments: 16, + max_yes_no_vote_poll_resource_path_bytes: 1024, + max_yes_no_vote_poll_minimum_voting_power: 2000, + max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, min_contract_moderation_election_window_seconds: 86_400, // one day diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index caa74744517..43b1b31420a 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -10,6 +10,8 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_field_value_size: 5120, //5 KiB // v12 is already active on live networks; the depth limit activates in v13 (see v3). max_document_value_depth: None, + max_typed_array_items: 1024, + max_references_per_document: 256, max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. @@ -30,6 +32,10 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_yes_no_vote_poll_resource_path_segments: 16, + max_yes_no_vote_poll_resource_path_bytes: 1024, + max_yes_no_vote_poll_minimum_voting_power: 2000, + max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, min_contract_moderation_election_window_seconds: 86_400, // one day diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index 19eb669d6c8..387c03808f9 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -12,6 +12,8 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { // Use the protocol's existing data-contract schema-depth ceiling as the conservative // instance budget, bounding pre-schema work well above known document requirements. max_document_value_depth: Some(256), + max_typed_array_items: 1024, + max_references_per_document: 256, max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. @@ -32,6 +34,10 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_yes_no_vote_poll_resource_path_segments: 16, + max_yes_no_vote_poll_resource_path_bytes: 1024, + max_yes_no_vote_poll_minimum_voting_power: 2000, + max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, min_contract_moderation_election_window_seconds: 86_400, // one day diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index 117c466fe36..f0d22df3d5d 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -50,12 +50,22 @@ use crate::version::system_limits::SystemLimits; /// * Elected moderation teams (protocol version 14): a contract that declares an elected /// moderation team sets its join window and vote window between one day and four weeks, /// and its challenge cool-down between two weeks and three years, all in seconds. +/// * Typed array document properties (protocol version 14): a typed array property declares +/// `maxItems`, at most 1024 elements (`max_typed_array_items`, backfilled into the +/// earlier tables, whose parsers never read it). +/// * References (protocol version 14): one document of a document type carries at most 256 +/// references, counted at registration as one per property with `refersTo` and +/// `maxItems` per typed array whose elements declare one (`max_references_per_document`, +/// backfilled into the earlier tables, whose parsers never read it). Each reference is a +/// billed state read when the document is written. pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB // Use the protocol's existing data-contract schema-depth ceiling as the conservative // instance budget, bounding pre-schema work well above known document requirements. max_document_value_depth: Some(256), + max_typed_array_items: 1024, // typed array properties (new in v14): contract registration caps their maxItems here + max_references_per_document: 256, // refersTo (new in v14): contract registration caps the references one document carries, a typed array of references counting its maxItems max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. @@ -76,6 +86,10 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_contract_moderators: 16, max_contract_suspension_until: 9_007_199_254_740_991, max_contract_moderation_reason_length: 1024, + max_yes_no_vote_poll_resource_path_segments: 16, + max_yes_no_vote_poll_resource_path_bytes: 1024, + max_yes_no_vote_poll_minimum_voting_power: 2000, + max_yes_no_vote_poll_minimum_voting_power_percent_of_total: 50, max_contract_warnings_per_identity: 16, max_contract_moderation_reason_documents: 16, min_contract_moderation_election_window_seconds: 86_400, // one day diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 9c8c36a843d..83b4ef63533 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -199,6 +199,12 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// reference checks, re-validates a `refersTo: deletableDocument` /// reference on every replace (a dead one must be repointed or cleared), /// and lets an `immutable` one be cleared once its target is deleted. +/// Document create structure validation 1 and replace structure +/// validation 0 (extended in place) refuse a `distinctFrom` identifier +/// property equal to the value it must differ from +/// (`DocumentPropertyNotDistinctError`, 10419); transfer and purchase +/// structure validation 0, extended in place, judge the stored document's +/// `$ownerId` declarations against the new owner. /// v13 keeps the v9 table and therefore keeps accepting all of these, so /// replay of pre-upgrade blocks is unchanged. /// * `DOCUMENT_VERSIONS_V4` bumps `document_serialization_version` to @@ -556,6 +562,203 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `ReferencedContractRequirementNotMetError` (40135). A changed /// `contractRequirements` is an incompatible schema change on update. /// +/// 25. **Typed arrays of scalars in document schemas**: a document property +/// may be `type: "array"` with an `items` element schema instead of +/// `byteArray` (meta-schema v3, `parse_typed_array` 0, +/// `DocumentPropertyType::TypedArray`). An element is an integer, a +/// number, a string, a boolean, a byte array or an identifier; objects +/// and arrays of arrays are refused. On the array `minItems` and +/// `maxItems` count elements, `maxItems` is required (with `minItems` +/// not above it) and at most `SYSTEM_LIMITS_V4.max_typed_array_items` +/// (1024), and `uniqueItems` refuses a document repeating an element. An +/// element's `enum` has members of the element type only (none on a byte +/// array or identifier element), and an integer element's `minimum` and +/// `maximum` are integers; the parser reads them so random documents stay +/// inside them. The array is stored inline, a varint element count followed by the +/// elements, each encoded exactly as a required scalar property of its +/// type: an identifier element is 32 raw bytes, an integer element takes +/// the width its bounds give it, a fixed-size byte array element is raw. +/// A contract update may not change how an element encodes +/// (`validate_update` 1). The array cannot be an index property or one +/// side of a `propertyAgreement`. Its identifier and byte array elements +/// are conversion paths (`find_identifier_and_binary_paths` 1). A byte array +/// refuses `items`, and an identifier (a byte array with the identifier +/// `contentMediaType`) now refuses `uniqueItems`, which would demand that +/// no byte repeat. +/// +/// 26. **Distinct identifier properties**: the `distinctFrom` property +/// keyword (meta-schema v3, `apply_distinct_from` 0, `DistinctFrom` on +/// `DocumentProperty`) requires an identifier property's value to differ +/// from the value of a named property of the same document, or from the +/// document's `$ownerId`; on the `items` of a typed array of identifiers +/// it binds every element. A pure structure rule: document create +/// structure validation 1 and replace structure validation 0 call +/// `validate_distinct_from_properties` (`validate_distinct_from` 0) on the +/// transition's data and owner id after the schema validation, transfer +/// and purchase structure validation 0 call it on the stored document and +/// its new owner (the three generation-0 modules were extended in place: +/// the call is inert before this version, where no property carries the +/// keyword), and each refuses an equal pair with +/// `DocumentPropertyNotDistinctError` (10419); an absent named property +/// passes. The parser checks the target at contract +/// registration and update (it must exist, be an identifier and not be +/// the declaring property), and a changed `distinctFrom` is an +/// incompatible schema change on update. +/// +/// 27. **`encryptedFor` on byte array properties**: a byte array property may +/// declare how its ciphertext was produced, so wallets read the recipe +/// from the contract instead of a side channel: `recipient` (an identifier +/// property of the same document type, or `$ownerId`), `recipientKey` and +/// `senderKey` (integer properties of the same type bounded to u32, +/// carrying key ids) and `scheme` (`ecdh-secp256k1-aes256-cbc`, the +/// dashpay contact request scheme: a 16-byte IV followed by AES-256-CBC +/// with PKCS7 padding under the ECDH shared key). Meta-schema v3 admits it +/// on byte arrays that are not identifiers, `apply_encrypted_for` 0 parses +/// it onto `DocumentProperty::encrypted_for` and checks the three named +/// properties exist with the right types at registration. Document create +/// structure validation 1 and replace structure validation 0 (extended in +/// place, inert before this version) call +/// `validate_encrypted_property_shapes` (`validate_encrypted_property_shapes` +/// 0, `None` before this version) to check the ciphertext shape of every declared property a transition supplies, +/// at least the IV plus one block and a multiple of the block, and refuse +/// it with `InvalidEncryptedPropertyShapeError` (10420). Nothing else about +/// the ciphertext is verifiable on chain. A changed `encryptedFor` is an +/// incompatible schema change on update. +/// +/// 28. **Property and document type names are word characters only**: +/// meta-schema v3 refuses `-` in a property name (top-level or nested, +/// and in the property paths of `refersTo` declarations) and generation +/// 3 of the document type parser refuses it in a document type name, +/// under full validation. Every earlier meta-schema and generation +/// admitted `-`, which the dotted and `list[]` path syntax was never +/// written for; a census of every contract create and update on mainnet +/// and testnet (2026-09-23) found no name carrying one, so nothing stored +/// is affected. Stored contracts are read as they are. +/// +/// 29. **Identity key references may require a purpose and a document type +/// bound**: an `identityPublicKey` `refersTo` declaration may carry +/// `keyRequirements`, what the referenced key must be beyond existing and +/// not being disabled, with `purpose` (the key's purpose, by its wire name, +/// any but `system`) and `boundTo` (the key's contract bounds must be +/// exactly the declaring contract and the named document type of it) as +/// the requirements (meta-schema v3, `apply_property_reference` 0, +/// `IdentityKeyReferenceRequirements` on +/// `DocumentPropertyReferenceTarget::IdentityPublicKey`). +/// `create_document_types_from_document_schemas` 1, edited in place (the +/// check is inert before this version, where no parsed reference carries +/// requirements), refuses a contract whose `boundTo` names a document type +/// it does not have or one no key of the required purpose can be bound +/// to, so the check never needs a second contract fetch and a declared +/// requirement can be met. The document reference validation +/// checks the requirements against the key it fetched for the existence +/// check, so they cost no further read, and refuses the first unmet one +/// with `ReferencedIdentityKeyRequirementNotMetError` (40136). A changed +/// `keyRequirements` is an incompatible schema change on update. +/// +/// 30. **Key references on the key id property**: an `identityPublicKey` +/// `refersTo` declaration may sit on the key id property itself, an +/// integer with `minimum` 0 and `maximum` 4294967295 (a `KeyID` is a +/// `u32`), naming through `identityProperty` whose key the value is: +/// `"$ownerId"` (the writer), `"$creatorId"` (the document's creator, +/// only on a document type that records creator ids) or the path of an +/// identifier property of the same document type (which must exist, be +/// an identifier and not carry an `identityPublicKey` reference of its +/// own); the last two are checked at contract registration (40125). The +/// declaration takes no `keyIdProperty`; the identifier form is unchanged +/// and every other `refersTo` form stays identifier-only (meta-schema v3, +/// `apply_property_reference` 0, `DocumentPropertyType::KeyIdWithReference` +/// over `KeyReferenceIdentityProperty`). At document create and replace the +/// reference validation reads the key id from the property, resolves the +/// identity (the writer, the creator the action carries, or the named +/// property's value, a key id set while it is unset being refused with +/// 40125) and fetches that key, so the key fetch is the only read; a key +/// that does not exist refuses the write, paid, with +/// `ReferencedIdentityKeyNotFoundError` (40123) and a disabled one with +/// `ReferencedIdentityKeyDisabledError` (40124), as for the identifier +/// form. A replace re-validates `$ownerId` touched or not, as the +/// `$ownerId` writer gate is, since the writer may not be the one who +/// wrote the key id; `$creatorId` when the key id changed; a property +/// path when the key id or that property changed (a transfer itself is +/// never checked: the reference governs writing, not holding). A +/// `keyIdProperty` may not name a property carrying this form (40125 at +/// registration). `keyRequirements` (item 29) sit on this form exactly +/// as on the identifier form, checked by the same key check and by the +/// same `boundTo` registration rule. Adding, removing or changing it is +/// an incompatible schema change on update, like the rest of a +/// `refersTo`. +/// +/// 31. **`refersTo` on the elements of a typed array**: an identifier element +/// of a typed array may carry a `refersTo` declaration on its `items`, +/// which every element then declares (meta-schema v3 `documentArrayItem` +/// reuses the property `refersTo` definition by `$ref` and refuses +/// `identityPublicKey` in both forms, which pair one key id with the +/// reference). +/// `parse_typed_array` 0 folds it into the element through the same +/// `apply_property_reference` 0 a scalar identifier goes through, so the +/// element is `IdentifierWithReference(target)` inside `item_type`, and +/// `DocumentPropertyType::reference` reports either kind. Contract +/// registration (`data_contract_reference_validation` 0) checks the +/// declaration as a single one, and document create state validation 2 +/// and replace state validation 1 (`document_reference_validation` 0, +/// extended in place: both are only reached from protocol version 14, +/// where the element arm is the only new path) check every element as a +/// single reference, refusing the first that fails with that +/// reference's error (40120, 40127, 40135 and the rest), its path the +/// element's list path (`reasons[2]`). A replace re-validates the +/// elements of a changed list the stored list did not hold (the replace +/// action carries `stored_changed_values`), and all of them when a +/// property bound by a `propertyAgreement` changed, for a `$ownerId` +/// agreement or for `deletableDocument` elements. A repeated element and +/// a foreign contract holding the referenced document type are fetched +/// once per list. Registration caps the references one document +/// can carry at `SYSTEM_LIMITS_V4.max_references_per_document` (256; one +/// per property declaring a reference, key id references of item 30 +/// included, `maxItems` per typed array of referencing elements; +/// backfilled into the earlier tables), and +/// refuses an `immutable` property holding a `deletableDocument` +/// reference no replace could clear (a typed array of them, or a single +/// one inside an immutable object), which could never be replaced once +/// a target is deleted. A changed +/// element `refersTo` is an incompatible schema change on update. +/// +/// 32. **Document references resolved through a unique index**: a +/// `permanentDocument` `refersTo`, on an identifier property or on the +/// elements of a typed array (item 31), may carry a `lookup` +/// (meta-schema v3, `apply_property_reference` 0, parsed to the appended +/// `DocumentPropertyReferenceTarget::PermanentDocumentLookup`, so an id +/// reference keeps its variant and its encoding): the value is then +/// not the referenced document's id, and the referenced document is the +/// one the named unique index of the referenced document type finds for +/// a key assembled from the referring document. `keys` maps every index +/// property to a property path of the referring type, `$ownerId` or `.` +/// (the value, or the element, exactly once). A `deletableDocument` +/// reference takes none: a key into a deletable type could find a new +/// document once the one it found is deleted. Generation 3 of the parser +/// checks on every parse that each property a key reads is a stored, +/// required, single value of the referring type; +/// `create_document_types_from_document_schemas` 1, edited in place like +/// for item 29 (inert before this version, where no parsed reference +/// carries a lookup), checks a lookup into a document type of the same +/// contract under full validation (the index exists, is unique, carries +/// no `timeRange` and is not on an indexOnly type, the keys cover it +/// exactly, every source shares its index property's value kind, and the +/// key cannot move off the document it found: its schema properties are +/// immutable, and `$ownerId` is only a part on a type that is neither +/// transferable nor tradeable), and the contract reference validation +/// checks one into another contract, refusing it with +/// `ReferencedDocumentLookupInvalidError` (40137). The document +/// reference validation (generation 0, reached only from this version) +/// queries the index for each value's key, billed as a document fetch, +/// refuses a write with no match with `ReferencedEntityNotFoundError` +/// (40120, an element named by its list path), checks a +/// `propertyAgreement` against the document found, and on replace +/// re-validates when a property the key reads changed. A key may read +/// `$ownerId` only on a referring type that is neither transferable nor +/// tradeable, checked on every parse. A changed `lookup` is an +/// incompatible schema change on update. Chained queries and composite +/// by-id joins refuse a lookup reference as a join property, and +/// preallocated indexes are never bound through one. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's @@ -619,11 +822,11 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// its gates on; Drive identity methods v2 rewrite the key and raise the remaining budget). pub const PLATFORM_V14: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_14, - drive: DRIVE_VERSION_V9, // changed: drive document method versions v4 — v2 index walkers (shared-prefix aggregate indexes become insertable) + the detect_ranked_mode slot; contract method versions v4: the moderation list trees, the document removal record trees and the moderation method table; apply_drive_operations 1 (a moderator's document deletion refunds nobody); index uniqueness gains validate_restored_document_uniqueness (a moderator's document restore) + drive: DRIVE_VERSION_V9, // changed: drive document method versions v4 — v2 index walkers (shared-prefix aggregate indexes become insertable) + the detect_ranked_mode slot; contract method versions v4: the moderation list trees, the document removal record trees and the moderation method table; apply_drive_operations 1 (a moderator's document deletion refunds nobody); index uniqueness gains validate_restored_document_uniqueness (a moderator's document restore); vote method versions v3: the yes/no poll kind drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, // changed: saved platform state structure 1 keeps masternodes and validator sets as one aux entry each - methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: records the per-block total credits history for the daily withdrawal limit - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate + methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: records the per-block total credits history for the daily withdrawal limit; closes and cleans up yes/no polls + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate; masternode vote state + transform 1 admit votes on yes/no polls withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, // changed: prune bound for the total credits history query: DRIVE_ABCI_QUERY_VERSIONS_V3, // changed: ranked + boolean-HAVING routing gate; the v1 handler also resolves IN_TIME_RANGE from committed block time checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, diff --git a/packages/rs-sdk-ffi/src/document/util.rs b/packages/rs-sdk-ffi/src/document/util.rs index 33b6cc69bf3..75d8c5ab5be 100644 --- a/packages/rs-sdk-ffi/src/document/util.rs +++ b/packages/rs-sdk-ffi/src/document/util.rs @@ -1,7 +1,13 @@ use crate::sdk::SDKWrapper; use crate::{DashSDKError, DashSDKErrorCode, DocumentHandle, FFIError, SDKHandle}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dash_sdk::dpp::document::{Document, DocumentV0Setters}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::prelude::Identifier; +use drive_proof_verifier::ContextProvider; +use std::collections::BTreeMap; use std::ffi::CStr; use std::os::raw::c_char; @@ -66,24 +72,53 @@ pub unsafe extern "C" fn dash_sdk_document_free(handle: *mut DocumentHandle) { /// Set document properties from JSON /// +/// The values are sanitized against the document type the way +/// `dash_sdk_document_create` sanitizes them: base58 or hex strings become +/// identifiers, hex or base64 strings become byte arrays and integers narrow +/// to their declared width, typed array elements included. The data contract +/// comes from the trusted context provider, the same lookup +/// `dash_sdk_document_replace_on_platform` makes. On any error the document +/// is left unchanged. +/// /// # Safety -/// - `document_handle` and `properties_json` must be valid, non-null pointers. -/// - `properties_json` must point to a NUL-terminated C string valid for the duration of the call. +/// - `sdk_handle`, `document_handle`, `data_contract_id`, `document_type_name` and +/// `properties_json` must be valid, non-null pointers. +/// - `data_contract_id` (base58 encoded), `document_type_name` and `properties_json` must point +/// to NUL-terminated C strings valid for the duration of the call. /// - Returns an error pointer on failure; caller must free with `dash_sdk_error_free`. #[no_mangle] pub unsafe extern "C" fn dash_sdk_document_set_properties( + sdk_handle: *const SDKHandle, document_handle: *mut DocumentHandle, + data_contract_id: *const c_char, + document_type_name: *const c_char, properties_json: *const c_char, ) -> *mut DashSDKError { - if document_handle.is_null() || properties_json.is_null() { + if sdk_handle.is_null() + || document_handle.is_null() + || data_contract_id.is_null() + || document_type_name.is_null() + || properties_json.is_null() + { return Box::into_raw(Box::new(DashSDKError::new( DashSDKErrorCode::InvalidParameter, "Invalid parameters".to_string(), ))); } + let wrapper = &*(sdk_handle as *const SDKWrapper); let document = &mut *(document_handle as *mut Document); + let contract_id_str = match CStr::from_ptr(data_contract_id).to_str() { + Ok(s) => s, + Err(e) => return Box::into_raw(Box::new(FFIError::from(e).into())), + }; + + let document_type_name_str = match CStr::from_ptr(document_type_name).to_str() { + Ok(s) => s, + Err(e) => return Box::into_raw(Box::new(FFIError::from(e).into())), + }; + let properties_str = match CStr::from_ptr(properties_json).to_str() { Ok(s) => s, Err(e) => { @@ -106,10 +141,10 @@ pub unsafe extern "C" fn dash_sdk_document_set_properties( }; // Convert Value to BTreeMap if it's an object - let properties_map = match properties_value { + let mut properties_map = match properties_value { Value::Map(vec_map) => { // Convert Vec<(Value, Value)> to BTreeMap - let mut btree_map = std::collections::BTreeMap::new(); + let mut btree_map = BTreeMap::new(); for (key, value) in vec_map { let key_str = match key { Value::Text(s) => s, @@ -132,8 +167,246 @@ pub unsafe extern "C" fn dash_sdk_document_set_properties( } }; + if let Err(e) = sanitize_properties_for_document_type( + wrapper, + contract_id_str, + document_type_name_str, + &mut properties_map, + ) { + return Box::into_raw(Box::new(e.into())); + } + // Set the properties on the document document.set_properties(properties_map); std::ptr::null_mut() } + +/// Sanitizes `properties` against the `document_type_name` type of the +/// contract the trusted context provider holds under `data_contract_id`. +fn sanitize_properties_for_document_type( + wrapper: &SDKWrapper, + data_contract_id: &str, + document_type_name: &str, + properties: &mut BTreeMap, +) -> Result<(), FFIError> { + let contract_id = Identifier::from_string(data_contract_id, Encoding::Base58) + .map_err(|e| FFIError::InternalError(format!("Invalid contract ID: {}", e)))?; + + let provider = wrapper.trusted_provider.as_ref().ok_or_else(|| { + FFIError::InternalError("No trusted context provider configured".to_string()) + })?; + + let data_contract = provider + .get_data_contract(&contract_id, wrapper.sdk.version()) + .map_err(|e| { + FFIError::InternalError(format!("Failed to get contract from context: {}", e)) + })? + .ok_or_else(|| { + FFIError::InternalError(format!( + "Contract {} not found in trusted context", + data_contract_id + )) + })?; + + let document_type = data_contract + .document_type_borrowed_for_name(document_type_name) + .map_err(|e| FFIError::InternalError(format!("Failed to get document type: {}", e)))?; + + document_type.sanitize_document_properties(properties); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dash_sdk_error_free; + use crate::test_utils::test_utils::destroy_mock_sdk_handle; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::data_contract::DataContractFactory; + use dash_sdk::dpp::document::{DocumentV0, DocumentV0Getters}; + use dash_sdk::dpp::platform_value::platform_value; + use dash_sdk::dpp::prelude::DataContract; + use dash_sdk::dpp::version::PlatformVersion; + use rs_sdk_trusted_context_provider::TrustedHttpContextProvider; + use std::ffi::CString; + use std::num::NonZeroUsize; + use std::sync::Arc; + + /// A contract with one `note` type: an identifier, a byte array and a + /// typed array of identifiers (protocol version 14). + fn note_contract() -> DataContract { + let documents = platform_value!({ + "note": { + "type": "object", + "properties": { + "author": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "payload": { + "type": "array", + "byteArray": true, + "minItems": 1, + "maxItems": 64, + "position": 1 + }, + "members": { + "type": "array", + "minItems": 0, + "maxItems": 8, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 2 + } + }, + "additionalProperties": false + } + }); + + let factory = DataContractFactory::new(PlatformVersion::latest().protocol_version) + .expect("factory for the latest protocol version"); + factory + .create_with_value_config(Identifier::new([1; 32]), 1, documents, None, None) + .expect("note contract") + .data_contract() + .clone() + } + + /// A mock SDK handle whose trusted context provider serves `contracts`. + /// The provider's quorum URL is an address literal, so nothing resolves + /// or connects. + fn sdk_handle_with_known_contracts(contracts: Vec) -> *mut SDKHandle { + let provider = TrustedHttpContextProvider::new_with_url( + Network::Regtest, + "http://127.0.0.1:22444".to_string(), + NonZeroUsize::new(1).expect("non-zero cache size"), + ) + .expect("offline trusted context provider") + .with_known_contracts(contracts); + let mut wrapper = SDKWrapper::new_mock(); + wrapper.trusted_provider = Some(Arc::new(provider)); + Box::into_raw(Box::new(wrapper)) as *mut SDKHandle + } + + fn fetched_note() -> Document { + Document::V0(DocumentV0 { + id: Identifier::new([2; 32]), + owner_id: Identifier::new([1; 32]), + properties: BTreeMap::new(), + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + contract_version: None, + }) + } + + #[test] + fn should_store_identifier_and_byte_properties_decoded_from_strings() { + let contract = note_contract(); + let contract_id = CString::new(contract.id().to_string(Encoding::Base58)) + .expect("no NUL in the contract id"); + let document_type_name = CString::new("note").expect("no NUL in the type name"); + let author = Identifier::new([7; 32]); + let first_member = Identifier::new([8; 32]); + let second_member = Identifier::new([9; 32]); + let properties_json = CString::new(format!( + r#"{{"author":"{}","payload":"deadbeef","members":["{}","{}"]}}"#, + author.to_string(Encoding::Base58), + first_member.to_string(Encoding::Base58), + second_member.to_string(Encoding::Base58), + )) + .expect("no NUL in the JSON"); + + let sdk_handle = sdk_handle_with_known_contracts(vec![contract]); + let document_handle = Box::into_raw(Box::new(fetched_note())) as *mut DocumentHandle; + let error = unsafe { + dash_sdk_document_set_properties( + sdk_handle, + document_handle, + contract_id.as_ptr(), + document_type_name.as_ptr(), + properties_json.as_ptr(), + ) + }; + assert!(error.is_null(), "set_properties failed"); + + let document = unsafe { Box::from_raw(document_handle as *mut Document) }; + destroy_mock_sdk_handle(sdk_handle); + let properties = document.properties(); + assert_eq!( + properties.get("author"), + Some(&Value::Identifier(author.to_buffer())) + ); + assert_eq!( + properties.get("payload"), + Some(&Value::Bytes(vec![0xde, 0xad, 0xbe, 0xef])) + ); + assert_eq!( + properties.get("members"), + Some(&Value::Array(vec![ + Value::Identifier(first_member.to_buffer()), + Value::Identifier(second_member.to_buffer()), + ])) + ); + } + + #[test] + fn should_leave_the_document_unchanged_when_the_document_type_cannot_be_resolved() { + let contract = note_contract(); + let known_contract_id = CString::new(contract.id().to_string(Encoding::Base58)) + .expect("no NUL in the contract id"); + let unknown_contract_id = + CString::new(Identifier::new([3; 32]).to_string(Encoding::Base58)) + .expect("no NUL in the contract id"); + let note = CString::new("note").expect("no NUL in the type name"); + let unknown_type = CString::new("letter").expect("no NUL in the type name"); + let properties_json = + CString::new(r#"{"payload":"deadbeef"}"#).expect("no NUL in the JSON"); + + let sdk_handle = sdk_handle_with_known_contracts(vec![contract]); + for (contract_id, document_type_name) in [ + (&unknown_contract_id, ¬e), + (&known_contract_id, &unknown_type), + ] { + let mut original = fetched_note(); + original.set("payload", Value::Bytes(vec![1, 2, 3])); + let document_handle = Box::into_raw(Box::new(original.clone())) as *mut DocumentHandle; + + let error = unsafe { + dash_sdk_document_set_properties( + sdk_handle, + document_handle, + contract_id.as_ptr(), + document_type_name.as_ptr(), + properties_json.as_ptr(), + ) + }; + + assert!(!error.is_null(), "an unresolvable type must be refused"); + assert_eq!(unsafe { (*error).code }, DashSDKErrorCode::InternalError); + unsafe { dash_sdk_error_free(error) }; + let document = unsafe { Box::from_raw(document_handle as *mut Document) }; + assert_eq!(*document, original); + } + destroy_mock_sdk_handle(sdk_handle); + } +} diff --git a/packages/rs-sdk/src/platform/dpns_usernames/contested_queries.rs b/packages/rs-sdk/src/platform/dpns_usernames/contested_queries.rs index 2980294e6b1..b1a76acdb1c 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/contested_queries.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/contested_queries.rs @@ -458,13 +458,19 @@ impl Sdk { } let mut last_timestamp = None; - let mut polls_in_last_group = 0; + let mut polls_in_page = 0; // Process each timestamp group for (timestamp, polls) in result.0 { - let mut dpns_polls_count = 0; + polls_in_page += polls.len(); + last_timestamp = Some(timestamp); - for VotePoll::ContestedDocumentResourceVotePoll(contested_poll) in polls { + for contested_poll in polls.iter().filter_map(|poll| match poll { + VotePoll::ContestedDocumentResourceVotePoll(contested_poll) => { + Some(contested_poll) + } + VotePoll::YesNoVotePoll(_) => None, + }) { if contested_poll.contract_id == dpns_contract_id && contested_poll.document_type_name == "domain" { @@ -472,31 +478,29 @@ impl Sdk { if contested_poll.index_values.len() >= 2 { if let Value::Text(label) = &contested_poll.index_values[1] { name_to_end_time.insert(label.clone(), timestamp); - dpns_polls_count += 1; } } } } - - if dpns_polls_count > 0 { - last_timestamp = Some(timestamp); - polls_in_last_group = dpns_polls_count; - } } - // Check if we should continue pagination - // If we got less than the limit, we've reached the end - if polls_in_last_group < query_limit as usize { + // The limit counts every poll of every kind across all of the page's end dates, so + // only a full page can have more behind it. + if polls_in_page < query_limit as usize { break; } - // Set up for next query - use the last timestamp as the new start - // with false (not included) to avoid duplicates - if let Some(last_ts) = last_timestamp { - current_start_time = Some((last_ts, false)); - } else { + // The limit may have cut the last end date short, so the next page starts at it + // again (a name seen twice is just written again). If that end date alone filled + // the page, starting at it again would return the same page, so move past it. + let Some(last_ts) = last_timestamp else { break; - } + }; + current_start_time = if current_start_time == Some((last_ts, true)) { + Some((last_ts, false)) + } else { + Some((last_ts, true)) + }; } Ok(name_to_end_time) diff --git a/packages/rs-sdk/src/platform/transition/broadcast.rs b/packages/rs-sdk/src/platform/transition/broadcast.rs index 74e40428159..73d1a107e98 100644 --- a/packages/rs-sdk/src/platform/transition/broadcast.rs +++ b/packages/rs-sdk/src/platform/transition/broadcast.rs @@ -18,7 +18,7 @@ use dpp::state_transition::batch_transition::batched_transition::document_transi use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; use dpp::state_transition::proof_result::{ - StateTransitionProofOutcome, StateTransitionProofResult, + StateTransitionProofGuarantee, StateTransitionProofOutcome, StateTransitionProofResult, }; use dpp::state_transition::StateTransition; use dpp::system_data_contracts::SystemDataContract; @@ -387,12 +387,13 @@ fn is_missing_transition_owner(transition: &StateTransition, error: &Error) -> b } /// Reject snapshot outcomes for the strict wait APIs with a typed error. -fn require_execution_proved( +pub fn require_execution_proved( outcome: StateTransitionProofOutcome, ) -> Result { - match outcome { - StateTransitionProofOutcome::ExecutionProved(result) => Ok(result), - StateTransitionProofOutcome::AffectedState(result) => Err(Error::ExecutionNotProved( + let (guarantee, result, _owner_balance) = outcome.into_parts(); + match guarantee { + StateTransitionProofGuarantee::ExecutionProved => Ok(result), + StateTransitionProofGuarantee::AffectedState => Err(Error::ExecutionNotProved( format!( "received a verified {} snapshot for this transition family; use the *_affected_state wait APIs and treat the result as a height-pinned snapshot", result @@ -402,7 +403,7 @@ fn require_execution_proved( } /// Convert the verified inner result into the caller's expected type. -fn convert_proof_result>( +pub fn convert_proof_result>( result: StateTransitionProofResult, ) -> Result { let variant_name = result.to_string(); @@ -418,7 +419,7 @@ fn convert_proof_result>( /// Internal wait primitive shared by the strict and affected-state public /// APIs. #[async_trait::async_trait] -trait WaitForOutcome { +pub trait WaitForOutcome { async fn wait_for_outcome_with_metadata( &self, sdk: &Sdk, @@ -989,13 +990,13 @@ mod tests { fn strict_wait_rejects_affected_state_outcomes() { let snapshot = StateTransitionProofResult::VerifiedTokenBalanceAbsence(Identifier::from([1u8; 32])); - let err = require_execution_proved(StateTransitionProofOutcome::AffectedState(snapshot)) + let err = require_execution_proved(StateTransitionProofOutcome::affected_state(snapshot)) .expect_err("affected-state outcomes must be rejected by the strict wait"); assert!(matches!(err, Error::ExecutionNotProved(_))); let proved = StateTransitionProofResult::VerifiedTokenBalanceAbsence(Identifier::from([1u8; 32])); - require_execution_proved(StateTransitionProofOutcome::ExecutionProved(proved)) + require_execution_proved(StateTransitionProofOutcome::execution_proved(proved)) .expect("execution-proved outcomes must pass the strict wait"); } } diff --git a/packages/rs-sdk/src/platform/transition/broadcast_request.rs b/packages/rs-sdk/src/platform/transition/broadcast_request.rs index abce2d60314..bd251b307b2 100644 --- a/packages/rs-sdk/src/platform/transition/broadcast_request.rs +++ b/packages/rs-sdk/src/platform/transition/broadcast_request.rs @@ -80,6 +80,8 @@ impl BroadcastRequestForStateTransition for StateTransition { version: Some(Version::V0(WaitForStateTransitionResultRequestV0 { state_transition_hash: self.transaction_id()?.to_vec(), prove: true, + // A proof of an owned, fee-paying transition carries the owner's balance itself. + request_user_balance: false, })), }) } diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index dd6d3396daa..792b356c7a3 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -1,6 +1,6 @@ use super::broadcast::BroadcastStateTransition; use super::validation::ensure_valid_state_transition_structure; -use super::waitable::Waitable; +use super::waitable::{wait_for_document_and_owner_balance, Waitable}; use crate::platform::transition::put_settings::PutSettings; use crate::{Error, Sdk}; use dpp::dashcore::secp256k1::rand::rngs::StdRng; @@ -9,6 +9,7 @@ use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::DocumentType; use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters, INITIAL_REVISION}; +use dpp::fee::Credits; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; use dpp::prelude::Identifier; @@ -46,6 +47,37 @@ pub trait PutDocument>: Waitable { signer: &S, settings: Option, ) -> Result; + + /// Puts a document on platform, waits for the confirmation proof and + /// returns the confirmed document together with the credit balance of the + /// document's owner after the write, which the proof carries next to the + /// document from protocol version 14 (a snapshot at the proof's block; + /// `None` for a proof made at an earlier version). + #[allow(clippy::too_many_arguments)] + async fn put_to_platform_and_wait_for_response_with_owner_balance( + &self, + sdk: &Sdk, + document_type: DocumentType, + document_state_transition_entropy: Option<[u8; 32]>, + identity_public_key: IdentityPublicKey, + token_payment_info: Option, + signer: &S, + settings: Option, + ) -> Result<(Document, Option), Error> { + let state_transition = self + .put_to_platform( + sdk, + document_type, + document_state_transition_entropy, + identity_public_key, + token_payment_info, + signer, + settings, + ) + .await?; + + wait_for_document_and_owner_balance(sdk, state_transition, settings).await + } } #[async_trait::async_trait] diff --git a/packages/rs-sdk/src/platform/transition/vote.rs b/packages/rs-sdk/src/platform/transition/vote.rs index 709310913c0..078fdbe8d42 100644 --- a/packages/rs-sdk/src/platform/transition/vote.rs +++ b/packages/rs-sdk/src/platform/transition/vote.rs @@ -11,7 +11,6 @@ use dpp::identity::IdentityPublicKey; use dpp::prelude::Identifier; use dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; -use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; use dpp::voting::votes::Vote; use rs_dapi_client::{DapiRequest, IntoInner}; @@ -116,8 +115,7 @@ impl> PutVote for Vote { let settings = settings.unwrap_or_default(); - let Vote::ResourceVote(resource_vote) = self; - let vote_poll_id = resource_vote.vote_poll().unique_id()?; + let vote_poll_id = self.vote_poll_unique_id()?; let masternode_vote_transition = MasternodeVoteTransition::try_from_vote_with_signer( self.clone(), @@ -142,14 +140,25 @@ impl> PutVote for Vote { //todo make this more reliable Err(e) => { return if e.to_string().contains("already exists") { - let vote = - Vote::fetch(sdk, VoteQuery::new(voter_pro_tx_hash, vote_poll_id)).await?; - vote.ok_or(Error::Generic( - "vote was proved to not exist but was said to exist".to_string(), - )) + match self { + Vote::ResourceVote(_) => { + let vote = + Vote::fetch(sdk, VoteQuery::new(voter_pro_tx_hash, vote_poll_id)) + .await?; + vote.ok_or(Error::Generic( + "vote was proved to not exist but was said to exist".to_string(), + )) + } + // `VoteQuery` reads the contested resource votes index only; no query + // fetches a yes/no vote yet, so the existing vote cannot be returned. + Vote::YesNoVote(_) => Err(Error::Generic( + "the yes/no vote already exists; fetching yes/no votes is not supported yet" + .to_string(), + )), + } } else { Err(e.into()) - } + }; } } Self::wait_for_response(sdk, masternode_vote_transition, Some(settings)).await diff --git a/packages/rs-sdk/src/platform/transition/waitable.rs b/packages/rs-sdk/src/platform/transition/waitable.rs index 9189a97264e..79750e8e924 100644 --- a/packages/rs-sdk/src/platform/transition/waitable.rs +++ b/packages/rs-sdk/src/platform/transition/waitable.rs @@ -1,11 +1,12 @@ use std::collections::BTreeMap; -use super::broadcast::BroadcastStateTransition; +use super::broadcast::{convert_proof_result, BroadcastStateTransition, WaitForOutcome}; use super::put_settings::PutSettings; use crate::platform::Fetch; use crate::Error; use crate::Sdk; use dpp::document::Document; +use dpp::fee::Credits; use dpp::prelude::{DataContract, Identifier, Identity}; use dpp::state_transition::identity_create_transition::accessors::IdentityCreateTransitionAccessorsV0; use dpp::state_transition::StateTransition; @@ -53,46 +54,83 @@ impl Waitable for Document { state_transition: StateTransition, settings: Option, ) -> Result { - let doc_id = if let StateTransition::Batch(transition) = &state_transition { - let ids = transition.modified_data_ids(); - if ids.len() != 1 { - return Err(Error::Protocol( - dpp::ProtocolError::InvalidStateTransitionType(format!( - "expected state transition with exactly one document, got {}", - ids.into_iter() - .map(|id| id - .to_string(dpp::platform_value::string_encoding::Encoding::Base58)) - .collect::>() - .join(", ") - )), - )); - } - ids[0] - } else { - return Err(Error::Protocol(ProtocolError::InvalidStateTransitionType( - format!( - "expected state transition to be a DocumentsBatchTransition, got {}", - state_transition.name() - ), - ))); - }; - + let doc_id = single_document_id(&state_transition)?; let mut documents: BTreeMap> = state_transition.wait_for_response(sdk, settings).await?; + take_document(&mut documents, doc_id) + } +} + +/// Waits for the proof of a document batch and returns the document it left +/// together with the credit balance of the batch's owner after it: from +/// protocol version 14 the proof carries both, read from one state, and the +/// verified outcome hands the balance out; a proof made at an earlier version +/// carries only the document and the balance is `None`. The balance is a +/// snapshot at the proof's block, so it may already include later transitions +/// of the same identity. +/// +/// Unlike [`Waitable::wait_for_response`] for a document, this accepts an +/// affected-state outcome too: an indexOnly document type's proof can only +/// attest the resulting entry, not this transition's execution, and its +/// document is then a snapshot rebuilt from the transition. +pub async fn wait_for_document_and_owner_balance( + sdk: &Sdk, + state_transition: StateTransition, + settings: Option, +) -> Result<(Document, Option), Error> { + let doc_id = single_document_id(&state_transition)?; - let document: Document = documents - .remove(&doc_id) - .ok_or(Error::InvalidProvedResponse( - "did not prove the sent document".to_string(), - ))? - .ok_or(Error::InvalidProvedResponse( - "expected there to actually be a document".to_string(), - ))?; + let (outcome, _metadata) = state_transition + .wait_for_outcome_with_metadata(sdk, settings) + .await?; + let owner_balance = outcome.owner_balance(); + let mut documents: BTreeMap> = + convert_proof_result(outcome.into_result())?; + take_document(&mut documents, doc_id).map(|document| (document, owner_balance)) +} - Ok(document) +/// The id of the one document a batch transition modifies. +fn single_document_id(state_transition: &StateTransition) -> Result { + if let StateTransition::Batch(transition) = state_transition { + let ids = transition.modified_data_ids(); + if ids.len() != 1 { + return Err(Error::Protocol( + dpp::ProtocolError::InvalidStateTransitionType(format!( + "expected state transition with exactly one document, got {}", + ids.into_iter() + .map(|id| id + .to_string(dpp::platform_value::string_encoding::Encoding::Base58)) + .collect::>() + .join(", ") + )), + )); + } + Ok(ids[0]) + } else { + Err(Error::Protocol(ProtocolError::InvalidStateTransitionType( + format!( + "expected state transition to be a DocumentsBatchTransition, got {}", + state_transition.name() + ), + ))) } } +/// The proved document out of a verified documents result. +fn take_document( + documents: &mut BTreeMap>, + doc_id: Identifier, +) -> Result { + documents + .remove(&doc_id) + .ok_or(Error::InvalidProvedResponse( + "did not prove the sent document".to_string(), + ))? + .ok_or(Error::InvalidProvedResponse( + "expected there to actually be a document".to_string(), + )) +} + #[async_trait::async_trait] impl Waitable for Identity { async fn wait_for_response( diff --git a/packages/rs-sdk/tests/fetch/broadcast.rs b/packages/rs-sdk/tests/fetch/broadcast.rs index 1dc45ab7016..0221633e25b 100644 --- a/packages/rs-sdk/tests/fetch/broadcast.rs +++ b/packages/rs-sdk/tests/fetch/broadcast.rs @@ -22,6 +22,7 @@ mod online { let request: WaitForStateTransitionResultRequest = WaitForStateTransitionResultRequestV0 { prove: false, + request_user_balance: false, state_transition_hash: [0u8; 32].to_vec(), } .into(); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift index 0c696701c3e..45043890e7c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DataContractParser.swift @@ -371,6 +371,14 @@ public struct DataContractParser { // Extract type let type = propertyDict["type"] as? String ?? "unknown" + // A protocol-version-14 typed array (an array declaring `items` + // instead of `byteArray`) is persisted like any array: `type` + // "array", `byteArray` false, `minItems` / `maxItems` counting + // elements. Its element schema needs no column of its own: + // `schemaJSON` is the whole type dictionary, and + // `PersistentDocumentType.typedArrays` reads it back. Keep that + // true when touching the schema stored there. + // Create persistent property let property = PersistentProperty( contractId: contractId, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentTypedArray.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentTypedArray.swift new file mode 100644 index 00000000000..98658662a6e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentTypedArray.swift @@ -0,0 +1,621 @@ +import CoreFoundation +import Foundation + +/// A typed array property of a document type (meta-schema v3, protocol +/// version 14): a list of scalars, declared as `type: "array"` with an +/// `items` schema naming what every element is and a required `maxItems`. +/// +/// ```json +/// "scores": { +/// "type": "array", +/// "items": { "type": "integer", "minimum": 0, "maximum": 100 }, +/// "minItems": 1, +/// "maxItems": 8, +/// "uniqueItems": true, +/// "position": 0 +/// } +/// ``` +/// +/// A property that declares `byteArray` itself is a byte array (or an +/// identifier), never a typed array, whatever else it declares. +/// +/// The fields mirror wasm-dpp2's `DocumentTypedArrayProperty` key for key. +/// DPP validated the declaration when the contract was registered; this type +/// only reads it back off the schema as authored and does not re-validate it. +/// The element checks of `Element.value(fromInput:)` are a client-side +/// courtesy: consensus is the authority on what a document may hold. +public struct DocumentTypedArray: Equatable, Sendable { + /// Dotted path of the property within the document type: `"reasons"`, or + /// `"team.leads"` for one nested in an object property. + public let path: String + + /// What every element is. + public let element: Element + + /// The fewest elements a document may hold; `nil` when not declared. + public let minItems: Int? + + /// The most elements a document may hold. Every typed array declares it. + public let maxItems: Int + + /// Whether a document repeating an element is refused. `false` when the + /// schema does not declare `uniqueItems`. + public let uniqueItems: Bool + + /// A declaration from its parts, as `init?(path:propertySchema:)` reads + /// them. + public init(path: String, element: Element, minItems: Int?, maxItems: Int, uniqueItems: Bool) { + self.path = path + self.element = element + self.minItems = minItems + self.maxItems = maxItems + self.uniqueItems = uniqueItems + } + + /// Read one property schema dictionary, as authored in the contract. + /// + /// `nil` when the property is not a typed array: not `type: "array"`, a + /// `byteArray` key on the property, no `items` object, an `items` schema + /// that is not one scalar DPP admits as an element, or no integer + /// `maxItems`. DPP refuses a contract declaring the last three, so they + /// can only reach a client through hand-edited JSON. + public init?(path: String, propertySchema: [String: Any]) { + guard propertySchema["type"] as? String == "array", + propertySchema["byteArray"] == nil, + let items = propertySchema["items"] as? [String: Any], + let element = Element(itemsSchema: items), + let maxItems = Self.jsonInteger(propertySchema["maxItems"]) + else { + return nil + } + self.init( + path: path, + element: element, + minItems: Self.jsonInteger(propertySchema["minItems"]), + maxItems: maxItems, + uniqueItems: Self.jsonBool(propertySchema["uniqueItems"]) ?? false + ) + } + + /// Every typed array a document type declares, sorted by path. Walks into + /// `object` properties, naming a nested typed array by its dotted path. + /// + /// `documentTypeSchema` is the whole document type dictionary as authored + /// in the contract (`PersistentDocumentType.schema`). + public static func all(inDocumentTypeSchema documentTypeSchema: [String: Any]?) -> [DocumentTypedArray] { + var found: [DocumentTypedArray] = [] + collect(properties: documentTypeSchema?["properties"], prefix: nil, into: &found) + return found.sorted { $0.path < $1.path } + } + + /// The typed array a document type declares as its top-level property + /// `name`, or `nil` when that property is absent or not a typed array. + /// A nested typed array is not found by its dotted path here: use + /// `all(inDocumentTypeSchema:)`. + public static func named( + _ name: String, + inDocumentTypeSchema documentTypeSchema: [String: Any]? + ) -> DocumentTypedArray? { + guard let properties = documentTypeSchema?["properties"] as? [String: Any], + let propertySchema = properties[name] as? [String: Any] + else { + return nil + } + return DocumentTypedArray(path: name, propertySchema: propertySchema) + } + + private static func collect( + properties: Any?, + prefix: String?, + into found: inout [DocumentTypedArray] + ) { + guard let properties = properties as? [String: Any] else { return } + for (name, value) in properties { + guard let propertySchema = value as? [String: Any] else { continue } + let path = prefix.map { "\($0).\(name)" } ?? name + if let typedArray = DocumentTypedArray(path: path, propertySchema: propertySchema) { + found.append(typedArray) + } else if propertySchema["type"] as? String == "object" { + collect(properties: propertySchema["properties"], prefix: path, into: &found) + } + } + } +} + +// MARK: - Element + +extension DocumentTypedArray { + /// What every element of a typed array is, read from its `items` schema. + /// + /// The cases mirror wasm-dpp2's `DocumentTypedArrayItem`. The bounds are + /// the schema keywords': `minLength` / `maxLength` count a string + /// element's characters, `minSize` / `maxSize` (the items' `minItems` / + /// `maxItems`) a byte array element's bytes, and `minimum` / `maximum` an + /// integer or number element's range. `allowedValues` is the items' + /// `enum`, in declared order. Each is `nil` when the schema omits it. + public enum Element: Equatable, Sendable { + case integer(minimum: Int?, maximum: Int?, allowedValues: [Int]?) + case number(minimum: Double?, maximum: Double?, allowedValues: [Double]?) + case boolean(allowedValues: [Bool]?) + case string(minLength: Int?, maxLength: Int?, allowedValues: [String]?) + case byteArray(minSize: Int?, maxSize: Int?) + /// A 32-byte identifier: a byte array element carrying the identifier + /// `contentMediaType`. + case identifier + } +} + +extension DocumentTypedArray.Element { + /// The `contentMediaType` that makes a byte array element an identifier. + static let identifierMediaType = "application/x.dash.dpp.identifier" + + /// Parse an `items` schema the way DPP types an element: an integer, + /// number, boolean or string schema, or a `byteArray: true` array, which + /// the identifier media type makes an identifier. Anything else (an + /// object, an array of arrays) is not an element DPP admits. + /// + /// An `enum` member or bound that no Swift value of the element type can + /// hold (an integer beyond `Int`) is left out, and an `enum` left empty + /// that way reads as `nil`: the client then checks less, and consensus + /// still checks everything. + init?(itemsSchema items: [String: Any]) { + typealias Reader = DocumentTypedArray + switch items["type"] as? String { + case "integer": + self = .integer( + minimum: Reader.jsonInteger(items["minimum"]), + maximum: Reader.jsonInteger(items["maximum"]), + allowedValues: Reader.members(items["enum"], Reader.jsonInteger) + ) + case "number": + self = .number( + minimum: Reader.jsonDouble(items["minimum"]), + maximum: Reader.jsonDouble(items["maximum"]), + allowedValues: Reader.members(items["enum"], Reader.jsonDouble) + ) + case "boolean": + self = .boolean(allowedValues: Reader.members(items["enum"], Reader.jsonBool)) + case "string": + self = .string( + minLength: Reader.jsonInteger(items["minLength"]), + maxLength: Reader.jsonInteger(items["maxLength"]), + allowedValues: Reader.members(items["enum"]) { $0 as? String } + ) + case "array": + guard Reader.jsonBool(items["byteArray"]) == true else { return nil } + if items["contentMediaType"] as? String == Self.identifierMediaType { + self = .identifier + } else { + self = .byteArray( + minSize: Reader.jsonInteger(items["minItems"]), + maxSize: Reader.jsonInteger(items["maxItems"]) + ) + } + default: + return nil + } + } +} + +// MARK: - JSON readers + +extension DocumentTypedArray { + /// A JSON number, but never a JSON boolean: `JSONSerialization` hands + /// both back as `NSNumber`, and an `NSNumber` boolean casts to `1` / `0`. + static func jsonNumber(_ value: Any?) -> NSNumber? { + guard let number = value as? NSNumber, + CFGetTypeID(number) != CFBooleanGetTypeID() + else { + return nil + } + return number + } + + /// A JSON integer that fits `Int`; `nil` for a fraction or a boolean. + static func jsonInteger(_ value: Any?) -> Int? { + jsonNumber(value).flatMap { Int(exactly: $0) } + } + + /// A JSON number read as `Double`; `nil` for a boolean. + static func jsonDouble(_ value: Any?) -> Double? { + jsonNumber(value)?.doubleValue + } + + /// A JSON boolean; `nil` for a number, which an `NSNumber` cast to `Bool` + /// would otherwise accept. + static func jsonBool(_ value: Any?) -> Bool? { + guard let number = value as? NSNumber, + CFGetTypeID(number) == CFBooleanGetTypeID() + else { + return nil + } + return number.boolValue + } + + /// The members of an `enum` list that `read` accepts, or `nil` when there + /// is no list or none of its members is readable. + static func members(_ value: Any?, _ read: (Any?) -> T?) -> [T]? { + guard let list = value as? [Any] else { return nil } + let members = list.compactMap { read($0) } + return members.isEmpty ? nil : members + } +} + +// MARK: - Element input + +extension DocumentTypedArray { + /// One element in the JSON form the platform wallet's schema sanitizer + /// takes (`DocumentType::sanitize_document_properties`): integers, numbers + /// and booleans as JSON numbers and booleans, which the sanitizer narrows + /// to the element's width but never parses out of a string; a string + /// element, an identifier as base58, and a byte array as hex, all as JSON + /// strings the sanitizer decodes. Never `Data`: `JSONSerialization` + /// cannot encode `Data` inside an array. + public enum ElementValue: Hashable, Sendable { + /// An integer element, sent as a JSON integer. + case integer(Int) + /// A number element, sent as a JSON number (always finite). + case number(Double) + /// A boolean element, sent as a JSON boolean. + case boolean(Bool) + /// A string element, an identifier as base58, or a byte array as + /// lowercase hex. + case string(String) + + /// The value as a `JSONSerialization`-encodable object: an `Int`, + /// `Double`, `Bool` or `String`. + public var jsonValue: Any { + switch self { + case let .integer(value): return value + case let .number(value): return value + case let .boolean(value): return value + case let .string(value): return value + } + } + + /// Text that `Element.value(fromInput:)` reads back as this value. A + /// whole number is written without a fraction (`2`, not `2.0`). + public var inputText: String { + switch self { + case let .integer(value): + return String(value) + case let .number(value): + if value.rounded() == value, abs(value) < 1e15 { + return String(Int64(value)) + } + return String(value) + case let .boolean(value): + return value ? "true" : "false" + case let .string(value): + return value + } + } + } + + /// Why the text entered for one element is not a value of its element. + public enum ElementInputError: Error, Equatable, Sendable, LocalizedError { + /// Nothing entered for an element that is not a string. + case empty + /// Not a whole number `Int` can hold. + case notAnInteger(String) + /// Not a finite decimal number. + case notANumber(String) + /// Neither `true` nor `false`. + case notABoolean(String) + /// Below `minimum` or above `maximum`; the bounds are given as input + /// text, `nil` when not declared. + case outOfRange(value: String, minimum: String?, maximum: String?) + /// Not a member of the element's `enum`, given as input text. + case notAllowed(value: String, allowed: [String]) + /// A string element's length in characters outside + /// `minLength` / `maxLength`. + case wrongLength(length: Int, minimum: Int?, maximum: Int?) + /// Not base58 text. + case invalidBase58(String) + /// Not an even number of hex digits. + case invalidHex(String) + /// A byte array's size, or an identifier's (which must be exactly 32), + /// outside the declared bounds. + case wrongByteCount(count: Int, minimum: Int?, maximum: Int?) + + public var errorDescription: String? { + switch self { + case .empty: + return "Enter a value." + case let .notAnInteger(text): + return "\"\(text)\" is not a whole number." + case let .notANumber(text): + return "\"\(text)\" is not a number." + case let .notABoolean(text): + return "\"\(text)\" is not true or false." + case let .outOfRange(value, minimum, maximum): + return "\(value) is outside the allowed range (\(Self.bounds(minimum, maximum)))." + case let .notAllowed(value, allowed): + return "\"\(value)\" is not one of the allowed values: \(allowed.joined(separator: ", "))." + case let .wrongLength(length, minimum, maximum): + return "\(length) characters; the element takes \(Self.bounds(minimum.map(String.init), maximum.map(String.init)))." + case .invalidBase58: + return "Not a valid base58 identifier." + case .invalidHex: + return "Not valid hex: use an even number of 0-9 and a-f digits." + case let .wrongByteCount(count, minimum, maximum): + return "\(count) bytes; the element takes \(Self.bounds(minimum.map(String.init), maximum.map(String.init)))." + } + } + + private static func bounds(_ minimum: String?, _ maximum: String?) -> String { + switch (minimum, maximum) { + case let (minimum?, maximum?) where minimum == maximum: + return "exactly \(minimum)" + case let (minimum?, maximum?): + return "\(minimum) to \(maximum)" + case let (minimum?, nil): + return "at least \(minimum)" + case let (nil, maximum?): + return "at most \(maximum)" + case (nil, nil): + return "any" + } + } + } +} + +extension DocumentTypedArray { + /// Why the rows entered for a typed array cannot be sent as its value. + /// The message names the property by its path, and a row by its + /// zero-based index in the path syntax (`scores[1]`). + public enum InputError: Error, Equatable, Sendable, LocalizedError { + /// Fewer rows than `minItems`. + case tooFewElements(path: String, count: Int, minimum: Int) + /// More rows than `maxItems`. + case tooManyElements(path: String, count: Int, maximum: Int) + /// The row at `index` is not a value of the element. + case invalidElement(path: String, index: Int, reason: ElementInputError) + /// Under `uniqueItems`, the row at `index` converts to the same value + /// as the row at `firstIndex`. + case repeatedElement(path: String, index: Int, firstIndex: Int) + + public var errorDescription: String? { + switch self { + case let .tooFewElements(path, count, minimum): + return "\(path): \(Self.elements(count)); the list takes at least \(minimum)." + case let .tooManyElements(path, count, maximum): + return "\(path): \(Self.elements(count)); the list takes at most \(maximum)." + case let .invalidElement(path, index, reason): + return "\(path)[\(index)]: \(reason.localizedDescription)" + case let .repeatedElement(path, index, firstIndex): + return "\(path)[\(index)]: repeats \(path)[\(firstIndex)], and the elements must be unique." + } + } + + private static func elements(_ count: Int) -> String { + count == 1 ? "1 element" : "\(count) elements" + } + } + + /// Convert every row entered for this typed array, in order, into the + /// list to send, or say why the list cannot be sent. + /// + /// Checks, in order: the row count against `minItems` / `maxItems`, each + /// row with `Element.value(fromInput:)` (the first bad row is reported), + /// and, under `uniqueItems`, that no two rows convert to the same value. + /// Uniqueness compares the converted values, so `1` and `1.0` in a number + /// list, or one identifier typed twice with different spacing, repeat. + /// A caller that leaves an empty optional list out of the document skips + /// this check for it; an empty list that is sent must pass `minItems`. + /// + /// Like the element check, this is a client-side courtesy that spares a + /// transition consensus would refuse and still charge for. + public func values( + fromInputs inputs: [String] + ) -> Result<[ElementValue], InputError> { + if let minItems, inputs.count < minItems { + return .failure(.tooFewElements(path: path, count: inputs.count, minimum: minItems)) + } + if inputs.count > maxItems { + return .failure(.tooManyElements(path: path, count: inputs.count, maximum: maxItems)) + } + + var values: [ElementValue] = [] + values.reserveCapacity(inputs.count) + for (index, input) in inputs.enumerated() { + switch element.value(fromInput: input) { + case let .success(value): + values.append(value) + case let .failure(reason): + return .failure(.invalidElement(path: path, index: index, reason: reason)) + } + } + + if uniqueItems { + var firstIndexOf: [ElementValue: Int] = [:] + for (index, value) in values.enumerated() { + if let firstIndex = firstIndexOf[value] { + return .failure(.repeatedElement(path: path, index: index, firstIndex: firstIndex)) + } + firstIndexOf[value] = index + } + } + + return .success(values) + } + + /// `values(fromInputs:)` as `JSONSerialization`-encodable elements: the + /// array to put in a document's properties JSON under this path. + public func jsonArray(fromInputs inputs: [String]) -> Result<[Any], InputError> { + values(fromInputs: inputs).map { $0.map(\.jsonValue) } + } +} + +extension DocumentTypedArray.Element { + /// The input text of each value the element's `enum` allows, in declared + /// order: what a picker offers. `value(fromInput:)` accepts every entry. + /// `nil` when the element declares no `enum`; byte array and identifier + /// elements never do. + public var allowedInputs: [String]? { + typealias Value = DocumentTypedArray.ElementValue + switch self { + case let .integer(_, _, allowedValues): + return allowedValues?.map { Value.integer($0).inputText } + case let .number(_, _, allowedValues): + return allowedValues?.map { Value.number($0).inputText } + case let .boolean(allowedValues): + return allowedValues?.map { Value.boolean($0).inputText } + case let .string(_, _, allowedValues): + return allowedValues + case .byteArray, .identifier: + return nil + } + } + + /// Convert the text entered for one element into the value to send, or + /// say why it is not one. + /// + /// - Integer: a whole number within `minimum` / `maximum`. + /// - Number: a finite decimal within `minimum` / `maximum`. A lone comma + /// reads as the decimal separator, which the decimal pad shows in some + /// locales. + /// - Boolean: `true` or `false`. + /// - String: the text exactly as entered, spaces and commas included, + /// with its length counted in Unicode scalars as JSON Schema counts + /// characters. An empty string is a value. + /// - Identifier: base58 text decoding to 32 bytes, sent as base58. + /// - Byte array: hex digits (an optional `0x` prefix is dropped) within + /// the declared size, sent as lowercase hex. + /// + /// Surrounding whitespace is ignored for every kind but string. A value + /// outside the element's `enum` fails. These checks spare the user a + /// transition consensus would refuse; they decide nothing consensus + /// does not decide again. + public func value( + fromInput text: String + ) -> Result { + typealias Value = DocumentTypedArray.ElementValue + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if case .string = self { + // A string element keeps its text exactly, and may be empty + } else if trimmed.isEmpty { + return .failure(.empty) + } + + switch self { + case let .string(minLength, maxLength, allowedValues): + return Self.stringValue( + text, minLength: minLength, maxLength: maxLength, allowedValues: allowedValues) + + case let .integer(minimum, maximum, allowedValues): + guard let value = Int(trimmed) else { return .failure(.notAnInteger(trimmed)) } + return Self.checked( + Value.integer(value), + isAllowed: allowedValues.map { $0.contains(value) }, + allowed: allowedInputs, + isBelowMinimum: minimum.map { value < $0 } ?? false, + isAboveMaximum: maximum.map { value > $0 } ?? false, + minimum: minimum.map { Value.integer($0).inputText }, + maximum: maximum.map { Value.integer($0).inputText } + ) + + case let .number(minimum, maximum, allowedValues): + let commaCount = trimmed.filter { $0 == "," }.count + let normalized = commaCount == 1 && !trimmed.contains(".") + ? trimmed.replacingOccurrences(of: ",", with: ".") + : trimmed + guard let value = Double(normalized), value.isFinite else { + return .failure(.notANumber(trimmed)) + } + return Self.checked( + Value.number(value), + isAllowed: allowedValues.map { $0.contains(value) }, + allowed: allowedInputs, + isBelowMinimum: minimum.map { value < $0 } ?? false, + isAboveMaximum: maximum.map { value > $0 } ?? false, + minimum: minimum.map { Value.number($0).inputText }, + maximum: maximum.map { Value.number($0).inputText } + ) + + case let .boolean(allowedValues): + let value: Bool + switch trimmed.lowercased() { + case "true": value = true + case "false": value = false + default: return .failure(.notABoolean(trimmed)) + } + return Self.checked( + Value.boolean(value), + isAllowed: allowedValues.map { $0.contains(value) }, + allowed: allowedInputs, + isBelowMinimum: false, + isAboveMaximum: false, + minimum: nil, + maximum: nil + ) + + case .identifier: + guard let bytes = Data.identifier(fromBase58: trimmed) else { + return .failure(.invalidBase58(trimmed)) + } + guard bytes.count == 32 else { + return .failure(.wrongByteCount(count: bytes.count, minimum: 32, maximum: 32)) + } + return .success(.string(bytes.toBase58String())) + + case let .byteArray(minSize, maxSize): + let digits = trimmed.hasPrefix("0x") || trimmed.hasPrefix("0X") + ? String(trimmed.dropFirst(2)) + : trimmed + guard !digits.isEmpty else { return .failure(.empty) } + // `isASCII` too: `isHexDigit` also admits the fullwidth digits + guard digits.count.isMultiple(of: 2), + digits.allSatisfy({ $0.isASCII && $0.isHexDigit }) + else { + return .failure(.invalidHex(trimmed)) + } + let count = digits.count / 2 + let tooSmall = minSize.map { count < $0 } ?? false + let tooLarge = maxSize.map { count > $0 } ?? false + if tooSmall || tooLarge { + return .failure(.wrongByteCount(count: count, minimum: minSize, maximum: maxSize)) + } + return .success(.string(digits.lowercased())) + } + } + + private static func stringValue( + _ text: String, + minLength: Int?, + maxLength: Int?, + allowedValues: [String]? + ) -> Result { + if let allowedValues, !allowedValues.contains(text) { + return .failure(.notAllowed(value: text, allowed: allowedValues)) + } + let length = text.unicodeScalars.count + if let minLength, length < minLength { + return .failure(.wrongLength(length: length, minimum: minLength, maximum: maxLength)) + } + if let maxLength, length > maxLength { + return .failure(.wrongLength(length: length, minimum: minLength, maximum: maxLength)) + } + return .success(.string(text)) + } + + /// The `enum` is checked first: when a value fails both, naming the + /// allowed values says more than naming the range. + private static func checked( + _ value: DocumentTypedArray.ElementValue, + isAllowed: Bool?, + allowed: [String]?, + isBelowMinimum: Bool, + isAboveMaximum: Bool, + minimum: String?, + maximum: String? + ) -> Result { + if isAllowed == false { + return .failure(.notAllowed(value: value.inputText, allowed: allowed ?? [])) + } + if isBelowMinimum || isAboveMaximum { + return .failure(.outOfRange(value: value.inputText, minimum: minimum, maximum: maximum)) + } + return .success(value) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift index 7705837b151..f6feefd05b1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/StateTransitionExtensions.swift @@ -716,9 +716,28 @@ extension SDK { // 2. Update the document properties // Use pre-serialized JSON to avoid capturing non-Sendable value types + // The FFI decodes base58 identifiers and hex/base64 bytes against the document type. let propertiesJson = propertiesJsonPre - _ = propertiesJson.withCString { propsCStr in - dash_sdk_document_set_properties(OpaquePointer(documentHandle), propsCStr) + let setPropertiesError = contractId.withCString { contractIdCStr in + documentType.withCString { docTypeCStr in + propertiesJson.withCString { propsCStr in + dash_sdk_document_set_properties( + handle, + OpaquePointer(documentHandle), + contractIdCStr, + docTypeCStr, + propsCStr + ) + } + } + } + + if let error = setPropertiesError { + let errorString = String(cString: error.pointee.message) + dash_sdk_error_free(error) + print("❌ [DOCUMENT REPLACE] Failed to set document properties: \(errorString)") + continuation.resume(throwing: SDKError.internalError("Failed to set document properties: \(errorString)")) + return } // 3. Get appropriate key for signing diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift index 08277a8bb37..2761654c802 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift @@ -128,6 +128,30 @@ extension PersistentDocumentType { immutability.immutableAllowSetting } + /// Every typed array property the type declares (protocol version 14), + /// those nested in object properties included under their dotted path + /// (`"team.leads"`), sorted by path. Empty when it declares none. + /// + /// Derived from the persisted schema rather than stored, for the same + /// reason as `immutability`: `schemaJSON` holds the whole document type + /// dictionary as authored, element schemas included, and a new stored + /// property on this model or on `PersistentProperty` would move an entity + /// hash, which costs a schema version and a fixture store (see + /// `DashModelContainer.modelTypes` and `DashModelMigrationTests`). + /// `PersistentProperty` keeps a typed array as an ordinary `"array"` row + /// with `byteArray` false and its element counts in `minItems` / + /// `maxItems`. + public var typedArrays: [DocumentTypedArray] { + DocumentTypedArray.all(inDocumentTypeSchema: schema) + } + + /// The typed array declared as the top-level property `name`, or `nil` + /// when that property is absent or is not a typed array (a byte array + /// among them). Read off the persisted schema; see `typedArrays`. + public func typedArray(named name: String) -> DocumentTypedArray? { + DocumentTypedArray.named(name, inDocumentTypeSchema: schema) + } + public var documentCount: Int { documents?.count ?? 0 } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift index 59f4c1f899a..2721b0f00ab 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentFieldsView.swift @@ -18,15 +18,43 @@ struct DocumentFieldsView: View { /// caption says so. var storedPropertyNames: Set = [] + /// Top-level typed array properties (protocol version 14) by name, read + /// off the persisted schema once per view value. A typed array gets the + /// list editor; any other `"array"` row keeps its old editor. + private let typedArrays: [String: DocumentTypedArray] + @State private var textFields: [String: String] = [:] @State private var numberFields: [String: String] = [:] @State private var boolFields: [String: Bool] = [:] @State private var arrayFields: [String: String] = [:] + /// One entry per element of each typed array, in list order, holding the + /// text entered for it (`"true"` / `"false"` for a boolean toggle, the + /// chosen member's input text for an `enum` picker). + @State private var typedArrayRows: [String: [TypedArrayRow]] = [:] /// Boolean fields the user has actually toggled. Untouched optional /// booleans are omitted from the payload (absence ≠ `false` for some /// schemas) rather than broadcast as the seeded `false` default. @State private var touchedBoolFields: Set = [] + init( + documentType: PersistentDocumentType, + fieldValues: Binding<[String: Any]>, + immutability: DocumentTypeImmutability = .none, + storedPropertyNames: Set = [] + ) { + self.documentType = documentType + self._fieldValues = fieldValues + self.immutability = immutability + self.storedPropertyNames = storedPropertyNames + + var typedArrays: [String: DocumentTypedArray] = [:] + for property in documentType.propertiesList ?? [] + where property.type == "array" && !property.byteArray { + typedArrays[property.name] = documentType.typedArray(named: property.name) + } + self.typedArrays = typedArrays + } + var body: some View { VStack(alignment: .leading, spacing: 16) { if let properties = documentType.propertiesList, !properties.isEmpty { @@ -121,6 +149,9 @@ struct DocumentFieldsView: View { if property.byteArray { // Byte arrays should be entered as hex strings byteArrayField(for: property) + } else if let typedArray = typedArrays[property.name] { + // Typed arrays: one typed input per element + typedArrayField(for: property, typedArray: typedArray) } else { // Regular arrays with comma-separated values VStack(alignment: .leading, spacing: 4) { @@ -229,6 +260,8 @@ struct DocumentFieldsView: View { case "array": if property.byteArray { textFields[property.name] = "" // Use text field for hex input + } else if typedArrays[property.name] != nil { + typedArrayRows[property.name] = [] // One row per element } else { arrayFields[property.name] = "" // Use array field for comma-separated } @@ -314,8 +347,32 @@ struct DocumentFieldsView: View { } } + // Add typed arrays: one JSON value per row, in row order. An empty + // optional list is left out; every other list goes through the + // whole-list check, `minItems` included. A list that fails it is + // represented by the `DocumentTypedArray.InputError` itself, which + // `CreateDocumentView.propertiesJSON` throws: a refused document + // transition is still paid for, so an invalid list must never be + // broadcast. + for (key, rows) in typedArrayRows { + guard let typedArray = typedArrays[key] else { continue } + if rows.isEmpty && !isRequired(key) { + continue + } + switch typedArray.jsonArray(fromInputs: rows.map(\.text)) { + case .success(let array): + values[key] = array + case .failure(let error): + values[key] = error + } + } + fieldValues = values } + + private func isRequired(_ propertyName: String) -> Bool { + documentType.propertiesList?.first(where: { $0.name == propertyName })?.isRequired ?? false + } } @@ -397,3 +454,314 @@ extension DocumentFieldsView { return stringCharacterSet.isSubset(of: hexCharacterSet) && string.count == expectedLength } } + +// MARK: - Typed Array Field Helper + +/// One element row of a typed array editor. Rows are identified by `id`, not +/// by position, so a binding captured before a removal cannot write into the +/// wrong row. +struct TypedArrayRow: Identifiable, Equatable { + let id = UUID() + var text: String +} + +extension DocumentFieldsView { + /// The list editor for a typed array (protocol version 14): one row per + /// element with an input suited to the element, an add button that stops + /// at `maxItems`, a remove button per row, and a caption stating what an + /// element is and how many the list takes. Each row is marked with the + /// verdict of `DocumentTypedArray.Element.value(fromInput:)`, and a list + /// failing `DocumentTypedArray.values(fromInputs:)` is never sent: the + /// submit refuses it (see `updateFieldValues`). + @ViewBuilder + private func typedArrayField(for property: PersistentProperty, typedArray: DocumentTypedArray) -> some View { + let name = property.name + let element = typedArray.element + let rows = typedArrayRows[name] ?? [] + let results = rows.map { element.value(fromInput: $0.text) } + // The whole-list refusal that `updateFieldValues` sends in place of + // the list. A bad row is already marked on its row, so only the count + // and repeat refusals are shown here. An empty optional list is left + // out of the document and is never checked. + let listError: DocumentTypedArray.InputError? = { + guard !rows.isEmpty || property.isRequired, + case let .failure(error) = typedArray.values(fromInputs: rows.map(\.text)) + else { + return nil + } + if case .invalidElement = error { return nil } + return error + }() + + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + // The index a refusal names (`tags[1]`) + Text("[\(index)]") + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.secondary) + + typedArrayElementInput(name: name, index: index, rowId: row.id, element: element) + + if case .failure = results[index] { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + .accessibilityLabel("Invalid \(name)[\(index)]") + .accessibilityIdentifier("createDocument.field.\(name).\(index).invalid") + } + + Button { + removeTypedArrayRow(name, id: row.id) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundColor(.red) + } + // Borderless: the whole editor sits in one Form row, + // where a default button would claim every tap in it + .buttonStyle(.borderless) + .accessibilityLabel("Remove \(name)[\(index)]") + .accessibilityIdentifier("createDocument.field.\(name).\(index).remove") + } + + if case let .failure(error) = results[index] { + Text(error.localizedDescription) + .font(.caption2) + .foregroundColor(.red) + } + } + } + + Button { + addTypedArrayRow(name, typedArray: typedArray) + } label: { + Label("Add element", systemImage: "plus.circle") + .font(.subheadline) + } + .buttonStyle(.borderless) + .disabled(rows.count >= typedArray.maxItems) + .accessibilityIdentifier("createDocument.field.\(name).add") + + Text("Each element: \(element.summary)") + .font(.caption2) + .foregroundColor(.secondary) + + Text(typedArrayCountCaption(typedArray, count: rows.count)) + .font(.caption2) + .foregroundColor(.secondary) + .accessibilityIdentifier("createDocument.field.\(name).count") + + if let listError { + Text(listError.localizedDescription) + .font(.caption2) + .foregroundColor(.orange) + .accessibilityIdentifier("createDocument.field.\(name).error") + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("createDocument.field.\(name)") + } + + /// The input for one element, chosen by the element kind: a picker when + /// an `enum` is declared, a toggle for a boolean, and otherwise a text + /// field with the keyboard the kind needs. + @ViewBuilder + private func typedArrayElementInput( + name: String, + index: Int, + rowId: UUID, + element: DocumentTypedArray.Element + ) -> some View { + let identifier = "createDocument.field.\(name).\(index)" + let text = typedArrayTextBinding(name: name, rowId: rowId) + + if let options = element.allowedInputs { + Picker("\(name)[\(index)]", selection: text) { + ForEach(Array(options.enumerated()), id: \.offset) { _, option in + Text(option).tag(option) + } + } + .pickerStyle(.menu) + .labelsHidden() + .accessibilityIdentifier(identifier) + Spacer() + } else { + switch element { + case .boolean: + Toggle(isOn: Binding( + get: { text.wrappedValue == "true" }, + set: { text.wrappedValue = $0 ? "true" : "false" } + )) { + Text(text.wrappedValue) + .font(.subheadline) + } + .accessibilityLabel("\(name)[\(index)]") + .accessibilityIdentifier(identifier) + + case let .integer(minimum, _, _): + TextField("Whole number", text: text) + // The number pad has no minus key: offer it only when + // the element cannot be negative + .keyboardType((minimum ?? -1) >= 0 ? .numberPad : .numbersAndPunctuation) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .accessibilityIdentifier(identifier) + + case let .number(minimum, _, _): + TextField("Number", text: text) + .keyboardType((minimum ?? -1) >= 0 ? .decimalPad : .numbersAndPunctuation) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .accessibilityIdentifier(identifier) + + case .string: + TextField("Text", text: text) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .accessibilityIdentifier(identifier) + + case .byteArray: + TextField("Hex bytes", text: text) + .font(.system(.body, design: .monospaced)) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + .accessibilityIdentifier(identifier) + + case .identifier: + TextField("Base58 identifier", text: text) + .font(.system(.body, design: .monospaced)) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + .accessibilityIdentifier(identifier) + } + } + } + + /// "2 of 1 to 5 elements", plus the uniqueness rule when declared. + private func typedArrayCountCaption(_ typedArray: DocumentTypedArray, count: Int) -> String { + var caption: String + if let minItems = typedArray.minItems, minItems > 0 { + caption = minItems == typedArray.maxItems + ? "\(count) of exactly \(minItems) elements" + : "\(count) of \(minItems) to \(typedArray.maxItems) elements" + } else { + caption = "\(count) of at most \(typedArray.maxItems) elements" + } + if typedArray.uniqueItems { + caption += " · elements must be unique" + } + return caption + } + + /// Binds one row's text by row id, so a stale binding after a removal + /// writes nothing rather than into another row. + private func typedArrayTextBinding(name: String, rowId: UUID) -> Binding { + Binding( + get: { typedArrayRows[name]?.first(where: { $0.id == rowId })?.text ?? "" }, + set: { newValue in + guard let index = typedArrayRows[name]?.firstIndex(where: { $0.id == rowId }) else { + return + } + typedArrayRows[name]?[index].text = newValue + updateFieldValues() + } + ) + } + + /// A new row starts on the first allowed value when an `enum` is declared + /// (a picker must select one of its tags), `false` for a boolean, and + /// empty otherwise. + private func addTypedArrayRow(_ name: String, typedArray: DocumentTypedArray) { + var rows = typedArrayRows[name] ?? [] + guard rows.count < typedArray.maxItems else { return } + let initialText: String + if let first = typedArray.element.allowedInputs?.first { + initialText = first + } else if case .boolean = typedArray.element { + initialText = "false" + } else { + initialText = "" + } + rows.append(TypedArrayRow(text: initialText)) + typedArrayRows[name] = rows + updateFieldValues() + } + + private func removeTypedArrayRow(_ name: String, id: UUID) { + typedArrayRows[name]?.removeAll { $0.id == id } + updateFieldValues() + } +} + +// MARK: - Typed Array Descriptions + +extension DocumentTypedArray.Element { + /// The element kind with its declared bounds and allowed values, for + /// captions and schema detail rows: "integer (1 to 10), one of 1, 5, 10". + var summary: String { + typealias Value = DocumentTypedArray.ElementValue + var text: String + switch self { + case let .integer(minimum, maximum, _): + text = "integer" + Self.bounds( + minimum.map { Value.integer($0).inputText }, + maximum.map { Value.integer($0).inputText }, + unit: nil) + case let .number(minimum, maximum, _): + text = "number" + Self.bounds( + minimum.map { Value.number($0).inputText }, + maximum.map { Value.number($0).inputText }, + unit: nil) + case .boolean: + text = "boolean" + case let .string(minLength, maxLength, _): + text = "string" + Self.bounds( + minLength.map(String.init), maxLength.map(String.init), unit: "characters") + case let .byteArray(minSize, maxSize): + text = "byte array, hex" + Self.bounds( + minSize.map(String.init), maxSize.map(String.init), unit: "bytes") + case .identifier: + text = "identifier, base58" + } + if let allowed = allowedInputs { + text += ", one of " + allowed.map { $0.isEmpty ? "\"\"" : $0 }.joined(separator: ", ") + } + return text + } + + private static func bounds(_ minimum: String?, _ maximum: String?, unit: String?) -> String { + let suffix = unit.map { " \($0)" } ?? "" + switch (minimum, maximum) { + case let (minimum?, maximum?) where minimum == maximum: + return " (exactly \(minimum)\(suffix))" + case let (minimum?, maximum?): + return " (\(minimum) to \(maximum)\(suffix))" + case let (minimum?, nil): + return " (at least \(minimum)\(suffix))" + case let (nil, maximum?): + return " (at most \(maximum)\(suffix))" + case (nil, nil): + return "" + } + } +} + +extension DocumentTypedArray { + /// "list of integer (1 to 10), 1 to 5 elements, unique": the whole + /// declaration in one line, for schema detail views. + var summary: String { + var text = "list of \(element.summary)" + if let minItems, minItems > 0 { + text += minItems == maxItems + ? "; exactly \(maxItems) elements" + : "; \(minItems) to \(maxItems) elements" + } else { + text += "; at most \(maxItems) elements" + } + if uniqueItems { + text += ", unique" + } + return text + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift index 84e104ec214..731d741e047 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift @@ -424,6 +424,14 @@ struct PropertyRowView: View { // Property attributes propertyAttributesView + // A typed array (protocol version 14): what its elements are + if let dict = propertyDict, + let typedArray = DocumentTypedArray(path: propertyName, propertySchema: dict) { + Label(typedArray.summary, systemImage: "list.bullet") + .font(.caption2) + .foregroundColor(.purple) + } + // Sub-properties for objects if propertyType == "object", let dict = propertyDict { subPropertiesView(dict: dict) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index c38bfbfd51d..c157d226459 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -1478,7 +1478,10 @@ struct CreateDocumentView: View { /// Field values produced by `DocumentFieldsView`. Byte-array fields /// arrive as `Data`, identifier fields as `Data`, scalars as - /// `Int`/`Double`/`Bool`/`String`, arrays as `[String]`. + /// `Int`/`Double`/`Bool`/`String`, plain arrays as `[String]`, and typed + /// arrays as `[Any]` of JSON-native elements (identifier elements as + /// base58 and byte array elements as hex strings, never `Data`), or as + /// the `DocumentTypedArray.InputError` refusing the list. @State private var fieldValues: [String: Any] = [:] @State private var isSubmitting = false @@ -1865,7 +1868,18 @@ struct CreateDocumentView: View { /// hex/base58 identifiers back to native values. `object`-typed /// fields arrive as the editor's raw JSON `String`; they are parsed /// back into a nested object so they serialize as objects, not as a - /// JSON string. Other values are JSON-native and pass through. + /// JSON string. Other values are JSON-native and pass through, + /// typed arrays included: their elements are already `Int` / `Double` / + /// `Bool` / `String`, which the same sanitize step narrows or decodes + /// per element. + /// + /// Throws, so that nothing is broadcast, when a typed array failed its + /// client-side check (the field value is then the + /// `DocumentTypedArray.InputError` itself: a refused transition is still + /// paid for) and when any value is one JSON cannot carry, such as NaN + /// from a number field, which `JSONSerialization` would otherwise raise + /// on as an Objective-C exception. The state-transition builder + /// (`TransitionDetailView`) encodes its document fields here too. static func propertiesJSON( from fieldValues: [String: Any], documentType: PersistentDocumentType @@ -1876,7 +1890,12 @@ struct CreateDocumentView: View { .map(\.name) ?? [] ) var jsonObject: [String: Any] = [:] - for (key, value) in fieldValues { + // Sorted, so the reported refusal does not depend on hash order + for key in fieldValues.keys.sorted() { + guard let value = fieldValues[key] else { continue } + if let refusal = value as? DocumentTypedArray.InputError { + throw refusal + } if let data = value as? Data { jsonObject[key] = data.toHexString() } else if objectFields.contains(key), let text = value as? String { @@ -1887,12 +1906,27 @@ struct CreateDocumentView: View { } else { jsonObject[key] = value } + if let encoded = jsonObject[key], !JSONSerialization.isValidJSONObject([encoded]) { + throw DocumentPropertiesEncodingError.notJSON(property: key) + } } let data = try JSONSerialization.data(withJSONObject: jsonObject, options: []) return String(data: data, encoding: .utf8) ?? "{}" } } +/// A document field value `JSONSerialization` cannot encode. +enum DocumentPropertiesEncodingError: LocalizedError { + case notJSON(property: String) + + var errorDescription: String? { + switch self { + case let .notJSON(property): + return "\(property) holds a value JSON cannot carry (a number must be finite)." + } + } +} + struct DetailRow: View { let label: String let value: String diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 6bbceba4243..7fe6ed1cd28 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1392,6 +1392,12 @@ struct PropertyStorageDetailView: View { label: "Max Items", value: record.maxItems.map { "\($0)" } ?? "—" ) + // A typed array's element schema has no column: it is read + // off the document type's persisted schema + if let typedArray = record.documentType?.typedArray(named: record.name) { + FieldRow(label: "Items", value: typedArray.element.summary) + FieldRow(label: "Unique Items", value: typedArray.uniqueItems ? "Yes" : "No") + } FieldRow( label: "Min Value", value: record.minValue.map { "\($0)" } ?? "—" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift index 8cc48e4cd02..af716cacf88 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/TransitionDetailView.swift @@ -39,6 +39,12 @@ struct TransitionDetailView: View { @State private var selectedContractId: String = "" @State private var selectedDocumentType: String = "" @State private var documentFieldValues: [String: Any] = [:] + /// Why the document fields could not be encoded (a typed array refused by + /// its client-side check, a malformed object field, a non-finite number). + /// While set, `formInputs["documentFields"]` is cleared, so the submit stays + /// disabled and cannot send stale fields, and the reason is shown below the + /// fields. + @State private var documentFieldsError: String? /// Guards the one-time form setup in `.onAppear`. Contract / document-type /// selection now pushes a child list onto the navigation stack; popping it /// re-fires this view's `.onAppear`, and re-running `clearForm()` there @@ -303,15 +309,34 @@ struct TransitionDetailView: View { get: { documentFieldValues }, set: { newValues in documentFieldValues = newValues - // Convert to JSON string for the form - if let jsonData = try? JSONSerialization.data(withJSONObject: newValues, options: [.prettyPrinted]), - let jsonString = String(data: jsonData, encoding: .utf8) { - formInputs["documentFields"] = jsonString + // The Create Document screen's encoding: `Data` as hex (which + // the Rust sanitizer decodes), object text as objects, and a + // refusal for a typed array that failed its check or a value + // JSON cannot carry. `JSONSerialization.data` raises an + // Objective-C exception on such a value, which `try?` cannot + // catch. + do { + formInputs["documentFields"] = try CreateDocumentView.propertiesJSON( + from: newValues, documentType: documentType) + documentFieldsError = nil + } catch { + formInputs["documentFields"] = nil + documentFieldsError = error.localizedDescription } } ), immutability: immutability ) + // A new editor per document type, so switching type re-encodes the + // fields rather than keeping the previous type's + .id(documentType.id) + + if let documentFieldsError { + Text("Could not encode document fields: \(documentFieldsError)") + .font(.caption) + .foregroundColor(.red) + .accessibilityIdentifier("transition.documentFields.error") + } } else { Text("Document type '\(documentTypeName)' not found in contract") .font(.caption) @@ -1079,6 +1104,10 @@ struct TransitionDetailView: View { throw SDKError.invalidParameter("Document type is required") } + if let documentFieldsError { + throw SDKError.invalidParameter("Could not encode document fields: \(documentFieldsError)") + } + guard let propertiesJson = formInputs["documentFields"], !propertiesJson.isEmpty else { throw SDKError.invalidParameter("Document properties are required") } @@ -1381,6 +1410,10 @@ struct TransitionDetailView: View { throw SDKError.invalidParameter("Document ID is required") } + if let documentFieldsError { + throw SDKError.invalidParameter("Could not encode document fields: \(documentFieldsError)") + } + guard let propertiesJson = formInputs["documentFields"], !propertiesJson.isEmpty else { throw SDKError.invalidParameter("Document properties are required") } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTypedArrayTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTypedArrayTests.swift new file mode 100644 index 00000000000..a49f1a5f7ba --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserTypedArrayTests.swift @@ -0,0 +1,403 @@ +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// Protocol version 14 adds typed arrays to document schemas: a +/// `type: "array"` property declared by an `items` schema (one scalar +/// element) instead of `byteArray: true`. `DataContractParser` persists one as +/// an ordinary `"array"` `PersistentProperty` row (`byteArray` false, element +/// counts in `minItems` / `maxItems`), and the element schema is read back off +/// the persisted document type schema by `PersistentDocumentType.typedArrays` +/// and `typedArray(named:)`, with no stored column of its own. +/// +/// These tests run the real parser over an in-memory store, so every +/// declaration below goes through the `JSONSerialization` round trip the +/// persisted `schemaJSON` takes: booleans and numbers come back as +/// `NSNumber`s, which the readers must not confuse. +@MainActor +final class DataContractParserTypedArrayTests: XCTestCase { + + private let contractId = Data(repeating: 0xC2, count: 32) + + private let identifierItems: [String: Any] = [ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + ] + + // MARK: - Persisted property rows + + func testTypedArrayPropertiesArePersistedAsArrayRowsWithoutThrowing() throws { + let (context, _) = try parse(properties: [ + "tags": [ + "type": "array", + "items": ["type": "string", "maxLength": 32], + "minItems": 1, + "maxItems": 8, + "position": 0 + ], + "reasons": [ + "type": "array", + "items": identifierItems, + "maxItems": 64, + "uniqueItems": true, + "position": 1 + ] + ]) + + let properties = try fetchProperties(in: context) + let tags = try XCTUnwrap(properties.first { $0.name == "tags" }) + XCTAssertEqual(tags.type, "array") + XCTAssertFalse(tags.byteArray) + XCTAssertEqual(tags.minItems, 1) + XCTAssertEqual(tags.maxItems, 8) + // The element's own bounds stay on the element, not on the row + XCTAssertNil(tags.maxLength) + + // An identifier element does not make the property an identifier + let reasons = try XCTUnwrap(properties.first { $0.name == "reasons" }) + XCTAssertEqual(reasons.type, "array") + XCTAssertFalse(reasons.byteArray) + XCTAssertNil(reasons.minItems) + XCTAssertEqual(reasons.maxItems, 64) + XCTAssertNil(reasons.contentMediaType) + } + + func testByteArrayPropertyStillParsesAndIsNotATypedArray() throws { + let (context, docType) = try parse(properties: [ + "owner": [ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + ], + "payload": [ + "type": "array", + "byteArray": true, + "maxItems": 64, + "position": 1 + ] + ]) + + let owner = try XCTUnwrap(try fetchProperties(in: context).first { $0.name == "owner" }) + XCTAssertEqual(owner.type, "array") + XCTAssertTrue(owner.byteArray) + XCTAssertEqual(owner.minItems, 32) + XCTAssertEqual(owner.maxItems, 32) + XCTAssertEqual(owner.contentMediaType, "application/x.dash.dpp.identifier") + + XCTAssertNil(docType.typedArray(named: "owner")) + XCTAssertNil(docType.typedArray(named: "payload")) + XCTAssertEqual(docType.typedArrays, []) + } + + // MARK: - Element kinds + + func testIntegerElementsReportTheirBoundsAndAllowedValues() throws { + let (_, docType) = try parse(properties: [ + "scores": [ + "type": "array", + "items": ["type": "integer", "minimum": 1, "maximum": 10, "enum": [1, 5, 10]], + "minItems": 1, + "maxItems": 5, + "position": 0 + ] + ]) + + XCTAssertEqual( + docType.typedArray(named: "scores"), + DocumentTypedArray( + path: "scores", + element: .integer(minimum: 1, maximum: 10, allowedValues: [1, 5, 10]), + minItems: 1, + maxItems: 5, + uniqueItems: false + ) + ) + } + + func testIntegerElementsWithoutBoundsReportNone() throws { + let (_, docType) = try parse(properties: [ + "counts": [ + "type": "array", + "items": ["type": "integer"], + "maxItems": 4, + "position": 0 + ] + ]) + + XCTAssertEqual( + docType.typedArray(named: "counts")?.element, + .integer(minimum: nil, maximum: nil, allowedValues: nil) + ) + } + + func testNumberElementsReportTheirBoundsAndAllowedValues() throws { + let (_, docType) = try parse(properties: [ + "weights": [ + "type": "array", + "items": ["type": "number", "minimum": -1.5, "maximum": 2, "enum": [-1.5, 0, 2]], + "maxItems": 3, + "uniqueItems": true, + "position": 0 + ] + ]) + + XCTAssertEqual( + docType.typedArray(named: "weights"), + DocumentTypedArray( + path: "weights", + element: .number(minimum: -1.5, maximum: 2, allowedValues: [-1.5, 0, 2]), + minItems: nil, + maxItems: 3, + uniqueItems: true + ) + ) + } + + /// `JSONSerialization` hands a JSON boolean back as an `NSNumber`, which + /// casts to an integer: the element must still read as booleans. + func testBooleanElementsReportTheirAllowedValues() throws { + let (_, docType) = try parse(properties: [ + "flags": [ + "type": "array", + "items": ["type": "boolean"], + "maxItems": 2, + "position": 0 + ], + "confirmations": [ + "type": "array", + "items": ["type": "boolean", "enum": [true]], + "maxItems": 2, + "position": 1 + ] + ]) + + XCTAssertEqual(docType.typedArray(named: "flags")?.element, .boolean(allowedValues: nil)) + XCTAssertEqual( + docType.typedArray(named: "confirmations")?.element, .boolean(allowedValues: [true])) + } + + func testStringElementsReportTheirLengthsAndAllowedValues() throws { + let (_, docType) = try parse(properties: [ + "moods": [ + "type": "array", + "items": [ + "type": "string", + "minLength": 2, + "maxLength": 5, + "enum": ["happy", "sad", "ok"] + ], + "minItems": 0, + "maxItems": 3, + "uniqueItems": false, + "position": 0 + ] + ]) + + XCTAssertEqual( + docType.typedArray(named: "moods"), + DocumentTypedArray( + path: "moods", + element: .string(minLength: 2, maxLength: 5, allowedValues: ["happy", "sad", "ok"]), + minItems: 0, + maxItems: 3, + uniqueItems: false + ) + ) + } + + /// An element's `minItems` / `maxItems` count its bytes; the array's own + /// count its elements. + func testByteArrayElementsReportTheirSize() throws { + let (_, docType) = try parse(properties: [ + "hashes": [ + "type": "array", + "items": ["type": "array", "byteArray": true, "minItems": 20, "maxItems": 32], + "minItems": 2, + "maxItems": 16, + "position": 0 + ] + ]) + + XCTAssertEqual( + docType.typedArray(named: "hashes"), + DocumentTypedArray( + path: "hashes", + element: .byteArray(minSize: 20, maxSize: 32), + minItems: 2, + maxItems: 16, + uniqueItems: false + ) + ) + } + + /// `distinctFrom` (protocol version 14) may ride on identifier elements; + /// it changes nothing about what an element is. + func testIdentifierElementsReportAsIdentifiers() throws { + var items = identifierItems + items["distinctFrom"] = ["$ownerId"] + let (_, docType) = try parse(properties: [ + "reasons": [ + "type": "array", + "items": items, + "maxItems": 64, + "uniqueItems": true, + "position": 0 + ] + ]) + + XCTAssertEqual( + docType.typedArray(named: "reasons"), + DocumentTypedArray( + path: "reasons", + element: .identifier, + minItems: nil, + maxItems: 64, + uniqueItems: true + ) + ) + } + + // MARK: - Nesting and lookup + + func testTypedArraysAreListedByPathIncludingThoseNestedInObjects() throws { + let (_, docType) = try parse(properties: [ + "tags": [ + "type": "array", + "items": ["type": "string"], + "maxItems": 8, + "position": 0 + ], + "team": [ + "type": "object", + "properties": [ + "leads": [ + "type": "array", + "items": identifierItems, + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "position": 0 + ], + "name": ["type": "string", "maxLength": 63, "position": 1] + ], + "additionalProperties": false, + "position": 1 + ], + "title": ["type": "string", "maxLength": 63, "position": 2] + ]) + + XCTAssertEqual(docType.typedArrays.map(\.path), ["tags", "team.leads"]) + XCTAssertEqual( + docType.typedArrays.last, + DocumentTypedArray( + path: "team.leads", + element: .identifier, + minItems: 1, + maxItems: 3, + uniqueItems: true + ) + ) + + // The named lookup reads top-level properties only + XCTAssertNotNil(docType.typedArray(named: "tags")) + XCTAssertNil(docType.typedArray(named: "team")) + XCTAssertNil(docType.typedArray(named: "leads")) + XCTAssertNil(docType.typedArray(named: "team.leads")) + XCTAssertNil(docType.typedArray(named: "title")) + XCTAssertNil(docType.typedArray(named: "missing")) + } + + func testDocumentTypeWithoutTypedArraysReportsNone() throws { + let (_, docType) = try parse(properties: [ + "title": ["type": "string", "maxLength": 63, "position": 0] + ]) + + XCTAssertEqual(docType.typedArrays, []) + } + + // MARK: - Declarations DPP refuses + + /// DPP refuses each of these at registration, so they reach a client only + /// through hand-edited JSON. None may be mistaken for a typed array. + func testDeclarationsThatAreNotTypedArraysReadAsNone() { + let notTypedArrays: [String: [String: Any]] = [ + "byteArray false": [ + "type": "array", "byteArray": false, + "items": ["type": "string"], "maxItems": 4 + ], + "no items": ["type": "array", "maxItems": 4], + "tuple items": ["type": "array", "items": [["type": "string"]], "maxItems": 4], + "object items": ["type": "array", "items": ["type": "object"], "maxItems": 4], + "array of arrays": [ + "type": "array", "items": ["type": "array", "items": ["type": "string"]], + "maxItems": 4 + ], + "no maxItems": ["type": "array", "items": ["type": "string"]], + "not an array": ["type": "string", "items": ["type": "string"], "maxItems": 4] + ] + + for (label, schema) in notTypedArrays { + XCTAssertNil(DocumentTypedArray(path: "p", propertySchema: schema), label) + } + } + + // MARK: - Helpers + + /// Run the real parser over a one-document-type contract and hand back + /// the context and the persisted document type row. `parseDocumentTypes` + /// needs the `PersistentDataContract` row to exist first. + private func parse( + properties: [String: Any] + ) throws -> (ModelContext, PersistentDocumentType) { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + + let contract = PersistentDataContract( + id: contractId, + name: "Fixture", + serializedContract: Data(), + network: .testnet + ) + context.insert(contract) + try context.save() + + try DataContractParser.parseDataContract( + contractData: [ + "documents": [ + "post": [ + "type": "object", + "properties": properties, + "additionalProperties": false + ] + ] + ], + contractId: contractId, + modelContext: context + ) + + let id = contractId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.contractId == id } + ) + let docType = try XCTUnwrap( + try context.fetch(descriptor).first, + "parser should have persisted one document type" + ) + return (context, docType) + } + + private func fetchProperties(in context: ModelContext) throws -> [PersistentProperty] { + let id = contractId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.contractId == id } + ) + return try context.fetch(descriptor) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentTypedArrayElementInputTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentTypedArrayElementInputTests.swift new file mode 100644 index 00000000000..bbb31f6662d --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentTypedArrayElementInputTests.swift @@ -0,0 +1,431 @@ +import Foundation +import XCTest + +@testable import SwiftDashSDK + +/// Coverage for `DocumentTypedArray.Element.value(fromInput:)`, which turns +/// the text a user entered for one typed array element into the JSON value +/// the platform wallet's schema sanitizer takes. +/// +/// The sanitizer narrows JSON numbers to the element's width, decodes base58 +/// identifiers and hex byte arrays, but never parses a number or a boolean out +/// of a string. So integers, numbers and booleans must leave as JSON numbers +/// and booleans, identifiers and byte arrays as strings (never `Data`, which +/// `JSONSerialization` cannot encode inside an array), and a string element +/// exactly as typed. The range, length and `enum` checks are a courtesy that +/// spares a transition consensus would refuse. +/// +/// `DocumentTypedArray.values(fromInputs:)` / `jsonArray(fromInputs:)` judge a +/// whole list the same way (count, every row, repeats under `uniqueItems`); +/// the example app refuses to encode a list that fails, so an invalid list is +/// never broadcast and paid for. +final class DocumentTypedArrayElementInputTests: XCTestCase { + + private typealias Element = DocumentTypedArray.Element + private typealias Value = DocumentTypedArray.ElementValue + private typealias ListError = DocumentTypedArray.InputError + + // MARK: - Integer + + func testIntegerInputBecomesAJSONInteger() { + let element = Element.integer(minimum: nil, maximum: nil, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "42"), .success(.integer(42))) + XCTAssertEqual(element.value(fromInput: " -7 "), .success(.integer(-7))) + } + + func testIntegerInputThatIsNotAWholeNumberIsRefused() { + let element = Element.integer(minimum: nil, maximum: nil, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "4.2"), .failure(.notAnInteger("4.2"))) + XCTAssertEqual(element.value(fromInput: "abc"), .failure(.notAnInteger("abc"))) + XCTAssertEqual(element.value(fromInput: " "), .failure(.empty)) + // Beyond Int64 is not a whole number Swift can send + XCTAssertEqual( + element.value(fromInput: "99999999999999999999"), + .failure(.notAnInteger("99999999999999999999"))) + } + + func testIntegerInputOutsideTheDeclaredRangeIsRefused() { + let element = Element.integer(minimum: 1, maximum: 10, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "1"), .success(.integer(1))) + XCTAssertEqual(element.value(fromInput: "10"), .success(.integer(10))) + XCTAssertEqual( + element.value(fromInput: "0"), + .failure(.outOfRange(value: "0", minimum: "1", maximum: "10"))) + XCTAssertEqual( + element.value(fromInput: "11"), + .failure(.outOfRange(value: "11", minimum: "1", maximum: "10"))) + } + + func testIntegerInputOutsideTheEnumIsRefusedNamingTheAllowedValues() { + let element = Element.integer(minimum: 1, maximum: 10, allowedValues: [1, 5, 10]) + + XCTAssertEqual(element.value(fromInput: "5"), .success(.integer(5))) + XCTAssertEqual( + element.value(fromInput: "4"), + .failure(.notAllowed(value: "4", allowed: ["1", "5", "10"]))) + // Failing both, the enum is what the user is told about + XCTAssertEqual( + element.value(fromInput: "11"), + .failure(.notAllowed(value: "11", allowed: ["1", "5", "10"]))) + } + + // MARK: - Number + + func testNumberInputBecomesAJSONNumber() { + let element = Element.number(minimum: nil, maximum: nil, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "2.5"), .success(.number(2.5))) + XCTAssertEqual(element.value(fromInput: "-3"), .success(.number(-3))) + XCTAssertEqual(element.value(fromInput: "1e3"), .success(.number(1000))) + } + + /// The decimal pad shows a comma as the decimal separator in some locales. + func testNumberInputReadsALoneCommaAsTheDecimalSeparator() { + let element = Element.number(minimum: nil, maximum: nil, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "2,5"), .success(.number(2.5))) + XCTAssertEqual(element.value(fromInput: "1,000,5"), .failure(.notANumber("1,000,5"))) + } + + /// JSON cannot carry NaN or infinity, and `JSONSerialization` raises on + /// them rather than throwing. + func testNumberInputThatIsNotAFiniteNumberIsRefused() { + let element = Element.number(minimum: nil, maximum: nil, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "nan"), .failure(.notANumber("nan"))) + XCTAssertEqual(element.value(fromInput: "inf"), .failure(.notANumber("inf"))) + XCTAssertEqual(element.value(fromInput: "two"), .failure(.notANumber("two"))) + XCTAssertEqual(element.value(fromInput: ""), .failure(.empty)) + } + + func testNumberInputOutsideTheDeclaredRangeOrEnumIsRefused() { + let bounded = Element.number(minimum: -1.5, maximum: 2, allowedValues: nil) + XCTAssertEqual(bounded.value(fromInput: "-1.5"), .success(.number(-1.5))) + XCTAssertEqual( + bounded.value(fromInput: "2.25"), + .failure(.outOfRange(value: "2.25", minimum: "-1.5", maximum: "2"))) + + // An integral input matches an integral member, as 1 equals 1.0 in JSON + let listed = Element.number(minimum: nil, maximum: nil, allowedValues: [1, 2.5]) + XCTAssertEqual(listed.value(fromInput: "1"), .success(.number(1))) + XCTAssertEqual( + listed.value(fromInput: "2"), + .failure(.notAllowed(value: "2", allowed: ["1", "2.5"]))) + } + + // MARK: - Boolean + + func testBooleanInputBecomesAJSONBoolean() { + let element = Element.boolean(allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "true"), .success(.boolean(true))) + XCTAssertEqual(element.value(fromInput: "False"), .success(.boolean(false))) + XCTAssertEqual(element.value(fromInput: "yes"), .failure(.notABoolean("yes"))) + XCTAssertEqual(element.value(fromInput: "1"), .failure(.notABoolean("1"))) + } + + func testBooleanInputOutsideTheEnumIsRefused() { + let element = Element.boolean(allowedValues: [true]) + + XCTAssertEqual(element.value(fromInput: "true"), .success(.boolean(true))) + XCTAssertEqual( + element.value(fromInput: "false"), + .failure(.notAllowed(value: "false", allowed: ["true"]))) + } + + // MARK: - String + + /// The comma-separated editor this replaces split "a, b" into two + /// elements; one element keeps its commas and spaces. + func testStringInputIsSentExactlyAsEntered() { + let element = Element.string(minLength: nil, maxLength: nil, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "a, b"), .success(.string("a, b"))) + XCTAssertEqual(element.value(fromInput: " padded "), .success(.string(" padded "))) + XCTAssertEqual(element.value(fromInput: ""), .success(.string(""))) + } + + func testStringInputOutsideTheDeclaredLengthIsRefused() { + let element = Element.string(minLength: 2, maxLength: 4, allowedValues: nil) + + XCTAssertEqual(element.value(fromInput: "ab"), .success(.string("ab"))) + XCTAssertEqual( + element.value(fromInput: "a"), + .failure(.wrongLength(length: 1, minimum: 2, maximum: 4))) + XCTAssertEqual( + element.value(fromInput: "abcde"), + .failure(.wrongLength(length: 5, minimum: 2, maximum: 4))) + XCTAssertEqual( + element.value(fromInput: ""), + .failure(.wrongLength(length: 0, minimum: 2, maximum: 4))) + } + + /// JSON Schema counts characters as Unicode code points: an emoji built + /// from two scalars is two characters, not one grapheme. + func testStringLengthIsCountedInUnicodeScalars() { + let element = Element.string(minLength: nil, maxLength: 1, allowedValues: nil) + let flag = "\u{1F1FA}\u{1F1F8}" + + XCTAssertEqual(flag.count, 1) + XCTAssertEqual( + element.value(fromInput: flag), + .failure(.wrongLength(length: 2, minimum: nil, maximum: 1))) + } + + func testStringInputOutsideTheEnumIsRefused() { + let element = Element.string(minLength: nil, maxLength: nil, allowedValues: ["happy", "sad"]) + + XCTAssertEqual(element.value(fromInput: "sad"), .success(.string("sad"))) + XCTAssertEqual( + element.value(fromInput: "Sad"), + .failure(.notAllowed(value: "Sad", allowed: ["happy", "sad"]))) + } + + // MARK: - Identifier + + func testIdentifierInputIsSentAsBase58() { + let id = Data(repeating: 0x11, count: 32).toBase58String() + + XCTAssertEqual(Element.identifier.value(fromInput: " \(id) "), .success(.string(id))) + } + + func testIdentifierInputThatIsNotBase58IsRefused() { + // 0, O, I and l are not in the base58 alphabet + XCTAssertEqual( + Element.identifier.value(fromInput: "0OIl"), .failure(.invalidBase58("0OIl"))) + XCTAssertEqual(Element.identifier.value(fromInput: ""), .failure(.empty)) + } + + func testIdentifierInputThatIsNotThirtyTwoBytesIsRefused() { + let short = Data(repeating: 0x22, count: 20).toBase58String() + + XCTAssertEqual( + Element.identifier.value(fromInput: short), + .failure(.wrongByteCount(count: 20, minimum: 32, maximum: 32))) + } + + // MARK: - Byte array + + func testByteArrayInputIsSentAsLowercaseHex() { + let element = Element.byteArray(minSize: nil, maxSize: nil) + + XCTAssertEqual(element.value(fromInput: "DEADbeef"), .success(.string("deadbeef"))) + XCTAssertEqual(element.value(fromInput: "0x00ff"), .success(.string("00ff"))) + } + + /// `Data(hexString:)` would drop an odd final digit and accept a `+` + /// sign; the element check must not. + func testByteArrayInputThatIsNotHexIsRefused() { + let element = Element.byteArray(minSize: nil, maxSize: nil) + + XCTAssertEqual(element.value(fromInput: "abc"), .failure(.invalidHex("abc"))) + XCTAssertEqual(element.value(fromInput: "+f"), .failure(.invalidHex("+f"))) + XCTAssertEqual(element.value(fromInput: "zz"), .failure(.invalidHex("zz"))) + // Fullwidth digits are hex digits to `Character`, not to the sanitizer + XCTAssertEqual(element.value(fromInput: "\u{FF10}\u{FF11}"), .failure(.invalidHex("\u{FF10}\u{FF11}"))) + XCTAssertEqual(element.value(fromInput: "0x"), .failure(.empty)) + } + + func testByteArrayInputOutsideTheDeclaredSizeIsRefused() { + let element = Element.byteArray(minSize: 2, maxSize: 3) + + XCTAssertEqual(element.value(fromInput: "aabb"), .success(.string("aabb"))) + XCTAssertEqual( + element.value(fromInput: "aa"), + .failure(.wrongByteCount(count: 1, minimum: 2, maximum: 3))) + XCTAssertEqual( + element.value(fromInput: "aabbccdd"), + .failure(.wrongByteCount(count: 4, minimum: 2, maximum: 3))) + } + + // MARK: - Allowed inputs + + /// A picker offers `allowedInputs`, so every entry must read back as the + /// member it stands for. + func testEveryAllowedInputReadsBackAsItsMember() { + let elements: [(Element, [Value])] = [ + (.integer(minimum: nil, maximum: nil, allowedValues: [3, -1]), [.integer(3), .integer(-1)]), + (.number(minimum: nil, maximum: nil, allowedValues: [2, 0.5, 1e20]), + [.number(2), .number(0.5), .number(1e20)]), + (.boolean(allowedValues: [false, true]), [.boolean(false), .boolean(true)]), + (.string(minLength: nil, maxLength: nil, allowedValues: ["a, b", " x"]), + [.string("a, b"), .string(" x")]) + ] + + for (element, members) in elements { + let inputs = element.allowedInputs ?? [] + XCTAssertEqual(inputs.count, members.count, "\(element)") + XCTAssertEqual(inputs.map { element.value(fromInput: $0) }, members.map { .success($0) }) + } + XCTAssertEqual( + Element.number(minimum: nil, maximum: nil, allowedValues: [2, 0.5]).allowedInputs, + ["2", "0.5"]) + } + + func testElementsWithoutAnEnumOfferNoAllowedInputs() { + XCTAssertNil(Element.integer(minimum: 1, maximum: 2, allowedValues: nil).allowedInputs) + XCTAssertNil(Element.boolean(allowedValues: nil).allowedInputs) + XCTAssertNil(Element.byteArray(minSize: nil, maxSize: nil).allowedInputs) + XCTAssertNil(Element.identifier.allowedInputs) + } + + // MARK: - Whole list + + private func list( + _ element: Element, + minItems: Int? = nil, + maxItems: Int = 8, + uniqueItems: Bool = false + ) -> DocumentTypedArray { + DocumentTypedArray( + path: "scores", element: element, + minItems: minItems, maxItems: maxItems, uniqueItems: uniqueItems) + } + + private let integers = Element.integer(minimum: nil, maximum: nil, allowedValues: nil) + + func testListInputBecomesTypedValuesInRowOrder() throws { + let scores = list(integers, minItems: 1, maxItems: 3) + + XCTAssertEqual( + scores.values(fromInputs: ["3", " 1", "2"]), + .success([.integer(3), .integer(1), .integer(2)])) + + let array = try scores.jsonArray(fromInputs: ["3", "1", "2"]).get() + let data = try JSONSerialization.data(withJSONObject: ["scores": array]) + XCTAssertEqual(String(data: data, encoding: .utf8), #"{"scores":[3,1,2]}"#) + } + + func testEmptyListPassesWhenNoMinItemsIsDeclared() { + XCTAssertEqual(list(integers).values(fromInputs: []), .success([])) + XCTAssertEqual(list(integers, minItems: 0).values(fromInputs: []), .success([])) + } + + func testListWithFewerRowsThanMinItemsIsRefused() { + let scores = list(integers, minItems: 2, maxItems: 4) + + XCTAssertEqual( + scores.values(fromInputs: ["1"]), + .failure(.tooFewElements(path: "scores", count: 1, minimum: 2))) + XCTAssertEqual( + scores.values(fromInputs: []), + .failure(.tooFewElements(path: "scores", count: 0, minimum: 2))) + } + + func testListWithMoreRowsThanMaxItemsIsRefused() { + XCTAssertEqual( + list(integers, maxItems: 2).values(fromInputs: ["1", "2", "3"]), + .failure(.tooManyElements(path: "scores", count: 3, maximum: 2))) + } + + func testListReportsTheFirstBadRowByIndex() { + XCTAssertEqual( + list(integers).values(fromInputs: ["1", "x", "y"]), + .failure(.invalidElement(path: "scores", index: 1, reason: .notAnInteger("x")))) + + // Every element check applies to its row, the range included + let bounded = list(.integer(minimum: 0, maximum: 10, allowedValues: nil)) + XCTAssertEqual( + bounded.values(fromInputs: ["4", "11"]), + .failure(.invalidElement( + path: "scores", index: 1, + reason: .outOfRange(value: "11", minimum: "0", maximum: "10")))) + } + + /// Repeats are judged on the converted values, as consensus judges the + /// stored ones, not on the text typed. + func testRepeatedElementsAreRefusedUnderUniqueItems() { + XCTAssertEqual( + list(integers, uniqueItems: true).values(fromInputs: ["5", "7", " 5"]), + .failure(.repeatedElement(path: "scores", index: 2, firstIndex: 0))) + + let numbers = Element.number(minimum: nil, maximum: nil, allowedValues: nil) + XCTAssertEqual( + list(numbers, uniqueItems: true).values(fromInputs: ["1", "1.0"]), + .failure(.repeatedElement(path: "scores", index: 1, firstIndex: 0))) + + let bytes = Element.byteArray(minSize: nil, maxSize: nil) + XCTAssertEqual( + list(bytes, uniqueItems: true).values(fromInputs: ["AABB", "0xaabb"]), + .failure(.repeatedElement(path: "scores", index: 1, firstIndex: 0))) + + let id = Data(repeating: 0x11, count: 32).toBase58String() + XCTAssertEqual( + list(.identifier, uniqueItems: true).values(fromInputs: [id, " \(id) "]), + .failure(.repeatedElement(path: "scores", index: 1, firstIndex: 0))) + } + + func testRepeatedElementsPassWithoutUniqueItems() { + XCTAssertEqual( + list(integers).values(fromInputs: ["5", "5"]), + .success([.integer(5), .integer(5)])) + } + + /// The count is judged before the rows, and every row before repeats. + func testListChecksTheCountThenTheRowsThenRepeats() { + let scores = list(integers, minItems: 3, maxItems: 4, uniqueItems: true) + + XCTAssertEqual( + scores.values(fromInputs: ["x", "x"]), + .failure(.tooFewElements(path: "scores", count: 2, minimum: 3))) + XCTAssertEqual( + scores.values(fromInputs: ["1", "1", "x"]), + .failure(.invalidElement(path: "scores", index: 2, reason: .notAnInteger("x")))) + } + + func testJSONArrayRefusesWhatValuesRefuses() { + guard case let .failure(error) = list(integers, minItems: 1).jsonArray(fromInputs: []) else { + return XCTFail("an empty list below minItems must be refused") + } + XCTAssertEqual(error, .tooFewElements(path: "scores", count: 0, minimum: 1)) + } + + func testListErrorDescriptionsNameThePropertyAndTheRow() { + let cases: [(ListError, String)] = [ + (.tooFewElements(path: "scores", count: 1, minimum: 2), + "scores: 1 element; the list takes at least 2."), + (.tooManyElements(path: "scores", count: 3, maximum: 2), + "scores: 3 elements; the list takes at most 2."), + (.invalidElement(path: "scores", index: 1, reason: .notAnInteger("x")), + "scores[1]: \"x\" is not a whole number."), + (.repeatedElement(path: "team.leads", index: 2, firstIndex: 0), + "team.leads[2]: repeats team.leads[0], and the elements must be unique.") + ] + + for (error, message) in cases { + XCTAssertEqual(error.localizedDescription, message) + } + } + + // MARK: - JSON form + + func testElementValuesEncodeAsTypedJSONInsideAnArray() throws { + let values: [Value] = [.integer(5), .number(2.5), .boolean(true), .string("a, b")] + let array = values.map(\.jsonValue) + + XCTAssertTrue(JSONSerialization.isValidJSONObject(["list": array])) + let data = try JSONSerialization.data(withJSONObject: ["list": array], options: [.sortedKeys]) + XCTAssertEqual(String(data: data, encoding: .utf8), #"{"list":[5,2.5,true,"a, b"]}"#) + } + + func testErrorDescriptionsNameTheProblem() { + typealias InputError = DocumentTypedArray.ElementInputError + let cases: [(InputError, String)] = [ + (.empty, "Enter a value"), + (.notAnInteger("x"), "\"x\" is not a whole number"), + (.outOfRange(value: "11", minimum: "1", maximum: "10"), "11 is outside the allowed range (1 to 10)"), + (.outOfRange(value: "-1", minimum: "0", maximum: nil), "(at least 0)"), + (.notAllowed(value: "4", allowed: ["1", "5"]), "allowed values: 1, 5"), + (.wrongLength(length: 5, minimum: nil, maximum: 4), "5 characters; the element takes at most 4"), + (.wrongByteCount(count: 20, minimum: 32, maximum: 32), "20 bytes; the element takes exactly 32") + ] + + for (error, fragment) in cases { + let message = error.localizedDescription + XCTAssertTrue(message.contains(fragment), "\(error): \(message)") + } + } +} diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 06fa5f6d474..149acab84b3 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -67,7 +67,7 @@ use dpp::consensus::state::data_trigger::DataTriggerError::{ }; use wasm_bindgen::{JsError, JsValue}; use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, DataContractInvalidRequiredFieldsUpdateError, ContestedUniqueIndexWithUniqueIndexError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, PreProgrammedDistributionAmountOverLimitError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; -use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; +use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyNotDistinctError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; use dpp::consensus::basic::group::GroupActionNotAllowedOnTransitionError; use dpp::consensus::basic::identity::{DataContractBoundsNotPresentError, DisablingKeyIdAlsoBeingAddedInSameTransitionError, InvalidIdentityCreditWithdrawalTransitionAmountError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidKeyPurposeForContractBoundsError, TooManyMasterPublicKeyError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError}; use dpp::consensus::basic::overflow_error::OverflowError; @@ -159,6 +159,8 @@ use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShield use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; use dpp::consensus::basic::state_transition::{StateTransitionNotActiveError, TransitionOverMaxInputsError, TransitionOverMaxOutputsError, InputWitnessCountMismatchError, TransitionNoInputsError, TransitionNoOutputsError, FeeStrategyEmptyError, FeeStrategyDuplicateError, FeeStrategyIndexOutOfBoundsError, FeeStrategyTooManyStepsError, InputBelowMinimumError, OutputBelowMinimumError, InputOutputBalanceMismatchError, OutputsNotGreaterThanInputsError, WithdrawalBalanceMismatchError, InsufficientFundingAmountError, InputsNotLessThanOutputsError, OutputAddressAlsoInputError, InvalidRemainderOutputCountError, WithdrawalBelowMinAmountError, ShieldedNoActionsError, ShieldedTooManyActionsError, ShieldedEmptyProofError, ShieldedZeroAnchorError, ShieldedInvalidValueBalanceError, ShieldedEncryptedNoteSizeMismatchError, ShieldedImplicitFeeCapExceededError, ShieldedInvalidDenominationError}; use dpp::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; +use dpp::consensus::state::document::referenced_identity_key_requirement_not_met_error::ReferencedIdentityKeyRequirementNotMetError; +use dpp::consensus::state::document::referenced_document_lookup_invalid_error::ReferencedDocumentLookupInvalidError; use dpp::consensus::state::voting::masternode_incorrect_voter_identity_id_error::MasternodeIncorrectVoterIdentityIdError; use dpp::consensus::state::voting::masternode_incorrect_voting_address_error::MasternodeIncorrectVotingAddressError; use dpp::consensus::state::voting::masternode_not_found_error::MasternodeNotFoundError; @@ -167,6 +169,7 @@ use dpp::consensus::state::voting::masternode_voted_too_many_times::MasternodeVo use dpp::consensus::state::voting::vote_poll_not_available_for_voting_error::VotePollNotAvailableForVotingError; use dpp::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; use dpp::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; +use dpp::consensus::state::voting::yes_no_vote_poll_not_available_for_voting_error::YesNoVotePollNotAvailableForVotingError; use crate::errors::consensus::basic::data_contract::{ DataContractErrorWasm, DataContractHaveNewUniqueIndexErrorWasm, @@ -687,6 +690,15 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::ReferencedContractRequirementNotMetError(e) => { generic_consensus_error!(ReferencedContractRequirementNotMetError, e).into() } + StateError::ReferencedIdentityKeyRequirementNotMetError(e) => { + generic_consensus_error!(ReferencedIdentityKeyRequirementNotMetError, e).into() + } + StateError::ReferencedDocumentLookupInvalidError(e) => { + generic_consensus_error!(ReferencedDocumentLookupInvalidError, e).into() + } + StateError::YesNoVotePollNotAvailableForVotingError(e) => { + generic_consensus_error!(YesNoVotePollNotAvailableForVotingError, e).into() + } } } @@ -943,6 +955,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::DocumentFieldMaxSizeExceededError(e) => { generic_consensus_error!(DocumentFieldMaxSizeExceededError, e).into() } + BasicError::DocumentPropertyNotDistinctError(e) => { + generic_consensus_error!(DocumentPropertyNotDistinctError, e).into() + } BasicError::ContestedUniqueIndexWithUniqueIndexError(e) => { generic_consensus_error!(ContestedUniqueIndexWithUniqueIndexError, e).into() } @@ -1264,6 +1279,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::InvalidContractModerationReasonDocumentsError(e) => { generic_consensus_error!(InvalidContractModerationReasonDocumentsError, e).into() } + BasicError::InvalidEncryptedPropertyShapeError(e) => { + generic_consensus_error!(InvalidEncryptedPropertyShapeError, e).into() + } } } diff --git a/packages/wasm-dpp/src/voting/state_transition/masternode_vote_transition/mod.rs b/packages/wasm-dpp/src/voting/state_transition/masternode_vote_transition/mod.rs index 6e171a6bdd4..2dab1cf7c21 100644 --- a/packages/wasm-dpp/src/voting/state_transition/masternode_vote_transition/mod.rs +++ b/packages/wasm-dpp/src/voting/state_transition/masternode_vote_transition/mod.rs @@ -302,8 +302,10 @@ impl MasternodeVoteTransitionWasm { Some(js_object) } + VotePoll::YesNoVotePoll(_) => None, } } + Vote::YesNoVote(_) => None, } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index d1bf70c531d..ba63519e79b 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -58,11 +58,21 @@ pub enum DocumentReferenceErrorCodeWasm { /// document, or a config flag (read-only, keeping history, the owner /// protection of its elected moderation declaration). ReferencedContractRequirementNotMet = 40135, + /// The referenced identity public key exists and is enabled but does not + /// meet what the reference's `keyRequirements` require of it: its + /// purpose, or a binding to a document type of the declaring contract. + ReferencedIdentityKeyRequirementNotMet = 40136, + /// A `refersTo` lookup into a document type of another contract cannot + /// resolve there, reported at contract registration: the named index is + /// missing or not unique, the keys do not cover it exactly, or a source + /// holds a different kind of value than its index property. (A lookup into + /// the declaring contract is refused by the contract parse instead.) + ReferencedDocumentLookupInvalid = 40137, } impl DocumentReferenceErrorCodeWasm { /// The reference-validation error a code names, or `None` when the code - /// is not in the 40120-40125 range, 40131 or 40135. + /// is not in the 40120-40125 range, 40131, 40135, 40136 or 40137. fn from_code(code: u32) -> Option { match code { 40120 => Some(Self::ReferencedEntityNotFound), @@ -73,6 +83,8 @@ impl DocumentReferenceErrorCodeWasm { 40125 => Some(Self::ReferencedKeyIdPropertyInvalid), 40131 => Some(Self::ReferencedDocumentTypeNotDeletable), 40135 => Some(Self::ReferencedContractRequirementNotMet), + 40136 => Some(Self::ReferencedIdentityKeyRequirementNotMet), + 40137 => Some(Self::ReferencedDocumentLookupInvalid), _ => None, } } @@ -111,6 +123,74 @@ impl DocumentImmutabilityErrorCodeWasm { } } +/// Consensus error codes emitted by the `distinctFrom` check on document +/// creates and replaces (protocol version 14+). +/// +/// Branch on an error's `code` against this instead of matching its message: +/// +/// ```js +/// try { +/// await sdk.documents.create({ document, identityKey, signer }); +/// } catch (e) { +/// if (e.code === DocumentDistinctFromErrorCode.DocumentPropertyNotDistinct) { +/// // an identifier property equals the value it must differ from +/// } +/// } +/// ``` +#[wasm_bindgen(js_name = "DocumentDistinctFromErrorCode")] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DocumentDistinctFromErrorCodeWasm { + /// A `distinctFrom` identifier property of the written document equals + /// the value it must differ from: the document's `$ownerId`, or the named + /// property of the same document. + DocumentPropertyNotDistinct = 10419, +} + +impl DocumentDistinctFromErrorCodeWasm { + /// The distinctFrom error a code names, or `None` for any other code. + fn from_code(code: u32) -> Option { + match code { + 10419 => Some(Self::DocumentPropertyNotDistinct), + _ => None, + } + } +} + +/// Consensus error codes emitted by `encryptedFor` validation, which runs +/// from protocol version 14 onward on every document create and replace. +/// +/// Branch on an error's `code` against these instead of matching its +/// message: +/// +/// ```js +/// try { +/// await sdk.documents.create({ document, identityKey, signer }); +/// } catch (e) { +/// if (e.code === DocumentEncryptionErrorCode.InvalidEncryptedPropertyShape) { +/// // the bytes are not the shape the declared scheme produces +/// } +/// } +/// ``` +#[wasm_bindgen(js_name = "DocumentEncryptionErrorCode")] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DocumentEncryptionErrorCodeWasm { + /// A property the document type declares `encryptedFor` was supplied + /// with bytes that are not a ciphertext of the declared scheme: shorter + /// than the IV plus one block, or not a multiple of the block length. + /// The shape is all consensus checks about a ciphertext. + InvalidEncryptedPropertyShape = 10420, +} + +impl DocumentEncryptionErrorCodeWasm { + /// The encryption error a code names, or `None` for any other code. + fn from_code(code: u32) -> Option { + match code { + 10420 => Some(Self::InvalidEncryptedPropertyShape), + _ => None, + } + } +} + #[wasm_bindgen(js_name = "ConsensusError")] pub struct ConsensusErrorWasm(ConsensusError); @@ -139,7 +219,7 @@ impl ConsensusErrorWasm { } /// The reference-validation error this is, or `undefined` when it is - /// not one of codes 40120-40125. + /// not one of codes 40120-40125, 40131, 40135 and 40136. #[wasm_bindgen(getter = "documentReferenceErrorCode")] pub fn document_reference_error_code(&self) -> Option { DocumentReferenceErrorCodeWasm::from_code(self.0.code()) @@ -151,6 +231,19 @@ impl ConsensusErrorWasm { pub fn document_immutability_error_code(&self) -> Option { DocumentImmutabilityErrorCodeWasm::from_code(self.0.code()) } + + /// The distinctFrom error this is, or `undefined` when it is not code + /// 10419. + #[wasm_bindgen(getter = "documentDistinctFromErrorCode")] + pub fn document_distinct_from_error_code(&self) -> Option { + DocumentDistinctFromErrorCodeWasm::from_code(self.0.code()) + } + /// The encrypted-property error this is, or `undefined` when it is not + /// code 10420. + #[wasm_bindgen(getter = "documentEncryptionErrorCode")] + pub fn document_encryption_error_code(&self) -> Option { + DocumentEncryptionErrorCodeWasm::from_code(self.0.code()) + } } impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); @@ -159,11 +252,13 @@ impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); mod tests { use super::*; use dpp::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; + use dpp::consensus::state::document::referenced_document_lookup_invalid_error::ReferencedDocumentLookupInvalidError; use dpp::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use dpp::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; use dpp::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; use dpp::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; use dpp::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; + use dpp::consensus::state::document::referenced_identity_key_requirement_not_met_error::ReferencedIdentityKeyRequirementNotMetError; use dpp::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; use dpp::consensus::state::state_error::StateError; use dpp::data_contract::document_type::DocumentPropertyReferenceTarget; @@ -205,6 +300,70 @@ mod tests { assert_eq!(DocumentImmutabilityErrorCodeWasm::from_code(40127), None); } + /// Built from the real DPP error, for the same reason as the test above. + #[test] + fn distinct_from_error_code_mirrors_the_dpp_error() { + use dpp::consensus::basic::BasicError; + use dpp::consensus::basic::document::DocumentPropertyNotDistinctError; + + let error: ConsensusError = + BasicError::DocumentPropertyNotDistinctError(DocumentPropertyNotDistinctError::new( + "delegation".to_string(), + "delegateId".to_string(), + "$ownerId".to_string(), + )) + .into(); + + assert_eq!( + DocumentDistinctFromErrorCodeWasm::from_code(error.code()), + Some(DocumentDistinctFromErrorCodeWasm::DocumentPropertyNotDistinct) + ); + assert_eq!( + DocumentDistinctFromErrorCodeWasm::DocumentPropertyNotDistinct as u32, + error.code() + ); + assert_eq!( + ConsensusErrorWasm(error).document_distinct_from_error_code(), + Some(DocumentDistinctFromErrorCodeWasm::DocumentPropertyNotDistinct) + ); + // A neighbouring code is not claimed. + assert_eq!(DocumentDistinctFromErrorCodeWasm::from_code(10418), None); + } + + /// Built from the real DPP error rather than a code literal, like the + /// immutability test above. + #[test] + fn encryption_error_code_mirrors_the_dpp_error() { + use dpp::consensus::basic::BasicError; + use dpp::consensus::basic::document::InvalidEncryptedPropertyShapeError; + + let error: ConsensusError = BasicError::InvalidEncryptedPropertyShapeError( + InvalidEncryptedPropertyShapeError::new( + "encryptedMessage".to_string(), + "ecdh-secp256k1-aes256-cbc".to_string(), + 47, + 32, + 16, + ), + ) + .into(); + + assert_eq!( + DocumentEncryptionErrorCodeWasm::from_code(error.code()), + Some(DocumentEncryptionErrorCodeWasm::InvalidEncryptedPropertyShape) + ); + assert_eq!( + DocumentEncryptionErrorCodeWasm::InvalidEncryptedPropertyShape as u32, + error.code() + ); + assert_eq!( + ConsensusErrorWasm(error).document_encryption_error_code(), + Some(DocumentEncryptionErrorCodeWasm::InvalidEncryptedPropertyShape) + ); + // A neighbouring code is not claimed. + assert_eq!(DocumentEncryptionErrorCodeWasm::from_code(10419), None); + } + /// The six reference-validation errors, paired with the JS enum variant /// each is advertised to be. /// @@ -236,6 +395,32 @@ mod tests { .into(), DocumentReferenceErrorCodeWasm::ReferencedContractRequirementNotMet, ), + ( + StateError::ReferencedIdentityKeyRequirementNotMetError( + ReferencedIdentityKeyRequirementNotMetError::new( + "joinRequest".to_string(), + "recipientId".to_string(), + id(), + 3, + "purpose".to_string(), + "decryption".to_string(), + "encryption".to_string(), + ), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedIdentityKeyRequirementNotMet, + ), + ( + StateError::ReferencedDocumentLookupInvalidError( + ReferencedDocumentLookupInvalidError::new( + "electedCharter.members".to_string(), + "bySubmittedCharter".to_string(), + "is not unique".to_string(), + ), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedDocumentLookupInvalid, + ), ( StateError::ReferencedDocumentTypeNotFoundError( ReferencedDocumentTypeNotFoundError::new( @@ -333,7 +518,7 @@ mod tests { #[test] fn codes_outside_the_reference_range_are_not_claimed() { - for code in [40119, 40126, 0, 40200] { + for code in [40119, 40126, 40138, 0, 40200] { assert_eq!(DocumentReferenceErrorCodeWasm::from_code(code), None); } } diff --git a/packages/wasm-dpp2/src/data_contract/document_type_distinct_from.rs b/packages/wasm-dpp2/src/data_contract/document_type_distinct_from.rs new file mode 100644 index 00000000000..8461adeceee --- /dev/null +++ b/packages/wasm-dpp2/src/data_contract/document_type_distinct_from.rs @@ -0,0 +1,99 @@ +//! `distinctFrom` declarations: the identifier properties a document type +//! requires to differ from another value of the same document, from +//! protocol version 14 onward. +//! +//! `distinctFrom` annotates an identifier property with what its value must +//! differ from: the document's `$ownerId`, or another identifier property of +//! the same document type. Consensus compares the two on every create and +//! replace and refuses an equal pair (basic code 10419); a declaration whose +//! named property is absent from the document passes. What this module adds +//! is the ability to *discover* the declarations, "which properties of this +//! document type must differ, and from what?", without hand-parsing the +//! contract's raw JSON schema. + +use crate::error::{WasmDppError, WasmDppResult}; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use js_sys::{Array, Object, Reflect}; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const DOCUMENT_PROPERTY_DISTINCT_FROM_TS: &'static str = r#" +/** + * A single `distinctFrom` declaration on a document type. + * + * Mirrors the `distinctFrom` keyword of the v3 document meta-schema, which is + * active from protocol version 14. The field name is the schema keyword's + * own, so what `contract.toJSON()` shows under `distinctFrom` and what these + * accessors return line up key for key. + */ +export type DocumentPropertyDistinctFrom = { + /** + * Dotted path of the declaring property within the document type, for + * example `"delegateId"`, or `"meta.reviewerId"` for a nested one. An + * identifier property, or a typed array of identifiers whose `items` + * carry the declaration, in which case every element is bound. This is the same string consensus reports in the `property` field + * of `DocumentPropertyNotDistinctError` (code 10419). + */ + path: string; + /** + * What the property's value must differ from: `"$ownerId"` for the + * document's owner, or the dotted path of another identifier property of + * the same document type. Consensus refuses a create or replace whose two + * values are equal; when the named property is absent from the document + * there is nothing to differ from, so the write passes. + */ + distinctFrom: string; +}; +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Array")] + pub type DocumentPropertyDistinctFromArrayJs; + + #[wasm_bindgen(typescript_type = "Map>")] + pub type DocumentPropertyDistinctFromMapJs; +} + +/// `Reflect::set` with the collection-getter error convention the `tokens` +/// and `groups` getters on `DataContract` already use. +fn set_field(target: &Object, key: &str, value: &JsValue, path: &str) -> WasmDppResult<()> { + Reflect::set(target, &JsValue::from_str(key), value).map_err(|_| { + WasmDppError::generic(format!( + "unable to serialize the `{key}` field of the distinctFrom declaration at '{path}'" + )) + })?; + Ok(()) +} + +/// Collect every `distinctFrom` declaration of one document type, in schema +/// property order. +/// +/// Walks `flattened_properties` rather than `properties` because that is +/// what the consensus check walks, and because its error `property` is +/// built from its dotted key. Using the nested map would miss nested +/// declarations entirely. +pub(crate) fn distinct_from_for_document_type( + document_type: DocumentTypeRef<'_>, +) -> WasmDppResult { + let declarations = Array::new(); + + for (path, property) in document_type.flattened_properties() { + let Some(distinct_from) = &property.distinct_from else { + continue; + }; + let object = Object::new(); + set_field(&object, "path", &JsValue::from_str(path), path)?; + set_field( + &object, + "distinctFrom", + &JsValue::from_str(distinct_from.as_str()), + path, + )?; + declarations.push(&object); + } + + Ok(declarations) +} diff --git a/packages/wasm-dpp2/src/data_contract/document_type_encryption.rs b/packages/wasm-dpp2/src/data_contract/document_type_encryption.rs new file mode 100644 index 00000000000..0d24201d355 --- /dev/null +++ b/packages/wasm-dpp2/src/data_contract/document_type_encryption.rs @@ -0,0 +1,134 @@ +//! `encryptedFor` declarations: how a byte array property's ciphertext was +//! produced, which a contract carries from protocol version 14 onward. +//! +//! `encryptedFor` annotates a byte array property with the recipient, the two +//! key ids and the scheme its bytes were encrypted under, so a wallet reads +//! the recipe from the contract instead of a side channel. Consensus checks +//! the shape of the bytes on every create and replace (code 10420) and +//! nothing more. What this module adds is the ability to *discover* the +//! declarations, "which properties of this document type are encrypted, and +//! with what?", without hand-parsing the contract's raw JSON schema. + +use crate::error::{WasmDppError, WasmDppResult}; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::document_type::EncryptedFor; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use js_sys::{Array, Object, Reflect}; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const DOCUMENT_PROPERTY_ENCRYPTION_TS: &'static str = r#" +/** + * The scheme an `encryptedFor` property's bytes were produced under. + * + * `ecdh-secp256k1-aes256-cbc` is the scheme of the dashpay contact request: + * the shared key is the libsecp256k1 ECDH of the sender's private key and + * the recipient's public key (SHA256 of the product point's parity byte and + * x coordinate), and the value is a random 16-byte IV followed by the + * plaintext under AES-256-CBC with PKCS7 padding and that IV. A ciphertext + * is therefore at least 32 bytes and always a multiple of 16. + */ +export type EncryptionScheme = 'ecdh-secp256k1-aes256-cbc'; + +/** + * A single `encryptedFor` declaration on a document type. + * + * Mirrors the `encryptedFor` keyword of the v3 document meta-schema, which + * is active from protocol version 14. The field names are the schema + * keyword's own, so what `contract.toJSON()` shows under `encryptedFor` and + * what these accessors return line up key for key. + */ +export type DocumentPropertyEncryption = { + /** + * Dotted path of the declaring byte array property within the document + * type, for example `"encryptedMessage"`, or `"meta.blob"` for a nested + * one. This is the string consensus reports in the `property` field of + * the shape error (code 10420). + */ + path: string; + /** + * Whose keys decrypt the bytes: the dotted path of an identifier property + * of the same document type whose value is the recipient identity's id, + * or `"$ownerId"` for a message the writer encrypts to themself. + */ + recipient: string; + /** + * Dotted path of the integer property of the same document type carrying + * the id of the recipient's key. + */ + recipientKey: string; + /** + * Dotted path of the integer property of the same document type carrying + * the id of the sender's key. + */ + senderKey: string; + /** How the bytes were produced. */ + scheme: EncryptionScheme; +}; +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Array")] + pub type DocumentPropertyEncryptionArrayJs; + + #[wasm_bindgen(typescript_type = "Map>")] + pub type DocumentPropertyEncryptionMapJs; +} + +/// `Reflect::set` with the collection-getter error convention the `tokens` +/// and `groups` getters on `DataContract` already use. +fn set_field(target: &Object, key: &str, value: &JsValue, path: &str) -> WasmDppResult<()> { + Reflect::set(target, &JsValue::from_str(key), value).map_err(|_| { + WasmDppError::generic(format!( + "unable to serialize the `{key}` field of the encryption declared at '{path}'" + )) + })?; + Ok(()) +} + +/// Build the flat JS object for one declaration. +fn encryption_to_js(path: &str, encrypted_for: &EncryptedFor) -> WasmDppResult { + let object = Object::new(); + set_field(&object, "path", &JsValue::from_str(path), path)?; + set_field( + &object, + "recipient", + &JsValue::from_str(encrypted_for.recipient.as_path()), + path, + )?; + set_field( + &object, + "recipientKey", + &JsValue::from_str(&encrypted_for.recipient_key), + path, + )?; + set_field( + &object, + "senderKey", + &JsValue::from_str(&encrypted_for.sender_key), + path, + )?; + set_field( + &object, + "scheme", + &JsValue::from_str(encrypted_for.scheme.as_str()), + path, + )?; + Ok(object.into()) +} + +/// Collect every `encryptedFor` declaration of one document type, in schema +/// property order, keyed by the dotted path consensus reports. +pub(crate) fn encryptions_for_document_type( + document_type: DocumentTypeRef<'_>, +) -> WasmDppResult { + let encryptions = Array::new(); + + for (path, encrypted_for) in document_type.encrypted_properties() { + encryptions.push(&encryption_to_js(path, encrypted_for)?); + } + + Ok(encryptions) +} 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 5315e8c8e4e..685ff077807 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -1,10 +1,12 @@ //! `refersTo` declarations — the document-reference metadata a contract //! carries from protocol version 14 onward. //! -//! `refersTo` annotates an identifier property with what it points at, and -//! consensus enforces that the target exists whenever a document carrying -//! it is written. It is a **write-time constraint only**: nothing anywhere -//! in the stack resolves a reference for a reader. What this module adds is +//! `refersTo` annotates an identifier property with what it points at (or, +//! for an `identityPublicKey` reference with `identityProperty`, a key id +//! property with whose key it names), and consensus enforces that the target +//! exists whenever a document carrying it is written. It is a **write-time +//! constraint only**: nothing anywhere in the stack resolves a reference for +//! a reader. What this module adds is //! the ability to *discover* the declarations — "which properties of this //! document type are references, and to what?" — without hand-parsing the //! contract's raw JSON schema. @@ -13,7 +15,8 @@ use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::IdentifierWasm; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::{ - DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRef, + DocumentPropertyReferenceTarget, DocumentTypeRef, IdentityKeyReferenceRequirements, + KeyIdReference, PropertyReference, }; use dpp::prelude::Identifier; use js_sys::{Array, Object, Reflect}; @@ -30,6 +33,15 @@ const DOCUMENT_PROPERTY_REFERENCE_TS: &'static str = r#" * own, so what `contract.toJSON()` shows under `refersTo` and what these * accessors return line up key for key. */ +/** + * What an `identityPublicKey` reference requires of the key it points at, + * beyond its existence and its not being disabled. + */ +export type IdentityKeyReferenceRequirements = { + purpose?: 'authentication' | 'encryption' | 'decryption' | 'transfer' | 'voting' | 'owner'; + boundTo?: string; +}; + export type DocumentPropertyReferenceTarget = | { type: 'identity' } | { @@ -97,6 +109,12 @@ export type DocumentPropertyReferenceTarget = * `{}`-valued — when the declaration carries none. */ propertyAgreement?: Record; + /** + * How the referenced document is found when the property's value is + * not its id. See {@link DocumentReferenceLookup}. Absent when the + * value is the referenced document's `$id`. + */ + lookup?: DocumentReferenceLookup; } | { type: 'identityPublicKey'; @@ -106,6 +124,48 @@ export type DocumentPropertyReferenceTarget = * identity id. A dotted path when the property is nested. */ keyIdProperty: string; + identityProperty?: never; + /** + * What the referenced key must be beyond existing and not being + * disabled, checked by consensus when the referring document is + * written against the key fetched for the existence check: + * `purpose` requires the key's purpose to be the named one, and + * `boundTo` requires the key's contract bounds to be exactly the + * declaring contract and the named document type of it; a + * whole-contract or contract group bound never meets it (code 40136 + * when either is unmet). Absent when the declaration carries no + * requirement. + */ + keyRequirements?: IdentityKeyReferenceRequirements; + } + | { + /** + * The inverse form, declared on the key id property itself: the + * declaring property (an integer from 0 to 4294967295) carries the + * key id, and `identityProperty` names whose key it is: `'$ownerId'`, + * the document's owner, `'$creatorId'`, its creator (only on a + * document type that records creator ids), or the dotted path of an + * identifier property of the same document type. Consensus fetches + * only the key (codes 40123 when it does not exist, 40124 when it is + * disabled; 40125 when a key id is set while the named property is + * not), and checks `keyRequirements` against it exactly as for the + * identifier form. + */ + type: 'identityPublicKey'; + identityProperty: '$ownerId' | '$creatorId' | (string & {}); + keyIdProperty?: never; + /** + * What the referenced key must be beyond existing and not being + * disabled, checked by consensus when the referring document is + * written against the key fetched for the existence check: + * `purpose` requires the key's purpose to be the named one, and + * `boundTo` requires the key's contract bounds to be exactly the + * declaring contract and the named document type of it; a + * whole-contract or contract group bound never meets it (code 40136 + * when either is unmet). Absent when the declaration carries no + * requirement. + */ + keyRequirements?: IdentityKeyReferenceRequirements; } | { /** @@ -133,18 +193,45 @@ export type DocumentPropertyReferenceTarget = propertyAgreement?: Record; }; +/** + * The `lookup` of a document reference: the referenced document is the one + * the unique index `index` of the referenced document type finds for a key + * assembled from the referring document, and the reference holds if that + * document exists (code 40120 when it does not). + * + * `keys` maps every property of the index, by its name on the referenced + * side (`$ownerId` among the system ones), to where its value comes from: + * a property path of the referring document type, `'$ownerId'` for the + * referring document's owner, or `'.'` for the value of the property that + * carries the reference (exactly once). To resolve a reference yourself, + * query the index with those values: at most one document matches. Only a + * `permanentDocument` reference carries a lookup, and its key cannot move + * off the document it found, so it keeps resolving. + */ +export type DocumentReferenceLookup = { + index: string; + keys: Record; +}; + /** * A single `refersTo` declaration on a document type. */ export type DocumentPropertyReference = { /** * Dotted path of the declaring property within the document type — for - * example `"author"`, or `"meta.parentId"` for a nested one. + * example `"author"`, or `"meta.parentId"` for a nested one. A + * declaration on the `items` of a typed array of identifiers, which every + * element carries, is listed with the list path of its elements, for + * example `"reasons[]"`; its `type` is never `identityPublicKey`, which + * an element cannot declare. * * This is the same string consensus reports in the `path` field of the - * document-write reference errors (codes 40120-40125). Note that contract - * *registration* errors prefix it with the document type name - * (`"."`) while document *write* errors do not. + * document-write reference errors (codes 40120-40125, 40131, 40135 and + * 40136), except that a write error names the failing element by its + * index (`"reasons[2]"` for + * the third). Note that contract *registration* errors prefix it with the + * document type name (`"."`, `".reasons[]"`) + * while document *write* errors do not. */ path: string; } & DocumentPropertyReferenceTarget; @@ -170,12 +257,60 @@ fn set_field(target: &Object, key: &str, value: &JsValue, path: &str) -> WasmDpp Ok(()) } +/// The flat, internally-tagged JS object for an `identityPublicKey` +/// declaration on the key id property: `identityProperty` names whose key +/// the property's value is. +fn key_id_reference_to_js(path: &str, reference: &KeyIdReference) -> WasmDppResult { + let object = Object::new(); + set_field(&object, "path", &JsValue::from_str(path), path)?; + set_field( + &object, + "type", + &JsValue::from_str("identityPublicKey"), + path, + )?; + set_field( + &object, + "identityProperty", + &JsValue::from_str(reference.identity_property.as_str()), + path, + )?; + set_key_requirements_field(&object, &reference.key_requirements, path)?; + Ok(object.into()) +} + +/// The `keyRequirements` field of either `identityPublicKey` form. Absent, +/// not `{}`-valued, when the declaration requires nothing, matching the +/// schema's own omission. +fn set_key_requirements_field( + object: &Object, + key_requirements: &IdentityKeyReferenceRequirements, + path: &str, +) -> WasmDppResult<()> { + if key_requirements.is_empty() { + return Ok(()); + } + let fields = Object::new(); + if let Some(purpose) = key_requirements.purpose { + set_field( + &fields, + "purpose", + &JsValue::from_str(purpose.wire_name()), + path, + )?; + } + if let Some(document_type_name) = &key_requirements.bound_to { + set_field( + &fields, + "boundTo", + &JsValue::from_str(document_type_name), + path, + )?; + } + set_field(object, "keyRequirements", &fields, path) +} + /// Build the flat, internally-tagged JS object for one declaration. -/// -/// `declaring_contract_id` resolves the document variants' -/// absent `contract_id`, which consensus reads as "the declaring contract" -/// — it computes `contract_id.unwrap_or(contract.id())` and treats an -/// explicit self-id identically, so collapsing the two here loses nothing. fn reference_to_js( path: &str, target: &DocumentPropertyReferenceTarget, @@ -183,16 +318,45 @@ fn reference_to_js( ) -> WasmDppResult { let object = Object::new(); set_field(&object, "path", &JsValue::from_str(path), path)?; + set_reference_target_fields(&object, target, declaring_contract_id, path)?; + Ok(object.into()) +} + +/// The `DocumentPropertyReferenceTarget` of a declaration as its own JS +/// object, as a typed array element's `refersTo` reports it. `path` only +/// names the declaration in an error. +pub(crate) fn reference_target_to_js( + target: &DocumentPropertyReferenceTarget, + declaring_contract_id: Identifier, + path: &str, +) -> WasmDppResult { + let object = Object::new(); + set_reference_target_fields(&object, target, declaring_contract_id, path)?; + Ok(object.into()) +} +/// Set the fields of one `DocumentPropertyReferenceTarget` on `object`. +/// +/// `declaring_contract_id` resolves the document variants' +/// absent `contract_id`, which consensus reads as "the declaring contract" +/// — it computes `contract_id.unwrap_or(contract.id())` and treats an +/// explicit self-id identically, so collapsing the two here loses nothing. +fn set_reference_target_fields( + object: &Object, + target: &DocumentPropertyReferenceTarget, + declaring_contract_id: Identifier, + path: &str, +) -> WasmDppResult<()> { let kind = match target { DocumentPropertyReferenceTarget::Identity => "identity", DocumentPropertyReferenceTarget::Contract { .. } => "contract", DocumentPropertyReferenceTarget::Token => "token", - DocumentPropertyReferenceTarget::PermanentDocument { .. } => "permanentDocument", + DocumentPropertyReferenceTarget::PermanentDocument { .. } + | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } => "permanentDocument", DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", DocumentPropertyReferenceTarget::DeletableDocument { .. } => "deletableDocument", }; - set_field(&object, "type", &JsValue::from_str(kind), path)?; + set_field(object, "type", &JsValue::from_str(kind), path)?; match target { DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Token => {} @@ -239,7 +403,7 @@ fn reference_to_js( set_field(&fields, name, &JsValue::from_bool(flag), path)?; } } - set_field(&object, "contractRequirements", &fields, path)?; + set_field(object, "contractRequirements", &fields, path)?; } } DocumentPropertyReferenceTarget::PermanentDocument { @@ -247,6 +411,12 @@ fn reference_to_js( document_type_name, property_agreement, } + | DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id, + document_type_name, + property_agreement, + .. + } | DocumentPropertyReferenceTarget::DeletableDocument { contract_id, document_type_name, @@ -254,13 +424,13 @@ fn reference_to_js( } => { let effective = contract_id.unwrap_or(declaring_contract_id); set_field( - &object, + object, "contractId", &JsValue::from(IdentifierWasm::from(effective)), path, )?; set_field( - &object, + object, "documentType", &JsValue::from_str(document_type_name), path, @@ -275,24 +445,53 @@ fn reference_to_js( for (referring, referenced) in property_agreement { set_field(&agreement, referring, &JsValue::from_str(referenced), path)?; } - set_field(&object, "propertyAgreement", &agreement, path)?; + set_field(object, "propertyAgreement", &agreement, path)?; + } + // Present only on a lookup reference, absent when the value is + // the referenced document's id, as the schema omits it; the + // sources keep their schema spelling. + if let DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. } = target + { + let lookup_object = Object::new(); + set_field( + &lookup_object, + "index", + &JsValue::from_str(&lookup.index), + path, + )?; + let keys = Object::new(); + for (index_property, source) in &lookup.keys { + set_field( + &keys, + index_property, + &JsValue::from_str(source.as_str()), + path, + )?; + } + set_field(&lookup_object, "keys", &keys, path)?; + set_field(object, "lookup", &lookup_object, path)?; } } - DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, + key_requirements, + } => { set_field( - &object, + object, "keyIdProperty", &JsValue::from_str(key_id_property), path, )?; + set_key_requirements_field(object, key_requirements, path)?; } } - Ok(object.into()) + Ok(()) } /// Collect every reference declaration of one document type, in schema -/// property order. +/// property order: an identifier property's own, and the one the elements +/// of a typed array of identifiers carry, listed at `path[]`. /// /// Walks `flattened_properties` rather than `properties` because that is /// what both consensus validators walk, and because their error `path` is @@ -305,8 +504,22 @@ pub(crate) fn references_for_document_type( let references = Array::new(); for (path, property) in document_type.flattened_properties() { - if let DocumentPropertyType::IdentifierWithReference(target) = &property.property_type { - references.push(&reference_to_js(path, target, declaring_contract_id)?); + match property.property_type.reference() { + Some(PropertyReference::KeyId(reference)) => { + references.push(&key_id_reference_to_js(path, reference)?); + } + Some(PropertyReference::Value(target)) => { + references.push(&reference_to_js(path, target, declaring_contract_id)?); + } + Some(PropertyReference::Elements { target, .. }) => { + let element_path = format!("{path}[]"); + references.push(&reference_to_js( + &element_path, + target, + declaring_contract_id, + )?); + } + None => {} } } diff --git a/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs b/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs new file mode 100644 index 00000000000..a73ddda879f --- /dev/null +++ b/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs @@ -0,0 +1,261 @@ +//! Typed array properties: the lists of scalars a document type may declare +//! from protocol version 14 onward. +//! +//! A typed array is `type: "array"` with an `items` schema naming what every +//! element is, where a byte array declares `byteArray: true` instead. It is +//! stored inline in the document as an element count followed by the +//! elements, and it cannot be indexed. What this module adds is the ability +//! to *discover* the declarations, "which properties of this document type +//! are lists, and of what?", without hand-parsing the contract's raw JSON +//! schema. + +use crate::data_contract::document_type_reference::reference_target_to_js; +use crate::error::{WasmDppError, WasmDppResult}; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::array::{ArrayItemConstraints, TypedArrayProperty}; +use dpp::data_contract::document_type::{DocumentPropertyType, DocumentTypeRef}; +use dpp::platform_value::Value; +use dpp::prelude::Identifier; +use js_sys::{Array, Object, Reflect}; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const DOCUMENT_TYPED_ARRAY_PROPERTY_TS: &'static str = r#" +/** + * What every element of a typed array is, parsed from its `items` schema. + * + * `type` names the element kind as DPP parses it: `byteArray` is an + * `items` schema with `byteArray: true`, and `identifier` one that also + * carries the identifier `contentMediaType`. The bound names are the schema + * keywords' own: `minLength` / `maxLength` count a string element's + * characters, `minItems` / `maxItems` a byte array element's bytes, + * `minimum` / `maximum` an integer or number element's range, and `enum` + * the values an element must be one of, in declared order. A bound is + * absent when the schema omits it. + */ +export type DocumentTypedArrayItem = + | { type: 'integer'; minimum?: number; maximum?: number; enum?: number[] } + | { type: 'number'; minimum?: number; maximum?: number; enum?: number[] } + | { type: 'boolean'; enum?: boolean[] } + | { type: 'string'; minLength?: number; maxLength?: number; enum?: string[] } + | { type: 'byteArray'; minItems?: number; maxItems?: number } + | { + type: 'identifier'; + /** + * The `refersTo` declaration every element carries, when the `items` + * schema declares one: consensus checks each element as a single + * reference when a document is created or replaced, and a write error + * names the failing element by its list path (`"reasons[2]"`). Never + * `identityPublicKey`. The same declaration is listed by + * `documentTypeReferences` at the path `"[]"`. + */ + refersTo?: DocumentPropertyReferenceTarget; + }; + +/** + * A single typed array property of a document type. + * + * Mirrors the typed array form of the v3 document meta-schema, which is + * active from protocol version 14. The field names are the schema + * keywords' own, so what `contract.toJSON()` shows and what these accessors + * return line up key for key. + */ +export type DocumentTypedArrayProperty = { + /** + * Dotted path of the property within the document type, for example + * `"reasons"`, or `"team.members"` for one nested in an object. + */ + path: string; + /** What every element is. */ + items: DocumentTypedArrayItem; + /** The fewest elements a document may hold; absent when not declared. */ + minItems?: number; + /** The most elements a document may hold; every typed array declares it. */ + maxItems: number; + /** Whether a document repeating an element is refused. */ + uniqueItems: boolean; +}; +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Array")] + pub type DocumentTypedArrayPropertyArrayJs; + + #[wasm_bindgen(typescript_type = "Map>")] + pub type DocumentTypedArrayPropertyMapJs; +} + +/// `Reflect::set` with the collection-getter error convention the `tokens` +/// and `groups` getters on `DataContract` already use. +fn set_field(target: &Object, key: &str, value: &JsValue, path: &str) -> WasmDppResult<()> { + Reflect::set(target, &JsValue::from_str(key), value).map_err(|_| { + WasmDppError::generic(format!( + "unable to serialize the `{key}` field of the typed array declared at '{path}'" + )) + })?; + Ok(()) +} + +/// Set a bound only when the schema declares it, matching the schema's own +/// omission. +fn set_bound>( + target: &Object, + key: &str, + bound: Option, + path: &str, +) -> WasmDppResult<()> { + match bound { + Some(bound) => set_field(target, key, &JsValue::from_f64(bound.into()), path), + None => Ok(()), + } +} + +/// A scalar element constraint as a JS value: strings, numbers and +/// booleans, which are the only member kinds the parser admits. +fn scalar_to_js(value: &Value) -> Option { + if let Some(text) = value.as_text() { + return Some(JsValue::from_str(text)); + } + if let Some(flag) = value.as_bool() { + return Some(JsValue::from_bool(flag)); + } + value.to_float().ok().map(JsValue::from_f64) +} + +/// Build the flat, internally-tagged JS object for one element type. +/// `declaring_contract_id` resolves an element reference's absent +/// `contractId`, as `documentTypeReferences` does. +fn item_to_js( + item_type: &DocumentPropertyType, + constraints: &ArrayItemConstraints, + declaring_contract_id: Identifier, + path: &str, +) -> WasmDppResult { + let object = Object::new(); + let kind = match item_type { + DocumentPropertyType::U8 + | DocumentPropertyType::I8 + | DocumentPropertyType::U16 + | DocumentPropertyType::I16 + | DocumentPropertyType::U32 + | DocumentPropertyType::I32 + | DocumentPropertyType::U64 + | DocumentPropertyType::I64 + | DocumentPropertyType::U128 + | DocumentPropertyType::I128 => "integer", + // No items schema parses to a date; reported as the number it + // decodes to rather than failing the whole collection + DocumentPropertyType::F64 | DocumentPropertyType::Date => "number", + DocumentPropertyType::Boolean => "boolean", + DocumentPropertyType::String(_) => "string", + DocumentPropertyType::ByteArray(_) => "byteArray", + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { + "identifier" + } + other => { + return Err(WasmDppError::generic(format!( + "the typed array declared at '{path}' has a {} element, which is not a scalar", + other.name() + ))); + } + }; + set_field(&object, "type", &JsValue::from_str(kind), path)?; + + match item_type { + DocumentPropertyType::String(sizes) => { + set_bound(&object, "minLength", sizes.min_length, path)?; + set_bound(&object, "maxLength", sizes.max_length, path)?; + } + DocumentPropertyType::ByteArray(sizes) => { + set_bound(&object, "minItems", sizes.min_size, path)?; + set_bound(&object, "maxItems", sizes.max_size, path)?; + } + DocumentPropertyType::IdentifierWithReference(target) => { + set_field( + &object, + "refersTo", + &reference_target_to_js(target, declaring_contract_id, path)?, + path, + )?; + } + _ => {} + } + + // The element constraints the parser reads, absent when undeclared + if let Some(minimum) = constraints.minimum.as_ref().and_then(scalar_to_js) { + set_field(&object, "minimum", &minimum, path)?; + } + if let Some(maximum) = constraints.maximum.as_ref().and_then(scalar_to_js) { + set_field(&object, "maximum", &maximum, path)?; + } + if let Some(allowed_values) = &constraints.allowed_values { + let members = Array::new(); + for member in allowed_values.iter().filter_map(scalar_to_js) { + members.push(&member); + } + set_field(&object, "enum", &members, path)?; + } + + Ok(object.into()) +} + +/// Build the JS object for one typed array property. +fn typed_array_to_js( + path: &str, + typed_array: &TypedArrayProperty, + declaring_contract_id: Identifier, +) -> WasmDppResult { + let object = Object::new(); + set_field(&object, "path", &JsValue::from_str(path), path)?; + set_field( + &object, + "items", + &item_to_js( + &typed_array.item_type, + &typed_array.item_constraints, + declaring_contract_id, + path, + )?, + path, + )?; + set_bound(&object, "minItems", typed_array.min_items, path)?; + set_field( + &object, + "maxItems", + &JsValue::from_f64(f64::from(typed_array.max_items)), + path, + )?; + set_field( + &object, + "uniqueItems", + &JsValue::from_bool(typed_array.unique_items), + path, + )?; + Ok(object.into()) +} + +/// Collect every typed array property of one document type, in schema +/// property order. +/// +/// Walks `flattened_properties`, which reaches a typed array nested in an +/// object property and names it by its dotted path. +pub(crate) fn typed_arrays_for_document_type( + document_type: DocumentTypeRef<'_>, + declaring_contract_id: Identifier, +) -> WasmDppResult { + let typed_arrays = Array::new(); + + for (path, property) in document_type.flattened_properties() { + if let DocumentPropertyType::TypedArray(typed_array) = &property.property_type { + typed_arrays.push(&typed_array_to_js( + path, + typed_array, + declaring_contract_id, + )?); + } + } + + Ok(typed_arrays) +} diff --git a/packages/wasm-dpp2/src/data_contract/mod.rs b/packages/wasm-dpp2/src/data_contract/mod.rs index 57c8a39ce52..a205ca2023c 100644 --- a/packages/wasm-dpp2/src/data_contract/mod.rs +++ b/packages/wasm-dpp2/src/data_contract/mod.rs @@ -1,18 +1,30 @@ pub mod contract_bounds; pub mod document; +pub mod document_type_distinct_from; +pub mod document_type_encryption; pub mod document_type_immutability; pub mod document_type_reference; +pub mod document_type_typed_arrays; pub mod model; pub mod transitions; pub use contract_bounds::ContractBoundsWasm; pub use document::DocumentWasm; +pub use document_type_distinct_from::{ + DocumentPropertyDistinctFromArrayJs, DocumentPropertyDistinctFromMapJs, +}; +pub use document_type_encryption::{ + DocumentPropertyEncryptionArrayJs, DocumentPropertyEncryptionMapJs, +}; pub use document_type_immutability::{ DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, }; pub use document_type_reference::{ DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, }; +pub use document_type_typed_arrays::{ + DocumentTypedArrayPropertyArrayJs, DocumentTypedArrayPropertyMapJs, +}; pub use model::{ DataContractJSONJs, DataContractObjectJs, DataContractWasm, tokens_configuration_from_js_value, }; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 7d01a65bd15..b84fbeb5938 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -1,3 +1,11 @@ +use crate::data_contract::document_type_distinct_from::{ + DocumentPropertyDistinctFromArrayJs, DocumentPropertyDistinctFromMapJs, + distinct_from_for_document_type, +}; +use crate::data_contract::document_type_encryption::{ + DocumentPropertyEncryptionArrayJs, DocumentPropertyEncryptionMapJs, + encryptions_for_document_type, +}; use crate::data_contract::document_type_immutability::{ DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, immutable_properties_for_document_type, @@ -5,6 +13,10 @@ use crate::data_contract::document_type_immutability::{ use crate::data_contract::document_type_reference::{ DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, references_for_document_type, }; +use crate::data_contract::document_type_typed_arrays::{ + DocumentTypedArrayPropertyArrayJs, DocumentTypedArrayPropertyMapJs, + typed_arrays_for_document_type, +}; use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; use crate::impl_try_from_js_value; @@ -736,6 +748,105 @@ impl DataContractWasm { Ok(JsValue::from(map).into()) } + /// The `distinctFrom` declarations of one document type, in schema + /// property order: each `{ path, distinctFrom }` names an identifier + /// property and what its value must differ from, `"$ownerId"` or the + /// dotted path of another identifier property of the same type. + /// + /// Empty when the document type declares none (the normal case). Throws + /// when the contract has no document type by that name, so "no such + /// type" and "nothing declared" stay distinguishable. + /// + /// The keyword is only parsed from protocol version 14 onward. A + /// contract deserialized against an earlier platform version reports an + /// empty list, which is exactly what consensus enforced at that version, + /// while `toJSON()` still shows the raw keyword either way. + #[wasm_bindgen(js_name = "documentTypeDistinctFrom")] + pub fn document_type_distinct_from( + &self, + #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, + ) -> WasmDppResult { + let document_type = self + .0 + .document_type_optional_for_name(document_type_name.as_str()) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + + let declarations = distinct_from_for_document_type(document_type)?; + Ok(JsValue::from(declarations).into()) + } + + /// Every document type that declares at least one `distinctFrom`, keyed + /// by document type name. + /// + /// Document types with no declarations are omitted, so an empty `Map` + /// means "this contract declares no distinctFrom at all". + #[wasm_bindgen(getter = "documentDistinctFrom")] + pub fn document_distinct_from(&self) -> WasmDppResult { + let map = js_sys::Map::new(); + + for (name, document_type) in self.0.document_types() { + let declarations = distinct_from_for_document_type(document_type.as_ref())?; + if declarations.length() > 0 { + map.set(&JsValue::from_str(name), &declarations.into()); + } + } + + Ok(JsValue::from(map).into()) + } + + /// All `encryptedFor` declarations of one document type, in schema + /// property order: which byte array properties are encrypted, for whom, + /// under which key ids and under which scheme. + /// + /// Returns an empty array when the document type declares none. Throws + /// when the contract has no document type by that name, so "no such + /// type" and "nothing encrypted" stay distinguishable. + /// + /// The keyword is only parsed from protocol version 14 onward. A + /// contract deserialized against an earlier platform version reports + /// none, which is exactly what consensus enforced at that version, while + /// `toJSON()` still shows the raw keyword either way. + #[wasm_bindgen(js_name = "documentTypeEncryptedProperties")] + pub fn document_type_encrypted_properties( + &self, + #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, + ) -> WasmDppResult { + let document_type = self + .0 + .document_type_optional_for_name(document_type_name.as_str()) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + + let encryptions = encryptions_for_document_type(document_type)?; + Ok(JsValue::from(encryptions).into()) + } + + /// Every document type that declares at least one encrypted property, + /// keyed by document type name. + /// + /// Document types with no declarations are omitted, so an empty `Map` + /// means "this contract declares no encrypted property at all". + #[wasm_bindgen(getter = "documentEncryptedProperties")] + pub fn document_encrypted_properties(&self) -> WasmDppResult { + let map = js_sys::Map::new(); + + for (name, document_type) in self.0.document_types() { + let encryptions = encryptions_for_document_type(document_type.as_ref())?; + if encryptions.length() > 0 { + map.set(&JsValue::from_str(name), &encryptions.into()); + } + } + + Ok(JsValue::from(map).into()) + } + /// The `immutable` / `immutableAllowSetting` declarations of one /// document type: `{ immutable: string[], immutableAllowSetting: /// string[] }`, both sorted by property name. @@ -789,6 +900,55 @@ impl DataContractWasm { Ok(JsValue::from(map).into()) } + + /// Every typed array property of one document type (`type: "array"` + /// with an `items` element schema), in schema property order: `{ path, + /// items, minItems?, maxItems?, uniqueItems }`. + /// + /// Returns an empty array when the document type declares none. Throws + /// when the contract has no document type by that name, so "no such + /// type" and "no typed arrays" stay distinguishable. + /// + /// Typed arrays are only parsed from protocol version 14 onward. A + /// contract deserialized against an earlier platform version cannot + /// carry one: the parsers of those versions refuse an array that is not + /// a byte array. + #[wasm_bindgen(js_name = "documentTypeTypedArrays")] + pub fn document_type_typed_arrays( + &self, + #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, + ) -> WasmDppResult { + let document_type = self + .0 + .document_type_optional_for_name(document_type_name.as_str()) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + + let typed_arrays = typed_arrays_for_document_type(document_type, self.0.id())?; + Ok(JsValue::from(typed_arrays).into()) + } + + /// Every document type that declares at least one typed array property, + /// keyed by document type name. + /// + /// Document types with none are omitted, so an empty `Map` means "this + /// contract declares no typed arrays at all". + #[wasm_bindgen(getter = "documentTypedArrays")] + pub fn document_typed_arrays(&self) -> WasmDppResult { + let map = js_sys::Map::new(); + + for (name, document_type) in self.0.document_types() { + let typed_arrays = typed_arrays_for_document_type(document_type.as_ref(), self.0.id())?; + if typed_arrays.length() > 0 { + map.set(&JsValue::from_str(name), &typed_arrays.into()); + } + } + + Ok(JsValue::from(map).into()) + } } impl DataContractWasm { diff --git a/packages/wasm-dpp2/src/enums/keys/purpose.rs b/packages/wasm-dpp2/src/enums/keys/purpose.rs index 7a250a65bcf..bb00350a3ff 100644 --- a/packages/wasm-dpp2/src/enums/keys/purpose.rs +++ b/packages/wasm-dpp2/src/enums/keys/purpose.rs @@ -51,19 +51,15 @@ impl TryFrom<&JsValue> for PurposeWasm { fn try_from(value: &JsValue) -> Result { if let Some(enum_val) = value.as_string() { - return match enum_val.to_lowercase().as_str() { - "authentication" => Ok(PurposeWasm::AUTHENTICATION), - "encryption" => Ok(PurposeWasm::ENCRYPTION), - "decryption" => Ok(PurposeWasm::DECRYPTION), - "transfer" => Ok(PurposeWasm::TRANSFER), - "system" => Ok(PurposeWasm::SYSTEM), - "voting" => Ok(PurposeWasm::VOTING), - "owner" => Ok(PurposeWasm::OWNER), - _ => Err(WasmDppError::invalid_argument(format!( - "unsupported purpose value ({})", - enum_val - ))), - }; + // The names are the purpose's own wire names, as the schema keywords spell them + return Purpose::from_wire_name(&enum_val.to_lowercase()) + .map(PurposeWasm::from) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "unsupported purpose value ({})", + enum_val + )) + }); } if let Some(enum_val) = value.as_f64() { diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/data_contract.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/data_contract.rs index 2a469b1a2ac..d57ac5b13ec 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/data_contract.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/data_contract.rs @@ -1,6 +1,6 @@ //! `VerifiedDataContract` proof-result wrapper. -use super::helpers::js_obj; +use super::helpers::{js_obj, json_safe_credits}; use crate::DataContractWasm; use crate::IdentifierWasm; use crate::PlatformVersionLikeJs; @@ -332,17 +332,6 @@ impl VerifiedContractFeeClaimWasm { } } -/// Credits in JSON: a number while it is exact in JavaScript, a decimal string past -/// `Number.MAX_SAFE_INTEGER`, where a number would silently round. -fn json_safe_credits(credits: u64) -> JsValue { - const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; - if credits <= MAX_SAFE_INTEGER { - JsValue::from_f64(credits as f64) - } else { - JsValue::from_str(&credits.to_string()) - } -} - impl_wasm_type_info!(VerifiedContractFeeClaimWasm, VerifiedContractFeeClaim); /// `VerifiedContractDocumentRemoval` proof-result wrapper: the record a moderator's document diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/document.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/document.rs index 47a51daa570..f39a9f5853e 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/document.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/document.rs @@ -1,8 +1,9 @@ //! `VerifiedDocuments` proof-result wrapper. -use super::helpers::js_obj; +use super::helpers::{js_obj, read_map_property}; use crate::error::WasmDppResult; use crate::impl_wasm_type_info; +use crate::serialization::conversions::normalize_js_value_for_json; use js_sys::Map; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::*; @@ -30,12 +31,12 @@ impl VerifiedDocumentsWasm { /// `JSON.stringify({documents: })` produces `{"documents":{}}`). #[wasm_bindgen(js_name = toJSON)] pub fn to_json(&self) -> WasmDppResult { - crate::serialization::conversions::normalize_js_value_for_json(&self.to_object()) + normalize_js_value_for_json(&self.to_object()) } #[wasm_bindgen(js_name = fromObject)] pub fn from_object(value: JsValue) -> WasmDppResult { - let documents = super::helpers::read_map_property(&value, "documents")?; + let documents = read_map_property(&value, "documents")?; Ok(VerifiedDocumentsWasm { documents }) } diff --git a/packages/wasm-dpp2/src/state_transitions/proof_result/helpers.rs b/packages/wasm-dpp2/src/state_transitions/proof_result/helpers.rs index 73cfda05f88..ffdbd9e08e2 100644 --- a/packages/wasm-dpp2/src/state_transitions/proof_result/helpers.rs +++ b/packages/wasm-dpp2/src/state_transitions/proof_result/helpers.rs @@ -108,3 +108,14 @@ pub(super) fn action_status_to_string( } } } + +/// Credits in JSON: a number while it is exact in JavaScript, a decimal string past +/// `Number.MAX_SAFE_INTEGER`, where a number would silently round. +pub(super) fn json_safe_credits(credits: u64) -> JsValue { + const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; + if credits <= MAX_SAFE_INTEGER { + JsValue::from_f64(credits as f64) + } else { + JsValue::from_str(&credits.to_string()) + } +} diff --git a/packages/wasm-dpp2/src/voting/resource_vote.rs b/packages/wasm-dpp2/src/voting/resource_vote.rs index c7a474fd97d..68f6ad47ae9 100644 --- a/packages/wasm-dpp2/src/voting/resource_vote.rs +++ b/packages/wasm-dpp2/src/voting/resource_vote.rs @@ -1,6 +1,8 @@ +use crate::error::WasmDppResult; use crate::impl_wasm_conversions_inner; use crate::impl_wasm_type_info; use crate::voting::resource_vote_choice::ResourceVoteChoiceWasm; +use crate::voting::vote::contested_poll_for_resource_vote; use crate::voting::vote_poll::VotePollWasm; use dpp::voting::votes::resource_vote::ResourceVote; use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; @@ -57,11 +59,14 @@ impl From for ResourceVote { #[wasm_bindgen(js_class = ResourceVote)] impl ResourceVoteWasm { #[wasm_bindgen(constructor)] - pub fn constructor(poll: &VotePollWasm, choice: &ResourceVoteChoiceWasm) -> Self { - ResourceVoteWasm(ResourceVote::V0(ResourceVoteV0 { - vote_poll: poll.clone().into(), + pub fn constructor( + poll: &VotePollWasm, + choice: &ResourceVoteChoiceWasm, + ) -> WasmDppResult { + Ok(ResourceVoteWasm(ResourceVote::V0(ResourceVoteV0 { + vote_poll: contested_poll_for_resource_vote(poll)?, resource_vote_choice: choice.clone().into(), - })) + }))) } #[wasm_bindgen(getter = poll)] @@ -75,16 +80,18 @@ impl ResourceVoteWasm { } #[wasm_bindgen(setter = poll)] - pub fn set_poll(&mut self, poll: &VotePollWasm) { + pub fn set_poll(&mut self, poll: &VotePollWasm) -> WasmDppResult<()> { let ResourceVote::V0(ResourceVoteV0 { resource_vote_choice, .. }) = self.0.clone(); self.0 = ResourceVote::V0(ResourceVoteV0 { - vote_poll: poll.clone().into(), + vote_poll: contested_poll_for_resource_vote(poll)?, resource_vote_choice, }); + + Ok(()) } #[wasm_bindgen(setter = choice)] diff --git a/packages/wasm-dpp2/src/voting/vote.rs b/packages/wasm-dpp2/src/voting/vote.rs index 5b409af1444..179adfb4915 100644 --- a/packages/wasm-dpp2/src/voting/vote.rs +++ b/packages/wasm-dpp2/src/voting/vote.rs @@ -1,12 +1,18 @@ +use crate::error::{WasmDppError, WasmDppResult}; use crate::impl_try_from_js_value; use crate::impl_wasm_conversions_inner; use crate::impl_wasm_type_info; use crate::voting::resource_vote_choice::ResourceVoteChoiceWasm; use crate::voting::vote_poll::VotePollWasm; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dpp::voting::vote_polls::VotePoll; use dpp::voting::votes::Vote; use dpp::voting::votes::resource_vote::ResourceVote; use dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; +use dpp::voting::votes::yes_no_vote::YesNoVote; +use dpp::voting::votes::yes_no_vote::accessors::v0::YesNoVoteGettersV0; +use dpp::voting::votes::yes_no_vote::v0::YesNoVoteV0; use wasm_bindgen::prelude::wasm_bindgen; #[wasm_bindgen(typescript_custom_section)] @@ -15,25 +21,39 @@ const TS_TYPES: &str = r#" * Vote serialized as a plain object. * * Internally tagged with `$type` ($-prefix because the level also carries - * the inner ResourceVote's `$formatVersion`). The single ResourceVote - * variant flattens its V0 body — no `data` wrapper. + * the inner vote's `$formatVersion`). Each variant flattens its V0 body — no + * `data` wrapper. A yes/no vote carries its poll's fields untagged. */ -export interface VoteObject { - $type: "resourceVote"; - $formatVersion: string; - votePoll: VotePollObject; - resourceVoteChoice: ResourceVoteChoiceObject; -} +export type VoteObject = + | { + $type: "resourceVote"; + $formatVersion: string; + votePoll: VotePollObject; + resourceVoteChoice: ResourceVoteChoiceObject; + } + | { + $type: "yesNoVote"; + $formatVersion: string; + votePoll: YesNoVotePollFieldsObject; + voteChoice: "yes" | "no" | "abstain"; + }; /** * Vote serialized as JSON. */ -export interface VoteJSON { - $type: "resourceVote"; - $formatVersion: string; - votePoll: VotePollJSON; - resourceVoteChoice: ResourceVoteChoiceJSON; -} +export type VoteJSON = + | { + $type: "resourceVote"; + $formatVersion: string; + votePoll: VotePollJSON; + resourceVoteChoice: ResourceVoteChoiceJSON; + } + | { + $type: "yesNoVote"; + $formatVersion: string; + votePoll: YesNoVotePollFieldsJSON; + voteChoice: "yes" | "no" | "abstain"; + }; "#; #[wasm_bindgen] @@ -69,48 +89,101 @@ impl VoteWasm { #[wasm_bindgen(js_name = "votePoll")] vote_poll: &VotePollWasm, #[wasm_bindgen(js_name = "resourceVoteChoice")] resource_vote_choice: &ResourceVoteChoiceWasm, - ) -> Self { - VoteWasm(Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { - vote_poll: vote_poll.clone().into(), - resource_vote_choice: resource_vote_choice.clone().into(), - }))) + ) -> WasmDppResult { + Ok(VoteWasm(Vote::ResourceVote(ResourceVote::V0( + ResourceVoteV0 { + vote_poll: contested_poll_for_resource_vote(vote_poll)?, + resource_vote_choice: resource_vote_choice.clone().into(), + }, + )))) } #[wasm_bindgen(getter = poll)] pub fn poll(&self) -> VotePollWasm { match &self.0 { Vote::ResourceVote(vote) => vote.vote_poll().clone().into(), + Vote::YesNoVote(vote) => VotePoll::YesNoVotePoll(vote.vote_poll().clone()).into(), } } + /// The resource vote choice of a resource vote. A yes/no vote has `yesNoChoice` instead. #[wasm_bindgen(getter = choice)] - pub fn choice(&self) -> ResourceVoteChoiceWasm { + pub fn choice(&self) -> WasmDppResult { + match &self.0 { + Vote::ResourceVote(vote) => Ok(vote.resource_vote_choice().into()), + Vote::YesNoVote(_) => Err(not_a_resource_vote()), + } + } + + /// The answer of a yes/no vote: `yes`, `no` or `abstain`. + #[wasm_bindgen(getter = "yesNoChoice")] + pub fn yes_no_choice(&self) -> WasmDppResult { match &self.0 { - Vote::ResourceVote(vote) => vote.resource_vote_choice().into(), + Vote::ResourceVote(_) => Err(WasmDppError::invalid_argument( + "this vote is a resource vote, not a yes/no vote".to_string(), + )), + Vote::YesNoVote(vote) => Ok(match vote.vote_choice() { + YesNoAbstainVoteChoice::Yes => "yes".to_string(), + YesNoAbstainVoteChoice::No => "no".to_string(), + YesNoAbstainVoteChoice::Abstain => "abstain".to_string(), + }), } } #[wasm_bindgen(setter = poll)] - pub fn set_poll(&mut self, poll: &VotePollWasm) { + pub fn set_poll(&mut self, poll: &VotePollWasm) -> WasmDppResult<()> { self.0 = match self.0.clone() { Vote::ResourceVote(vote) => Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { - vote_poll: poll.clone().into(), + vote_poll: contested_poll_for_resource_vote(poll)?, resource_vote_choice: vote.resource_vote_choice(), })), - } + Vote::YesNoVote(vote) => { + let VotePoll::YesNoVotePoll(vote_poll) = poll.clone().into() else { + return Err(WasmDppError::invalid_argument( + "a yes/no vote answers a yes/no vote poll".to_string(), + )); + }; + Vote::YesNoVote(YesNoVote::V0(YesNoVoteV0 { + vote_poll, + vote_choice: vote.vote_choice(), + })) + } + }; + + Ok(()) } #[wasm_bindgen(setter = choice)] - pub fn set_choice(&mut self, choice: &ResourceVoteChoiceWasm) { + pub fn set_choice(&mut self, choice: &ResourceVoteChoiceWasm) -> WasmDppResult<()> { self.0 = match self.0.clone() { Vote::ResourceVote(vote) => Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { vote_poll: vote.vote_poll().clone(), resource_vote_choice: choice.clone().into(), })), - } + Vote::YesNoVote(_) => return Err(not_a_resource_vote()), + }; + + Ok(()) } } +/// The poll of a resource vote: a contested document resource poll. Consensus refuses a resource +/// vote that names a yes/no poll, so it is refused here as a yes/no vote refuses a contested poll. +pub(crate) fn contested_poll_for_resource_vote(poll: &VotePollWasm) -> WasmDppResult { + match poll.clone().into() { + VotePoll::YesNoVotePoll(_) => Err(WasmDppError::invalid_argument( + "a resource vote answers a contested document resource vote poll, not a yes/no vote poll" + .to_string(), + )), + vote_poll => Ok(vote_poll), + } +} + +/// The resource vote choice exists on a resource vote only. +fn not_a_resource_vote() -> WasmDppError { + WasmDppError::invalid_argument("this vote is a yes/no vote, not a resource vote".to_string()) +} + impl_try_from_js_value!(VoteWasm, "Vote"); impl_wasm_conversions_inner!(VoteWasm, Vote, Vote, VoteObjectJs, VoteJSONJs); impl_wasm_type_info!(VoteWasm, Vote); diff --git a/packages/wasm-dpp2/src/voting/vote_poll.rs b/packages/wasm-dpp2/src/voting/vote_poll.rs index 45a433fa059..65e502cea58 100644 --- a/packages/wasm-dpp2/src/voting/vote_poll.rs +++ b/packages/wasm-dpp2/src/voting/vote_poll.rs @@ -28,31 +28,72 @@ export interface VotePollOptions { indexValues: any[]; } +/** + * A yes/no poll's fields as a plain object. A yes/no vote carries its poll + * like this, without a `$type` tag. The minimum is a fixed voting power or a + * share of the masternode list's total voting power when the poll closes. + */ +export interface YesNoVotePollFieldsObject { + resourcePath: Uint8Array[]; + supermajorityNumerator: number; + supermajorityDenominator: number; + minimumVotingPower: + | { absolute: number } + | { + fractionOfTotal: { + numerator: number; + denominator: number; + rounding: "up" | "down" | "downPlusOne" | "upMinusOne"; + }; + }; +} + +/** + * A yes/no poll's fields as JSON (resource path segments in base64). + */ +export interface YesNoVotePollFieldsJSON { + resourcePath: string[]; + supermajorityNumerator: number; + supermajorityDenominator: number; + minimumVotingPower: + | { absolute: number } + | { + fractionOfTotal: { + numerator: number; + denominator: number; + rounding: "up" | "down" | "downPlusOne" | "upMinusOne"; + }; + }; +} + /** * VotePoll serialized as a plain object. * - * Internally tagged with `type` (plain — no `$`-prefixed neighbors at - * this level). Inner ContestedDocumentResourceVotePoll fields flatten at - * the same level — no `data` wrapper. + * Internally tagged with `$type`. The inner poll's fields flatten at the + * same level — no `data` wrapper. */ -export interface VotePollObject { - $type: "contestedDocumentResourceVotePoll"; - contractId: Uint8Array; - documentTypeName: string; - indexName: string; - indexValues: any[]; -} +export type VotePollObject = + | { + $type: "contestedDocumentResourceVotePoll"; + contractId: Uint8Array; + documentTypeName: string; + indexName: string; + indexValues: any[]; + } + | ({ $type: "yesNoVotePoll" } & YesNoVotePollFieldsObject); /** * VotePoll serialized as JSON. */ -export interface VotePollJSON { - $type: "contestedDocumentResourceVotePoll"; - contractId: string; - documentTypeName: string; - indexName: string; - indexValues: any[]; -} +export type VotePollJSON = + | { + $type: "contestedDocumentResourceVotePoll"; + contractId: string; + documentTypeName: string; + indexName: string; + indexValues: any[]; + } + | ({ $type: "yesNoVotePoll" } & YesNoVotePollFieldsJSON); "#; #[wasm_bindgen] @@ -118,26 +159,42 @@ impl VotePollWasm { self.0.to_string() } + /// The kind of poll: `contestedDocumentResourceVotePoll` or `yesNoVotePoll`. + #[wasm_bindgen(getter = "kind")] + pub fn kind(&self) -> String { + match &self.0 { + VotePoll::ContestedDocumentResourceVotePoll(_) => { + "contestedDocumentResourceVotePoll".to_string() + } + VotePoll::YesNoVotePoll(_) => "yesNoVotePoll".to_string(), + } + } + #[wasm_bindgen(getter = "contractId")] - pub fn contract_id(&self) -> IdentifierWasm { + pub fn contract_id(&self) -> WasmDppResult { match &self.0 { VotePoll::ContestedDocumentResourceVotePoll(poll) => { - IdentifierWasm::from(poll.contract_id) + Ok(IdentifierWasm::from(poll.contract_id)) } + VotePoll::YesNoVotePoll(_) => Err(not_a_contested_poll()), } } #[wasm_bindgen(getter = "documentTypeName")] - pub fn document_type_name(&self) -> String { + pub fn document_type_name(&self) -> WasmDppResult { match &self.0 { - VotePoll::ContestedDocumentResourceVotePoll(poll) => poll.document_type_name.clone(), + VotePoll::ContestedDocumentResourceVotePoll(poll) => { + Ok(poll.document_type_name.clone()) + } + VotePoll::YesNoVotePoll(_) => Err(not_a_contested_poll()), } } #[wasm_bindgen(getter = "indexName")] - pub fn index_name(&self) -> String { + pub fn index_name(&self) -> WasmDppResult { match &self.0 { - VotePoll::ContestedDocumentResourceVotePoll(poll) => poll.index_name.clone(), + VotePoll::ContestedDocumentResourceVotePoll(poll) => Ok(poll.index_name.clone()), + VotePoll::YesNoVotePoll(_) => Err(not_a_contested_poll()), } } @@ -166,6 +223,7 @@ impl VotePollWasm { Ok(js_array) } + VotePoll::YesNoVotePoll(_) => Err(not_a_contested_poll()), } } @@ -182,6 +240,7 @@ impl VotePollWasm { VotePoll::ContestedDocumentResourceVotePoll(poll) } + VotePoll::YesNoVotePoll(_) => return Err(not_a_contested_poll()), }; Ok(()) @@ -191,25 +250,34 @@ impl VotePollWasm { pub fn set_document_type_name( &mut self, #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, - ) { + ) -> WasmDppResult<()> { self.0 = match self.0.clone() { VotePoll::ContestedDocumentResourceVotePoll(mut poll) => { poll.document_type_name = document_type_name; VotePoll::ContestedDocumentResourceVotePoll(poll) } - } + VotePoll::YesNoVotePoll(_) => return Err(not_a_contested_poll()), + }; + + Ok(()) } #[wasm_bindgen(setter = "indexName")] - pub fn set_index_name(&mut self, #[wasm_bindgen(js_name = "indexName")] index_name: String) { + pub fn set_index_name( + &mut self, + #[wasm_bindgen(js_name = "indexName")] index_name: String, + ) -> WasmDppResult<()> { self.0 = match self.0.clone() { VotePoll::ContestedDocumentResourceVotePoll(mut poll) => { poll.index_name = index_name; VotePoll::ContestedDocumentResourceVotePoll(poll) } + VotePoll::YesNoVotePoll(_) => return Err(not_a_contested_poll()), }; + + Ok(()) } #[wasm_bindgen(setter = "indexValues")] @@ -229,12 +297,21 @@ impl VotePollWasm { VotePoll::ContestedDocumentResourceVotePoll(poll) } + VotePoll::YesNoVotePoll(_) => return Err(not_a_contested_poll()), }; Ok(()) } } +/// The contested index fields exist on a contested document resource poll only. +fn not_a_contested_poll() -> WasmDppError { + WasmDppError::invalid_argument( + "this vote poll is a yes/no vote poll, not a contested document resource vote poll" + .to_string(), + ) +} + impl_try_from_js_value!(VotePollWasm, "VotePoll"); impl_try_from_options!(VotePollWasm); impl_wasm_conversions_inner!( diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyEncryption.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyEncryption.spec.ts new file mode 100644 index 00000000000..d5d3e850105 --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyEncryption.spec.ts @@ -0,0 +1,239 @@ +/** + * Verifies the `encryptedFor` metadata surface introduced with protocol + * version 14. + * + * A byte array property may declare how its ciphertext was produced: for + * which recipient, under which key ids and under which scheme. Consensus + * checks only the shape of the bytes on every create and replace (code + * 10420). What the JS layer offers is *discovery* (which properties of a + * document type are encrypted, and with what) plus a branchable error code + * for when a write is rejected. + */ +import { expect } from './helpers/chai.ts'; +import { initWasm, wasm } from '../../dist/dpp.compressed.js'; + +let PlatformVersion: typeof wasm.PlatformVersion; + +before(async () => { + await initWasm(); + ({ PlatformVersion } = wasm); +}); + +const ownerId = '11111111111111111111111111111111'; + +const identifier = { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', +}; + +const keyId = { type: 'integer', minimum: 0, maximum: 4294967295 }; + +/** + * A `secret` whose message is encrypted to a recipient, a `note` whose + * body is encrypted to its own writer, next to a `plain` type declaring + * nothing. + */ +const schemas = { + secret: { + type: 'object', + properties: { + recipientId: { ...identifier, position: 0 }, + recipientKeyId: { ...keyId, position: 1 }, + senderKeyId: { ...keyId, position: 2 }, + encryptedMessage: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 1040, + position: 3, + encryptedFor: { + recipient: 'recipientId', + recipientKey: 'recipientKeyId', + senderKey: 'senderKeyId', + scheme: 'ecdh-secp256k1-aes256-cbc', + }, + }, + }, + required: ['recipientId', 'recipientKeyId', 'senderKeyId', 'encryptedMessage'], + additionalProperties: false, + }, + note: { + type: 'object', + properties: { + keyId: { ...keyId, position: 0 }, + body: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 4096, + position: 1, + encryptedFor: { + recipient: '$ownerId', + recipientKey: 'keyId', + senderKey: 'keyId', + scheme: 'ecdh-secp256k1-aes256-cbc', + }, + }, + }, + additionalProperties: false, + }, + plain: { + type: 'object', + properties: { + message: { type: 'string', position: 0, maxLength: 64 }, + }, + additionalProperties: false, + }, +}; + +function buildContract(platformVersion: number, fullValidation = true) { + return new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas, + definitions: null, + fullValidation, + platformVersion: new PlatformVersion(platformVersion), + }); +} + +type Encryption = { + path: string; + recipient: string; + recipientKey: string; + senderKey: string; + scheme: string; +}; + +describe('DataContract: encrypted properties (v14)', () => { + describe('documentTypeEncryptedProperties()', () => { + it('should report the declaration with the schema keyword names', () => { + const contract = buildContract(14); + + expect(contract.documentTypeEncryptedProperties('secret')).to.deep.equal([ + { + path: 'encryptedMessage', + recipient: 'recipientId', + recipientKey: 'recipientKeyId', + senderKey: 'senderKeyId', + scheme: 'ecdh-secp256k1-aes256-cbc', + }, + ]); + }); + + it('should spell an owner recipient as $ownerId', () => { + const contract = buildContract(14); + + const [body] = contract.documentTypeEncryptedProperties('note') as Encryption[]; + expect(body.path).to.equal('body'); + expect(body.recipient).to.equal('$ownerId'); + }); + + it('should return an empty array for a document type declaring none', () => { + const contract = buildContract(14); + + expect(contract.documentTypeEncryptedProperties('plain')).to.deep.equal([]); + }); + + /** + * An empty array would conflate "no such type" with "nothing encrypted", + * which is a difference a caller acting on the result needs. + */ + it('should throw for an unknown document type', () => { + const contract = buildContract(14); + + expect(() => contract.documentTypeEncryptedProperties('doesNotExist')).to.throw(/not found/); + }); + + /** + * The keyword is only parsed from protocol version 14 onward. A contract + * deserialized against an earlier version reports nothing encrypted even + * though its raw schema still carries it. + */ + it('should report nothing on a pre-v14 contract, while the raw schema keeps the keyword', () => { + const contract = buildContract(13, false); + + expect(contract.documentTypeEncryptedProperties('secret')).to.deep.equal([]); + + const rawSchemas = contract.schemas as Record< + string, + { properties: Record }> } + >; + expect(rawSchemas.secret.properties.encryptedMessage.encryptedFor).to.deep.equal({ + recipient: 'recipientId', + recipientKey: 'recipientKeyId', + senderKey: 'senderKeyId', + scheme: 'ecdh-secp256k1-aes256-cbc', + }); + }); + + /** + * The declaration is consensus-validated at registration: a recipient + * that is not an identifier property, a key path naming nothing, or an + * unknown scheme is a contract error, not something the accessor has to + * guard against. + */ + it('should refuse a contract whose recipient key names no property', () => { + const build = () => new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: { + secret: { + ...schemas.secret, + properties: { + ...schemas.secret.properties, + encryptedMessage: { + ...schemas.secret.properties.encryptedMessage, + encryptedFor: { + ...schemas.secret.properties.encryptedMessage.encryptedFor, + recipientKey: 'nowhere', + }, + }, + }, + }, + }, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + + expect(build).to.throw(/not a property of the document type/); + }); + }); + + describe('documentEncryptedProperties', () => { + it('should key declarations by document type and omit types declaring none', () => { + const contract = buildContract(14); + const map = contract.documentEncryptedProperties as Map; + + expect([...map.keys()].sort()).to.deep.equal(['note', 'secret']); + expect(map.get('secret')).to.deep.equal(contract.documentTypeEncryptedProperties('secret')); + }); + + it('should be empty for a contract encrypting nothing at all', () => { + const contract = new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: { plain: schemas.plain }, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + + const map = contract.documentEncryptedProperties as Map; + expect(map.size).to.equal(0); + }); + }); + + describe('DocumentEncryptionErrorCode', () => { + it('should expose the shape error code both ways', () => { + const { DocumentEncryptionErrorCode } = wasm; + + expect(DocumentEncryptionErrorCode.InvalidEncryptedPropertyShape).to.equal(10420); + expect(DocumentEncryptionErrorCode[10420]).to.equal('InvalidEncryptedPropertyShape'); + }); + }); +}); diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index e63c0ef1018..2ca3c2a75c8 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -24,7 +24,9 @@ const foreignContractId = '4fJLR2GYTPFdomuTVvNy3VRrvWgvkKPzqehEBpNf2nk6'; /** * `refersTo` is only allowed on properties with exactly this shape — a - * 32-byte identifier. The meta-schema rejects it anywhere else. + * 32-byte identifier. The one exception is the `identityPublicKey` form + * declared on the key id property itself (`senderKeyId` below). The + * meta-schema rejects it anywhere else. */ function identifierProperty(position: number, refersTo: object): object { return { @@ -48,6 +50,9 @@ const schemas = { // A `permanentDocument` target must not be deletable, and `note` // references itself below. canBeDeleted: false, + // `recipientKey` below requires a decryption key bound to `note`, which + // only a type taking bound decryption keys can be. + requiresIdentityDecryptionBoundedKey: 2, properties: { author: identifierProperty(0, { type: 'identity' }), sourceContract: identifierProperty(1, { type: 'contract' }), @@ -78,6 +83,38 @@ const schemas = { }, additionalProperties: false, }, + // `keyRequirements`: the referenced key must have this purpose and be + // bound to this contract's `note` type (PV14 #4918). + recipientKey: identifierProperty(8, { + type: 'identityPublicKey', + keyIdProperty: 'recipientKeyId', + keyRequirements: { purpose: 'decryption', boundTo: 'note' }, + }), + recipientKeyId: { type: 'integer', position: 9, minimum: 0 }, + // The inverse key reference: the property carries the key id and the + // declaration names whose key it is (the writer's), so it sits on a + // u32 integer rather than an identifier; `keyRequirements` apply to it + // exactly as to the identifier form. + senderKeyId: { + type: 'integer', + minimum: 0, + maximum: 4294967295, + position: 10, + refersTo: { + type: 'identityPublicKey', + identityProperty: '$ownerId', + keyRequirements: { purpose: 'encryption' }, + }, + }, + // The same form naming an identifier property of this type: the key + // is one of `author`'s. + authorKeyId: { + type: 'integer', + minimum: 0, + maximum: 4294967295, + position: 11, + refersTo: { type: 'identityPublicKey', identityProperty: 'author' }, + }, }, additionalProperties: false, }, @@ -107,7 +144,61 @@ type Reference = { contractId?: { toBase58(): string }; documentType?: string; keyIdProperty?: string; + keyRequirements?: { purpose?: string; boundTo?: string }; + identityProperty?: string; propertyAgreement?: Record; + lookup?: { index: string; keys: Record }; +}; + +/** + * A `joinRequest` type unique on (`submittedCharterId`, `$ownerId`), and a + * `charter` whose `memberId` names the owner of a join request for the + * charter's own `submittedCharterId` rather than holding a request's id. + */ +const plainIdentifier = { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + position: 0, +}; + +const lookupSchemas = { + joinRequest: { + type: 'object', + canBeDeleted: false, + // A permanentDocument lookup needs a key the join request keeps for good + documentsMutable: false, + properties: { + submittedCharterId: plainIdentifier, + }, + indices: [ + { + name: 'bySubmittedCharter', + properties: [{ submittedCharterId: 'asc' }, { $ownerId: 'asc' }], + unique: true, + }, + ], + required: ['submittedCharterId'], + additionalProperties: false, + }, + charter: { + type: 'object', + properties: { + submittedCharterId: plainIdentifier, + memberId: identifierProperty(1, { + type: 'permanentDocument', + documentType: 'joinRequest', + lookup: { + index: 'bySubmittedCharter', + keys: { submittedCharterId: 'submittedCharterId', $ownerId: '.' }, + }, + }), + }, + required: ['submittedCharterId'], + additionalProperties: false, + }, }; describe('DataContract — refersTo declarations (v14)', () => { @@ -124,6 +215,9 @@ describe('DataContract — refersTo declarations (v14)', () => { 'otherDoc', 'signerKey', 'meta.ownerRef', + 'recipientKey', + 'senderKeyId', + 'authorKeyId', ]); }); @@ -139,6 +233,9 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(byPath.get('otherDoc')!.type).to.equal('permanentDocument'); expect(byPath.get('signerKey')!.type).to.equal('identityPublicKey'); expect(byPath.get('meta.ownerRef')!.type).to.equal('identity'); + expect(byPath.get('recipientKey')!.type).to.equal('identityPublicKey'); + expect(byPath.get('senderKeyId')!.type).to.equal('identityPublicKey'); + expect(byPath.get('authorKeyId')!.type).to.equal('identityPublicKey'); }); it('should carry no target fields for the bare kinds', () => { @@ -200,6 +297,41 @@ describe('DataContract — refersTo declarations (v14)', () => { const signerKey = references.find((reference) => reference.path === 'signerKey')!; expect(signerKey.keyIdProperty).to.equal('signerKeyId'); + expect(signerKey.identityProperty).to.equal(undefined); + }); + + it('should carry identityProperty for an identityPublicKey reference on the key id property', () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + const senderKeyId = references.find((reference) => reference.path === 'senderKeyId')!; + + expect(senderKeyId).to.deep.equal({ + path: 'senderKeyId', + type: 'identityPublicKey', + identityProperty: '$ownerId', + keyRequirements: { purpose: 'encryption' }, + }); + + const authorKeyId = references.find((reference) => reference.path === 'authorKeyId')!; + expect(authorKeyId.identityProperty).to.equal('author'); + expect(authorKeyId.keyIdProperty).to.equal(undefined); + expect(authorKeyId).to.not.have.property('keyRequirements'); + }); + + it('should carry keyRequirements when declared and omit them otherwise', () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + const recipientKey = references.find((reference) => reference.path === 'recipientKey')!; + const signerKey = references.find((reference) => reference.path === 'signerKey')!; + + expect(recipientKey).to.deep.equal({ + path: 'recipientKey', + type: 'identityPublicKey', + keyIdProperty: 'recipientKeyId', + keyRequirements: { purpose: 'decryption', boundTo: 'note' }, + }); + // Absent, not `{}`-valued, like the schema's own omission. + expect(signerKey).to.not.have.property('keyRequirements'); }); it('should return an empty array for a document type declaring none', () => { @@ -237,6 +369,89 @@ describe('DataContract — refersTo declarations (v14)', () => { }); }); + describe('lookup', () => { + it('should carry the lookup of a reference resolved through a unique index', () => { + const contract = new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: lookupSchemas, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + const [member] = contract.documentTypeReferences('charter') as Reference[]; + + expect(member.path).to.equal('memberId'); + expect(member.type).to.equal('permanentDocument'); + expect(member.documentType).to.equal('joinRequest'); + expect(member.lookup).to.deep.equal({ + index: 'bySubmittedCharter', + keys: { $ownerId: '.', submittedCharterId: 'submittedCharterId' }, + }); + }); + + it('should carry the lookup the elements of a typed array declare', () => { + const withMembers = structuredClone(lookupSchemas); + (withMembers.charter.properties as Record).members = { + type: 'array', + minItems: 0, + maxItems: 15, + uniqueItems: true, + items: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + distinctFrom: '$ownerId', + refersTo: (withMembers.charter.properties.memberId as { refersTo: object }).refersTo, + }, + position: 2, + }; + const contract = new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: withMembers, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + const members = (contract.documentTypeReferences('charter') as Reference[]).find( + (reference) => reference.path === 'members[]', + )!; + + expect(members.type).to.equal('permanentDocument'); + expect(members.lookup).to.deep.equal({ + index: 'bySubmittedCharter', + keys: { $ownerId: '.', submittedCharterId: 'submittedCharterId' }, + }); + }); + + it('should omit lookup for a reference holding the referenced document id', () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + const other = references.find((reference) => reference.path === 'otherDoc')!; + + expect(other).to.not.have.property('lookup'); + }); + + it('should refuse a lookup into an index that is not unique', () => { + const notUnique = structuredClone(lookupSchemas); + delete (notUnique.joinRequest.indices[0] as { unique?: boolean }).unique; + + const build = () => new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: notUnique, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + + expect(build).to.throw(/is not unique/); + }); + }); + describe('documentReferences', () => { it('should key declarations by document type and omit types with none', () => { const contract = buildContract(14); @@ -275,6 +490,10 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(wasm.DocumentReferenceErrorCode.ReferencedIdentityKeyNotFound).to.equal(40123); expect(wasm.DocumentReferenceErrorCode.ReferencedIdentityKeyDisabled).to.equal(40124); expect(wasm.DocumentReferenceErrorCode.ReferencedKeyIdPropertyInvalid).to.equal(40125); + expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentTypeNotDeletable).to.equal(40131); + expect(wasm.DocumentReferenceErrorCode.ReferencedContractRequirementNotMet).to.equal(40135); + expect(wasm.DocumentReferenceErrorCode.ReferencedIdentityKeyRequirementNotMet).to.equal(40136); + expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentLookupInvalid).to.equal(40137); }); it('should resolve a code back to its name', () => { @@ -282,6 +501,7 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(codes[40123]).to.equal('ReferencedIdentityKeyNotFound'); expect(codes[40125]).to.equal('ReferencedKeyIdPropertyInvalid'); + expect(codes[40136]).to.equal('ReferencedIdentityKeyRequirementNotMet'); }); }); }); diff --git a/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts new file mode 100644 index 00000000000..3e0b24929fe --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts @@ -0,0 +1,277 @@ +/** + * Verifies the typed array metadata surface introduced with protocol + * version 14. + * + * A typed array is `type: 'array'` with an `items` schema naming what every + * element is, where a byte array declares `byteArray: true` instead. It is + * stored inline in the document and validated against the JSON schema like + * any other property. What the JS layer offers is *discovery*: which + * properties of a document type are lists, and of what. + */ +import { expect } from './helpers/chai.ts'; +import { initWasm, wasm } from '../../dist/dpp.compressed.js'; + +let PlatformVersion: typeof wasm.PlatformVersion; + +before(async () => { + await initWasm(); + ({ PlatformVersion } = wasm); +}); + +const ownerId = '11111111111111111111111111111111'; + +/** + * A `charter` with typed arrays of identifiers, strings and byte arrays + * (one nested in an object), next to a `plain` type declaring none. + */ +const schemas = { + charter: { + type: 'object', + properties: { + reasons: { + type: 'array', + minItems: 0, + maxItems: 64, + uniqueItems: true, + items: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + }, + position: 0, + }, + labels: { + type: 'array', + maxItems: 5, + items: { + type: 'string', minLength: 1, maxLength: 20, enum: ['spam', 'abuse', 'offTopic'], + }, + position: 1, + }, + scores: { + type: 'array', + maxItems: 4, + items: { type: 'integer', minimum: 0, maximum: 100 }, + position: 3, + }, + team: { + type: 'object', + position: 2, + properties: { + digests: { + type: 'array', + maxItems: 3, + items: { + type: 'array', byteArray: true, minItems: 4, maxItems: 8, + }, + position: 0, + }, + }, + additionalProperties: false, + }, + }, + required: ['reasons'], + additionalProperties: false, + }, + plain: { + type: 'object', + properties: { + message: { type: 'string', position: 0, maxLength: 64 }, + }, + additionalProperties: false, + }, +}; + +function buildContract(contractSchemas: Record, platformVersion = 14) { + return new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: contractSchemas, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(platformVersion), + }); +} + +describe('DataContract: typed arrays (v14)', () => { + describe('documentTypeTypedArrays()', () => { + it('should report every typed array with its element type and bounds', () => { + const contract = buildContract(schemas); + + expect(contract.documentTypeTypedArrays('charter')).to.deep.equal([ + { + path: 'reasons', + items: { type: 'identifier' }, + minItems: 0, + maxItems: 64, + uniqueItems: true, + }, + { + path: 'labels', + items: { + type: 'string', minLength: 1, maxLength: 20, enum: ['spam', 'abuse', 'offTopic'], + }, + maxItems: 5, + uniqueItems: false, + }, + { + path: 'team.digests', + items: { type: 'byteArray', minItems: 4, maxItems: 8 }, + maxItems: 3, + uniqueItems: false, + }, + { + path: 'scores', + items: { type: 'integer', minimum: 0, maximum: 100 }, + maxItems: 4, + uniqueItems: false, + }, + ]); + }); + + it('should return an empty array for a document type declaring none', () => { + const contract = buildContract(schemas); + + expect(contract.documentTypeTypedArrays('plain')).to.deep.equal([]); + }); + + it('should throw for an unknown document type', () => { + const contract = buildContract(schemas); + + expect(() => contract.documentTypeTypedArrays('doesNotExist')).to.throw(/not found/); + }); + + /** + * The meta-schemas before protocol version 14 only have byte arrays. + */ + it('should refuse a typed array on a pre-v14 contract', () => { + expect(() => buildContract(schemas, 13)).to.throw(); + }); + }); + + /** + * An identifier element may carry `refersTo`, which every element then + * declares and consensus checks element by element. + */ + describe('element references', () => { + const referencingSchemas = { + reason: { + type: 'object', + canBeDeleted: false, + properties: { + topic: { type: 'string', position: 0, maxLength: 63 }, + }, + additionalProperties: false, + }, + submittedCharter: { + type: 'object', + properties: { + reasons: { + type: 'array', + minItems: 0, + maxItems: 64, + uniqueItems: true, + items: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + refersTo: { + type: 'permanentDocument', + documentType: 'reason', + propertyAgreement: { topic: 'topic' }, + }, + }, + position: 0, + }, + topic: { type: 'string', position: 1, maxLength: 63 }, + }, + additionalProperties: false, + }, + }; + + type ElementReference = { + type: string; + contractId: { toBase58(): string }; + documentType: string; + propertyAgreement?: Record; + }; + + it('should report the element reference on the typed array items', () => { + const contract = buildContract(referencingSchemas); + const [reasons] = contract.documentTypeTypedArrays('submittedCharter') as { + path: string; + items: { type: string; refersTo: ElementReference }; + }[]; + + expect(reasons.path).to.equal('reasons'); + expect(reasons.items.type).to.equal('identifier'); + expect(reasons.items.refersTo.type).to.equal('permanentDocument'); + expect(reasons.items.refersTo.contractId.toBase58()).to.equal(contract.id.toBase58()); + expect(reasons.items.refersTo.documentType).to.equal('reason'); + expect(reasons.items.refersTo.propertyAgreement).to.deep.equal({ topic: 'topic' }); + }); + + it('should list the element reference among the references at its list path', () => { + const contract = buildContract(referencingSchemas); + const references = contract.documentTypeReferences('submittedCharter') as { + path: string; + type: string; + }[]; + + expect(references.map((reference) => [reference.path, reference.type])).to.deep.equal([ + ['reasons[]', 'permanentDocument'], + ]); + }); + + it('should refuse an identityPublicKey reference on the elements', () => { + const schemasWithKeyReference = { + submittedCharter: { + type: 'object', + properties: { + reasons: { + type: 'array', + maxItems: 4, + items: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + refersTo: { type: 'identityPublicKey', keyIdProperty: 'keyId' }, + }, + position: 0, + }, + keyId: { + type: 'integer', minimum: 0, maximum: 4294967295, position: 1, + }, + }, + additionalProperties: false, + }, + }; + + expect(() => buildContract(schemasWithKeyReference)).to.throw( + /identityPublicKey refersTo is not allowed on the elements of a typed array/, + ); + }); + }); + + describe('documentTypedArrays', () => { + it('should key typed arrays by document type and omit types declaring none', () => { + const contract = buildContract(schemas); + const map = contract.documentTypedArrays as Map; + + expect([...map.keys()]).to.deep.equal(['charter']); + expect(map.get('charter')).to.deep.equal(contract.documentTypeTypedArrays('charter')); + }); + + it('should be empty for a contract declaring no typed arrays at all', () => { + const contract = buildContract({ plain: schemas.plain }); + + expect((contract.documentTypedArrays as Map).size).to.equal(0); + }); + }); +}); diff --git a/packages/wasm-drive-verify/src/state_transition/verify_state_transition_was_executed_with_proof.rs b/packages/wasm-drive-verify/src/state_transition/verify_state_transition_was_executed_with_proof.rs index ce880edd231..12a16bc9a56 100644 --- a/packages/wasm-drive-verify/src/state_transition/verify_state_transition_was_executed_with_proof.rs +++ b/packages/wasm-drive-verify/src/state_transition/verify_state_transition_was_executed_with_proof.rs @@ -23,6 +23,7 @@ pub struct VerifyStateTransitionWasExecutedWithProofResult { root_hash: Vec, execution_proved: bool, proof_result: JsValue, + owner_balance: Option, } #[wasm_bindgen] @@ -32,6 +33,18 @@ impl VerifyStateTransitionWasExecutedWithProofResult { self.root_hash.to_uint8array() } + /// The credit balance of the transition's owner after it executed, when + /// the proof carried it (from protocol version 14, for owned fee-paying + /// transitions), as a decimal string; `undefined` otherwise. A snapshot at + /// the proof's block. + #[wasm_bindgen(getter, js_name = ownerBalance)] + pub fn owner_balance(&self) -> JsValue { + match self.owner_balance { + Some(owner_balance) => JsValue::from_str(&owner_balance.to_string()), + None => JsValue::UNDEFINED, + } + } + /// Whether the proof established that this specific transition executed. /// When `false`, `proof_result` is a verified snapshot of the state the /// transition affects at the proof's block (transition families whose @@ -92,6 +105,7 @@ pub fn verify_state_transition_was_executed_with_proof( .map_err(|e| JsValue::from_str(&format!("Verification failed: {:?}", e)))?; let execution_proved = outcome.is_execution_proved(); + let owner_balance = outcome.owner_balance(); // Convert proof result to JS value let proof_result_js = convert_proof_result_to_js(outcome.result())?; @@ -100,6 +114,7 @@ pub fn verify_state_transition_was_executed_with_proof( root_hash: root_hash.to_vec(), execution_proved, proof_result: proof_result_js, + owner_balance, }) } diff --git a/packages/wasm-sdk/src/queries/chained_document.rs b/packages/wasm-sdk/src/queries/chained_document.rs index ce33811891d..63a2976c4e5 100644 --- a/packages/wasm-sdk/src/queries/chained_document.rs +++ b/packages/wasm-sdk/src/queries/chained_document.rs @@ -42,7 +42,9 @@ const CHAINED_DOCUMENTS_QUERY_TS: &'static str = r#" * an index carrying `joinProperty`, and `joinProperty` must declare a * same-contract `refersTo: permanentDocument` targeting * `outerDocumentType` ("posts I liked": inner `like` through `byLiker`, - * join `postId`, outer `post`). There are no outer-side clauses by + * join `postId`, outer `post`) whose `refersTo` carries no `lookup`: a + * reference resolved through a unique index holds no document ids, so it is + * refused as a join property. There are no outer-side clauses by * design — the verifier derives the outer query from the proven inner * results. */ diff --git a/packages/wasm-sdk/src/queries/composite_document.rs b/packages/wasm-sdk/src/queries/composite_document.rs index 7a25ff9731f..c063bb65231 100644 --- a/packages/wasm-sdk/src/queries/composite_document.rs +++ b/packages/wasm-sdk/src/queries/composite_document.rs @@ -65,8 +65,10 @@ export interface CompositeBind { * permanentDocument`, where a missing document is a verification * error, or `refersTo: deletableDocument`, where a document deleted * since is proven absent and left out, targeting the sub-query's - * document type); otherwise `$ownerId` or an indexed property (a - * LOOKUP, where absence is a proven fact). + * document type, and whose `refersTo` carries no `lookup`: a reference + * resolved through a unique index holds no document ids); otherwise + * `$ownerId` or an indexed property (a LOOKUP, where absence is a proven + * fact). */ field: string; } diff --git a/packages/wasm-sdk/src/queries/system.rs b/packages/wasm-sdk/src/queries/system.rs index 0f068698282..71716411e9e 100644 --- a/packages/wasm-sdk/src/queries/system.rs +++ b/packages/wasm-sdk/src/queries/system.rs @@ -1168,14 +1168,25 @@ pub struct StateTransitionResultWasm { pub status: String, #[wasm_bindgen(getter_with_clone)] pub error: Option, + /// The credit balance of the transition's owner after it executed, as + /// DAPI read it without a proof. Present when the SDK did not ask for a + /// proof (it then asks for the balance); a proved wait of an owned, + /// fee-paying transition carries the balance inside the proof instead. + pub owner_balance: Option, } impl StateTransitionResultWasm { - fn new(state_transition_hash: String, status: String, error: Option) -> Self { + fn new( + state_transition_hash: String, + status: String, + error: Option, + owner_balance: Option, + ) -> Self { Self { state_transition_hash, status, error, + owner_balance, } } } @@ -1556,6 +1567,8 @@ impl WasmSdk { version: Some(Version::V0(WaitForStateTransitionResultRequestV0 { state_transition_hash: hash_bytes, prove: self.prove(), + // Without a proof, ask for the owner's balance instead. + request_user_balance: !self.prove(), })), }; @@ -1574,24 +1587,32 @@ impl WasmSdk { Version as ResponseVersion, }; - let (status, error) = match response.inner.version { + let (status, error, owner_balance) = match response.inner.version { Some(ResponseVersion::V0(v0)) => match v0.result { Some(V0Result::Error(e)) => { let error_message = format!("Code: {}, Message: {}", e.code, e.message); - ("ERROR".to_string(), Some(error_message)) + ("ERROR".to_string(), Some(error_message), None) } Some(V0Result::Proof(_)) => { // State transition was successful - ("SUCCESS".to_string(), None) + ("SUCCESS".to_string(), None, None) + } + // A wait that asked for the owner's balance without a proof + Some(V0Result::SuccessWithOwnerBalance(success)) => { + ("SUCCESS".to_string(), None, Some(success.owner_balance)) } + // A wait without a proof answers success with no result + None if !self.prove() => ("SUCCESS".to_string(), None, None), None => ( "UNKNOWN".to_string(), Some("No result returned".to_string()), + None, ), }, None => ( "UNKNOWN".to_string(), Some("No version in response".to_string()), + None, ), }; @@ -1599,6 +1620,7 @@ impl WasmSdk { state_transition_hash.to_string(), status, error, + owner_balance, )) } diff --git a/packages/wasm-sdk/src/state_transitions/broadcast.rs b/packages/wasm-sdk/src/state_transitions/broadcast.rs index 7f42ee7fab8..c77b1d13cb2 100644 --- a/packages/wasm-sdk/src/state_transitions/broadcast.rs +++ b/packages/wasm-sdk/src/state_transitions/broadcast.rs @@ -11,9 +11,10 @@ use dash_sdk::dpp::state_transition::batch_transition::accessors::DocumentsBatch use dash_sdk::dpp::state_transition::contract_fee_claim_transition::accessors::ContractFeeClaimTransitionAccessorsV0; use dash_sdk::dpp::state_transition::contract_user_moderation_transition::accessors::ContractUserModerationTransitionAccessorsV0; use dash_sdk::dpp::state_transition::contract_user_moderation_transition::ContractUserModerationAction; -use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; use dash_sdk::dpp::state_transition::StateTransition; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::broadcast::{ + require_execution_proved, BroadcastStateTransition, WaitForOutcome, +}; use dash_sdk::platform::ContextProvider; use std::collections::BTreeSet; use wasm_bindgen::prelude::*; @@ -174,12 +175,14 @@ impl WasmSdk { // Preserve the error kind: callers distinguish `ExecutionNotProved`, // which reports that this transition family has no execution proof // rather than that anything went wrong, from a genuine failure. - let result = st - .wait_for_response::(self.as_ref(), put_settings) + let (outcome, _metadata) = st + .wait_for_outcome_with_metadata(self.as_ref(), put_settings) .await .map_err(WasmSdkError::from)?; + let owner_balance = outcome.owner_balance(); + let result = require_execution_proved(outcome).map_err(WasmSdkError::from)?; - convert_proof_result(result).map_err(WasmSdkError::from) + with_owner_balance(convert_proof_result(result)?, owner_balance) } /// Broadcasts a state transition and waits for the result. @@ -202,12 +205,18 @@ impl WasmSdk { let put_settings = parse_put_settings(settings)?; self.prepare_state_transition_context(&st).await?; - let result = st - .broadcast_and_wait::(self.as_ref(), put_settings) + st.broadcast(self.as_ref(), put_settings) + .await + .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; + let (outcome, _metadata) = st + .wait_for_outcome_with_metadata(self.as_ref(), put_settings) .await .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; + let owner_balance = outcome.owner_balance(); + let result = require_execution_proved(outcome) + .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; - convert_proof_result(result).map_err(WasmSdkError::from) + with_owner_balance(convert_proof_result(result)?, owner_balance) } /// Waits for a state transition response, accepting proofs that only @@ -234,14 +243,16 @@ impl WasmSdk { let put_settings = parse_put_settings(settings)?; self.prepare_state_transition_context(&st).await?; - let result = st - .wait_for_affected_state::(self.as_ref(), put_settings) + let (outcome, _metadata) = st + .wait_for_outcome_with_metadata(self.as_ref(), put_settings) .await .map_err(|e| { WasmSdkError::generic(format!("Failed to wait for state transition result: {}", e)) })?; + let owner_balance = outcome.owner_balance(); + let result = outcome.into_result(); - convert_proof_result(result).map_err(WasmSdkError::from) + with_owner_balance(convert_proof_result(result)?, owner_balance) } /// Broadcasts a state transition and waits for the result, accepting @@ -261,16 +272,36 @@ impl WasmSdk { let put_settings = parse_put_settings(settings)?; self.prepare_state_transition_context(&st).await?; - let result = st - .broadcast_and_wait_for_affected_state::( - self.as_ref(), - put_settings, - ) + st.broadcast(self.as_ref(), put_settings) .await .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; + let (outcome, _metadata) = st + .wait_for_outcome_with_metadata(self.as_ref(), put_settings) + .await + .map_err(|e| WasmSdkError::generic(format!("Failed to broadcast: {}", e)))?; + let owner_balance = outcome.owner_balance(); + let result = outcome.into_result(); + + with_owner_balance(convert_proof_result(result)?, owner_balance) + } +} - convert_proof_result(result).map_err(WasmSdkError::from) +/// Hands the owner's credit balance the verified outcome carried (from +/// protocol version 14, for owned fee-paying transitions) to JavaScript as an +/// `ownerBalance` `BigInt` property on the verified result; absent otherwise. +fn with_owner_balance( + result: StateTransitionProofResultTypeJs, + owner_balance: Option, +) -> Result { + if let Some(owner_balance) = owner_balance { + js_sys::Reflect::set( + result.as_ref(), + &JsValue::from_str("ownerBalance"), + &js_sys::BigInt::from(owner_balance).into(), + ) + .map_err(|_| WasmSdkError::generic("Failed to set ownerBalance".to_string()))?; } + Ok(result) } #[cfg(test)] diff --git a/packages/wasm-sdk/src/state_transitions/identity.rs b/packages/wasm-sdk/src/state_transitions/identity.rs index d222c497e63..7901aa6cc22 100644 --- a/packages/wasm-sdk/src/state_transitions/identity.rs +++ b/packages/wasm-sdk/src/state_transitions/identity.rs @@ -795,6 +795,13 @@ impl WasmSdk { // Extract complex types first (borrows &options) let vote_poll: dash_sdk::dpp::voting::vote_polls::VotePoll = VotePollWasm::try_from_options(&options, "votePoll")?.into(); + // This casts a resource vote, which answers a contested document resource poll only; + // consensus refuses one naming a yes/no poll. + if let dash_sdk::dpp::voting::vote_polls::VotePoll::YesNoVotePoll(_) = vote_poll { + return Err(WasmSdkError::invalid_argument( + "masternodeVote casts a resource vote, which cannot answer a yes/no vote poll", + )); + } let resource_vote_choice: dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice = ResourceVoteChoiceWasm::try_from_options(&options, "voteChoice")?.into(); let voting_public_key: IdentityPublicKey =