diff --git a/CHANGELOG.md b/CHANGELOG.md index 348c4c85324..10576806219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ ### Changed +- **dpp (Rust API):** `GeneratedFrom` gains a public `range: Option` field, and + `SystemFunction` a `Random(RandomFunction)` variant, for `sys.random.uniform` (protocol + version 15). This is a source change for Rust code whichever protocol version it runs: a + `GeneratedFrom` literal sets `range: None` for every function but a random one, and an + exhaustive match on `SystemFunction` needs a `Random` arm. + - **drive, dpp (Rust API):** `Drive::calculate_fee` is now a method and no longer takes `epochs_per_era` or `previous_fee_versions`: `drive.calculate_fee(base_operations, drive_operations, &epoch, platform_version)`. diff --git a/book/src/contract-keywords/generated-from.md b/book/src/contract-keywords/generated-from.md index 9f2dc2f5a4f..3e5c22d9986 100644 --- a/book/src/contract-keywords/generated-from.md +++ b/book/src/contract-keywords/generated-from.md @@ -2,14 +2,14 @@ `generatedFrom` says that the platform generates a string property's value with a system function of other properties of the same document, its params. The functions change the case of a string (`lowercase`, `uppercase`, `capitalize`, `camelCase`, `snakeCase`), or fold a name so that names differing only by case or by look-alike characters read the same (`homographSafeASCII`): reach for that one when a unique index must treat `Bob`, `BOB` and `B0B` as one name. A client may leave the property out, and the platform generates it when the document arrives; a client that sends it has it checked. The check reads only the document being written, so it costs no state reads. -From protocol version 15 an integer property can be generated too, with `sys.tokens.decimals`: the decimals of the token an identifier property names, read from the token's contract. A value read from state is generated when the document is created and kept after (see [Functions that read state](#functions-that-read-state)). +From protocol version 15 an integer property can be generated too, with `sys.tokens.decimals`: the decimals of the token an identifier property names, read from the token's contract. A value read from state is generated when the document is created and kept after (see [Functions that read state](#functions-that-read-state)). Protocol version 15 also parses `sys.random.uniform`, an integer the platform draws from the block's random beacon, but no protocol version activates it yet: registration refuses it until block execution draws the values (see [Functions drawn at random](#functions-drawn-at-random)). | | | |---|---| -| **Where** | A string property, at the top level or inside an object; from protocol version 15 also an integer property, for `sys.tokens.decimals`. Not on a typed array or its `items`, and not beside `$ref` | -| **Value** | `{ "function": , "params": [, ...] }`: a system function, and as many params as it takes, each the dotted path of a property of the same document type (`"profile.display"` for a nested one), 1 to 256 characters (ASCII, as property names are, so as many bytes) | +| **Where** | A string property, at the top level or inside an object; from protocol version 15 also an integer property, for `sys.tokens.decimals` and `sys.random.uniform`. Not on a typed array or its `items`, and not beside `$ref` | +| **Value** | `{ "function": , "params": [, ...] }`: a system function, and as many params as it takes, each the dotted path of a property of the same document type (`"profile.display"` for a nested one), 1 to 256 characters (ASCII, as property names are, so as many bytes). `sys.random.uniform` takes none: `params` may be left out, or be `[]` | | **Default** | Absent: no rule | -| **Since** | protocol version 14; `sys.tokens.decimals` protocol version 15 | +| **Since** | protocol version 14; `sys.tokens.decimals` protocol version 15; `sys.random.uniform` parsed from protocol version 15, refused at registration until a later protocol version activates it | | **On update** | Fixed: adding, removing or changing it is refused (`IncompatibleDocumentTypeSchemaError`, 10246); a property an update adds may declare it only when one of its params is new too (`DocumentTypeUpdateError`, 40212) | | **Errors** | `DocumentPropertyNotGeneratedError` (10424) on a document, and from protocol version 15 `ReferencedEntityNotFoundError` (40120) for a token that does not exist; at registration `JsonSchemaError` (10101) or `InvalidContractStructure` (10231); on update `IncompatibleDocumentTypeSchemaError` (10246) or `DocumentTypeUpdateError` (40212) | @@ -105,6 +105,51 @@ A token's decimals live in its contract and can change (a `Conventions` update b Here `price` is per whole token at the order's `decimals`, so a later change of the token's decimals does not reprice the order. +### Functions drawn at random + +The `sys.random` functions take no params. The platform draws their value from the block's random beacon, the threshold signature the validators' prevotes for the block combine into, which no one can compute before the block is accepted: + +| Function | Returns | Property | +|---|---|---| +| `sys.random.uniform` | An integer drawn uniformly from the property's `minimum` to its `maximum`, both included | An integer property of at most 64 bits declaring both a `minimum` and a `maximum` inside its type, the `minimum` not above the `maximum`, with no `enum`, `const`, `exclusiveMinimum`, `exclusiveMaximum` or `multipleOf`, so every value drawn passes its schema | + +Protocol version 15 parses the function, but it is not active at any protocol version yet: registration refuses it with `InvalidContractStructure` (10231), "not active at this protocol version", until block execution draws the values from the beacon Tenderdash delivers. What follows is how it works once a protocol version activates it. + +```json +"card": { + "type": "object", + "documentsMutable": false, + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 }, + "rarity": { + "type": "integer", "minimum": 1, "maximum": 100, + "generatedFrom": { "function": "sys.random.uniform" }, + "position": 1 + } + }, + "indices": [{ "name": "byRarity", "properties": [{ "rarity": "asc" }] }], + "required": ["name", "rarity"], + "additionalProperties": false +} +``` + +- **Create.** The document is written with a placeholder, the `minimum` (here `1`), and a sent value is refused with `DocumentPropertyNotGeneratedError` (10424): a client cannot pick it. Once every transition of the block is accepted, the platform draws each such value and rewrites the document in place, its index entries included, before the block commits. A document a later transition of the same block deleted is skipped. +- **Replace.** The document keeps the value drawn for it: a left-out value is the stored one, and a sent one must equal it. A replace never draws again. +- **Cost.** The draw itself is not billed. It moves the document's entry in each index holding the property to the drawn value, and may create the trees that value is keyed under, so a create of a type indexing such a property pays for those entries once more, at the average case: what Drive's fee estimation gives for inserting the document's entries into those indexes without reading state. +- **Clients.** As for a value read from state: the SDK's transition builders remove the property, the document checks do not refuse it left out (a client's schema check reads the `minimum` in its place), and a proof verifier takes the drawn value from the proved document, which the block committed after the draw. + +A value nothing can refuse once it is drawn may not be read by anything that judges a write, which would judge the placeholder, nor feed anything that has to stay within bounds. So registration also refuses the function: + +- on an [indexOnly](index-only.md) type, which keeps no document, on a type that keeps history (`documentsKeepHistory`), which would keep the placeholder, and on a type with a contested index anywhere, since a contested document waits in its contest and the one a contest awards would keep the placeholder; +- on a property in a unique index, where two drawn values could collide, and on a property summed or averaged, by `documentsSummable` or an index's `summable` or `averageable`, whose total could not be held to its bounds; an ordinary index may hold it, but not one that sums or averages another property, since the draw moves the document's contribution into the group of the drawn value; +- in an index the create could not prepay the draw's entries for: an `integerRange` index on the property, which files a drawn value in more windows than the placeholder, an index skipping documents on a stored property a replace or a moderator's change in the same block could add before the draw (`skipIfAbsent`, or a typed array), and an `integerRange` index whose source a replace could move into more windows; a property is safe there when the type's documents are immutable, or the type lists it `immutable`, and no moderator may change it; +- on a type whose moderators delete documents keeping a record of them (`deleteKeepsRecord`, on by default): a restore puts a removed document back as it was, with the placeholder if it was removed before the draw, and nothing draws it after; +- on a property a reference declared on the type reads (a `where` value, a `findBy` source, a function's params, a key id), or an `encryptedFor` names as a key; +- on a property a rule reads: a [`propertyConstraints`](property-constraints.md) or `deleteConstraints` rule (`$old.` included), an `immutable` entry's condition or `retractedWhen`, and a `countOf` keyed by it; +- on a property another type reads through a reference: a `where` entry comparing it, a rule's `referenced` operand, a derived index property (`cardId.rarity`), a lookup's key or a `chooseFirst` order, since the draw moves nothing they wrote or judged with the placeholder. + +The property and every object holding it are declared in place, as for a `sys.tokens` function. + ## Params For now a param is always a property of the same document, written as its dotted path: a string property for the string transformations, an identifier property (one carrying `refersTo` included) for the token functions. The form leaves room to add, in a later protocol version, literals (`{ "const": ... }`), system values such as `"$ownerId"` and nested calls, without changing what parses today. @@ -116,14 +161,15 @@ For now a param is always a property of the same document, written as its dotted - **Absent params.** A property one of whose params is absent must be absent too. To make the params required in effect, list the generated property in `required`: a document without them then fails the schema. - **Replace.** A replace is judged on the whole new document. Leave the property out to have it generated from the new params; a stale value sent with changed params is refused. - **Read from state.** A `sys.tokens` value is written or checked while the platform builds the write, before the rest, and kept from the document's creation: see [Functions that read state](#functions-that-read-state). +- **Drawn at random.** A `sys.random` value is written as its placeholder while the platform builds a create, and drawn once the block's transitions are accepted: see [Functions drawn at random](#functions-drawn-at-random). - **Transfers, purchases and deletes by id** do not change the data and are not judged. An index-only delete is: its values are generated and checked like a create's, so a stale value, or one without its params, refuses it. For a string transformation, the SDK's transition builders generate the property from the document's params, replacing any value the document holds and leaving it out when a param is absent, so a transition built from a document carries the value the platform would generate, and contest detection sees it. A document fetched, edited and sent back through them therefore carries the value of its new params, not the stale one. The property-constraint pre-checks of the JavaScript and FFI SDKs judge the document the same way. A client that validates a document it built, before building a transition, should generate it first (in Rust, `DocumentTypeBasicMethods::regenerate_generated_properties`) or set the value; otherwise the local check reports the property missing. The proof a client verifies after a create or replace is checked against the document with the generated value, as the platform stored it. A `sys.tokens` property is the exception: the builders and `regenerate_generated_properties` remove it rather than guess a value from state, as described under [Functions that read state](#functions-that-read-state). ## Rules at registration -- The keyword is allowed only on a string property (from protocol version 15 also an integer property, for `sys.tokens.decimals`, whose `minimum` and `maximum` admit 0 to 255 and which declares no `enum`, `const`, `exclusiveMinimum`, `exclusiveMaximum` or `multipleOf`), and not beside `$ref`, whose definition replaces every keyword written next to it (declare it in the definition instead). On any other property, a typed array and its `items` included, the meta-schema refuses it (`JsonSchemaError`, 10101). -- `function` must name a system function, and `params` must list 1 to 16 params, as many as the function takes. The meta-schema refuses an unknown function or an empty or overlong list (`JsonSchemaError`, 10101). +- The keyword is allowed only on a string property (from protocol version 15 also an integer property, for `sys.tokens.decimals`, whose `minimum` and `maximum` admit 0 to 255 and which declares no `enum`, `const`, `exclusiveMinimum`, `exclusiveMaximum` or `multipleOf`, and for `sys.random.uniform` once a protocol version activates it, as described under [Functions drawn at random](#functions-drawn-at-random)), and not beside `$ref`, whose definition replaces every keyword written next to it (declare it in the definition instead). On any other property, a typed array and its `items` included, the meta-schema refuses it (`JsonSchemaError`, 10101). +- `function` must name a system function, and `params` must list 1 to 16 params, as many as the function takes (none for `sys.random.uniform`, which may leave `params` out). The meta-schema refuses an unknown function or an empty or overlong list (`JsonSchemaError`, 10101). - Every param must name another string property of the same document type (an identifier property for a `sys.tokens` function; not an object, not a system property, not the declaring property), and that property may not be generated itself. - A `sys.tokens` function is refused on an [indexOnly](index-only.md) type, whose deletes send their values again where a kept value could not be checked, on a property in a contested index, since a client, which leaves the property out, could not tell whether its document joins a contest, and on a property inside an object declaring `const` or `enum`, which compare the whole object, the value included (to allow some tokens only, declare `enum` on the token property). The property and every object holding it are declared in place, without a `$ref`: a client checks its document with a value the property admits in place of the one it leaves out, and a `$ref` would bring in schemas, the definition's own references and the keywords beside it included, that registration does not judge. - Neither the declaring property nor a param may be [transient](transient.md) or sit inside a transient object: a transient value is never stored. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 9c9b33c5f72..94b6ac7600c 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 @@ -3446,17 +3446,28 @@ fn apply_delete_keeps_fields( } /// The top-level properties of `document_type` that a reference declared on it -/// reads when a document is written: each property holding a reference, and -/// the referring side of every `where`, `findBy` source (a function's params -/// included), `chooseFirst` function param and key id property, for every leaf of every reference expression, the -/// `ownerRefersTo` and `creatorRefersTo` declarations included. System -/// properties (`$ownerId`) are left out: they are no property a moderator can -/// name. +/// reads when a document is written: the top-level property of each path +/// [`paths_read_by_references`] gives. System properties (`$ownerId`) are left +/// out: they are no property a moderator can name. fn properties_read_by_references(document_type: DocumentTypeRef<'_>) -> BTreeSet<&str> { + paths_read_by_references(document_type) + .into_iter() + .map(top_level_property) + .collect() +} + +/// The property paths of `document_type` that a reference declared on it reads +/// when a document is written: each property holding a reference, and the +/// referring side of every `where`, `findBy` source (a function's params +/// included), `chooseFirst` function param and key id property, for every leaf +/// of every reference expression, the `ownerRefersTo` and `creatorRefersTo` +/// declarations included. The referring side of a `where` may name the writer, +/// `$ownerId`, which is left out. +pub(super) fn paths_read_by_references(document_type: DocumentTypeRef<'_>) -> BTreeSet<&str> { let mut read = BTreeSet::new(); for (holder, reference) in document_type.reference_declarations() { if let ReferenceHolder::Property(path) = holder { - read.insert(top_level_property(path)); + read.insert(path); } // A key id property reads the identity property whose key it names if let PropertyReference::KeyId(KeyIdReference { @@ -3464,7 +3475,7 @@ fn properties_read_by_references(document_type: DocumentTypeRef<'_>) -> BTreeSet .. }) = reference { - read.insert(top_level_property(path)); + read.insert(path.as_str()); } let Some(target) = reference.target() else { continue; @@ -3474,26 +3485,21 @@ fn properties_read_by_references(document_type: DocumentTypeRef<'_>) -> BTreeSet key_id_property, .. } = leaf { - read.insert(top_level_property(key_id_property)); + read.insert(key_id_property.as_str()); } let Some(declaration) = leaf.as_any_document_reference() else { continue; }; if let Some(lookup) = declaration.lookup { - read.extend(lookup.referring_properties().map(top_level_property)); + read.extend(lookup.referring_properties()); // A computed key's params: the key is judged on the create only, so a // change would leave the document no longer hashing to what it revealed if let Some((_, key)) = lookup.hash_key() { - read.extend(key.properties_read().map(top_level_property)); + read.extend(key.properties_read()); } // The `chooseFirst` `validWhen` function's params, for the same reason if let Some(choose_first) = &lookup.choose_first { - read.extend( - choose_first - .valid_when - .properties_read() - .map(top_level_property), - ); + read.extend(choose_first.valid_when.properties_read()); } } // The `where` of a list element names the property holding its @@ -3501,8 +3507,7 @@ fn properties_read_by_references(document_type: DocumentTypeRef<'_>) -> BTreeSet read.extend( declaration .where_referring_properties() - .filter(|property| !property.starts_with('$')) - .map(top_level_property), + .filter(|property| !property.starts_with('$')), ); } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index e7edf4e6255..0cab58e0656 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 @@ -25,18 +25,19 @@ 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_path_listed, is_referenced_system_agreement_property, - is_referring_system_agreement_property, is_transient, property_at_path, property_names, - ChooseFirstOrder, ContractReferenceModeration, ContractReferenceOwner, - ContractReferenceRequirements, DistinctFrom, DocumentProperty, DocumentPropertyReferenceTarget, - DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentReferenceDeclaration, - DocumentReferenceKind, DocumentReferenceLookup, DocumentType, DocumentTypeRef, EncryptedFor, - EncryptedForRecipient, EncryptionScheme, GeneratedFrom, GenerationParam, - IdentityKeyReferenceRequirements, KeyIdReference, KeyReferenceIdentityProperty, - ListElementReference, LookupChooseFirst, LookupHashKey, LookupKeySource, PropertyReference, - ReferenceCombinator, ReferenceOperands, SystemFunction, TokenFunction, WhereBoundRead, - WhereCondition, WhereContainsBound, WhereLiteral, WhereLiteralKind, WhereNotEqualBound, - WhereOrdering, WhereOrderingBound, COMBINABLE_REFERENCE_TARGET_TYPES, + integer_type_bounds, is_drawn_at_random, is_path_listed, + is_referenced_system_agreement_property, is_referring_system_agreement_property, is_transient, + property_at_path, property_names, ChooseFirstOrder, ContractReferenceModeration, + ContractReferenceOwner, ContractReferenceRequirements, DistinctFrom, DocumentProperty, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentPropertyTypeParsingOptions, + DocumentReferenceDeclaration, DocumentReferenceKind, DocumentReferenceLookup, DocumentType, + DocumentTypeRef, EncryptedFor, EncryptedForRecipient, EncryptionScheme, GeneratedFrom, + GenerationParam, IdentityKeyReferenceRequirements, KeyIdReference, + KeyReferenceIdentityProperty, ListElementReference, LookupChooseFirst, LookupHashKey, + LookupKeySource, PropertyReference, RandomFunction, RandomRange, ReferenceCombinator, + ReferenceOperands, SystemFunction, TokenFunction, WhereBoundRead, WhereCondition, + WhereContainsBound, WhereLiteral, WhereLiteralKind, WhereNotEqualBound, WhereOrdering, + WhereOrderingBound, COMBINABLE_REFERENCE_TARGET_TYPES, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; @@ -53,6 +54,8 @@ use platform_value::{Identifier, Value, ValueMapHelper}; use platform_version::version::PlatformVersion; use std::collections::{BTreeMap, BTreeSet}; +use common::paths_read_by_references; + mod common; #[cfg(feature = "validation")] mod native_schema; @@ -2974,7 +2977,7 @@ fn apply_generated_from( { None => Ok(None), Some(0) => apply_generated_from_v0(inner_properties, property_type), - Some(1) => apply_generated_from_v1(inner_properties, property_type), + Some(1) => apply_generated_from_v1(inner_properties, property_type, platform_version), Some(version) => Err(DataContractError::Unsupported(format!( "apply_generated_from version {version} is not supported" ))), @@ -3100,7 +3103,11 @@ fn apply_generated_from_v0( }) .collect::, _>>()?; - Ok(Some(GeneratedFrom { function, params })) + Ok(Some(GeneratedFrom { + function, + params, + range: None, + })) } /// Generation 1 (protocol version 15): generation 0, and a property may also be @@ -3110,9 +3117,15 @@ fn apply_generated_from_v0( /// declares them, admit 0 to 255, and it declares none of the keywords that can refuse some of /// those values (`enum`, `const`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`). A /// string transformation still generates a string property only. +/// +/// A property may also be generated with `sys.random.uniform`, which takes no parameters +/// (`params` may be left out) and draws an integer from the property's `minimum` to its +/// `maximum`, read into [`GeneratedFrom::range`] ([`uniform_range`]), where the platform +/// version's `random_generated_from` admits it; `None`, in every table for now, refuses it. fn apply_generated_from_v1( inner_properties: &BTreeMap, property_type: &DocumentPropertyType, + platform_version: &PlatformVersion, ) -> Result, DataContractError> { if let DocumentPropertyType::TypedArray(_) = property_type { if inner_properties.contains_key(property_names::GENERATED_FROM) @@ -3178,6 +3191,7 @@ fn apply_generated_from_v1( // The property's type is the one its function generates; `sys.hash` belongs // to the `refersTo` lookup keys + let mut range = None; match function { SystemFunction::StringTransformation(_) => { if !matches!(property_type, DocumentPropertyType::String(_)) { @@ -3207,10 +3221,38 @@ fn apply_generated_from_v1( ))); } } + SystemFunction::Random(RandomFunction::Uniform) => { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .random_generated_from + { + // Nothing draws the values yet: block execution does not call + // `draw_random_document_properties` until Tenderdash delivers the block's + // random beacon, so a document would keep its placeholder for good + None => { + return Err(DataContractError::InvalidContractStructure(format!( + "generatedFrom function {function} is not active at this protocol \ + version: the platform does not draw random values yet" + ))); + } + Some(0) => { + range = Some(uniform_range(inner_properties, property_type, function)?); + } + Some(version) => { + return Err(DataContractError::Unsupported(format!( + "random_generated_from version {version} is not supported" + ))); + } + } + } SystemFunction::Hash(_) => { return Err(DataContractError::InvalidContractStructure(format!( "generatedFrom function {function} does not generate a string or an integer: a \ - property is generated with a sys.stringTransformations or sys.tokens function" + property is generated with a sys.stringTransformations, sys.tokens or \ + sys.random function" ))); } } @@ -3218,10 +3260,13 @@ fn apply_generated_from_v1( return Err(shape_error()); }; - let params = generated_from_map - .get(property_names::PARAMS) - .and_then(|value| value.as_array()) - .ok_or_else(shape_error)?; + // A function that takes no parameters (`sys.random`) may leave `params` out + let no_params = Vec::new(); + let params = match generated_from_map.get(property_names::PARAMS) { + Some(params) => params.as_array().ok_or_else(shape_error)?, + None if parameter_count == 0 => &no_params, + None => return Err(shape_error()), + }; if params.len() != parameter_count { return Err(DataContractError::InvalidContractStructure(format!( "generatedFrom function {function} takes {parameter_count} parameter(s), but params \ @@ -3253,7 +3298,76 @@ fn apply_generated_from_v1( }) .collect::, _>>()?; - Ok(Some(GeneratedFrom { function, params })) + Ok(Some(GeneratedFrom { + function, + params, + range, + })) +} + +/// The values a property generated with `function`, `sys.random.uniform`, is drawn from: its +/// `minimum` to its `maximum`, both included. The property must be an integer one of at most +/// 64 bits, declare both bounds, with `minimum` not above `maximum` and both inside its type, +/// and declare none of the keywords that refuse some values in between (`enum`, `const`, +/// `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`), so every value drawn passes its +/// schema. At most 64 bits keeps the range within 2^64 values, which a draw maps 128 random +/// bits onto. +fn uniform_range( + map: &BTreeMap, + property_type: &DocumentPropertyType, + function: SystemFunction, +) -> Result { + let refusal = |reason: String| { + DataContractError::InvalidContractStructure(format!( + "generatedFrom function {function} draws an integer from the property's minimum to \ + its maximum, so {reason}" + )) + }; + let Some((type_minimum, type_maximum)) = integer_type_bounds(property_type) else { + return Err(refusal(format!( + "it is only allowed on an integer property of at most 64 bits, not on a {}", + property_type.name() + ))); + }; + let minimum = map.get_optional_integer::(property_names::MINIMUM)?; + let maximum = map.get_optional_integer::(property_names::MAXIMUM)?; + let (Some(minimum), Some(maximum)) = (minimum, maximum) else { + return Err(refusal( + "the property must declare both a minimum and a maximum".to_string(), + )); + }; + if minimum > maximum { + return Err(refusal(format!( + "its minimum, {minimum}, may not be above its maximum, {maximum}" + ))); + } + if minimum < type_minimum || maximum > type_maximum { + return Err(refusal(format!( + "both must fit the property's type, {}", + property_type.name() + ))); + } + if let Some(keyword) = [ + property_names::ENUM, + property_names::CONST, + property_names::EXCLUSIVE_MINIMUM, + property_names::EXCLUSIVE_MAXIMUM, + property_names::MULTIPLE_OF, + ] + .into_iter() + .find(|keyword| map.contains_key(*keyword)) + { + return Err(refusal(format!( + "the property may not declare {keyword}, which would refuse some of the values \ + in between" + ))); + } + // Inside a type of at most 64 bits, so at most 2^64 values + RandomRange::new(minimum, maximum).ok_or_else(|| { + refusal(format!( + "its range, {minimum} to {maximum}, may hold at most 2^64 values" + )) + }) } /// Whether the property schema `map` admits every token's decimals, 0 to 255: its `minimum` @@ -3374,6 +3488,30 @@ fn refuse_state_generated_schema_path(schema: &Value, path: &str) -> Result<(), /// `enum`, which compare the whole object, the value included /// ([`refuse_state_generated_schema_path`]). /// +/// A `sys.random` function (protocol version 15) takes no parameters. A client cannot know +/// the value either, so the same in-place declaration is required, and the type may not be +/// indexOnly. The platform fills the value after every transition of the block is accepted, +/// when nothing can be refused any more, so the property may not be in a unique index, where +/// two drawn values could collide, and the type may not keep history, which would keep the +/// placeholder. Nor may the type have a contested index, anywhere: a contested create waits in +/// its contest, outside the documents the draw rewrites, and the document a contest awards +/// would keep the placeholder. The property may not be summed, by `documentsSummable` or as the +/// `summable` (or averaged) property of an index: the total would take the drawn value when +/// nothing can be refused any more, past `i64::MAX` included. Nor may an index holding it sum +/// another property, since the draw moves the document's contribution into the group of the +/// drawn value, whose total could pass `i64::MAX` the same way. A create prepays the entries +/// the draw may create in the indexes holding it, priced on the document it writes +/// (`prepay_random_document_index_entries`), so the draw may not create more entries than the +/// create wrote: such an index may not be an `integerRange` index on it, which files a value +/// in every window holding it, more for a drawn value than for the placeholder (the inputs a +/// replace or a moderator could change are judged once the moderators' abilities are known, +/// [`validate_random_declarations_after_moderation`]). +/// And nothing a write is judged by may read it: not a reference declared on the type (the property holding it, a `where` +/// value, a `findBy` source, a function's params, a key id), nor an `encryptedFor` key, which +/// would name another key once drawn. The rules reading it are refused where their reads are +/// checked ([`property_read_error`]), and a value of another type reading it through a +/// reference is not fixed once written (`schema_property_is_fixed_once_written`). +/// /// Owned by parser generation 3: the only generation that admits the keyword. pub(super) fn validate_generated_from_declarations( document_type: &DocumentTypeV2, @@ -3438,13 +3576,142 @@ pub(super) fn validate_generated_from_declarations( })?; } + if let SystemFunction::Random(_) = generated_from.function { + if document_type.index_only { + return Err(structure_error(format!( + "function {} draws a value the platform keeps from the document's creation, \ + and an indexOnly type keeps no document: draw it on a type that stores its \ + documents", + generated_from.function + ))); + } + if document_type.documents_keep_history { + return Err(structure_error(format!( + "function {} draws a value the platform writes once the block is accepted, \ + and a type that keeps history would keep the placeholder written before it", + generated_from.function + ))); + } + if let Some(index) = document_type.indices.values().find(|index| { + index.unique + && index + .properties + .iter() + .any(|property| &property.name == path) + }) { + return Err(structure_error(format!( + "function {} draws a value once the block is accepted, when nothing can be \ + refused any more, so the property may not be in unique index \"{}\", where \ + two drawn values could collide", + generated_from.function, index.name + ))); + } + // A contested create waits in its contest, outside the documents tree the draw + // rewrites, so the document a contest awards would keep the placeholder + if let Some(index) = document_type + .indices + .values() + .find(|index| index.contested_index.is_some()) + { + return Err(structure_error(format!( + "function {} draws a value into the documents a block stored, and the type \ + has contested index \"{}\": a contested document waits in its contest \ + until it is awarded, so it would keep the placeholder", + generated_from.function, index.name + ))); + } + // A total takes the drawn value once nothing can be refused any more, so it could + // not be held below i64::MAX + let summed_by = if document_type.documents_summable.as_deref() == Some(path.as_str()) { + Some("documentsSummable".to_string()) + } else { + document_type + .indices + .values() + .find(|index| index.summable.as_deref() == Some(path.as_str())) + .map(|index| format!("index \"{}\"", index.name)) + }; + if let Some(summed_by) = summed_by { + return Err(structure_error(format!( + "function {} draws a value once the block is accepted, when nothing can be \ + refused any more, so the property may not be summed or averaged, as \ + {summed_by} does: the total could not be held to its bounds", + generated_from.function + ))); + } + for index in document_type.indices.values().filter(|index| { + index + .properties + .iter() + .any(|property| &property.name == path) + }) { + if let Some(summed) = &index.summable { + return Err(structure_error(format!( + "function {} draws a value once the block is accepted, when nothing \ + can be refused any more, so index \"{}\" holding the property may \ + not sum or average \"{summed}\": the draw moves the document's \ + contribution into the group of the drawn value, whose total could not \ + be held to its bounds", + generated_from.function, index.name + ))); + } + if index + .integer_range + .as_ref() + .is_some_and(|transform| &transform.source == path) + { + return Err(structure_error(format!( + "function {} draws a value once the block is accepted, and integerRange \ + index \"{}\" files a value in every window holding it, so the draw \ + could create more entries than the create wrote and prepaid", + generated_from.function, index.name + ))); + } + } + // A reference is judged when the document is written, before the draw + if paths_read_by_references(DocumentTypeRef::V2(document_type)).contains(path.as_str()) + { + return Err(structure_error(format!( + "function {} draws a value once the block is accepted, and a reference \ + declared on the type reads the property: it would be judged with the \ + placeholder written before the draw", + generated_from.function + ))); + } + if let Some((holder, _)) = flattened_properties.iter().find(|(_, property)| { + property + .encrypted_for + .as_ref() + .is_some_and(|encrypted_for| { + &encrypted_for.recipient_key == path || &encrypted_for.sender_key == path + }) + }) { + return Err(structure_error(format!( + "function {} draws a value once the block is accepted, and the encryptedFor \ + of \"{holder}\" names the property as a key: the ciphertext was made for \ + the key the placeholder names", + generated_from.function + ))); + } + // A client leaves the value out, and its schema check reads a stand-in, as for a + // value read from state + refuse_state_generated_schema_path(&document_type.schema, path).map_err(|message| { + structure_error(format!( + "function {} draws a value a client cannot know, so {message}: the client \ + could not tell whether its document matches", + generated_from.function + )) + })?; + } + // The kind of property a param names: the messages of the string functions are // those protocol version 14 gives let (param_kind, a_param_kind) = match generated_from.function { SystemFunction::Token(_) => ("identifier", "an identifier"), - SystemFunction::StringTransformation(_) | SystemFunction::Hash(_) => { - ("string", "a string") - } + // A random function takes no parameters, so the loop below never reads its kind + SystemFunction::StringTransformation(_) + | SystemFunction::Hash(_) + | SystemFunction::Random(_) => ("string", "a string"), }; for param in generated_from.property_params() { if param == path { @@ -3479,7 +3746,9 @@ pub(super) fn validate_generated_from_declarations( ))); } } - SystemFunction::StringTransformation(_) | SystemFunction::Hash(_) => { + SystemFunction::StringTransformation(_) + | SystemFunction::Hash(_) + | SystemFunction::Random(_) => { if !matches!( param_property.property_type, DocumentPropertyType::String(_) @@ -3521,6 +3790,102 @@ pub(super) fn validate_generated_from_declarations( Ok(()) } +/// What a `sys.random` property (protocol version 15) needs of the rest of its type once the +/// moderators' abilities are applied (`apply_moderator_abilities`) and the derived index +/// properties are (`apply_derived_index_properties`), both after +/// [`validate_generated_from_declarations`]. +/// +/// A create prepays the entries the draw may create in the indexes holding the property, +/// priced on the document it writes (`prepay_random_document_index_entries`), so no write +/// between the create and the draw may make the draw create more: an index holding it may not +/// skip documents on a stored property a replace or a moderator's change could add before the +/// draw (`skipIfAbsent`, or a typed array), nor be an `integerRange` index whose source either +/// could move into more windows. A property is safe there only when it is fixed once written +/// (`schema_property_is_fixed_once_written`: the type's documents are immutable, or it lists +/// the property `immutable`, and no moderator may change it). And a moderator may not delete +/// such a document keeping a record of it (`deleteKeepsRecord`, on by default): a restore puts +/// the removed document back as it was, with the placeholder if the block's draw had not +/// reached it yet, and nothing draws it after. +/// +/// Owned by parser generation 3: the only generation that admits the keyword. +pub(super) fn validate_random_declarations_after_moderation( + document_type: &DocumentTypeV2, + document_type_name: &str, +) -> Result<(), DataContractError> { + for (path, property) in &document_type.flattened_properties { + let Some(generated_from) = &property.generated_from else { + continue; + }; + let SystemFunction::Random(_) = generated_from.function else { + continue; + }; + let structure_error = |message: String| { + DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" generatedFrom \ + {message}" + )) + }; + + if document_type.documents_can_be_deleted_by_moderators + && document_type.moderator_deletions_keep_records + { + return Err(structure_error(format!( + "function {} draws a value once the block is accepted, and the type's \ + moderators delete its documents keeping a record of them, from which a \ + restore puts a removed document back as it was, with the placeholder if it \ + was removed before the draw: set deleteKeepsRecord to false", + generated_from.function + ))); + } + + let fixed_once_written = |path: &str| { + schema_property_is_fixed_once_written(DocumentTypeRef::V2(document_type), path) + }; + for index in document_type.indices.values().filter(|index| { + index + .properties + .iter() + .any(|property| &property.name == path) + }) { + // A replace or a moderator's change in the block of the create may move a window + // source it can change before the draw, into more windows than the create's + if let Some(source) = index + .integer_range + .as_ref() + .map(|transform| transform.source.as_str()) + .filter(|source| !fixed_once_written(source)) + { + return Err(structure_error(format!( + "function {} draws a value once the block is accepted, and integerRange \ + index \"{}\" files a document in every window its source \"{source}\" \ + falls in, which a replace or a moderator's change in the block of the \ + create could change before the draw, creating entries the create did not \ + prepay: window such an index only on a property fixed once written", + generated_from.function, index.name + ))); + } + // A derived property is fixed once written, so only a stored one a replace or a + // moderator's change can change can be added + if let Some(skipped_on) = index.skip_if_absent_properties.iter().find(|skipped_on| { + !document_type + .derived_index_properties + .contains_key(skipped_on.as_str()) + && !fixed_once_written(skipped_on) + }) { + return Err(structure_error(format!( + "function {} draws a value once the block is accepted, and index \"{}\" \ + skips a document without \"{skipped_on}\", which a replace or a \ + moderator's change in the block of the create could add before the draw, \ + creating entries the create did not prepay: skip only on a property fixed \ + once written", + generated_from.function, index.name + ))); + } + } + } + Ok(()) +} + /// Reads the `propertyConstraints` keyword onto the document type and checks /// every property its rules read: by its value, an integer or boolean /// property of the type (a nested one named by its dotted path, as the @@ -3878,8 +4243,9 @@ fn stored_read_in_delete_rule(subject: &str, path: &str) -> String { /// Why `document_type` has no property at `lookup` a rule `subject` can read /// as `read`, the message naming it `path`; `None` when it has one: of the kind -/// the read needs, neither transient nor inside a transient object. Shared by -/// the reads of the type's own properties and those of a referenced document. +/// the read needs, neither transient nor inside a transient object, nor drawn at +/// random ([`is_drawn_at_random`]). Shared by the reads of the type's own +/// properties and those of a referenced document. pub(super) fn property_read_error( document_type: DocumentTypeRef, subject: &str, @@ -4150,6 +4516,17 @@ pub(super) fn property_read_error( could not be held to the rule" )); } + // A value drawn once the block's transitions are accepted is the placeholder while they + // are judged. In place, inert before protocol version 15: no type parsed earlier declares + // a `sys.random` function (`apply_generated_from` 0 refuses every function but a string + // transformation), and none does from it until `random_generated_from` admits one + if is_drawn_at_random(document_type, lookup) { + return Some(format!( + "{subject} {reads} \"{path}\", which the platform draws at random once the \ + block's transitions are accepted: a write would be judged against the placeholder \ + written before the draw" + )); + } None } @@ -5298,6 +5675,16 @@ fn validate_property_constraint_aggregates_with( \"{counted_name}\" (a nested one is named by its dotted path)" ))); }; + // The documents a block created hold their placeholder while the block's + // writes are judged. In place, inert before protocol version 15, where no + // type declares a `sys.random` function + if is_drawn_at_random(counted, key) { + return Err(error(format!( + "{totals} by \"{key}\", which \"{counted_name}\" draws at random \ + once the block's transitions are accepted: the total would \ + count the documents the block created under their placeholder" + ))); + } let Some(kind) = (grammar.kind_of)(&property.property_type) else { return Err(error(format!( "{totals} by \"{key}\", which has type {}: {}", diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/properties.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/properties.rs index a81be9ffd65..ba674cd1533 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/properties.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/native_schema/properties.rs @@ -410,12 +410,20 @@ impl Checker<'_> { fn generated_from(&self, value: &JsonValue) -> Checked { let map = self.object(value)?; self.closed(map, &["function", "params"])?; - self.required(map, &["function", "params"])?; + // `sys.random.uniform` takes no parameters, so it may leave `params` out + let takes_no_params = + map.get("function").and_then(JsonValue::as_str) == Some("sys.random.uniform"); + if takes_no_params { + self.required(map, &["function"])?; + } else { + self.required(map, &["function", "params"])?; + } self.member(map, "function", |c, value| { c.string(value)?; c.one_of_strings( value, &[ + "sys.random.uniform", "sys.stringTransformations.camelCase", "sys.stringTransformations.capitalize", "sys.stringTransformations.homographSafeASCII", @@ -428,7 +436,11 @@ impl Checker<'_> { })?; self.member(map, "params", |c, value| { let items = c.array(value)?; - c.item_count(items, Some(1), Some(16))?; + if takes_no_params { + c.item_count(items, Some(0), Some(0))?; + } else { + c.item_count(items, Some(1), Some(16))?; + } for (index, item) in items.iter().enumerate() { c.at(index, |c| c.string_of_length(item, 1, Some(256)).map(drop))?; } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_random_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_random_tests.rs new file mode 100644 index 00000000000..18ae2d9e8d0 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_random_tests.rs @@ -0,0 +1,878 @@ +//! `generatedFrom` with a random function (protocol version 15): `sys.random.uniform` +//! draws an integer property from its `minimum` to its `maximum` once the block's transitions +//! are accepted. The parse is `apply_generated_from` 1 and the checks against the rest of the +//! type are `validate_generated_from_declarations`; the rules reading such a property are +//! refused where their reads are checked, and a value of another type read from it is not +//! fixed once written. A create writes the placeholder and a replace keeps the stored value +//! (`generate_random_properties`), a client leaves the value out +//! (`regenerate_generated_properties`), and a proof verifier takes it from the proved document +//! (`adopt_state_generated_properties`). +//! +//! No protocol version admits the function yet (`random_generated_from` is `None` in every +//! table, until block execution draws the values), so these tests parse with the latest +//! platform version with it admitted (`drawing_platform_version`). + +use super::refusal_test_support::{assert_refused, CONTRACT_ID}; +use super::typed_array_test_helpers::{ + expect_json_schema_error, expect_structure_error, parse_dispatched, +}; +use super::*; +use crate::data_contract::config::moderation::{ContractModerationConfig, ContractModerators}; +use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; +use crate::data_contract::document_type::{ + GeneratedFrom, RandomFunction, RandomRange, SystemFunction, +}; +use platform_value::platform_value; + +const UNIFORM: &str = "sys.random.uniform"; + +fn rarity_property() -> Value { + platform_value!({ + "type": "integer", + "minimum": 1, + "maximum": 100, + "generatedFrom": { "function": UNIFORM }, + "position": 1 + }) +} + +/// A `card` type: its `name`, and a `rarity` drawn from 1 to 100. +fn card_schema() -> Value { + platform_value!({ + "type": "object", + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 }, + "rarity": rarity_property() + }, + "required": ["name", "rarity"], + "additionalProperties": false + }) +} + +/// [`card_schema`] with `rarity` replaced by `rarity`. +fn card_schema_with_rarity(rarity: Value) -> Value { + let mut schema = card_schema(); + schema + .get_mut("properties") + .expect("properties") + .expect("properties are present") + .insert("rarity".to_string(), rarity) + .expect("properties are a map"); + schema +} + +/// [`card_schema`] with `key` set to `value` at the type level. +fn card_schema_with(key: &str, value: Value) -> Value { + let mut schema = card_schema(); + schema + .insert(key.to_string(), value) + .expect("the schema is a map"); + schema +} + +/// The latest platform version with `sys.random.uniform` admitted. +fn drawing_platform_version() -> PlatformVersion { + let mut platform_version = PlatformVersion::latest().clone(); + platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .random_generated_from = Some(0); + platform_version +} + +fn parse(schema: Value) -> DocumentType { + parse_dispatched(schema, &drawing_platform_version(), true).expect("the schema parses") +} + +/// Both parses refuse the declaration: the validating one natively or with the parser's +/// structure error, the one that skips the schema check with the parser's. +fn expect_refused(schema: Value, needle: &str) { + let platform_version = drawing_platform_version(); + assert!( + parse_dispatched(schema.clone(), &platform_version, true).is_err(), + "a validating parse refuses it ({needle})" + ); + expect_structure_error(parse_dispatched(schema, &platform_version, false), needle); +} + +/// Registration refuses the declaration with the parser's structure error. +fn expect_refused_at_registration(schema: Value, needle: &str) { + expect_structure_error( + parse_dispatched(schema, &drawing_platform_version(), true), + needle, + ); +} + +/// `schema` registered as a `card` of a contract whose owner moderates it, so the type may +/// declare `moderatorAbilities`. +fn parse_moderated(schema: Value) -> Result { + let platform_version = drawing_platform_version(); + let config = DataContractConfig::default_for_version(&platform_version) + .expect("default config available") + .with_moderation(Some(ContractModerationConfig { + banlist: false, + suspensions: false, + moderators: ContractModerators::ContractOwner, + warnings: false, + })); + DocumentType::try_from_schema( + Identifier::new(CONTRACT_ID), + 1, + config.version(), + "card", + schema, + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + &platform_version, + ) +} + +/// The document types of a contract holding `types`, registered. +fn parse_contract( + types: Vec<(&str, Value)>, +) -> Result, ProtocolError> { + let platform_version = drawing_platform_version(); + let config = DataContractConfig::default_for_version(&platform_version) + .expect("default config available"); + DocumentType::create_document_types_from_document_schemas( + Identifier::new(CONTRACT_ID), + 1, + config.version(), + types + .into_iter() + .map(|(name, schema)| (name.to_string(), schema)) + .collect(), + None, + &BTreeMap::new(), + &config, + true, + false, + &mut vec![], + &platform_version, + ) +} + +/// An identifier property at `position`, referring as `refers_to` says. +fn identifier(position: u64, refers_to: Value) -> Value { + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position, + "refersTo": refers_to + }) +} + +/// A card nobody changes or deletes, so a `permanentDocument` reference may point at it, its +/// `rarity` drawn, with `extra` set at the type level. +fn permanent_card(extra: Vec<(&str, Value)>) -> Value { + let mut card = card_schema_with("documentsMutable", Value::Bool(false)); + card.insert("canBeDeleted".to_string(), Value::Bool(false)) + .expect("the schema is a map"); + for (key, value) in extra { + card.insert(key.to_string(), value) + .expect("the schema is a map"); + } + card +} + +fn data(entries: Vec<(&str, Value)>) -> BTreeMap { + entries + .into_iter() + .map(|(key, value)| (key.to_string(), value)) + .collect() +} + +#[test] +fn should_parse_sys_random_uniform_onto_a_bounded_integer_property_from_protocol_version_15() { + let expected = Some(GeneratedFrom { + function: SystemFunction::Random(RandomFunction::Uniform), + params: vec![], + range: RandomRange::new(1, 100), + }); + let rarity = |document_type: DocumentType| { + document_type + .as_ref() + .flattened_properties() + .get("rarity") + .expect("rarity is parsed") + .generated_from + .clone() + }; + assert_eq!(rarity(parse(card_schema())), expected); + + // `params` may be written out, empty + let explicit = card_schema_with_rarity(platform_value!({ + "type": "integer", + "minimum": 1, + "maximum": 100, + "generatedFrom": { "function": UNIFORM, "params": [] }, + "position": 1 + })); + assert_eq!(rarity(parse(explicit)), expected); + + // A signed range parses too + let signed = card_schema_with_rarity(platform_value!({ + "type": "integer", + "minimum": -5, + "maximum": 5, + "generatedFrom": { "function": UNIFORM }, + "position": 1 + })); + assert_eq!( + parse(signed) + .as_ref() + .flattened_properties() + .get("rarity") + .expect("rarity is parsed") + .generated_from + .as_ref() + .and_then(|generated_from| generated_from.range), + RandomRange::new(-5, 5) + ); +} + +#[test] +fn should_refuse_sys_random_uniform_while_no_protocol_version_draws_the_values() { + // `random_generated_from` is `None` at the latest protocol version: nothing draws the + // values yet, so a document would keep its placeholder + let platform_version = PlatformVersion::latest(); + expect_structure_error( + parse_dispatched(card_schema(), platform_version, true), + "is not active at this protocol version", + ); + expect_structure_error( + parse_dispatched(card_schema(), platform_version, false), + "is not active at this protocol version", + ); +} + +#[test] +fn should_refuse_sys_random_uniform_before_protocol_version_15() { + let platform_version = PlatformVersion::get(14).expect("protocol version 14"); + // Protocol version 14's meta-schema refuses the function, and its parser refuses a + // generated integer property + expect_json_schema_error(parse_dispatched(card_schema(), platform_version, true)); + expect_structure_error( + parse_dispatched(card_schema(), platform_version, false), + "only allowed on string properties", + ); +} + +#[test] +fn should_refuse_sys_random_uniform_on_a_property_without_a_bounded_range() { + let generated = platform_value!({ "function": UNIFORM }); + let refuse = |rarity: Value, needle: &str| { + expect_refused(card_schema_with_rarity(rarity), needle); + }; + refuse( + platform_value!({ "type": "integer", "maximum": 100, "generatedFrom": generated.clone(), "position": 1 }), + "must declare both a minimum and a maximum", + ); + refuse( + platform_value!({ "type": "integer", "minimum": 1, "generatedFrom": generated.clone(), "position": 1 }), + "must declare both a minimum and a maximum", + ); + refuse( + platform_value!({ "type": "integer", "minimum": 10, "maximum": 1, "generatedFrom": generated.clone(), "position": 1 }), + "may not be above its maximum", + ); + for (keyword, value) in [ + ("enum", platform_value!([1, 2, 3])), + ("multipleOf", platform_value!(2)), + ("exclusiveMaximum", platform_value!(100)), + ] { + let mut rarity = platform_value!({ "type": "integer", "minimum": 1, "maximum": 100, "generatedFrom": generated.clone(), "position": 1 }); + rarity + .insert(keyword.to_string(), value) + .expect("rarity is a map"); + refuse(rarity, &format!("may not declare {keyword}")); + } + // A string has no range to draw from + refuse( + platform_value!({ "type": "string", "maxLength": 8, "generatedFrom": generated, "position": 1 }), + "only allowed on an integer property of at most 64 bits", + ); + // The function takes no parameters + refuse( + platform_value!({ "type": "integer", "minimum": 1, "maximum": 100, "generatedFrom": { "function": UNIFORM, "params": ["name"] }, "position": 1 }), + "takes 0 parameter(s)", + ); +} + +#[test] +fn should_admit_sys_random_uniform_in_an_ordinary_index_and_refuse_it_in_a_unique_one() { + let indices = |unique: bool| platform_value!([{ "name": "byRarity", "properties": [{ "rarity": "asc" }], "unique": unique }]); + let document_type = parse(card_schema_with("indices", indices(false))); + assert!(document_type.as_ref().indexes().contains_key("byRarity")); + + expect_refused( + card_schema_with("indices", indices(true)), + "may not be in unique index \"byRarity\"", + ); +} + +#[test] +fn should_refuse_sys_random_uniform_on_a_type_that_keeps_no_document_or_keeps_history() { + expect_refused( + card_schema_with("documentsKeepHistory", Value::Bool(true)), + "would keep the placeholder", + ); + + let index_only = platform_value!({ + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "indices": [ + { + "name": "byRarity", + "properties": [{ "name": "asc" }, { "rarity": "asc" }], + "terminal": "$ownerId" + } + ], + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 }, + "rarity": rarity_property() + }, + "required": ["name", "rarity"], + "additionalProperties": false + }); + expect_refused(index_only, "an indexOnly type keeps no document"); +} + +#[test] +fn should_write_the_placeholder_on_a_create_and_refuse_a_sent_value() { + let document_type = parse(card_schema()); + + let mut created = data(vec![("name", Value::Text("Ace".to_string()))]); + assert_eq!( + document_type + .as_ref() + .generate_random_properties(&mut created, None), + None + ); + // The placeholder is the range's minimum, stored at the property's width + assert_eq!(created.get("rarity"), Some(&Value::U8(1))); + + // A client cannot pick the value + let mut sent = data(vec![ + ("name", Value::Text("Ace".to_string())), + ("rarity", Value::U8(100)), + ]); + assert!(document_type + .as_ref() + .generate_random_properties(&mut sent, None) + .is_some()); +} + +#[test] +fn should_keep_the_stored_value_on_a_replace() { + let document_type = parse(card_schema()); + let stored = data(vec![ + ("name", Value::Text("Ace".to_string())), + ("rarity", Value::U8(73)), + ]); + + // Left out, the stored value is written back + let mut replaced = data(vec![("name", Value::Text("King".to_string()))]); + assert_eq!( + document_type + .as_ref() + .generate_random_properties(&mut replaced, Some(&stored)), + None + ); + assert_eq!(replaced.get("rarity"), Some(&Value::U8(73))); + + // Sent, it must be the stored value, at any width + let mut kept = data(vec![ + ("name", Value::Text("King".to_string())), + ("rarity", Value::U64(73)), + ]); + assert_eq!( + document_type + .as_ref() + .generate_random_properties(&mut kept, Some(&stored)), + None + ); + let mut rerolled = data(vec![ + ("name", Value::Text("King".to_string())), + ("rarity", Value::U8(99)), + ]); + assert!(document_type + .as_ref() + .generate_random_properties(&mut rerolled, Some(&stored)) + .is_some()); +} + +#[test] +fn should_leave_the_value_to_the_platform_on_a_client() { + let document_type = parse(card_schema()); + let platform_version = &drawing_platform_version(); + + // The transition builders remove it, whatever the document holds + let mut properties = data(vec![ + ("name", Value::Text("Ace".to_string())), + ("rarity", Value::U8(73)), + ]); + document_type + .regenerate_generated_properties(&mut properties, platform_version) + .expect("the regeneration runs"); + assert_eq!( + properties, + data(vec![("name", Value::Text("Ace".to_string()))]) + ); + + // The document check does not refuse it left out, nor held + let check = |properties: BTreeMap| { + document_type + .as_ref() + .validate_generated_from_properties(&Value::from(properties), platform_version) + .expect("the check runs") + }; + assert!(check(data(vec![("name", Value::Text("Ace".to_string()))])).is_valid()); + assert!(check(data(vec![ + ("name", Value::Text("Ace".to_string())), + ("rarity", Value::U8(73)) + ])) + .is_valid()); +} + +#[test] +fn should_take_the_proved_value_into_a_rebuilt_document() { + let document_type = parse(card_schema()); + let proved = data(vec![ + ("name", Value::Text("Ace".to_string())), + ("rarity", Value::U8(73)), + ]); + + let mut rebuilt = data(vec![("name", Value::Text("Ace".to_string()))]); + document_type + .as_ref() + .adopt_state_generated_properties(&mut rebuilt, &proved); + assert_eq!(rebuilt, proved); +} + +#[test] +fn should_refuse_sys_random_uniform_on_a_type_with_a_contested_index() { + // The contested index is on another property: the document a contest awards would still + // keep the placeholder + let contested = platform_value!([{ + "name": "byName", + "properties": [{ "name": "asc" }], + "unique": true, + "contested": { + "fieldMatches": [{ "field": "name", "regexPattern": "^[a-z]{3,5}$" }], + "resolution": 0 + } + }]); + expect_refused( + permanent_card(vec![("indices", contested)]), + "has contested index \"byName\"", + ); +} + +#[test] +fn should_refuse_summing_or_averaging_a_property_drawn_at_random() { + expect_refused( + card_schema_with("documentsSummable", Value::Text("rarity".to_string())), + "as documentsSummable does", + ); + for keyword in ["summable", "averageable"] { + let mut index = platform_value!({ "name": "byName", "properties": [{ "name": "asc" }] }); + index + .insert(keyword.to_string(), Value::Text("rarity".to_string())) + .expect("the index is a map"); + expect_refused( + card_schema_with("indices", Value::Array(vec![index])), + "as index \"byName\" does", + ); + } +} + +/// [`card_schema`] with a property `name` of `schema`, required when `required`, and +/// `indices`. +fn card_schema_with_property_and_indices( + name: &str, + schema: Value, + required: bool, + indices: Value, +) -> Value { + let mut card = card_schema_with("indices", indices); + card.get_mut("properties") + .expect("properties") + .expect("properties are present") + .insert(name.to_string(), schema) + .expect("properties are a map"); + if required { + card.get_mut("required") + .expect("required") + .expect("required is present") + .push(Value::Text(name.to_string())) + .expect("required is an array"); + } + card +} + +#[test] +fn should_refuse_an_index_holding_a_property_drawn_at_random_that_sums_or_windows_it() { + // The draw would move the document's amount into the total of another rarity + expect_refused( + card_schema_with_property_and_indices( + "amount", + platform_value!({ "type": "integer", "minimum": 0, "maximum": 1000, "position": 2 }), + true, + platform_value!([{ + "name": "byRarity", + "properties": [{ "rarity": "asc" }], + "summable": "amount" + }]), + ), + "index \"byRarity\" holding the property may not sum or average \"amount\"", + ); + // A drawn rarity sits in more windows than the placeholder at the bottom of the range + expect_refused( + card_schema_with( + "indices", + platform_value!([{ + "name": "byRarityWindow", + "properties": [{ "rarity": "asc" }], + "integerRange": { "on": "rarity", "range": 20u64, "step": 10u64 } + }]), + ), + "integerRange index \"byRarityWindow\" files a value in every window holding it", + ); +} + +#[test] +fn should_refuse_a_window_index_holding_a_property_drawn_at_random_on_a_mutable_type() { + // A replace could move the price into more windows before the draw + let schema = card_schema_with_property_and_indices( + "price", + platform_value!({ "type": "integer", "minimum": 0, "maximum": 1000, "position": 2 }), + true, + platform_value!([{ + "name": "byPriceWindowAndRarity", + "properties": [{ "price": "asc" }, { "rarity": "asc" }], + "integerRange": { "on": "price", "range": 240u64, "step": 10u64 } + }]), + ); + expect_refused( + schema.clone(), + "integerRange index \"byPriceWindowAndRarity\" files a document in every window its \ + source \"price\" falls in", + ); + + // An immutable card keeps its price, and so does a mutable one listing it immutable + let mut immutable = schema.clone(); + immutable + .insert("documentsMutable".to_string(), Value::Bool(false)) + .expect("the schema is a map"); + let mut immutable_price = schema; + immutable_price + .insert("immutable".to_string(), platform_value!(["price"])) + .expect("the schema is a map"); + for schema in [immutable, immutable_price] { + let document_type = parse(schema); + assert!(document_type + .as_ref() + .indexes() + .contains_key("byPriceWindowAndRarity")); + } +} + +#[test] +fn should_refuse_skipping_on_a_stored_property_beside_one_drawn_at_random_on_a_mutable_type() { + let schema = card_schema_with_property_and_indices( + "tag", + platform_value!({ "type": "string", "maxLength": 8, "position": 2 }), + false, + platform_value!([{ + "name": "byRarityTag", + "properties": [{ "rarity": "asc" }, { "tag": "asc" }], + "skipIfAbsent": true + }]), + ); + expect_refused( + schema.clone(), + "index \"byRarityTag\" skips a document without \"tag\"", + ); + + // A replace can not add the tag to an immutable card, nor to a mutable one listing it + // immutable + let mut immutable = schema.clone(); + immutable + .insert("documentsMutable".to_string(), Value::Bool(false)) + .expect("the schema is a map"); + let mut immutable_tag = schema; + immutable_tag + .insert("immutable".to_string(), platform_value!(["tag"])) + .expect("the schema is a map"); + for schema in [immutable, immutable_tag] { + let document_type = parse(schema); + assert!(document_type.as_ref().indexes().contains_key("byRarityTag")); + } +} + +#[test] +fn should_refuse_skipping_beside_a_property_drawn_at_random_on_what_a_moderator_sets() { + // An immutable card whose moderators may set its tag: a change in the block of the create + // could add the tag before the draw. (A window source is required, which a field only + // moderators set can not be, so no moderator moves one.) + let moderated = |mut schema: Value, field: &str| { + schema + .insert("documentsMutable".to_string(), Value::Bool(false)) + .expect("the schema is a map"); + schema + .insert( + "moderatorAbilities".to_string(), + platform_value!({ "changeFields": [field] }), + ) + .expect("the schema is a map"); + schema + .get_mut("required") + .expect("required") + .expect("required is present") + .push(Value::Text("$updatedAt".to_string())) + .expect("required is an array"); + schema + }; + expect_structure_error( + parse_moderated(moderated( + card_schema_with_property_and_indices( + "tag", + platform_value!({ "type": "string", "maxLength": 8, "position": 2 }), + false, + platform_value!([{ + "name": "byRarityTag", + "properties": [{ "rarity": "asc" }, { "tag": "asc" }], + "skipIfAbsent": true + }]), + ), + "tag", + )), + "index \"byRarityTag\" skips a document without \"tag\"", + ); +} + +#[test] +fn should_refuse_a_property_drawn_at_random_on_a_type_whose_moderators_keep_removal_records() { + // A restore would put back a card removed before the draw, placeholder included + let with_moderator_delete = |abilities: Value| { + let mut schema = card_schema_with( + "indices", + platform_value!([{ "name": "byRarity", "properties": [{ "rarity": "asc" }] }]), + ); + schema + .insert("moderatorAbilities".to_string(), abilities) + .expect("the schema is a map"); + schema + }; + expect_structure_error( + parse_moderated(with_moderator_delete(platform_value!({ "delete": true }))), + "moderators delete its documents keeping a record of them", + ); + // Without a record there is nothing to restore + let document_type = parse_moderated(with_moderator_delete( + platform_value!({ "delete": true, "deleteKeepsRecord": false }), + )) + .expect("the schema parses"); + assert!(document_type.as_ref().indexes().contains_key("byRarity")); +} + +#[test] +fn should_refuse_a_reference_of_the_type_reading_a_property_drawn_at_random() { + let with_deck = |where_clause: Value| { + let mut schema = card_schema(); + schema + .get_mut("properties") + .expect("properties") + .expect("properties are present") + .insert( + "deckId".to_string(), + identifier( + 2, + platform_value!({ + "type": "permanentDocument", + "documentType": "deck", + "where": where_clause + }), + ), + ) + .expect("properties are a map"); + schema + }; + // The deck's level must equal the card's rarity, or be above it + for where_clause in [ + platform_value!({ "level": "rarity" }), + platform_value!({ "level": { "greaterThan": "rarity" } }), + ] { + expect_refused( + with_deck(where_clause), + "a reference declared on the type reads the property", + ); + } +} + +#[test] +fn should_refuse_an_encrypted_for_key_drawn_at_random() { + let mut schema = card_schema(); + let properties = schema + .get_mut("properties") + .expect("properties") + .expect("properties are present"); + properties + .insert( + "senderKeyId".to_string(), + platform_value!({ "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 2 }), + ) + .expect("properties are a map"); + properties + .insert( + "secret".to_string(), + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 1040, + "position": 3, + "encryptedFor": { + "recipient": "$ownerId", + "recipientKey": "rarity", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + } + }), + ) + .expect("properties are a map"); + expect_refused(schema, "names the property as a key"); +} + +#[test] +fn should_refuse_a_rule_reading_a_property_drawn_at_random() { + let drawn = "which the platform draws at random once the block's transitions are accepted"; + // A rule judging a create or a replace, reading the document written or the stored one + for rule in [ + platform_value!({ "greaterThan": ["rarity", 50] }), + platform_value!({ "greaterThan": ["$old.rarity", 50] }), + platform_value!({ "anyOf": [{ "absent": "name" }, { "equal": ["rarity", 1] }] }), + ] { + expect_refused( + card_schema_with("propertyConstraints", platform_value!({ "rare": rule })), + drawn, + ); + } + // A rule judging the owner's delete, which a delete in the block that created the + // document would judge against the placeholder + expect_refused( + card_schema_with( + "deleteConstraints", + platform_value!({ "common": { "lessThan": ["rarity", 50] } }), + ), + drawn, + ); + // The condition an immutable entry is frozen under + expect_refused_at_registration( + card_schema_with( + "immutable", + platform_value!([{ "property": "name", "when": { "greaterThan": ["rarity", 50] } }]), + ), + drawn, + ); +} + +#[test] +fn should_refuse_a_rule_of_another_type_reading_a_property_drawn_at_random() { + let binder = |rules: Value| { + platform_value!({ + "type": "object", + "properties": { + "cardId": identifier(0, platform_value!({ + "type": "permanentDocument", + "documentType": "card" + })), + "label": { "type": "string", "maxLength": 32, "position": 1 } + }, + "required": ["cardId"], + "propertyConstraints": rules, + "additionalProperties": false + }) + }; + // Through the reference to the card + assert_refused( + parse_contract(vec![ + ("card", permanent_card(vec![])), + ( + "binder", + binder(platform_value!({ + "rareOnly": { "greaterThan": [{ "referenced": ["cardId", "rarity"] }, 50] } + })), + ), + ]), + "draws at random", + ); + // A count of the cards keyed by their drawn rarity, which a countable index keeps + let counted_card = permanent_card(vec![( + "indices", + platform_value!([{ + "name": "byRarity", + "properties": [{ "rarity": "asc" }], + "countable": "countable" + }]), + )]); + assert_refused( + parse_contract(vec![ + ("card", counted_card), + ( + "binder", + binder(platform_value!({ + "fewLegends": { "lessThan": [{ "countOf": ["card", { "rarity": 100 }] }, 10] } + })), + ), + ]), + "\"card\" draws at random", + ); +} + +#[test] +fn should_refuse_a_derived_index_property_reading_a_property_drawn_at_random() { + // The card is immutable and never deleted, so its rarity would be fixed once written but + // for the draw, which moves no entry another type keyed by the placeholder + let binder = |index_property: &str| { + platform_value!({ + "type": "object", + "documentsMutable": false, + "properties": { + "cardId": identifier(0, platform_value!({ + "type": "permanentDocument", + "documentType": "card" + })), + "label": { "type": "string", "maxLength": 32, "position": 1 } + }, + "indices": [{ + "name": "byDerived", + "properties": [{ index_property: "asc" }, { "$createdAt": "asc" }] + }], + "required": ["cardId", "$createdAt"], + "additionalProperties": false + }) + }; + parse_contract(vec![ + ("card", permanent_card(vec![])), + ("binder", binder("cardId.name")), + ]) + .expect("a derived index property reading the card's name registers"); + assert_refused( + parse_contract(vec![ + ("card", permanent_card(vec![])), + ("binder", binder("cardId.rarity")), + ]), + "is drawn at random once the block's transitions are accepted", + ); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_tests.rs index f0fd8a6b376..06d9ee42278 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_tests.rs @@ -160,6 +160,7 @@ fn should_parse_generated_from_onto_string_properties_at_any_depth() { StringTransformation::HomographSafeAscii, ), params: vec![GenerationParam::Property(source.to_string())], + range: None, }) }; @@ -320,8 +321,8 @@ fn should_refuse_a_malformed_generated_from() { "function": "sys.stringTransformations.homographSafe", "params": ["label"] }), - "function \"sys.stringTransformations.homographSafe\" is unknown, expected one of \ - \"sys.stringTransformations.camelCase\", \"sys.stringTransformations.capitalize\", \ + // Protocol version 15 lists its random and token functions around these + "\"sys.stringTransformations.camelCase\", \"sys.stringTransformations.capitalize\", \ \"sys.stringTransformations.homographSafeASCII\", \"sys.stringTransformations.lowercase\", \ \"sys.stringTransformations.snakeCase\", \"sys.stringTransformations.uppercase\"", ), diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_token_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_token_tests.rs index e910ce59465..459487c277c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_token_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_token_tests.rs @@ -123,6 +123,7 @@ fn should_parse_sys_tokens_decimals_onto_an_integer_property_from_protocol_versi Some(GeneratedFrom { function: SystemFunction::Token(TokenFunction::Decimals), params: vec![GenerationParam::Property("tokenId".to_string())], + range: None, }) ); // An identifier carrying a `refersTo` names the token as well diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 0dbec5f6a71..1d111066089 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 @@ -36,12 +36,12 @@ use crate::data_contract::document_type::index::{ PreallocationBinding, }; use crate::data_contract::document_type::property::DocumentPropertyType; -use crate::data_contract::document_type::property::{is_path_listed, DocumentReferenceKind}; #[cfg(feature = "validation")] use crate::data_contract::document_type::property::{ - is_transient, DocumentPropertyReferenceTarget, PropertyReference, ReferenceHolder, - ReferenceOperands, + is_drawn_at_random, is_transient, DocumentPropertyReferenceTarget, PropertyReference, + ReferenceHolder, ReferenceOperands, }; +use crate::data_contract::document_type::property::{is_path_listed, DocumentReferenceKind}; use crate::data_contract::document_type::property_names; use crate::data_contract::document_type::reference_lookup::{ owner_can_change, schema_property_is_fixed_once_written, why_value_can_change, @@ -75,7 +75,8 @@ use super::schema_at_path; use super::{ apply_property_constraints, parse_doctype_reference, validate_encrypted_for_declarations, validate_generated_from_declarations, validate_list_element_sources, - validate_reference_lookup_sources, validate_referencing_rules, validate_stored_version_reads, + validate_random_declarations_after_moderation, validate_reference_lookup_sources, + validate_referencing_rules, validate_stored_version_reads, }; mod ranked_prefix_overlap; @@ -770,6 +771,11 @@ fn parse_generation_3( // immutable list and the fields only moderators write decide, on a type storing its // documents. apply_derived_index_properties(&mut v2, data_contract_id, name, full_validation)?; + // After `apply_moderator_abilities` and `apply_derived_index_properties`: an index holding a + // `sys.random` property may only key what neither a replace nor a moderator's change can + // add or move before the draw, and a moderator's restore may not bring back a placeholder. + validate_random_declarations_after_moderation(&v2, name) + .map_err(consensus_or_protocol_data_contract_error)?; // The flags are read from the parsed result (not the raw schema) so // the check sees `canBeDeleted` resolved against the contract config @@ -1374,6 +1380,14 @@ fn derived_index_field_error( )); } if !schema_property_is_fixed_once_written(referenced, path) { + if is_drawn_at_random(referenced, path) { + return Some(format!( + "\"{path}\" of \"{referenced_name}\" is drawn at random once the block's \ + transitions are accepted, which moves no entry another type keyed by the \ + placeholder, so the entry would no longer be found under the value it was \ + written under" + )); + } return Some(format!( "\"{path}\" of \"{referenced_name}\" can change once written, and the entry would \ no longer be found under the value it was written under: make \ @@ -2117,6 +2131,8 @@ mod refusal_test_support; #[cfg(all(test, feature = "validation"))] mod summable_off_count_index_tests; +#[cfg(all(test, feature = "validation"))] +mod generated_from_random_tests; #[cfg(all(test, feature = "validation"))] mod generated_from_tests; #[cfg(all(test, feature = "validation"))] diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index 0140e1fa03a..b3c68e8a9cc 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 @@ -31,8 +31,8 @@ use crate::data_contract::document_type::property_constraints::{ #[cfg(feature = "validation")] use crate::data_contract::document_type::StringPropertySizes; use crate::data_contract::document_type::{ - DocumentPropertyReferenceTarget, DocumentPropertyType, GeneratedFrom, SystemFunction, - TokenFunction, + integer_value_for_type, DocumentPropertyReferenceTarget, DocumentPropertyType, GeneratedFrom, + SystemFunction, TokenFunction, }; use crate::fee::Credits; use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; @@ -419,13 +419,91 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { Ok(None) } + /// Writes or checks every `generatedFrom` property of `data` that a random function + /// generates (`sys.random`, protocol version 15). The platform draws such a value from the + /// block's random beacon once every transition of the block is accepted, so no client + /// knows it when it sends the document: + /// + /// * on a create (`stored` is `None`), a left-out property is written with the placeholder, + /// the `minimum` of its range, which the draw replaces before the block commits; a sent + /// one is refused, since a client cannot pick the value; + /// * on a replace (`stored`, the replaced document's properties), the stored value is kept: + /// a left-out property is written with it, and a sent one must equal it. + /// + /// Returns the refusal of the first property that does not pass + /// (`DocumentPropertyNotGeneratedError`). Leaves alone a property whose path repeats a key, + /// which the schema validation and `validate_generated_from_properties` refuse on their + /// own. Only the platform calls this: a client leaves such a property out + /// (`regenerate_generated_properties` removes it). + /// + /// Inert on a type without such a declaration, which covers every type parsed before + /// protocol version 15 (generation 1 of `apply_generated_from` is the first to parse a + /// `sys.random` function), and every type parsed from it until a table's + /// `random_generated_from` admits one. + fn generate_random_properties( + &self, + data: &mut BTreeMap, + stored: Option<&BTreeMap>, + ) -> Option + where + Self: DocumentTypeV2Getters, + { + for (path, generated_from) in self.generated_from_fields() { + let (SystemFunction::Random(_), Some(range)) = + (generated_from.function, generated_from.range) + else { + continue; + }; + let Ok(sent) = read_data_at_path(data, path) else { + continue; + }; + let sent = sent.cloned(); + let value = match stored { + None => { + if sent.is_some() { + return Some(not_generated(self.name(), path, generated_from)); + } + let Some(placeholder) = + self.flattened_properties().get(path).and_then(|property| { + integer_value_for_type(&property.property_type, range.minimum()) + }) + else { + // Registration admits the function only on an integer property whose + // type holds its minimum + continue; + }; + placeholder + } + Some(stored) => { + let Ok(Some(kept)) = read_data_at_path(stored, path) else { + continue; + }; + match sent { + Some(sent) if !same_integer(&sent, kept) => { + return Some(not_generated(self.name(), path, generated_from)); + } + Some(_) => continue, + None => kept.clone(), + } + } + }; + // Registration declares the property in place, so the objects on the way are maps + // whenever the document holds them. If the insert ever failed, the property would + // stay absent and the schema validation would judge the document without it. + let _ = data.insert_at_path(path, value); + } + None + } + /// Writes into `data` (a document rebuilt from a create or replace transition) every - /// property generated from state (`sys.tokens`) that it leaves out while supplying the - /// parameter, as `stored` (the document as the platform stored it) holds it. A proof + /// property only the platform generates (`sys.tokens`, read from state, and `sys.random`, + /// drawn from the block's random beacon) that it leaves out while supplying the + /// parameters, as `stored` (the document as the platform stored it) holds it. A proof /// verifier rebuilds the document a transition wrote to compare it with the proved one, /// and holds no state: it takes such a value from the proved document, which the - /// platform judged when it wrote it (`generate_state_read_properties`). A value the - /// transition sent stays, so a proved document holding another fails the comparison. + /// platform judged when it wrote it (`generate_state_read_properties`, + /// `generate_random_properties`). A value the transition sent stays, so a proved document + /// holding another fails the comparison. /// /// Inert on a type without such a declaration, which covers every type parsed before /// protocol version 15. @@ -437,7 +515,7 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { Self: DocumentTypeV2Getters, { for (path, generated_from) in self.generated_from_fields() { - if !generated_from.function.reads_state() { + if !generated_from.function.platform_generated() { continue; } let params_supplied = generated_from @@ -590,12 +668,12 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { if arguments.iter().any(Option::is_none) { // Nothing to generate from: the property must be left out too value.is_none() - } else if generated_from.function.reads_state() { - // Generated from state (protocol version 15, inert before: no type - // parsed earlier declares such a function): the platform wrote or - // checked it when it built the write - // (`generate_state_read_properties`), and a client, which holds no - // state, leaves it out + } else if generated_from.function.platform_generated() { + // Generated from state or drawn at random (protocol version 15, inert + // before: no type parsed earlier declares such a function): the + // platform wrote or checked it when it built the write + // (`generate_state_read_properties`, `generate_random_properties`), + // and a client, which cannot know it, leaves it out true } else { let texts: Option> = arguments @@ -706,10 +784,11 @@ pub fn read_data_at_path<'a>( read_path_segments(value, segments) } -/// The paths of the properties generated from state (`sys.tokens`, protocol version 15) that -/// `properties`, a document's data, leaves out while supplying their parameter. The platform -/// writes such a value when it builds the write (`generate_state_read_properties`), before it -/// judges the rules; a client, which reads no state, leaves it out +/// The paths of the properties only the platform generates (`sys.tokens`, read from state, and +/// `sys.random`, drawn from the block's random beacon, protocol version 15) that `properties`, +/// a document's data, leaves out while supplying their parameters. The platform writes such a +/// value when it builds the write (`generate_state_read_properties`, +/// `generate_random_properties`), before it judges the rules; a client leaves it out /// (`regenerate_generated_properties`) and does not know it. Empty on a type without such a /// declaration, which covers every type parsed before protocol version 15. pub(crate) fn state_generated_paths_left_out<'a>( @@ -719,7 +798,7 @@ pub(crate) fn state_generated_paths_left_out<'a>( generated_from_fields .iter() .filter(|(path, generated_from)| { - generated_from.function.reads_state() + generated_from.function.platform_generated() && matches!(read_path_segments(properties, path.split('.')), Ok(None)) && generated_from.property_params().all(|param| { matches!( @@ -739,7 +818,9 @@ pub(crate) fn state_generated_paths_left_out<'a>( /// not know. Registration admits `sys.tokens.decimals` only on an integer property holding /// every value from 0 to 255, declared in place with every object holding it (no `$ref`, /// which would bring in schemas it does not judge), inside no object declaring `const` or -/// `enum` (which would compare the value), so 0 stands in. A `required` list naming such a property, and its +/// `enum` (which would compare the value), so 0 stands in. `sys.random.uniform` is admitted on +/// the same terms, on an integer property whose `minimum` and `maximum` bound the values drawn, +/// so its `minimum` stands in. A `required` list naming such a property, and its /// bounds, then pass, the keys of every object are those the platform's document has, and /// every other check reads the data as it is. `properties` as it /// is on a type without such a declaration, which covers every type parsed before protocol @@ -752,7 +833,18 @@ pub(crate) fn with_state_generated_stand_ins( { let stand_in = match generated_from.function { SystemFunction::Token(TokenFunction::Decimals) => Value::U8(0), - // These read no state + SystemFunction::Random(_) => { + let Some(minimum) = generated_from.range.map(|range| range.minimum()) else { + continue; + }; + // At most 64 bits wide, so one of the two holds it + match (i64::try_from(minimum), u64::try_from(minimum)) { + (Ok(minimum), _) => Value::I64(minimum), + (_, Ok(minimum)) => Value::U64(minimum), + _ => continue, + } + } + // A client generates these itself SystemFunction::StringTransformation(_) | SystemFunction::Hash(_) => continue, }; // The parameter was read through the objects on the way, which registration puts diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 06cf577d693..32231a20ae5 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -50,6 +50,8 @@ use crate::fee::Credits; use crate::version::PlatformVersion; use crate::ProtocolError; use derive_more::From; +use indexmap::IndexMap; +use std::collections::BTreeMap; pub const DEFAULT_HASH_SIZE: usize = 32; pub const DEFAULT_FLOAT_SIZE: usize = 8; @@ -456,6 +458,76 @@ impl DocumentTypeRef<'_> { DocumentTypeRef::V2(v2) => DocumentType::V2((*v2).to_owned()), } } + + /// This document type with only the indexes `keep` admits, everything else as it is, and + /// its index structure built from those indexes alone, as the parse builds the structure of + /// a type declaring only them. Drive prices with it the entries a document takes in some of + /// its type's indexes: a create of a type with a property drawn at random prepays the + /// entries of the indexes holding one (`Drive::prepay_random_document_index_entries`). + pub fn with_only_indexes( + &self, + keep: impl Fn(&Index) -> bool, + platform_version: &PlatformVersion, + ) -> Result { + let mut document_type = self.to_owned_document_type(); + match &mut document_type { + DocumentType::V0(v0) => keep_only_indexes( + &mut v0.indices, + &mut v0.index_structure, + &v0.flattened_properties, + &v0.name, + keep, + platform_version, + )?, + DocumentType::V1(v1) => keep_only_indexes( + &mut v1.indices, + &mut v1.index_structure, + &v1.flattened_properties, + &v1.name, + keep, + platform_version, + )?, + DocumentType::V2(v2) => keep_only_indexes( + &mut v2.indices, + &mut v2.index_structure, + &v2.flattened_properties, + &v2.name, + keep, + platform_version, + )?, + } + Ok(document_type) + } +} + +/// Drops from `indices` every index `keep` refuses, and builds `index_structure` from the rest +/// as the parse builds a type's: [`IndexLevel::try_from_indices`], then the levels of the typed +/// arrays an index keys documents by stamped with their `maxItems`. +fn keep_only_indexes( + indices: &mut BTreeMap, + index_structure: &mut IndexLevel, + flattened_properties: &IndexMap, + name: &str, + keep: impl Fn(&Index) -> bool, + platform_version: &PlatformVersion, +) -> Result<(), ProtocolError> { + indices.retain(|_, index| keep(index)); + let mut kept_structure = + IndexLevel::try_from_indices(indices.values(), name, platform_version)?; + let indexed_arrays: BTreeMap = indices + .values() + .filter_map(|index| { + let array = index.indexed_array.as_ref()?; + index + .indexed_array_fan_out(flattened_properties) + .map(|(_, max_items)| (array.clone(), max_items)) + }) + .collect(); + if !indexed_arrays.is_empty() { + kept_structure.stamp_indexed_arrays(&indexed_arrays); + } + *index_structure = kept_structure; + Ok(()) } impl DocumentTypeBasicMethods for DocumentType { diff --git a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/mod.rs index 2bcecdd49a3..7fcc12e3f59 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/mod.rs @@ -11,7 +11,9 @@ pub mod system_function; -pub use system_function::{HashFunction, StringTransformation, SystemFunction, TokenFunction}; +pub use system_function::{ + HashFunction, RandomFunction, RandomRange, StringTransformation, SystemFunction, TokenFunction, +}; use serde::{Deserialize, Serialize}; @@ -27,6 +29,11 @@ use serde::{Deserialize, Serialize}; /// reads, none transient, none generated itself, and each inside every object /// that holds the declaring property, so a document supplying the parameters /// always has somewhere to put the generated value. +/// +/// From protocol version 15 an integer property may also be declared +/// `"generatedFrom": { "function": "sys.random.uniform" }`, with no `params`: the +/// platform draws its value from the property's `minimum` to its `maximum`, which +/// registration reads into [`GeneratedFrom::range`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct GeneratedFrom { @@ -34,6 +41,10 @@ pub struct GeneratedFrom { pub function: SystemFunction, /// What the function is applied to, in order. pub params: Vec, + /// The values a `sys.random` function draws from, read from the property's + /// `minimum` and `maximum` at registration. `None` for every other function. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub range: Option, } impl GeneratedFrom { @@ -74,6 +85,7 @@ mod tests { StringTransformation::HomographSafeAscii, ), params: vec![GenerationParam::Property("label".to_string())], + range: None, }; assert_eq!(declaration.params_description(), "label"); assert_eq!( diff --git a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/mod.rs index cef670f790f..93b1b5c16dd 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/mod.rs @@ -7,13 +7,18 @@ //! lookup key is computed with. `sys.tokens` is [`TokenFunction`], in //! [`tokens`], which a `generatedFrom` property is generated with from protocol //! version 15: unlike the string transformations, its functions read state. -//! Each keyword takes the functions of its own namespaces only. +//! `sys.random` is [`RandomFunction`], in [`random`], which a `generatedFrom` +//! property is generated with from protocol version 15 too: the platform draws its +//! value from the block's random beacon. Each keyword takes the functions of its +//! own namespaces only. pub mod hash; +pub mod random; pub mod string_transformations; pub mod tokens; pub use hash::HashFunction; +pub use random::{RandomFunction, RandomRange}; pub use string_transformations::StringTransformation; pub use tokens::TokenFunction; @@ -26,8 +31,9 @@ use std::fmt; /// A closed list of built-ins. Every node must compute exactly the same value, /// so each function is defined without any table that could differ between /// builds (Unicode case mappings change between Rust releases): the string -/// transformations change ASCII characters only, and the token functions read -/// committed state. +/// transformations change ASCII characters only, the token functions read +/// committed state, and the random functions read the block's random beacon, +/// which every node recovers to the same bytes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SystemFunction { /// `sys.stringTransformations.*`: one string to a string. @@ -37,20 +43,35 @@ pub enum SystemFunction { /// `sys.tokens.*`: one token id to what the token's configuration holds /// (protocol version 15). Token(TokenFunction), + /// `sys.random.*`: no parameters to a value drawn from the block's random + /// beacon (protocol version 15). + Random(RandomFunction), } impl SystemFunction { - /// Every function, in wire-name order: the hashes, the string - /// transformations, then the token functions. + /// Every function, in wire-name order: the hashes, the random functions, the + /// string transformations, then the token functions. pub const ALL: [SystemFunction; - HashFunction::ALL.len() + StringTransformation::ALL.len() + TokenFunction::ALL.len()] = { + HashFunction::ALL.len() + + RandomFunction::ALL.len() + + StringTransformation::ALL.len() + + TokenFunction::ALL.len()] = { let mut all = [SystemFunction::Hash(HashFunction::ALL[0]); - HashFunction::ALL.len() + StringTransformation::ALL.len() + TokenFunction::ALL.len()]; + HashFunction::ALL.len() + + RandomFunction::ALL.len() + + StringTransformation::ALL.len() + + TokenFunction::ALL.len()]; let mut index = 0; while index < HashFunction::ALL.len() { all[index] = SystemFunction::Hash(HashFunction::ALL[index]); index += 1; } + let mut random = 0; + while random < RandomFunction::ALL.len() { + all[index] = SystemFunction::Random(RandomFunction::ALL[random]); + index += 1; + random += 1; + } let mut transformation = 0; while transformation < StringTransformation::ALL.len() { all[index] = @@ -74,6 +95,7 @@ impl SystemFunction { SystemFunction::StringTransformation(transformation) => transformation.as_str(), SystemFunction::Hash(hash) => hash.as_str(), SystemFunction::Token(token) => token.as_str(), + SystemFunction::Random(random) => random.as_str(), } } @@ -85,22 +107,25 @@ impl SystemFunction { } /// How many parameters the function takes: a string transformation one - /// string, a token function one token id, `None` for a hash, which takes - /// one or more. + /// string, a token function one token id, a random function none, `None` + /// for a hash, which takes one or more. pub const fn parameter_count(&self) -> Option { match self { SystemFunction::StringTransformation(_) | SystemFunction::Token(_) => Some(1), + SystemFunction::Random(_) => Some(0), SystemFunction::Hash(_) => None, } } /// Whether a property the function generates is an integer one: `sys.tokens.decimals` - /// generates a token's decimals. A string transformation generates a string, and a hash - /// generates no property (it keys a `refersTo` lookup). Every function is named, so one - /// added must say which it generates. + /// generates a token's decimals, and `sys.random.uniform` an integer of the property's + /// range. A string transformation generates a string, and a hash generates no property + /// (it keys a `refersTo` lookup). Every function is named, so one added must say which it + /// generates. pub const fn generates_an_integer(&self) -> bool { match self { - SystemFunction::Token(TokenFunction::Decimals) => true, + SystemFunction::Token(TokenFunction::Decimals) + | SystemFunction::Random(RandomFunction::Uniform) => true, SystemFunction::StringTransformation(_) | SystemFunction::Hash(_) => false, } } @@ -114,10 +139,20 @@ impl SystemFunction { matches!(self, SystemFunction::Token(_)) } + /// Whether only the platform can generate a property the function generates, so a + /// client leaves it out: a token function reads state, and a random function draws from + /// the block's random beacon, which no one knows when the document is sent. Such a value + /// is generated when the document is created and kept after. A string transformation + /// generates from the document's own data, which a client can do too. + pub const fn platform_generated(&self) -> bool { + matches!(self, SystemFunction::Token(_) | SystemFunction::Random(_)) + } + /// The string value for `arguments`, the parameters' values in `params` /// order. `None` when their count is not the function's, which registration /// rules out, and for a function that does not return a string from - /// strings (a hash, or a token function, which reads state). + /// strings (a hash, a token function, which reads state, or a random + /// function, which the platform draws). /// The platform writes this value into a document that leaves the property /// out, and compares a sent value with it. pub fn apply(&self, arguments: &[&str]) -> Option { @@ -190,12 +225,24 @@ mod tests { assert_eq!(function.parameter_count(), Some(1), "{function}"); assert_eq!(function.apply(&["a"]), None, "{function}"); } + SystemFunction::Random(_) => { + assert_eq!(function.parameter_count(), Some(0), "{function}"); + assert_eq!(function.apply(&["a"]), None, "{function}"); + } } assert_eq!( function.reads_state(), matches!(function, SystemFunction::Token(_)), "{function}" ); + assert_eq!( + function.platform_generated(), + matches!( + function, + SystemFunction::Token(_) | SystemFunction::Random(_) + ), + "{function}" + ); assert_eq!(function.apply(&[]), None, "{function}"); assert_eq!(function.apply(&["a", "b"]), None, "{function}"); } @@ -211,6 +258,7 @@ mod tests { names, [ "sys.hash.sha256d", + "sys.random.uniform", "sys.stringTransformations.camelCase", "sys.stringTransformations.capitalize", "sys.stringTransformations.homographSafeASCII", @@ -248,9 +296,9 @@ mod tests { /// Meta-schema v3 lists every system function the parser knew at protocol /// version 14, each where its keyword takes it, so a name it admits always /// parses: the string transformations for `generatedFrom`, the hashes for a - /// lookup key. The token functions came with protocol version 15, whose - /// registration is checked natively (`native_schema`), and the meta-schema, - /// frozen with protocol version 14, keeps refusing them. + /// lookup key. The token and random functions came with protocol version 15, + /// whose registration is checked natively (`native_schema`), and the + /// meta-schema, frozen with protocol version 14, keeps refusing them. #[test] fn should_list_every_function_in_the_meta_schema() { let meta_schema: serde_json::Value = serde_json::from_str(include_str!( diff --git a/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/random.rs b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/random.rs new file mode 100644 index 00000000000..e2a00ae0a4c --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/random.rs @@ -0,0 +1,226 @@ +//! `sys.random`: system functions that draw a value nobody can know when the document is +//! sent (protocol version 15). +//! +//! A property one of them generates is written with a placeholder when the document is +//! created, and the platform replaces the placeholder before the block commits, with a value +//! derived from the block's random beacon: the threshold signature the validators' prevotes +//! for the block combine into, which no one can compute before the block is accepted. The +//! value is kept after: a replace leaves it as it is. A client cannot know it, so it leaves +//! the property out, like a property generated from state. + +use crate::data_contract::document_type::DocumentPropertyType; +use platform_value::Value; +use serde::{Deserialize, Serialize}; + +/// A `sys.random` function. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RandomFunction { + /// `sys.random.uniform`: an integer drawn uniformly from the property's `minimum` to its + /// `maximum`, both included ([`RandomRange`]). + Uniform, +} + +impl RandomFunction { + /// Every random function, in wire-name order. + pub const ALL: [RandomFunction; 1] = [RandomFunction::Uniform]; + + /// The wire name, the value of `generatedFrom.function`. + pub const fn as_str(&self) -> &'static str { + match self { + RandomFunction::Uniform => "sys.random.uniform", + } + } +} + +/// The integers a `sys.random.uniform` property is drawn from: the property's `minimum` to its +/// `maximum`, both included. Registration reads both from the property's schema and requires +/// them to fit the property's integer type, at most 64 bits wide. [`Self::new`] holds every +/// range to `minimum <= maximum` and at most 2^64 values, which a draw maps 128 random bits +/// onto, and so does the deserialization of one: the fields are private, so no range breaks +/// either. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", try_from = "UncheckedRandomRange")] +pub struct RandomRange { + /// The smallest value drawn, and the placeholder the document holds until the draw. + minimum: i128, + /// The largest value drawn. + maximum: i128, +} + +/// The fields of a [`RandomRange`] as sent, checked by its `TryFrom`. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct UncheckedRandomRange { + minimum: i128, + maximum: i128, +} + +impl TryFrom for RandomRange { + type Error = String; + + fn try_from(unchecked: UncheckedRandomRange) -> Result { + let UncheckedRandomRange { minimum, maximum } = unchecked; + RandomRange::new(minimum, maximum).ok_or_else(|| { + format!( + "a random range holds its minimum, {minimum}, to its maximum, {maximum}: the \ + minimum may not be above the maximum, and the range may hold at most 2^64 values" + ) + }) + } +} + +impl RandomRange { + /// The most values a range holds, 2^64: a draw maps 128 random bits onto the range, which + /// favours no value of a range this size or smaller by more than 2^-64. + pub const MAX_SIZE: u128 = 1 << 64; + + /// The range from `minimum` to `maximum`, both included; `None` when `minimum` is above + /// `maximum`, or the range would hold more than [`Self::MAX_SIZE`] values. + pub fn new(minimum: i128, maximum: i128) -> Option { + // The values past the minimum: not negative, and at most 2^64 - 1, so a u64 holds them + let span = maximum.checked_sub(minimum)?; + (0..=i128::from(u64::MAX)) + .contains(&span) + .then_some(RandomRange { minimum, maximum }) + } + + /// The smallest value drawn, and the placeholder the document holds until the draw. + pub fn minimum(&self) -> i128 { + self.minimum + } + + /// The largest value drawn. + pub fn maximum(&self) -> i128 { + self.maximum + } + + /// The value at `offset` from `minimum`, for an `offset` below [`Self::size`]. + pub fn value_at(&self, offset: u128) -> Option { + let offset = i128::try_from(offset).ok()?; + let value = self.minimum.checked_add(offset)?; + (value <= self.maximum).then_some(value) + } + + /// How many values the range holds: at least one and at most [`Self::MAX_SIZE`]. One more + /// than a u64, so the sum cannot overflow. + pub fn size(&self) -> u128 { + u128::from(self.span()) + 1 + } + + /// How many values the range holds past its minimum. + fn span(&self) -> u64 { + // `new` admits only a `maximum` at most `u64::MAX` past `minimum`, so neither the + // difference nor the conversion fails + self.maximum + .checked_sub(self.minimum) + .and_then(|span| u64::try_from(span).ok()) + .unwrap_or(u64::MAX) + } +} + +/// `value` as `property_type` stores it, `None` when the type is not an integer one of at most +/// 64 bits or `value` does not fit it. +pub fn integer_value_for_type(property_type: &DocumentPropertyType, value: i128) -> Option { + Some(match property_type { + DocumentPropertyType::U8 => Value::U8(u8::try_from(value).ok()?), + DocumentPropertyType::U16 => Value::U16(u16::try_from(value).ok()?), + DocumentPropertyType::U32 => Value::U32(u32::try_from(value).ok()?), + DocumentPropertyType::U64 => Value::U64(u64::try_from(value).ok()?), + DocumentPropertyType::I8 => Value::I8(i8::try_from(value).ok()?), + DocumentPropertyType::I16 => Value::I16(i16::try_from(value).ok()?), + DocumentPropertyType::I32 => Value::I32(i32::try_from(value).ok()?), + DocumentPropertyType::I64 => Value::I64(i64::try_from(value).ok()?), + _ => return None, + }) +} + +/// The smallest and largest values `property_type` holds, for the integer types of at most 64 +/// bits, the ones a `sys.random.uniform` property may have. +pub fn integer_type_bounds(property_type: &DocumentPropertyType) -> Option<(i128, i128)> { + Some(match property_type { + DocumentPropertyType::U8 => (0, u8::MAX.into()), + DocumentPropertyType::U16 => (0, u16::MAX.into()), + DocumentPropertyType::U32 => (0, u32::MAX.into()), + DocumentPropertyType::U64 => (0, u64::MAX.into()), + DocumentPropertyType::I8 => (i8::MIN.into(), i8::MAX.into()), + DocumentPropertyType::I16 => (i16::MIN.into(), i16::MAX.into()), + DocumentPropertyType::I32 => (i32::MIN.into(), i32::MAX.into()), + DocumentPropertyType::I64 => (i64::MIN.into(), i64::MAX.into()), + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_count_and_index_every_value_of_the_range() { + let range = RandomRange::new(-2, 2).expect("a range"); + assert_eq!(range.size(), 5); + assert_eq!(range.value_at(0), Some(-2)); + assert_eq!(range.value_at(4), Some(2)); + assert_eq!(range.value_at(5), None); + + let single = RandomRange::new(7, 7).expect("a range of one value"); + assert_eq!(single.size(), 1); + assert_eq!(single.value_at(0), Some(7)); + + let widest = RandomRange::new(0, u64::MAX.into()).expect("2^64 values"); + assert_eq!(widest.size(), RandomRange::MAX_SIZE); + assert_eq!(widest.value_at((1u128 << 64) - 1), Some(u64::MAX.into())); + let widest_signed = + RandomRange::new(i64::MIN.into(), i64::MAX.into()).expect("2^64 values"); + assert_eq!(widest_signed.size(), RandomRange::MAX_SIZE); + } + + #[test] + fn should_refuse_a_range_upside_down_or_of_more_than_2_pow_64_values() { + assert_eq!(RandomRange::new(10, 1), None); + assert_eq!(RandomRange::new(0, i128::from(u64::MAX) + 1), None); + assert_eq!(RandomRange::new(-1, u64::MAX.into()), None); + // The widest pair of bounds, whose difference does not even fit an i128 + assert_eq!(RandomRange::new(i128::MIN, i128::MAX), None); + } + + #[test] + fn should_deserialize_only_a_range_new_admits() { + let range: RandomRange = + serde_json::from_str(r#"{ "minimum": 1, "maximum": 100 }"#).expect("a range"); + assert_eq!((range.minimum(), range.maximum()), (1, 100)); + assert_eq!( + serde_json::to_value(range).expect("serializes"), + serde_json::json!({ "minimum": 1, "maximum": 100 }) + ); + assert!( + serde_json::from_str::(r#"{ "minimum": 100, "maximum": 1 }"#).is_err() + ); + assert!(serde_json::from_str::( + r#"{ "minimum": -1, "maximum": 18446744073709551615 }"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{ "minimum": 1, "maximum": 100, "extra": 0 }"# + ) + .is_err()); + } + + #[test] + fn should_store_a_value_at_the_property_width_only_when_it_fits() { + assert_eq!( + integer_value_for_type(&DocumentPropertyType::U8, 200), + Some(Value::U8(200)) + ); + assert_eq!(integer_value_for_type(&DocumentPropertyType::U8, 256), None); + assert_eq!( + integer_value_for_type(&DocumentPropertyType::I16, -5), + Some(Value::I16(-5)) + ); + assert_eq!(integer_value_for_type(&DocumentPropertyType::U128, 1), None); + assert_eq!( + integer_type_bounds(&DocumentPropertyType::I64), + Some((i64::MIN.into(), i64::MAX.into())) + ); + assert_eq!(integer_type_bounds(&DocumentPropertyType::I128), None); + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index a1f37c863f9..361a8cb2daf 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 @@ -18,7 +18,7 @@ use crate::data_contract::config::v1::DataContractConfigGettersV1; use crate::data_contract::config::v2::DataContractConfigGettersV2; use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; -use crate::data_contract::document_type::{property_names, DocumentTypeRef}; +use crate::data_contract::document_type::{property_names, DocumentTypeRef, Index}; use crate::data_contract::DataContract; use crate::document::property_names::{CREATOR_ID, ID, OWNER_ID}; use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -49,9 +49,10 @@ pub mod reference_lookup; pub mod reference_where; pub use encrypted_for::{EncryptedFor, EncryptedForRecipient, EncryptionScheme}; +pub use generated_from::system_function::random::{integer_type_bounds, integer_value_for_type}; pub use generated_from::{ - GeneratedFrom, GenerationParam, HashFunction, StringTransformation, SystemFunction, - TokenFunction, + GeneratedFrom, GenerationParam, HashFunction, RandomFunction, RandomRange, + StringTransformation, SystemFunction, TokenFunction, }; pub use list_element_reference::ListElementReference; pub use lookup_preimage::{ @@ -1594,6 +1595,31 @@ pub fn is_transient(document_type: DocumentTypeRef, path: &str) -> bool { is_path_listed(document_type.transient_fields(), path) } +/// Whether the property at the dotted `path` of `document_type` is drawn at random +/// (`generatedFrom` with a `sys.random` function, protocol version 15): written with a +/// placeholder when its document is created, and drawn from the block's random beacon once the +/// block's transitions are accepted, when nothing can be refused any more. A rule judging a +/// write that reads it would judge the placeholder, and a value read from it would move with the +/// draw, so registration refuses both. `false` on every type parsed before protocol version 15, +/// whose parsers admit no such function. +pub fn is_drawn_at_random(document_type: DocumentTypeRef, path: &str) -> bool { + document_type + .flattened_properties() + .get(path) + .and_then(|property| property.generated_from.as_ref()) + .is_some_and(|generated_from| matches!(generated_from.function, SystemFunction::Random(_))) +} + +/// Whether `index`, of `document_type`, holds a property drawn at random +/// ([`is_drawn_at_random`]): the draw moves the document's entry in it, and may create the +/// trees the drawn value is keyed under, which the document's creation prepays. +pub fn indexes_a_random_property(document_type: DocumentTypeRef, index: &Index) -> bool { + index + .properties + .iter() + .any(|property| is_drawn_at_random(document_type, &property.name)) +} + impl std::fmt::Display for DocumentPropertyReferenceTarget { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { 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 index fa80bb8cb1f..a7de8ff909c 100644 --- 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 @@ -28,7 +28,8 @@ use crate::data_contract::document_type::property::lookup_preimage::{ LookupHashKey, LookupKeyParam, }; use crate::data_contract::document_type::property::{ - is_transient, top_level_property, DocumentPropertyReferenceTarget, DocumentPropertyType, + is_drawn_at_random, is_transient, top_level_property, DocumentPropertyReferenceTarget, + DocumentPropertyType, }; /// Why a key part moves when it is a field only the contract's moderators write @@ -38,6 +39,10 @@ const MODERATORS_CHANGE: &str = "the contract's moderators change"; /// Why a key part moves when it is an immutable, optional `deletableDocument` /// reference by id: a replace may clear it once its document is deleted. const CLEARED_ONCE_DELETED: &str = "a replace can clear once its document is deleted"; + +/// Why a key part moves when it is drawn at random (`sys.random`): the draw replaces the +/// placeholder its document was created with once the block's transitions are accepted. +const DRAWN_AT_RANDOM: &str = "the draw from the block's random beacon changes"; use crate::data_contract::document_type::{property_names, DocumentTypeRef, Index}; use crate::data_contract::errors::DataContractError; use crate::document::property_names::{ @@ -1115,7 +1120,7 @@ pub fn owner_can_change(document_type: DocumentTypeRef) -> bool { /// with a replace; a schema property is fixed when /// [`schema_property_is_fixed_once_written`] says so and a replace cannot /// clear it ([`clearable_once_its_document_is_deleted`]), and its moderators' -/// fields are named as such. Shared by a lookup's key and the +/// fields and a property drawn at random are named as such. Shared by a lookup's key and the /// `summableOffCountIndex` lossless rule, so the two judge a value alike. pub(crate) fn why_value_can_change( referenced: DocumentTypeRef, @@ -1140,6 +1145,8 @@ pub(crate) fn why_value_can_change( .contains(top_level_property(property)) { Some(MODERATORS_CHANGE) + } else if is_drawn_at_random(referenced, property) { + Some(DRAWN_AT_RANDOM) } else if !schema_property_is_fixed_once_written(referenced, property) { Some("a replace can change") } else { @@ -1156,20 +1163,27 @@ pub(crate) fn why_value_can_change( /// under `immutable`, and in either case the contract's moderators do not /// write it (`moderatorAbilities.changeFields`, which the parser keeps apart /// from `immutable`). A property listed under `immutable` with a condition is -/// not fixed: a replace may change it while its condition does not hold. The +/// not fixed: a replace may change it while its condition does not hold. Nor is +/// a property drawn at random ([`is_drawn_at_random`]): the draw replaces the +/// placeholder once the block's transitions are accepted, after a value read +/// from it was written, and moves nothing that read it. The /// flags and the moderators' fields are immutable on contract update and the /// properties `immutable` lists without a condition may only grow, so the /// answer holds for good. The one rule -/// both a lookup's key parts and a list element's list are judged by. +/// both a lookup's key parts and a list element's list are judged by, and the +/// field a derived index property of another type reads. pub(crate) fn schema_property_is_fixed_once_written( document_type: DocumentTypeRef, path: &str, ) -> bool { let top_level = top_level_property(path); + // In place, inert before protocol version 15: no type parsed earlier declares a + // `sys.random` function, and none does from it until `random_generated_from` admits one (!document_type.documents_mutable() || document_type.immutable_fields().contains(top_level)) && !document_type .moderator_changeable_fields() .contains(top_level) + && !is_drawn_at_random(document_type, path) } /// Whether a replace may clear the top-level property of `path` of a document diff --git a/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/property.rs b/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/property.rs index 5831f6365c2..b9f40c2493b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/property.rs +++ b/packages/rs-dpp/src/data_contract/document_type/serialized_version/v0/property.rs @@ -11,8 +11,8 @@ use crate::data_contract::document_type::{ ByteArrayPropertySizes, DistinctFrom, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, EncryptedFor, EncryptedForRecipient, EncryptionScheme, GeneratedFrom, GenerationParam, HashFunction, IdentityKeyReferenceRequirements, KeyIdReference, - KeyReferenceIdentityProperty, StringPropertySizes, StringTransformation, SystemFunction, - TokenFunction, + KeyReferenceIdentityProperty, RandomFunction, RandomRange, StringPropertySizes, + StringTransformation, SystemFunction, TokenFunction, }; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; @@ -140,12 +140,19 @@ pub(super) struct StoredGeneratedFrom { params: Vec, } -/// [`SystemFunction`]. +/// [`SystemFunction`], and for a random function the [`GeneratedFrom::range`] it draws from, +/// which only a random function has: every other declaration keeps the bytes it was compiled +/// to before random functions existed. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] pub(super) enum StoredSystemFunction { StringTransformation(StoredStringTransformation), Hash(HashFunction), Token(StoredTokenFunction), + Random { + function: StoredRandomFunction, + /// [`GeneratedFrom::range`]: `(minimum, maximum)`. + range: Option<(i128, i128)>, + }, } /// [`TokenFunction`]. @@ -154,6 +161,12 @@ pub(super) enum StoredTokenFunction { Decimals, } +/// [`RandomFunction`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] +pub(super) enum StoredRandomFunction { + Uniform, +} + /// [`StringTransformation`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] pub(super) enum StoredStringTransformation { @@ -245,7 +258,7 @@ impl TryFrom for DocumentProperty { StoredDistinctFrom::Property(path) => DistinctFrom::Property(path), }), encrypted_for: encrypted_for.map(EncryptedFor::from), - generated_from: generated_from.map(GeneratedFrom::from), + generated_from: generated_from.map(GeneratedFrom::try_from).transpose()?, revealed_reference, }) } @@ -519,7 +532,11 @@ impl From for EncryptedFor { impl From<&GeneratedFrom> for StoredGeneratedFrom { fn from(generated_from: &GeneratedFrom) -> Self { - let GeneratedFrom { function, params } = generated_from; + let GeneratedFrom { + function, + params, + range, + } = generated_from; StoredGeneratedFrom { function: match function { SystemFunction::StringTransformation(transformation) => { @@ -538,6 +555,12 @@ impl From<&GeneratedFrom> for StoredGeneratedFrom { SystemFunction::Token(token) => StoredSystemFunction::Token(match token { TokenFunction::Decimals => StoredTokenFunction::Decimals, }), + SystemFunction::Random(random) => StoredSystemFunction::Random { + function: match random { + RandomFunction::Uniform => StoredRandomFunction::Uniform, + }, + range: range.map(|range| (range.minimum(), range.maximum())), + }, }, params: params .iter() @@ -549,10 +572,34 @@ impl From<&GeneratedFrom> for StoredGeneratedFrom { } } -impl From for GeneratedFrom { - fn from(stored: StoredGeneratedFrom) -> Self { +impl TryFrom for GeneratedFrom { + type Error = ProtocolError; + + /// Refuses a random declaration without a range, and a stored range [`RandomRange::new`] + /// does not admit, neither of which registration stores, rather than construct a + /// declaration the draw could not read. + fn try_from(stored: StoredGeneratedFrom) -> Result { let StoredGeneratedFrom { function, params } = stored; - GeneratedFrom { + let range = match function { + // Registration gives every random declaration its range, which the draw and the + // placeholder read: one without it could only be skipped by both + StoredSystemFunction::Random { range: None, .. } => { + return Err(stored_error( + "a random generatedFrom declaration holds no range".to_string(), + )) + } + StoredSystemFunction::Random { + range: Some((minimum, maximum)), + .. + } => Some(RandomRange::new(minimum, maximum).ok_or_else(|| { + stored_error(format!( + "generatedFrom range {minimum} to {maximum} is upside down or holds more \ + than 2^64 values" + )) + })?), + _ => None, + }; + Ok(GeneratedFrom { function: match function { StoredSystemFunction::StringTransformation(transformation) => { SystemFunction::StringTransformation(match transformation { @@ -570,8 +617,107 @@ impl From for GeneratedFrom { StoredSystemFunction::Token(token) => SystemFunction::Token(match token { StoredTokenFunction::Decimals => TokenFunction::Decimals, }), + StoredSystemFunction::Random { function, .. } => { + SystemFunction::Random(match function { + StoredRandomFunction::Uniform => RandomFunction::Uniform, + }) + } }, params: params.into_iter().map(GenerationParam::Property).collect(), + range, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use platform_serialization::PLATFORM_BINCODE_CONFIG; + + fn stored_uniform(range: Option<(i128, i128)>) -> StoredGeneratedFrom { + StoredGeneratedFrom { + function: StoredSystemFunction::Random { + function: StoredRandomFunction::Uniform, + range, + }, + params: vec![], + } + } + + #[test] + fn should_read_back_a_stored_random_range() { + let generated_from = GeneratedFrom { + function: SystemFunction::Random(RandomFunction::Uniform), + params: vec![], + range: RandomRange::new(-5, 5), + }; + let stored = StoredGeneratedFrom::from(&generated_from); + assert_eq!(stored, stored_uniform(Some((-5, 5)))); + assert_eq!( + GeneratedFrom::try_from(stored).expect("the range reads back"), + generated_from + ); + } + + #[test] + fn should_keep_the_bytes_of_a_declaration_drawing_nothing() { + // Compiled contracts hold these bytes, so a declaration of any function but a random + // one encodes as it did before random functions existed: the function's variant and + // its own, then the params + let cases: [(SystemFunction, &str, &[u8]); 3] = [ + ( + SystemFunction::StringTransformation(StringTransformation::Lowercase), + "label", + &[0, 3, 1, 5, b'l', b'a', b'b', b'e', b'l'], + ), + ( + SystemFunction::Hash(HashFunction::Sha256d), + "label", + &[1, 0, 1, 5, b'l', b'a', b'b', b'e', b'l'], + ), + ( + SystemFunction::Token(TokenFunction::Decimals), + "tokenId", + &[2, 0, 1, 7, b't', b'o', b'k', b'e', b'n', b'I', b'd'], + ), + ]; + for (function, param, expected) in cases { + let generated_from = GeneratedFrom { + function, + params: vec![GenerationParam::Property(param.to_string())], + range: None, + }; + let bytes = bincode::encode_to_vec( + StoredGeneratedFrom::from(&generated_from), + PLATFORM_BINCODE_CONFIG, + ) + .expect("encodes"); + assert_eq!(bytes, expected, "the bytes of {function}"); + } + } + + #[test] + fn should_refuse_a_stored_random_declaration_without_its_range() { + assert!(matches!( + GeneratedFrom::try_from(stored_uniform(None)), + Err(ProtocolError::CorruptedSerialization(_)) + )); + } + + #[test] + fn should_refuse_a_stored_random_range_it_could_not_draw_from() { + for range in [ + (10, 1), + (0, i128::from(u64::MAX) + 1), + (i128::MIN, i128::MAX), + ] { + assert!( + matches!( + GeneratedFrom::try_from(stored_uniform(Some(range))), + Err(ProtocolError::CorruptedSerialization(_)) + ), + "{range:?} is refused" + ); } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v3/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v3/mod.rs index f54ca388c23..96bb5884543 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v3/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v3/mod.rs @@ -167,3 +167,208 @@ impl DocumentCreateTransitionActionStateValidationV3 for DocumentCreateTransitio Ok(SimpleConsensusValidationResult::new()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform_types::platform::PlatformStateRef; + use crate::test::helpers::setup::TestPlatformBuilder; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; + use dpp::data_contract::DataContract; + use dpp::fee::fee_result::FeeResult; + use dpp::platform_value::{platform_value, Value}; + 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::v0::DocumentCreateTransitionV0; + use dpp::version::DefaultForPlatformVersion; + use dpp::document::Document; + use drive::drive::contract::DataContractFetchInfo; + use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentFromCreateTransitionAction; + use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionActionV0; + use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; + use drive::state_transition_action::batch::batched_transition::BatchedTransitionAction; + use std::collections::BTreeMap; + use std::sync::Arc; + + const OWNER: [u8; 32] = [42; 32]; + const CONTRACT_ID: [u8; 32] = [9; 32]; + + /// The latest platform version with `sys.random.uniform` admitted, which no protocol + /// version does until block execution draws the values. + fn drawing_platform_version() -> PlatformVersion { + let mut platform_version = PlatformVersion::latest().clone(); + platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .random_generated_from = Some(0); + platform_version + } + + /// A card whose `rarity` is drawn from 1 to 100, indexed by it when `indexed`. + fn card_schema(indexed: bool) -> Value { + let mut schema = platform_value!({ + "type": "object", + "documentsMutable": false, + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 }, + "rarity": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "generatedFrom": { "function": "sys.random.uniform" }, + "position": 1 + } + }, + "required": ["name", "rarity"], + "additionalProperties": false + }); + if indexed { + schema + .insert( + "indices".to_string(), + platform_value!([{ "name": "byRarity", "properties": [{ "rarity": "asc" }] }]), + ) + .expect("the schema is a map"); + } + schema + } + + /// The precalculated fees the state validation of a create of a `document_type_name` + /// document charged, and what `Drive::prepay_random_document_index_entries` prices for + /// its document. + fn state_validation_charges(document_type_name: &str) -> (Vec, FeeResult) { + let platform_version = drawing_platform_version(); + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + let contract = DataContract::from_value( + platform_value!({ + "$formatVersion": "1", + "id": Identifier::from(CONTRACT_ID), + "ownerId": Identifier::from(OWNER), + "version": 1u32, + "documentSchemas": { + "card": card_schema(true), + "looseCard": card_schema(false) + } + }), + true, + &platform_version, + ) + .expect("the contract parses"); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + None, + None, + &platform_version, + ) + .expect("the contract is stored"); + let fetch_info: Arc = platform + .drive + .get_contract_with_fetch_info(CONTRACT_ID, false, None, &platform_version) + .expect("the contract reads back") + .expect("the contract is stored"); + + let transition = DocumentCreateTransitionV0 { + base: DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([1; 32]), + identity_contract_nonce: 1, + document_type_name: document_type_name.to_string(), + data_contract_id: Identifier::from(CONTRACT_ID), + }), + entropy: [2; 32], + data: BTreeMap::from([("name".to_string(), Value::Text("Ace".to_string()))]), + prefunded_voting_balance: None, + }; + let (result, _) = + DocumentCreateTransitionActionV0::try_from_borrowed_document_create_transition_with_contract_lookup( + &platform.drive, + Identifier::from(OWNER), + None, + &transition, + &BlockInfo::default(), + 0, + |_| Ok(fetch_info.clone()), + &platform_version, + ) + .expect("the action builds"); + let BatchedTransitionAction::DocumentAction(DocumentTransitionAction::CreateAction( + mut action, + )) = result.into_data().expect("an action") + else { + panic!("a create action"); + }; + + let state = platform.state.load(); + let platform_ref = PlatformStateRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + }; + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(&platform_version) + .expect("an execution context"); + let validation = action + .validate_state_v3( + &platform_ref, + Identifier::from(OWNER), + &BlockInfo::default(), + &mut vec![], + &mut execution_context, + None, + &platform_version, + ) + .expect("the state validation runs"); + assert!(validation.is_valid(), "{:?}", validation.errors); + + let charged = execution_context + .operations_slice() + .iter() + .filter_map(|operation| match operation { + ValidationOperation::PrecalculatedOperation(fee_result) => Some(fee_result.clone()), + _ => None, + }) + .collect(); + let document = Document::try_from_create_transition_action( + &action, + Identifier::from(OWNER), + &platform_version, + ) + .expect("the document"); + let prepayment = platform + .drive + .prepay_random_document_index_entries( + &document, + Identifier::from(OWNER), + &fetch_info.contract, + fetch_info + .contract + .document_type_for_name(document_type_name) + .expect("the type"), + &BlockInfo::default(), + None, + &platform_version, + ) + .expect("the prepayment is priced"); + (charged, prepayment) + } + + #[test] + fn should_leave_the_draw_prepayment_to_the_insert() { + // State validation charges a create indexing a random property what it charges one + // that indexes none: the prepayment rides on the document's insert, so a create + // refused at a later stage, whose document is never inserted, prepays nothing + let (indexed, prepayment) = state_validation_charges("card"); + let (unindexed, nothing) = state_validation_charges("looseCard"); + assert_ne!(prepayment, FeeResult::default()); + assert_eq!(nothing, FeeResult::default()); + assert_eq!(indexed, unindexed); + } +} 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 dc5e2c6272c..9b227565a74 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 @@ -7,7 +7,7 @@ use dpp::data_contract::document_type::class_methods::{ }; use dpp::data_contract::document_type::property_constraints::referenced_fields; use dpp::data_contract::document_type::{ - is_path_listed, is_referenced_system_agreement_property, + is_drawn_at_random, is_path_listed, is_referenced_system_agreement_property, is_referring_system_agreement_property, is_transient, typed_array_property, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, DocumentReferenceKind, DocumentTypeRef, KeyReferenceIdentityProperty, PreallocatedKeySource, @@ -54,9 +54,10 @@ fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { /// value to compare on a document of `referenced_document_type`, of /// `referenced_contract`, `None` when it does: `$creatorId` on a type that /// records none, a property the type does not define, or one no stored -/// document carries. `$ownerId` and `$id` always hold one. Inert before -/// protocol version 14, which alone reaches this module (contract create and -/// update state validation 1). +/// document carries, or one drawn at random, which a document created in the +/// same block holds as its placeholder. `$ownerId` and `$id` always hold one. +/// Inert before protocol version 14, which alone reaches this module (contract +/// create and update state validation 1). fn referenced_side_error( referenced_contract: &DataContract, referenced_document_type: DocumentTypeRef, @@ -96,6 +97,16 @@ fn referenced_side_error( document carries its value, so none could be compared with", )); } + // In place, inert before protocol version 15: no type parsed earlier declares a + // `sys.random` function, and none does from it until `random_generated_from` admits one + if is_drawn_at_random(referenced_document_type, referenced_property) { + return Ok(Some( + "the referenced property is drawn at random once the block's transitions are \ + accepted: a referring document written in the block that created the referenced \ + one would be compared with its placeholder, and one written later with the drawn \ + value", + )); + } Ok(None) } @@ -1423,4 +1434,61 @@ mod tests { // is keyed by its value assert_eq!(keyed_by(&contract_keeping(None)), None); } + + #[test] + fn should_refuse_a_where_entry_comparing_a_property_drawn_at_random() { + // No protocol version admits `sys.random.uniform` yet: the contract parses at the + // latest one with it admitted + let mut platform_version = PlatformVersion::latest().clone(); + platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .random_generated_from = Some(0); + let contract = DataContract::from_value( + platform_value!({ + "$formatVersion": "1", + "id": Value::Identifier([7; 32]), + "ownerId": Value::Identifier([8; 32]), + "version": 1, + "documentSchemas": { + "card": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 }, + "rarity": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "generatedFrom": { "function": "sys.random.uniform" }, + "position": 1, + }, + }, + "required": ["name", "rarity"], + "additionalProperties": false, + }, + }, + }), + false, + &platform_version, + ) + .expect("the contract parses"); + let card = contract + .document_type_for_name("card") + .expect("the card type"); + let error = referenced_side_error(&contract, card, "rarity", &platform_version) + .expect("the check runs"); + assert!( + error.is_some_and(|reason| reason.contains("drawn at random")), + "a where entry comparing the drawn rarity is refused" + ); + assert_eq!( + referenced_side_error(&contract, card, "name", &platform_version) + .expect("the check runs"), + None + ); + } } diff --git a/packages/rs-drive/src/drive/document/cost/mod.rs b/packages/rs-drive/src/drive/document/cost/mod.rs index ef01fcda46a..e009ccb5dc6 100644 --- a/packages/rs-drive/src/drive/document/cost/mod.rs +++ b/packages/rs-drive/src/drive/document/cost/mod.rs @@ -36,7 +36,7 @@ use dpp::data_contract::document_type::accessors::{ }; use dpp::data_contract::document_type::action_fees::{ActionFeePricing, DocumentActionFee}; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; -use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::document_type::{indexes_a_random_property, DocumentTypeRef}; use dpp::data_contract::DataContract; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dpp::document::{Document, DocumentV0Getters}; @@ -272,7 +272,11 @@ impl DocumentCreateCost { /// document with a `ttl` pays for its bytes by its lifetime, carries no flags /// (so nothing is refunded) and prepays its deletion. The storage is an /// uncontested create's: a value that starts a contest is stored in the -/// contest's vote poll until it ends, which this does not price. +/// contest's vote poll until it ends, which this does not price. A type +/// indexing a property drawn at random (protocol version 15) is refused: its +/// create also prepays the index entries the draw may create +/// (`Drive::prepay_random_document_index_entries`), which this does not +/// price either, and an estimate leaving it out would understate the create. pub fn document_create_cost( contract: &DataContract, document_type: DocumentTypeRef, @@ -281,6 +285,16 @@ pub fn document_create_cost( platform_version: &PlatformVersion, ) -> Result { check_mirrored_method_versions(platform_version)?; + if document_type + .indexes() + .values() + .any(|index| indexes_a_random_property(document_type, index)) + { + return Err(Error::Drive(DriveError::NotSupported( + "the cost of creating a document of a type indexing a property drawn at random, \ + whose create also prepays the draw's index entries", + ))); + } let fee_version = &platform_version.fee_version; // A document with a `ttl` pays for its bytes by the lifetime it has // left, all of its `ttl` when it is created. diff --git a/packages/rs-drive/src/drive/document/cost/tests.rs b/packages/rs-drive/src/drive/document/cost/tests.rs index 057712b413f..e65ef0c43b2 100644 --- a/packages/rs-drive/src/drive/document/cost/tests.rs +++ b/packages/rs-drive/src/drive/document/cost/tests.rs @@ -14,6 +14,7 @@ use crate::util::storage_flags::StorageFlags; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; use crate::util::test_helpers::setup_contract; use dpp::block::block_info::BlockInfo; +use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; use dpp::data_contract::document_type::random_document::CreateRandomDocument; use dpp::data_contract::DataContractFactory; use dpp::document::document_methods::DocumentMethodsV0; @@ -406,6 +407,60 @@ fn note_document(contract: &DataContract) -> Document { document } +#[test] +fn should_refuse_the_cost_of_a_type_indexing_a_property_drawn_at_random() { + // Registered at the latest platform version with `sys.random.uniform` admitted + let mut platform_version = PlatformVersion::latest().clone(); + platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .random_generated_from = Some(0); + let contract = DataContract::from_value( + platform_value!({ + "$formatVersion": "1", + "id": Identifier::from([7; 32]), + "ownerId": Identifier::from([8; 32]), + "version": 1u32, + "documentSchemas": { + "card": { + "type": "object", + "documentsMutable": false, + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 }, + "rarity": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "generatedFrom": { "function": "sys.random.uniform" }, + "position": 1 + } + }, + "indices": [{ "name": "byRarity", "properties": [{ "rarity": "asc" }] }], + "required": ["name", "rarity"], + "additionalProperties": false + } + } + }), + true, + &platform_version, + ) + .expect("the contract parses"); + let card = contract.document_type_for_name("card").expect("card"); + // The create also prepays the draw's index entries, which the estimate does not price + assert!(matches!( + document_type_create_cost( + &contract, + card, + &BTreeMap::new(), + &CostAssumptions::new(&platform_version), + &platform_version, + ), + Err(Error::Drive(DriveError::NotSupported(_))) + )); +} + #[test] fn should_split_the_storage_into_primary_storage_and_each_index() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive/src/drive/document/draw_random_document_properties/mod.rs b/packages/rs-drive/src/drive/document/draw_random_document_properties/mod.rs new file mode 100644 index 00000000000..930620fdbcb --- /dev/null +++ b/packages/rs-drive/src/drive/document/draw_random_document_properties/mod.rs @@ -0,0 +1,142 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::DataContract; +use dpp::document::Document; +use dpp::fee::fee_result::FeeResult; +use dpp::prelude::Identifier; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +/// A document a block created, whose `sys.random` properties the block's random beacon fills in. +/// Recorded for every document a block creates; the draw reads the document's type, and leaves +/// alone one that declares no such property. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RandomDocumentRequest { + /// The contract defining the document's type. + pub contract_id: Identifier, + /// The document's type. + pub document_type_name: String, + /// The document. + pub document_id: Identifier, +} + +impl Drive { + /// Fills each `sys.random` property of the documents `requests` names with a value drawn + /// from `beacon`, the block's random beacon (the threshold signature the block's prevotes + /// combine into), once every transition of the block is accepted. Each document was written + /// with the placeholder, its range's minimum (`generate_random_properties`), and is + /// rewritten in place: its primary storage, and each index entry the drawn value moves. + /// A document a later transition of the block deleted is skipped. Nobody pays: the + /// document's creation paid for writing the placeholder, and prepaid the index entries the + /// draw may create ([`Self::prepay_random_document_index_entries`]), so the draw is free. + /// + /// # Parameters + /// - `beacon`: the block's random beacon. + /// - `requests`: the documents the block created, in the order the block created them. + /// - `block_info`: the block. + /// - `transaction`: the block's transaction. + /// - `platform_version`: selects the method version; `None` before protocol version 15, + /// whose parser is the first to admit a `sys.random` property. + /// + /// # Returns + /// How many documents had properties drawn. + pub fn draw_random_document_properties( + &self, + beacon: &[u8], + requests: &[RandomDocumentRequest], + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .document + .draw_random_document_properties + { + Some(0) => self.draw_random_document_properties_v0( + beacon, + requests, + block_info, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "draw_random_document_properties".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "draw_random_document_properties".to_string(), + known_versions: vec![0], + })), + } + } + + /// What a create of `document`, of `document_type` in `contract`, written by `owner_id`, + /// pays in advance for the draw of its `sys.random` properties + /// ([`Self::draw_random_document_properties`]). The draw moves the document's entry in each + /// index holding such a property from the placeholder's value to the drawn one, and the + /// branch it moves into may not exist yet: the draw creates it, its elements carrying the + /// document's storage flags, so its owner is refunded for them on delete, and the draw + /// itself is not billed. So the create pays for those entries once more, at what Drive's + /// fee estimation gives for inserting the document's entries into those indexes without + /// reading state: the average layer information it prices a dry run with, as if the + /// branches had to be created (the average case, whatever the draw finds). Selected by the + /// draw's method version, whose cost model it is part of. + /// + /// # Parameters + /// - `document`: the document the create writes, holding the placeholders. + /// - `owner_id`: its owner, whose storage flags the entries carry. + /// - `contract`: the contract defining its type. + /// - `document_type`: its type. + /// - `block_info`: the block, whose epoch prices the entries. + /// - `transaction`: the block's transaction. + /// - `platform_version`: selects the method version; `None` before protocol version 15, + /// whose parser is the first to admit a `sys.random` property. + /// + /// # Returns + /// The fee of those entries; zero when no index of the type holds such a property. + #[allow(clippy::too_many_arguments)] + pub fn prepay_random_document_index_entries( + &self, + document: &Document, + owner_id: Identifier, + contract: &DataContract, + document_type: DocumentTypeRef, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .document + .draw_random_document_properties + { + Some(0) => self.prepay_random_document_index_entries_v0( + document, + owner_id, + contract, + document_type, + block_info, + transaction, + platform_version, + ), + Some(version) => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "prepay_random_document_index_entries".to_string(), + known_versions: vec![0], + received: version, + })), + None => Err(Error::Drive(DriveError::VersionNotActive { + method: "prepay_random_document_index_entries".to_string(), + known_versions: vec![0], + })), + } + } +} diff --git a/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs b/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs new file mode 100644 index 00000000000..1418547c0ff --- /dev/null +++ b/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs @@ -0,0 +1,816 @@ +use crate::drive::document::draw_random_document_properties::RandomDocumentRequest; +use crate::drive::document::expiration::pricing::{ + document_remaining_lifetime_ms, document_ttl_pricing, +}; +use crate::drive::document::paths::contract_documents_primary_key_path; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::estimated_layers::EstimatedLayers; +use crate::util::grove_operations::DirectQueryType; +use crate::util::object_size_info::DocumentInfo::{DocumentOwnedInfo, DocumentRefInfo}; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::{ + indexes_a_random_property, integer_value_for_type, DocumentPropertyType, DocumentTypeRef, + Index, RandomRange, SystemFunction, +}; +use dpp::data_contract::DataContract; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::fee::fee_result::FeeResult; +use dpp::platform_value::btreemap_extensions::BTreeValueMapInsertionPathHelper; +use dpp::prelude::Identifier; +use dpp::util::hash::hash_single; +use dpp::version::PlatformVersion; +use grovedb::{Element, TransactionArg}; +use std::borrow::Cow; + +/// The domain of a draw's seed, so a beacon never seeds anything else the same way. +const DRAW_DOMAIN: &[u8] = b"dash-platform-random-v1"; + +/// What a seed draws for: a document property (the only kind in this version). +const DOCUMENT_PROPERTY_DRAW: u8 = 0; + +impl Drive { + #[inline(always)] + pub(super) fn draw_random_document_properties_v0( + &self, + beacon: &[u8], + requests: &[RandomDocumentRequest], + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let mut drawn = 0u32; + for request in requests { + if self.draw_random_document_v0( + beacon, + request, + block_info, + transaction, + platform_version, + )? { + drawn = drawn.saturating_add(1); + } + } + Ok(drawn) + } + + /// Generation 0 of [`Self::prepay_random_document_index_entries`]: the indexes of + /// `document_type` holding a `sys.random` property, alone in a copy of the type + /// (`with_only_indexes`), and the document's entries in them gathered as the insert gathers + /// a document's index entries in a dry run, with the layers above the type estimated as for + /// any document insert and the storage flags the create writes them with, then priced as + /// the dry run's operations are. As the insert, the dry run first completes the document + /// with the values of the derived index properties (`with_derived_index_values`), which a + /// create's document does not carry yet, estimated: nothing is read, and the reads are + /// priced by their worst case. + #[allow(clippy::too_many_arguments)] + #[inline(always)] + pub(super) fn prepay_random_document_index_entries_v0( + &self, + document: &Document, + owner_id: Identifier, + contract: &DataContract, + document_type: DocumentTypeRef, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let drawn_into = |index: &Index| indexes_a_random_property(document_type, index); + if !document_type.indexes().values().any(drawn_into) { + return Ok(FeeResult::default()); + } + let indexes_drawn_into = document_type.with_only_indexes(drawn_into, platform_version)?; + let owner = owner_id.to_buffer(); + // The flags the create writes the document's elements with + let storage_flags = StorageFlags::new_single_epoch(block_info.epoch.index, Some(owner)); + let document_and_contract_info = DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((document, Some(Cow::Owned(storage_flags)))), + owner_id: Some(owner), + }, + contract, + document_type: indexes_drawn_into.as_ref(), + } + .without_storage_flags_if_expiring(); + + let mut estimated_layers = EstimatedLayers::new(); + Self::add_estimation_costs_for_levels_up_to_contract_document_type_excluded( + contract, + &mut estimated_layers, + &platform_version.drive, + )?; + let mut estimated_costs_only_with_layer_info = Some(estimated_layers); + let mut batch_operations: Vec = vec![]; + let document_and_contract_info = self.with_derived_index_values( + document_and_contract_info, + true, + transaction, + &mut batch_operations, + platform_version, + )?; + self.add_indices_for_top_index_level_for_contract_operations( + &document_and_contract_info, + &mut None, + &mut estimated_costs_only_with_layer_info, + block_info.time_ms, + transaction, + &mut batch_operations, + platform_version, + )?; + // A type whose documents expire prices the entries by the lifetime the document has + // left, as its insert prices its own (`add_document_ttl_operations`): the entries the + // draw moves live as long as the document + if let Some(ttl_seconds) = document_type.documents_ttl_seconds() { + let pricing = document_ttl_pricing( + document_remaining_lifetime_ms( + document.created_at(), + ttl_seconds, + block_info.time_ms, + )?, + self.config.epoch_time_length_s, + self.config.epochs_per_era, + &platform_version.fee_version, + )?; + batch_operations = batch_operations + .into_iter() + .map(|operation| operation.retag_document_ttl(pricing)) + .collect(); + } + let mut drive_operations: Vec = vec![]; + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + &mut drive_operations, + &platform_version.drive, + )?; + self.calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + platform_version, + ) + } + + /// Draws the `sys.random` properties of the document `request` names and rewrites it; + /// `false` when there is nothing to draw: its type declares no such property, or a later + /// transition of the block deleted it. + fn draw_random_document_v0( + &self, + beacon: &[u8], + request: &RandomDocumentRequest, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + // Unbilled, like the rest of the draw: the creation that recorded the request paid for + // its write. + let (_, Some(contract)) = self.get_contract_with_fetch_info_and_fee( + request.contract_id.to_buffer(), + None, + true, + transaction, + platform_version, + )? + else { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "the contract of a document the block created exists", + ))); + }; + let document_type = contract + .contract + .document_type_for_name(&request.document_type_name) + .map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "the type of a document the block created exists", + )) + })?; + let draws: Vec<(String, RandomRange, DocumentPropertyType)> = document_type + .generated_from_fields() + .iter() + .filter_map(|(path, generated_from)| { + let (SystemFunction::Random(_), Some(range)) = + (generated_from.function, generated_from.range) + else { + return None; + }; + let property_type = document_type + .flattened_properties() + .get(path)? + .property_type + .clone(); + Some((path.clone(), range, property_type)) + }) + .collect(); + if draws.is_empty() { + return Ok(false); + } + + let primary_key_path = contract_documents_primary_key_path( + contract.contract.id_ref().as_bytes(), + document_type.name().as_str(), + ); + let element = self.grove_get_raw_optional( + (&primary_key_path).into(), + request.document_id.as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + &mut vec![], + &platform_version.drive, + )?; + let (serialized, element_flags) = match element { + Some(Element::Item(serialized, flags)) + | Some(Element::ItemWithSumItem(serialized, _, flags)) => (serialized, flags), + // A later transition of the block deleted it + None => return Ok(false), + Some(_) => { + return Err(Error::Drive(DriveError::CorruptedDocumentNotItem( + "a document the block created is stored as an item", + ))) + } + }; + let mut document = Document::from_bytes(&serialized, document_type, platform_version)?; + let storage_flags = StorageFlags::map_some_element_flags_ref(&element_flags)?; + + for (path, range, property_type) in &draws { + let drawn = draw_v0(beacon, request, path, *range).ok_or(Error::Drive( + DriveError::CorruptedCodeExecution("a range holds every offset below its size"), + ))?; + let value = integer_value_for_type(property_type, drawn).ok_or(Error::Drive( + DriveError::CorruptedCodeExecution( + "registration admits sys.random only on an integer type holding its range", + ), + ))?; + // The document holds the placeholder at the path, so the objects on the way are + // maps + document + .properties_mut() + .insert_at_path(path, value) + .map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a document holding a placeholder holds the objects on its path", + )) + })?; + } + + let owner_id = document.owner_id().to_buffer(); + let operations = self.update_stored_document_operations_v1( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentOwnedInfo((document, storage_flags.map(Cow::Owned))), + owner_id: Some(owner_id), + }, + contract: &contract.contract, + document_type, + }, + block_info, + &mut None, + &mut None, + transaction, + platform_version, + )?; + // No fee: the creation paid for the placeholder's write, which takes the same storage + self.apply_batch_low_level_drive_operations( + None, + transaction, + operations, + &mut vec![], + &platform_version.drive, + )?; + Ok(true) + } +} + +/// The value the property at `path` of the document `request` names draws from `beacon`: +/// SHA-256 of the domain, the beacon and what the draw is for (a document property, its +/// contract, type, document and path, each variable part prefixed by its length), whose first +/// 16 bytes, big-endian, reduced modulo the range's size, offset the range's minimum. A range +/// holds at most 2^64 values, so the reduction favours no value by more than 2^-64. `None` +/// only for a part longer than a length prefix holds, which registration rules out. +fn draw_v0( + beacon: &[u8], + request: &RandomDocumentRequest, + path: &str, + range: RandomRange, +) -> Option { + let mut preimage = Vec::with_capacity( + DRAW_DOMAIN.len() + + 2 + + beacon.len() + + 1 + + 32 + + 2 + + request.document_type_name.len() + + 32 + + 2 + + path.len(), + ); + preimage.extend_from_slice(DRAW_DOMAIN); + push_with_length(&mut preimage, beacon)?; + preimage.push(DOCUMENT_PROPERTY_DRAW); + preimage.extend_from_slice(request.contract_id.as_slice()); + push_with_length(&mut preimage, request.document_type_name.as_bytes())?; + preimage.extend_from_slice(request.document_id.as_slice()); + push_with_length(&mut preimage, path.as_bytes())?; + let seed = hash_single(&preimage); + let mut bits = [0u8; 16]; + bits.copy_from_slice(&seed[..16]); + range.value_at(u128::from_be_bytes(bits) % range.size()) +} + +/// Appends `part` after its length as a big-endian `u16`. +fn push_with_length(preimage: &mut Vec, part: &[u8]) -> Option<()> { + let length = u16::try_from(part.len()).ok()?; + preimage.extend_from_slice(&length.to_be_bytes()); + preimage.extend_from_slice(part); + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::DriveConfig; + use crate::query::DriveDocumentQuery; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; + use dpp::document::{DocumentV0, DocumentV0Setters}; + use dpp::platform_value::{platform_value, Value}; + use std::collections::BTreeMap; + + const OWNER: [u8; 32] = [42; 32]; + const BEACON: [u8; 96] = [7; 96]; + + /// The latest platform version with `sys.random.uniform` admitted, which no protocol + /// version does until block execution draws the values. + fn drawing_platform_version() -> PlatformVersion { + let mut platform_version = PlatformVersion::latest().clone(); + platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .random_generated_from = Some(0); + platform_version + } + + /// A contract with an immutable `card` type, its `rarity` drawn from 1 to 100 and indexed, + /// and a `plain` type drawing nothing, registered at `platform_version`. + fn contract(platform_version: &PlatformVersion) -> DataContract { + DataContract::from_value( + platform_value!({ + "$formatVersion": "1", + "id": Identifier::from([9; 32]), + "ownerId": Identifier::from(OWNER), + "version": 1u32, + "documentSchemas": { + "card": { + "type": "object", + "documentsMutable": false, + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 }, + "rarity": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "generatedFrom": { "function": "sys.random.uniform" }, + "position": 1 + } + }, + "indices": [{ "name": "byRarity", "properties": [{ "rarity": "asc" }] }], + "required": ["name", "rarity"], + "additionalProperties": false + }, + "plain": { + "type": "object", + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 } + }, + "indices": [{ "name": "byName", "properties": [{ "name": "asc" }] }], + "required": ["name"], + "additionalProperties": false + }, + "fleetingCard": { + "type": "object", + "documentsMutable": false, + "ttl": 3600, + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 }, + "rarity": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "generatedFrom": { "function": "sys.random.uniform" }, + "position": 1 + } + }, + "indices": [{ "name": "byRarity", "properties": [{ "rarity": "asc" }] }], + "required": ["name", "rarity", "$createdAt"], + "additionalProperties": false + } + } + }), + true, + platform_version, + ) + .expect("contract") + } + + fn setup(platform_version: &PlatformVersion) -> (Drive, DataContract) { + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = contract(platform_version); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("contract applies"); + (drive, contract) + } + + /// A `card` holding the placeholder, the way a create transition writes it. + fn card(marker: u8) -> Document { + Document::V0(DocumentV0 { + id: Identifier::from([marker; 32]), + owner_id: Identifier::from(OWNER), + properties: BTreeMap::from([ + ("name".to_string(), Value::Text("Ace".to_string())), + ("rarity".to_string(), Value::U8(1)), + ]), + ..Default::default() + }) + } + + /// Inserts a `card` holding the placeholder, the way a create transition writes it. + fn insert_card( + drive: &Drive, + contract: &DataContract, + marker: u8, + platform_version: &PlatformVersion, + ) -> Identifier { + let document = card(marker); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + Some(Cow::Owned(StorageFlags::new_single_epoch(0, Some(OWNER)))), + )), + owner_id: Some(OWNER), + }, + contract, + document_type: contract.document_type_for_name("card").expect("card"), + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("the card inserts"); + document.id() + } + + fn request( + contract_id: Identifier, + document_type_name: &str, + document_id: Identifier, + ) -> RandomDocumentRequest { + RandomDocumentRequest { + contract_id, + document_type_name: document_type_name.to_string(), + document_id, + } + } + + /// The cards whose rarity is `rarity`, through the `byRarity` index. + fn cards_with_rarity( + drive: &Drive, + contract: &DataContract, + rarity: i128, + platform_version: &PlatformVersion, + ) -> Vec { + let query = DriveDocumentQuery::from_sql_expr( + &format!("select * from card where rarity = {rarity}"), + contract, + Some(&DriveConfig::default()), + platform_version, + ) + .expect("the query builds"); + let (results, _, _) = query + .execute_raw_results_no_proof(drive, None, None, platform_version) + .expect("the query runs"); + let card = contract.document_type_for_name("card").expect("card"); + results + .iter() + .map(|bytes| Document::from_bytes(bytes, card, platform_version).expect("decodes")) + .collect() + } + + fn rarity_range() -> RandomRange { + RandomRange::new(1, 100).expect("a range") + } + + #[test] + fn should_draw_into_an_immutable_document_and_move_its_index_entry() { + let platform_version = drawing_platform_version(); + let (drive, contract) = setup(&platform_version); + let id = insert_card(&drive, &contract, 1, &platform_version); + let request = request(contract.id(), "card", id); + let expected = draw_v0(&BEACON, &request, "rarity", rarity_range()).expect("draws"); + assert!((1..=100).contains(&expected)); + + let drawn = drive + .draw_random_document_properties( + &BEACON, + &[request], + &BlockInfo::default(), + None, + &platform_version, + ) + .expect("the draw runs"); + assert_eq!(drawn, 1); + + let found = cards_with_rarity(&drive, &contract, expected, &platform_version); + assert_eq!(found.len(), 1); + assert_eq!(found[0].id(), id); + assert_eq!( + found[0] + .get("rarity") + .and_then(|rarity| rarity.to_integer::().ok()), + Some(expected) + ); + if expected != 1 { + assert!(cards_with_rarity(&drive, &contract, 1, &platform_version).is_empty()); + } + } + + #[test] + fn should_draw_each_document_and_beacon_independently_and_reproducibly() { + let widest = RandomRange::new(0, u64::MAX.into()).expect("2^64 values"); + let contract_id = Identifier::from([9; 32]); + let first = request(contract_id, "card", Identifier::from([1; 32])); + let second = request(contract_id, "card", Identifier::from([2; 32])); + let draw = |beacon: &[u8], request: &RandomDocumentRequest, path: &str| { + draw_v0(beacon, request, path, widest).expect("draws") + }; + assert_eq!( + draw(&BEACON, &first, "rarity"), + draw(&BEACON, &first, "rarity") + ); + assert_ne!( + draw(&BEACON, &first, "rarity"), + draw(&BEACON, &second, "rarity") + ); + assert_ne!( + draw(&BEACON, &first, "rarity"), + draw(&[8; 96], &first, "rarity") + ); + assert_ne!( + draw(&BEACON, &first, "rarity"), + draw(&BEACON, &first, "power") + ); + // The draw is consensus: every node must compute these values forever + assert_eq!(draw(&BEACON, &first, "rarity"), DRAW_VECTOR); + assert_eq!( + draw_v0(&BEACON, &first, "rarity", rarity_range()), + Some(RARITY_VECTOR) + ); + } + + const DRAW_VECTOR: i128 = 16_905_443_507_565_715_185; + const RARITY_VECTOR: i128 = 22; + + #[test] + fn should_skip_a_deleted_document_and_a_type_drawing_nothing() { + let platform_version = drawing_platform_version(); + let (drive, contract) = setup(&platform_version); + let drawn = drive + .draw_random_document_properties( + &BEACON, + &[ + request(contract.id(), "card", Identifier::from([3; 32])), + request(contract.id(), "plain", Identifier::from([4; 32])), + ], + &BlockInfo::default(), + None, + &platform_version, + ) + .expect("the draw runs"); + assert_eq!(drawn, 0); + } + + #[test] + fn should_skip_a_card_a_later_transition_of_the_block_deleted() { + // The card was stored with its placeholder, then deleted before the draw: the draw + // finds nothing to rewrite, and writes nothing back + let platform_version = drawing_platform_version(); + let (drive, contract) = setup(&platform_version); + let id = insert_card(&drive, &contract, 1, &platform_version); + drive + .delete_document_for_contract( + id, + &contract, + "card", + BlockInfo::default(), + true, + None, + &platform_version, + ) + .expect("the card deletes"); + + let drawn = drive + .draw_random_document_properties( + &BEACON, + &[request(contract.id(), "card", id)], + &BlockInfo::default(), + None, + &platform_version, + ) + .expect("the draw runs"); + assert_eq!(drawn, 0); + let expected = draw_v0( + &BEACON, + &request(contract.id(), "card", id), + "rarity", + rarity_range(), + ) + .expect("draws"); + for rarity in [1, expected] { + assert!( + cards_with_rarity(&drive, &contract, rarity, &platform_version).is_empty(), + "no card is filed under rarity {rarity}" + ); + } + } + + #[test] + fn should_not_draw_before_protocol_version_15() { + let (drive, _contract) = setup(&drawing_platform_version()); + let result = drive.draw_random_document_properties( + &BEACON, + &[], + &BlockInfo::default(), + None, + PlatformVersion::get(14).expect("protocol version 14"), + ); + assert!(matches!( + result, + Err(Error::Drive(DriveError::VersionNotActive { .. })) + )); + } + + #[test] + fn should_prepay_the_draw_once_with_the_insert_of_the_document() { + // The insert of a card, and of one that expires in an hour, is charged the prepayment + // on top of what it costs at a version whose draw method version is unset, which + // prepays nothing + let platform_version = drawing_platform_version(); + let mut without_draw = platform_version.clone(); + without_draw + .drive + .methods + .document + .draw_random_document_properties = None; + let mut fleeting_card = card(2); + fleeting_card.set_created_at(Some(0)); + for (document_type_name, document) in [("card", card(1)), ("fleetingCard", fleeting_card)] { + let insert_fee = |platform_version: &PlatformVersion| { + let (drive, contract) = setup(platform_version); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo(( + &document, + Some(Cow::Owned(StorageFlags::new_single_epoch( + 0, + Some(OWNER), + ))), + )), + owner_id: Some(OWNER), + }, + contract: &contract, + document_type: contract + .document_type_for_name(document_type_name) + .expect("the type"), + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("the document inserts") + }; + let (drive, contract) = setup(&platform_version); + let prepayment = drive + .prepay_random_document_index_entries( + &document, + Identifier::from(OWNER), + &contract, + contract + .document_type_for_name(document_type_name) + .expect("the type"), + &BlockInfo::default(), + None, + &platform_version, + ) + .expect("the prepayment is priced"); + assert_ne!(prepayment, FeeResult::default(), "{document_type_name}"); + + let with = insert_fee(&platform_version); + let without = insert_fee(&without_draw); + assert_eq!( + with.storage_fee, + without.storage_fee + prepayment.storage_fee, + "{document_type_name}" + ); + assert_eq!( + with.processing_fee, + without.processing_fee + prepayment.processing_fee, + "{document_type_name}" + ); + } + } + + #[test] + fn should_prepay_the_draw_of_an_expiring_document_for_the_lifetime_it_has_left() { + // A card that expires in an hour prepays its `byRarity` entry for that hour, where a + // card that never expires pays for it for good + let platform_version = drawing_platform_version(); + let (drive, contract) = setup(&platform_version); + let prepay = |document_type_name: &str, document: &Document| { + drive + .prepay_random_document_index_entries( + document, + Identifier::from(OWNER), + &contract, + contract + .document_type_for_name(document_type_name) + .expect("the type"), + &BlockInfo::default(), + None, + &platform_version, + ) + .expect("the prepayment is priced") + }; + let lasting = prepay("card", &card(1)); + let mut fleeting_card = card(2); + fleeting_card.set_created_at(Some(0)); + let fleeting = prepay("fleetingCard", &fleeting_card); + + assert!(lasting.lifetime_storage_fees.is_empty()); + assert!(!fleeting.lifetime_storage_fees.is_empty()); + assert!( + fleeting.storage_fee < lasting.storage_fee, + "an hour of storage costs less than storage for good: {} against {}", + fleeting.storage_fee, + lasting.storage_fee + ); + } + + #[test] + fn should_prepay_the_entries_of_the_indexes_holding_a_random_property_only() { + let platform_version = drawing_platform_version(); + let (drive, contract) = setup(&platform_version); + let prepay = |document: &Document, document_type_name: &str| { + drive + .prepay_random_document_index_entries( + document, + Identifier::from(OWNER), + &contract, + contract + .document_type_for_name(document_type_name) + .expect("the type"), + &BlockInfo::default(), + None, + &platform_version, + ) + .expect("the prepayment is priced") + }; + let card_prepayment = prepay(&card(5), "card"); + assert!(card_prepayment.storage_fee > 0); + assert!(card_prepayment.processing_fee > 0); + + // Indexed, but by nothing drawn + let plain = Document::V0(DocumentV0 { + id: Identifier::from([6; 32]), + owner_id: Identifier::from(OWNER), + properties: BTreeMap::from([("name".to_string(), Value::Text("Ace".to_string()))]), + ..Default::default() + }); + assert_eq!(prepay(&plain, "plain"), FeeResult::default()); + } +} diff --git a/packages/rs-drive/src/drive/document/insert/add_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document_for_contract_operations/v1/mod.rs index 7918056f35d..bad7f643d66 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_for_contract_operations/v1/mod.rs @@ -16,6 +16,7 @@ use crate::util::object_size_info::{DocumentAndContractInfo, DocumentInfoV0Metho use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::indexes_a_random_property; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dpp::document::DocumentV0Getters; @@ -298,6 +299,46 @@ impl Drive { )?; } + // The create of a document whose type indexes a `sys.random` property (protocol version + // 15) prepays the entries the draw from the block's random beacon may create in those + // indexes (`Self::prepay_random_document_index_entries`), here, with the insert: a + // create refused at any stage inserts nothing, so it prepays nothing. Only a create + // inserts such a document, since a moderator's restore is refused on such a type, and a + // change's dry run creates no draw. Added in place to this shipped generation: no type + // declares such a property before protocol version 15, whose draw method version is the + // first to be set. + if !estimates_a_change + && platform_version + .drive + .methods + .document + .draw_random_document_properties + .is_some() + { + let document_type = document_and_contract_info.document_type; + let draws_into_an_index = document_type + .indexes() + .values() + .any(|index| indexes_a_random_property(document_type, index)); + if let Some(document) = document_and_contract_info + .owned_document_info + .document_info + .get_borrowed_document() + .filter(|_| draws_into_an_index) + { + let prepayment = self.prepay_random_document_index_entries( + document, + document.owner_id(), + document_and_contract_info.contract, + document_type, + block_info, + transaction, + platform_version, + )?; + batch_operations.push(LowLevelDriveOperation::PreCalculatedFeeResult(prepayment)); + } + } + Ok(batch_operations) } diff --git a/packages/rs-drive/src/drive/document/mod.rs b/packages/rs-drive/src/drive/document/mod.rs index e9db8f85e46..d48f286b1c9 100644 --- a/packages/rs-drive/src/drive/document/mod.rs +++ b/packages/rs-drive/src/drive/document/mod.rs @@ -52,6 +52,9 @@ mod delete; /// The values of derived index properties, read from the documents the references point at #[cfg(feature = "server")] pub(crate) mod derived_index_values; +/// The draw of `sys.random` document properties from a block's random beacon +#[cfg(feature = "server")] +pub mod draw_random_document_properties; #[cfg(feature = "server")] mod estimation_costs; /// Document expiry: the expirations tree of documents whose type declares a `ttl`, their diff --git a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs index 1c46f23d9ac..73a418e51f3 100644 --- a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs @@ -177,13 +177,6 @@ impl Drive { transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error> { - let drive_version = &platform_version.drive; - let mut batch_operations: Vec = vec![]; - // A document whose type declares a `ttl` stays without storage flags when replaced, - // transferred, bought or repriced. Dropped before anything is sized, so the replaced - // elements' sizes agree with what is stored. - let document_and_contract_info = - document_and_contract_info.without_storage_flags_if_expiring(); if !document_and_contract_info.document_type.requires_revision() // if it requires revision then there are reasons for us to be able to update in drive { @@ -191,6 +184,40 @@ impl Drive { "this document type is not mutable", ))); } + self.update_stored_document_operations_v1( + document_and_contract_info, + block_info, + previous_batch_operations, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ) + } + + /// Gathers operations for rewriting a stored document with `document_and_contract_info`'s + /// document: its primary storage, and each index entry whose values changed, moved from + /// the stored document's position to the new one. The body of + /// [`Self::update_document_for_contract_operations_v1`] after its refusal of document + /// types a client cannot update, split out unchanged (the refusal reads only the document + /// type, which dropping storage flags leaves as it is), so an update gathers the same + /// operations as before at every protocol version. `draw_random_document_properties` + /// calls it to fill the `sys.random` properties of a document of any type. + pub(in crate::drive::document) fn update_stored_document_operations_v1( + &self, + document_and_contract_info: DocumentAndContractInfo, + block_info: &BlockInfo, + previous_batch_operations: &mut Option<&mut Vec>, + estimated_costs_only_with_layer_info: &mut Option, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive_version = &platform_version.drive; + let mut batch_operations: Vec = vec![]; + // A document whose type declares a `ttl` stays without storage flags when replaced, + // transferred, bought or repriced. Dropped before anything is sized, so the replaced + // elements' sizes agree with what is stored. + let document_and_contract_info = + document_and_contract_info.without_storage_flags_if_expiring(); // If we are going for estimated costs do an add instead as it always worse than an update if document_and_contract_info diff --git a/packages/rs-drive/src/query/filter.rs b/packages/rs-drive/src/query/filter.rs index abbe939ad2a..2d1684d030f 100644 --- a/packages/rs-drive/src/query/filter.rs +++ b/packages/rs-drive/src/query/filter.rs @@ -144,10 +144,10 @@ pub enum DocumentActionMatchClauses { } /// Refuses a clause of `clauses`, which judge a create's or a replace's data, on a property -/// `document_type` generates with a function reading state (`sys.tokens.decimals`, protocol -/// version 15): a transition leaves such a property out, and only the platform generates it, -/// from state, so a filter cannot judge it. A clause on the original document reads the stored -/// value and is allowed. +/// only the platform generates (`sys.tokens.decimals`, read from state, and +/// `sys.random.uniform`, drawn from the block's random beacon, protocol version 15): a +/// transition leaves such a property out, so a filter cannot judge it. A clause on the original +/// document reads the stored value and is allowed. #[cfg(any(feature = "server", feature = "verify"))] fn refuse_state_generated_fields( clauses: &InternalClauses, @@ -165,15 +165,23 @@ fn refuse_state_generated_fields( ) .chain(clauses.equal_clauses.keys().map(String::as_str)); for field in fields { - if let Some((_, generated_from)) = document_type - .generated_from_fields() - .iter() - .find(|(path, generated_from)| path == field && generated_from.function.reads_state()) + if let Some((_, generated_from)) = + document_type + .generated_from_fields() + .iter() + .find(|(path, generated_from)| { + path == field && generated_from.function.platform_generated() + }) { + let how = if generated_from.function.reads_state() { + "from state" + } else { + "at random" + }; return QuerySyntaxSimpleValidationResult::new_with_error( QuerySyntaxError::Unsupported(format!( "a create or replace filter cannot judge {field}, which the platform \ - generates from state with {}: a transition leaves it out", + generates {how} with {}: a transition leaves it out", generated_from.function )), ); diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs index f58a681f521..c31e652150e 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/transformer.rs @@ -104,6 +104,12 @@ impl DocumentCreateTransitionActionV0 { transaction, platform_version, )?; + // Added in place at protocol version 15, inert before it, where no type declares a + // `sys.random` function: every such property is written with its placeholder here, + // which the draw from the block's random beacon replaces before the block commits, and + // a sent value is refused, before the contest resolution and every later check read + // the data. A refusal bumps the nonce. + let refusal = refusal.or_else(|| document_type.generate_random_properties(&mut data, None)); if let Some(error) = refusal { let bump_action = BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action( @@ -219,3 +225,359 @@ impl DocumentCreateTransitionActionV0 { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; + use dpp::data_contract::DataContract; + use dpp::platform_value::{platform_value, Value}; + use dpp::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; + use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; + use crate::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentFromCreateTransitionAction; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::document::Document; + use grovedb_costs::OperationCost; + use std::collections::BTreeMap; + + const OWNER: [u8; 32] = [42; 32]; + const CONTRACT_ID: [u8; 32] = [9; 32]; + + /// The latest platform version with `sys.random.uniform` admitted, which no protocol + /// version does until block execution draws the values. + fn drawing_platform_version() -> PlatformVersion { + let mut platform_version = PlatformVersion::latest().clone(); + platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .random_generated_from = Some(0); + platform_version + } + + /// A card whose `rarity` is drawn from 1 to 100, indexed by it when `indexed`. + fn card_schema(indexed: bool) -> Value { + let mut schema = platform_value!({ + "type": "object", + "documentsMutable": false, + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 }, + "rarity": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "generatedFrom": { "function": "sys.random.uniform" }, + "position": 1 + } + }, + "required": ["name", "rarity"], + "additionalProperties": false + }); + if indexed { + schema + .insert( + "indices".to_string(), + platform_value!([{ "name": "byRarity", "properties": [{ "rarity": "asc" }] }]), + ) + .expect("the schema is a map"); + } + schema + } + + /// A card of a `set`, filed by its drawn rarity and its set's name, which the card never + /// stores: a derived index property. + fn set_card_schema() -> Value { + let mut schema = card_schema(false); + schema + .insert( + "indices".to_string(), + platform_value!([{ + "name": "byRarityAndSetName", + "properties": [{ "rarity": "asc" }, { "setId.name": "asc" }] + }]), + ) + .expect("the schema is a map"); + schema + .get_mut("properties") + .expect("the schema has properties") + .expect("the schema has properties") + .insert( + "setId".to_string(), + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "permanentDocument", "documentType": "set" }, + "position": 2 + }), + ) + .expect("the properties are a map"); + schema + .get_mut("required") + .expect("the schema has required") + .expect("the schema has required") + .push(Value::Text("setId".to_string())) + .expect("required is an array"); + schema + } + + /// A contract with a `card` indexed by its drawn rarity and a `looseCard` that is not, + /// registered at `platform_version`. + fn contract(platform_version: &PlatformVersion) -> Arc { + contract_with( + platform_value!({ + "card": card_schema(true), + "looseCard": card_schema(false) + }), + platform_version, + ) + } + + /// A contract with a `set` and a `setCard` indexed by its drawn rarity and its set's name, + /// registered at `platform_version`. + fn set_contract(platform_version: &PlatformVersion) -> Arc { + contract_with( + platform_value!({ + "set": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 } + }, + "required": ["name"], + "additionalProperties": false + }, + "setCard": set_card_schema() + }), + platform_version, + ) + } + + /// A contract of `document_schemas`, registered at `platform_version`. + fn contract_with( + document_schemas: Value, + platform_version: &PlatformVersion, + ) -> Arc { + let contract = DataContract::from_value( + platform_value!({ + "$formatVersion": "1", + "id": Identifier::from(CONTRACT_ID), + "ownerId": Identifier::from(OWNER), + "version": 1u32, + "documentSchemas": document_schemas + }), + true, + platform_version, + ) + .expect("the contract parses"); + Arc::new(DataContractFetchInfo { + contract, + storage_flags: None, + cost: OperationCost::default(), + fee: None, + }) + } + + /// A contract with a `namedCard` indexed by its drawn rarity and its name. + fn named_contract(platform_version: &PlatformVersion) -> Arc { + let mut schema = card_schema(false); + schema + .insert( + "indices".to_string(), + platform_value!([{ + "name": "byRarityAndName", + "properties": [{ "rarity": "asc" }, { "name": "asc" }] + }]), + ) + .expect("the schema is a map"); + contract_with(platform_value!({ "namedCard": schema }), platform_version) + } + + /// Builds the action of a create of a `document_type_name` document of the contract + /// `contract` builds, holding `data` (a name `Ace` unless `data` names one), and returns + /// what building it bills and, when its document is valid, what its insert prepays for the + /// draw: `Drive::prepay_random_document_index_entries` of the document the action writes. + fn create( + contract: fn(&PlatformVersion) -> Arc, + document_type_name: &str, + mut data: BTreeMap, + ) -> (FeeResult, FeeResult) { + data.entry("name".to_string()) + .or_insert_with(|| Value::Text("Ace".to_string())); + let platform_version = drawing_platform_version(); + let drive = setup_drive_with_initial_state_structure(Some(&platform_version)); + let contract = contract(&platform_version); + let transition = DocumentCreateTransitionV0 { + base: DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([1; 32]), + identity_contract_nonce: 1, + document_type_name: document_type_name.to_string(), + data_contract_id: Identifier::from(CONTRACT_ID), + }), + entropy: [2; 32], + data, + prefunded_voting_balance: None, + }; + let (result, build_fee) = + DocumentCreateTransitionActionV0::try_from_borrowed_document_create_transition_with_contract_lookup( + &drive, + Identifier::from(OWNER), + None, + &transition, + &BlockInfo::default(), + 0, + |_| Ok(contract.clone()), + &platform_version, + ) + .expect("the action builds"); + assert!( + result.is_valid(), + "the create is valid: {:?}", + result.errors + ); + let BatchedTransitionAction::DocumentAction(DocumentTransitionAction::CreateAction(action)) = + result.into_data().expect("an action") + else { + panic!("a create action"); + }; + let document = Document::try_from_create_transition_action( + &action, + Identifier::from(OWNER), + &platform_version, + ) + .expect("the document"); + let prepayment = drive + .prepay_random_document_index_entries( + &document, + Identifier::from(OWNER), + &contract.contract, + contract + .contract + .document_type_for_name(document_type_name) + .expect("the type"), + &BlockInfo::default(), + None, + &platform_version, + ) + .expect("the prepayment is priced"); + (build_fee, prepayment) + } + + /// What a valid create of a `document_type_name` document of [`contract`] prepays. + fn prepayment(document_type_name: &str) -> FeeResult { + let (build_fee, prepayment) = create(contract, document_type_name, BTreeMap::new()); + // Building the action bills no prepayment: the insert of its document charges it + assert_eq!(build_fee, FeeResult::default()); + prepayment + } + + #[test] + fn should_prepay_the_draw_of_an_indexed_random_property_on_create() { + let indexed = prepayment("card"); + let unindexed = prepayment("looseCard"); + assert!(indexed.storage_fee > unindexed.storage_fee); + assert!(indexed.processing_fee > unindexed.processing_fee); + // Pinned: the estimated cost of the `byRarity` entry a draw may create. A change to + // the estimate, the index layout or the fee schedule moves it. + assert_eq!( + indexed, + FeeResult { + storage_fee: 13_392_000, + processing_fee: 6_650_100, + ..Default::default() + } + ); + } + + #[test] + fn should_prepay_the_draw_into_an_index_holding_a_derived_index_property() { + // The create's document does not carry its set's name until its references are + // validated: the prepayment completes it as the insert does, estimated, rather than + // key an entry on a value it lacks + let (build_fee, fee) = create( + set_contract, + "setCard", + BTreeMap::from([("setId".to_string(), Value::Identifier([3; 32]))]), + ); + assert_eq!(build_fee, FeeResult::default()); + // Pinned: the estimated cost of the `byRarityAndSetName` entry a draw may create, with + // the set's name at its typical size (nothing is read), and the worst-case read of the + // set. A change to the estimate, the index layout or the fee schedule moves it. + assert_eq!( + fee, + FeeResult { + storage_fee: 20_574_000, + processing_fee: 9_003_340, + ..Default::default() + } + ); + } + + #[test] + fn should_build_the_action_of_a_create_whose_values_the_schema_refuses() { + // Building the action reads no value of the indexes a draw moves: a name of the wrong + // type, or a set reference that is no identifier, builds the action, unbilled, and the + // schema refuses it later, bumping the nonce + for (contract, document_type_name, data) in [ + ( + named_contract as fn(&PlatformVersion) -> Arc, + "namedCard", + BTreeMap::from([("name".to_string(), Value::Bool(false))]), + ), + ( + set_contract, + "setCard", + BTreeMap::from([( + "setId".to_string(), + Value::Text("no identifier".to_string()), + )]), + ), + ] { + let platform_version = drawing_platform_version(); + let drive = setup_drive_with_initial_state_structure(Some(&platform_version)); + let contract = contract(&platform_version); + let mut data = data; + data.entry("name".to_string()) + .or_insert_with(|| Value::Text("Ace".to_string())); + let transition = DocumentCreateTransitionV0 { + base: DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::from([1; 32]), + identity_contract_nonce: 1, + document_type_name: document_type_name.to_string(), + data_contract_id: Identifier::from(CONTRACT_ID), + }), + entropy: [2; 32], + data, + prefunded_voting_balance: None, + }; + let (result, build_fee) = + DocumentCreateTransitionActionV0::try_from_borrowed_document_create_transition_with_contract_lookup( + &drive, + Identifier::from(OWNER), + None, + &transition, + &BlockInfo::default(), + 0, + |_| Ok(contract.clone()), + &platform_version, + ) + .expect("the action builds"); + assert!( + result.is_valid(), + "{document_type_name}: {:?}", + result.errors + ); + assert_eq!(build_fee, FeeResult::default(), "{document_type_name}"); + } + } + + #[test] + fn should_prepay_nothing_for_a_random_property_in_no_index() { + assert_eq!(prepayment("looseCard"), FeeResult::default()); + } +} 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 83990724b9e..ab3a08faa7a 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 @@ -103,6 +103,16 @@ impl DocumentReplaceTransitionActionV0 { transaction, platform_version, )?; + // Added in place at protocol version 15, inert before it, where no type declares a + // `sys.random` function: every such property keeps the stored value, written back when + // left out and refused when sent with another, so a replace cannot draw again. A + // refusal bumps the nonce. + let refusal = match refusal { + Some(error) => Some(error), + None => base + .document_type()? + .generate_random_properties(&mut data, Some(original_document.properties())), + }; if let Some(error) = refusal { let bump_action = BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action( diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs index 2b67fe1762a..4ef9844c2e4 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 @@ -216,6 +216,13 @@ pub struct DocumentTypeSchemaVersions { /// contract registration. `None` on versions that predate the keyword: they ignore /// it entirely, exactly as they parsed before it existed. pub apply_generated_from: OptionalFeatureVersion, + /// Admits the `generatedFrom` function `sys.random.uniform` in `apply_generated_from` 1: + /// an integer property the platform draws from the block's random beacon once the block's + /// transitions are accepted. `None` refuses the function at registration, which every + /// table does for now, protocol version 15's included: block execution does not draw the + /// values yet (`draw_random_document_properties` waits for the beacon Tenderdash + /// delivers), so a document would keep its placeholder for good. 0 parses it. + pub random_generated_from: OptionalFeatureVersion, /// `DocumentType::expand_property_type_shorthands`: rewrites the property /// type shorthands of a document type schema and of the contract's /// `$defs` into the long form they stand for, `"type": "identifier"` into diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs index 2e37ed6d894..9d4a170ac44 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 @@ -56,6 +56,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { validation_schema: None, parse_private_document: None, apply_generated_from: None, + random_generated_from: None, expand_property_type_shorthands: None, validate_max_depth: 0, max_depth: 256, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs index 4f91a868ec8..0e23f1ea507 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 @@ -56,6 +56,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { validation_schema: None, parse_private_document: None, apply_generated_from: None, + random_generated_from: None, expand_property_type_shorthands: None, validate_max_depth: 0, max_depth: 256, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs index e6e257b610e..75eef046c24 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 @@ -58,6 +58,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { validation_schema: None, parse_private_document: None, apply_generated_from: None, + random_generated_from: None, expand_property_type_shorthands: None, validate_max_depth: 0, max_depth: 256, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs index 3f5e73a2bc6..aed160b1d32 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 @@ -58,6 +58,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { validation_schema: None, parse_private_document: None, apply_generated_from: None, + random_generated_from: None, expand_property_type_shorthands: None, validate_max_depth: 0, max_depth: 256, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs index f40afeeddbf..a255b2da4bc 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 @@ -60,6 +60,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { validation_schema: None, parse_private_document: None, apply_generated_from: None, + random_generated_from: None, expand_property_type_shorthands: None, validate_max_depth: 0, max_depth: 256, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs index c21b0bf83db..a73296484b9 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 @@ -93,6 +93,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { validation_schema: None, parse_private_document: None, apply_generated_from: Some(0), // changed: the meta-schema v3 `generatedFrom` keyword (a string property whose value a built-in function generates from other properties of the same document) is parsed onto the property and the properties it reads are checked at registration; None before this version means the keyword is ignored, as it was before it existed + random_generated_from: None, expand_property_type_shorthands: Some(0), // new: a property schema of `"type": "identifier"`, or `"type": "bytes"` with a `size`, is read as the byte array it stands for, before the meta-schema, the parser and the document validator see it; the contract is stored as sent. None before this version leaves the schema as sent, which every earlier meta-schema refuses validate_max_depth: 1, // changed: a scalar `$ref` target is not walked and the visited set is never cleared, so the depth check is bounded; v0 was exponential on a crafted `$defs` chain max_depth: 256, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs index af4d9953712..e2b60e41711 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v7.rs @@ -22,7 +22,9 @@ use crate::version::dpp_versions::dpp_contract_versions::{ /// private document type, which only the native schema check of registration admits; /// - `apply_generated_from`, `Some(1)` rather than `Some(0)`: a `generatedFrom` property may /// also be generated with `sys.tokens.decimals`, the decimals of the token an identifier -/// property names, on an integer property; +/// property names, on an integer property. Generation 1 parses `sys.random.uniform` too, but +/// `random_generated_from` stays `None`, as in v6, so registration refuses it until block +/// execution draws the values; /// - `contract_storage_serialization_version`, 2 rather than 1: Drive stores a contract in /// format version 2, its document types compiled, while state transitions and clients keep /// format version 1. @@ -47,6 +49,8 @@ pub const CONTRACT_VERSIONS_V7: DPPContractVersions = DPPContractVersions { // changed: the `private` keyword declares a private document type parse_private_document: Some(0), apply_generated_from: Some(1), // changed: admits sys.tokens.decimals + // unchanged: sys.random.uniform stays refused until block execution draws the values + random_generated_from: None, ..CONTRACT_VERSIONS_V6.document_type_versions.schema }, ..CONTRACT_VERSIONS_V6.document_type_versions diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs index cbd815cb9ef..038ee1d363e 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs @@ -42,6 +42,13 @@ pub struct DriveDocumentMethodVersions { /// header (the block's height and time). `Some(0)` from `DRIVE_DOCUMENT_METHOD_VERSIONS_V5` /// (protocol version 15), `None` before. pub append_private_document_entries_operations: OptionalFeatureVersion, + /// `draw_random_document_properties`: fills each `sys.random` property of the documents a + /// block created with a value drawn from the block's random beacon, once every transition + /// of the block is accepted, and `prepay_random_document_index_entries`, the same + /// generation's price of the index entries the draw may create, which a create pays. `Some(0)` + /// from `DRIVE_DOCUMENT_METHOD_VERSIONS_V5` (protocol version 15), `None` in every earlier + /// table, which admit no `sys.random` property. + pub draw_random_document_properties: OptionalFeatureVersion, } /// Drive methods of document expiry: the expirations tree under `Misc` that indexes every diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs index f75c5277222..f68c85c36bc 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs @@ -94,6 +94,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V1: DriveDocumentMethodVersions = fetch_private_document_entries: None, // no private document type before protocol version 15 prove_private_document_entries: None, append_private_document_entries_operations: None, // no private document type before protocol version 15 + draw_random_document_properties: None, expiration: DriveDocumentExpirationMethodVersions { insert_document_ttl_trees: 0, add_document_expiration_operations: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs index 6d7a5711d5c..2739164978f 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs @@ -128,6 +128,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V2: DriveDocumentMethodVersions = fetch_private_document_entries: None, // no private document type before protocol version 15 prove_private_document_entries: None, append_private_document_entries_operations: None, // no private document type before protocol version 15 + draw_random_document_properties: None, expiration: DriveDocumentExpirationMethodVersions { insert_document_ttl_trees: 0, add_document_expiration_operations: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs index 6e66106a5ff..284eff3164d 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs @@ -125,6 +125,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V3: DriveDocumentMethodVersions = fetch_private_document_entries: None, // no private document type before protocol version 15 prove_private_document_entries: None, append_private_document_entries_operations: None, // no private document type before protocol version 15 + draw_random_document_properties: None, expiration: DriveDocumentExpirationMethodVersions { insert_document_ttl_trees: 0, add_document_expiration_operations: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs index 7a98b33664c..e011baaee14 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs @@ -195,6 +195,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = fetch_private_document_entries: None, // no private document type before protocol version 15 prove_private_document_entries: None, append_private_document_entries_operations: None, // no private document type before protocol version 15 + draw_random_document_properties: None, expiration: DriveDocumentExpirationMethodVersions { insert_document_ttl_trees: 0, add_document_expiration_operations: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v5.rs index a5f32ea84d2..971ad1a891e 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v5.rs @@ -13,6 +13,9 @@ use crate::version::drive_versions::drive_document_method_versions::{ /// `prove_private_document_entries` are `Some(0)` too (`None` before): a page of a store's /// entries can be read and proved, and `append_private_document_entries_operations` is /// `Some(0)` (`None` before): a `PrivateDocumentCreateFromShieldedPool` appends entries to one. +/// * `draw_random_document_properties` is `Some(0)` (`None` in every earlier table): the +/// documents a block created get their `sys.random` properties drawn from the block's random +/// beacon. /// * `delete.remove_reference_for_index_level_for_contract_operations` is 2: a delete's estimate /// describes an index's entries for a value (its `[0]` layer) as the references they are, as /// the insert's does, not as subtrees. @@ -24,6 +27,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V5: DriveDocumentMethodVersions = fetch_private_document_entries: Some(0), // changed: a page of a store's entries can be read prove_private_document_entries: Some(0), // changed: and proved append_private_document_entries_operations: Some(0), // changed: a PrivateDocumentCreateFromShieldedPool appends entries to a store + draw_random_document_properties: Some(0), // changed: sys.random properties are drawn delete: DriveDocumentDeleteMethodVersions { remove_reference_for_index_level_for_contract_operations: 2, // changed: the entries layer is described as references ..DRIVE_DOCUMENT_METHOD_VERSIONS_V4.delete diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index aa794085509..08ee98bc2e3 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -567,9 +567,35 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// block's rate. Every schedule so far has the same storage rate, so no refund changes. A /// removal from an earlier epoch pays once per such epoch for reading its record, the only /// fee this changes; generation 0 reads nothing. +/// +/// 28. **Properties drawn at random, inactive**: `apply_generated_from` 1 parses the +/// `generatedFrom` function `sys.random.uniform`, which takes no `params` (they may be left +/// out, or be an empty list), on an integer property of at most 64 bits declaring both a +/// `minimum` and a `maximum` inside its type and no `enum`, `const`, `exclusiveMinimum`, +/// `exclusiveMaximum` or `multipleOf`: the platform draws its value from that range, both +/// included, out of the block's random beacon once the block's transitions are accepted +/// (`draw_random_document_properties` 0, `DRIVE_DOCUMENT_METHOD_VERSIONS_V5`). A create +/// writes the placeholder, the range's minimum, and refuses a sent value; a replace keeps +/// the stored value, written back when left out and refused when sent with another +/// (`DocumentPropertyNotGeneratedError`); clients leave the property out and take it from +/// the proof. Registration refuses it on an indexOnly type, on a type that keeps history or +/// has a contested index, in a unique index, summed or averaged, read by a reference or a +/// rule, by an `encryptedFor` key, or by another type through a reference (a derived index +/// property, a lookup, a `where` entry). A create of a type indexing such a property +/// prepays, at Drive's estimate without reading state, the entries those indexes take, +/// which the draw writes again unbilled (`prepay_random_document_index_entries` 0). The +/// compiled form of a contract (format version 2, note 8) stores the range with the +/// declaration, and reading one back refuses a range upside down or of more than 2^64 +/// values. The function stays inactive: `CONTRACT_VERSIONS_V7`'s `random_generated_from` +/// is `None`, as in every earlier table, so registration refuses it with +/// `InvalidContractStructure` until block execution draws the values from the beacon +/// Tenderdash delivers, which a later change wires and activates. The call sites this +/// extended in place (the document create and replace transformers, the parser's read +/// checks, the reference validation's referenced side) are inert at every version, where +/// no type declares such a function. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, - drive: DRIVE_VERSION_V10, // changed: calculate_fee 1 prices a refund at the storing epoch's rate (note 27); apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10) and verify_private_document_entries Some(0) a page of a private type's store (note 13); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11); document method versions v5: insert_empty_private_document_store_operation Some(0) creates a private document type's store, and fetch_private_document_entries and prove_private_document_entries Some(0) read and prove a page of it (note 13); apply_drive_operations 2 resolves a batch's estimate, remove_reference_for_index_level_for_contract_operations 2, stateless_delete_of_non_tree_for_costs 1 and for_total_system_credits_update 1 describe their layers as they are (note 14); create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description (note 17); fetch_core_fork_activation_height and store_core_fork_activation_height Some(0) keep the Core fork activations (note 18); document method versions v5: append_private_document_entries_operations Some(0) appends a private document create's entries (note 25); grove_version GROVE_V5 (note 26) + drive: DRIVE_VERSION_V10, // changed: calculate_fee 1 prices a refund at the storing epoch's rate (note 27); apply_contract_with_serialization 1 writes nothing for a contract whose format version 1 bytes match the stored one's (note 8); vote method versions v4: register_identity_vote 1 registers a yes/no vote, remove_all_votes_given_by_identities 1 also removes the yes/no votes of a masternode that left the list, every yes_no method and remove_vote_poll_end_date_query_operations Some(0) make yes/no polls active, add_vote_poll_end_date_query_operations 1 adds only the entry when the batch already queues the end date's tree with other storage flags, and verify methods v4 make the yes/no proof verifiers active (note 10) and verify_private_document_entries Some(0) a page of a private type's store (note 13); state transition method versions v5: document_create_transition 1 deletes a consumed document from what its lookup read (note 11); document method versions v5: insert_empty_private_document_store_operation Some(0) creates a private document type's store, and fetch_private_document_entries and prove_private_document_entries Some(0) read and prove a page of it (note 13), and draw_random_document_properties Some(0) draws the sys.random properties of a block's documents and prices what their create prepays, which nothing calls while the function is inactive (note 28); apply_drive_operations 2 resolves a batch's estimate, remove_reference_for_index_level_for_contract_operations 2, stateless_delete_of_non_tree_for_costs 1 and for_total_system_credits_update 1 describe their layers as they are (note 14); create_initial_state_structure 5 adds the token marketplace escrow trees; calculate_total_tokens_balance 2 counts escrowed tokens; the escrow primitives; the identity balance, token balance, prefunded specialized balance and contract fee pot estimations (for_balances 1, for_token_balances 1, estimated_cost_for_prefunded_specialized_balance_update 2, add_estimation_costs_for_contract_fee_pot_update 1) keep a deeper layer description (note 17); fetch_core_fork_activation_height and store_core_fork_activation_height Some(0) keep the Core fork activations (note 18); document method versions v5: append_private_document_entries_operations Some(0) appends a private document create's entries (note 25); grove_version GROVE_V5 (note 26) drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: the yes/no poll's tally, record, clean-up and finish hook are Some(0) (note 10); verify_chain_lock_locally 1 rejects a chain lock locally only when Platform holds the quorums active at its signing height, and verify_chain_lock_through_core 1 has Core's verifychainlock check the others before submitchainlock; consensus_params_update 2 installs the full evidence policy (note 16); update_core_info 1 records the chain locked height at which Core's V24 is first seen active (note 18); build_untied_withdrawal_transactions_from_documents 1 emits asset unlock payload version 2 once V24 is recorded as active (note 20); update_voter_identity 1 re-enables the voter key of the address a masternode returns to (note 22) @@ -585,7 +611,7 @@ pub const PLATFORM_V15: PlatformVersion = PlatformVersion { state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, state_transitions: STATE_TRANSITION_VERSIONS_V4, - contract_versions: CONTRACT_VERSIONS_V7, // changed: parse_property_constraints 1 lets countOf and sumOf match byte array keys (note 4); validate_document 2 checks documents natively (note 6); check_document_type_schema 1 checks schemas natively at registration (note 7); contract_storage_serialization_version 2 stores contracts compiled (note 8); parse_private_document 0 parses private document types (note 13); apply_generated_from 1 admits sys.tokens.decimals (note 15) + contract_versions: CONTRACT_VERSIONS_V7, // changed: parse_property_constraints 1 lets countOf and sumOf match byte array keys (note 4); validate_document 2 checks documents natively (note 6); check_document_type_schema 1 checks schemas natively at registration (note 7); contract_storage_serialization_version 2 stores contracts compiled (note 8); parse_private_document 0 parses private document types (note 13); apply_generated_from 1 admits sys.tokens.decimals (note 15) and parses sys.random.uniform, which random_generated_from None still refuses (note 28) document_versions: DOCUMENT_VERSIONS_V4, identity_versions: IDENTITY_VERSIONS_V1, voting_versions: VOTING_VERSION_V3, // changed: the yes/no poll's stored info, id, parameter rules and decision rules are active (note 10), and the decision proposal's poll (note 12)