From 58abc9a312b086d9d1574cfca0849d8bf9349296 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 01:17:49 +0700 Subject: [PATCH 01/12] feat(platform)!: sys.random.uniform document properties A contract can declare an integer property drawn from the block's random beacon: `"generatedFrom": { "function": "sys.random.uniform" }` on a property with a minimum and a maximum (protocol version 15). A create writes the placeholder (the minimum) and refuses a sent value; a replace keeps the stored value; clients leave it out and take it from the proof. - dpp: the `sys.random` namespace, `SystemFunction::platform_generated`, the range on `GeneratedFrom` (and its stored form), parsing and registration checks (ordinary indexes allowed, unique ones refused, not on indexOnly or history-keeping types), and `generate_random_properties`. - drive: the create and replace transformers write and keep the value, and `Drive::draw_random_document_properties` draws each value from the beacon and rewrites the document in place, index entries included, reusing the update path's body split out of `update_document_for_contract_operations` v1 unchanged. Nothing calls the draw yet: the beacon arrives through Tenderdash's ApplyBlockRandomness ABCI method in follow-ups. Refs #4682 Co-Authored-By: Claude Opus 5.5 --- .../class_methods/try_from_schema/mod.rs | 162 +++++- .../native_schema/properties.rs | 16 +- .../v3/generated_from_random_tests.rs | 347 +++++++++++++ .../v3/generated_from_tests.rs | 5 +- .../v3/generated_from_token_tests.rs | 1 + .../class_methods/try_from_schema/v3/mod.rs | 2 + .../document_type/methods/mod.rs | 131 ++++- .../property/generated_from/mod.rs | 14 +- .../generated_from/system_function/mod.rs | 82 ++- .../generated_from/system_function/random.rs | 135 +++++ .../document_type/property/mod.rs | 5 +- .../serialized_version/v0/property.rs | 33 +- .../tests/document/generated_from_random.rs | 419 +++++++++++++++ .../batch/tests/document/mod.rs | 1 + .../draw_random_document_properties/mod.rs | 75 +++ .../draw_random_document_properties/v0/mod.rs | 482 ++++++++++++++++++ packages/rs-drive/src/drive/document/mod.rs | 3 + .../v1/mod.rs | 41 +- packages/rs-drive/src/query/filter.rs | 26 +- .../v0/transformer.rs | 6 + .../v0/transformer.rs | 10 + .../drive_document_method_versions/mod.rs | 5 + .../drive_document_method_versions/v1.rs | 1 + .../drive_document_method_versions/v2.rs | 1 + .../drive_document_method_versions/v3.rs | 1 + .../drive_document_method_versions/v4.rs | 1 + .../drive_document_method_versions/v5.rs | 4 + 27 files changed, 1930 insertions(+), 79 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/generated_from_random_tests.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/random.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from_random.rs create mode 100644 packages/rs-drive/src/drive/document/draw_random_document_properties/mod.rs create mode 100644 packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs 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..aafd2014ccb 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,7 +25,7 @@ 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, + integer_type_bounds, 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, @@ -34,9 +34,10 @@ use crate::data_contract::document_type::{ 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, + 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}; @@ -3100,7 +3101,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,6 +3115,10 @@ 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`]). fn apply_generated_from_v1( inner_properties: &BTreeMap, property_type: &DocumentPropertyType, @@ -3178,6 +3187,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 +3217,14 @@ fn apply_generated_from_v1( ))); } } + SystemFunction::Random(RandomFunction::Uniform) => { + range = Some(uniform_range(inner_properties, property_type, function)?); + } 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 +3232,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 +3270,71 @@ 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" + ))); + } + Ok(RandomRange { minimum, maximum }) } /// Whether the property schema `map` admits every token's decimals, 0 to 255: its `minimum` @@ -3374,6 +3455,13 @@ 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. +/// /// Owned by parser generation 3: the only generation that admits the keyword. pub(super) fn validate_generated_from_declarations( document_type: &DocumentTypeV2, @@ -3438,13 +3526,55 @@ 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 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 +3609,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(_) 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..81a92f58285 --- /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,347 @@ +//! `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`. 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`). + +use super::typed_array_test_helpers::{ + expect_json_schema_error, expect_structure_error, parse_dispatched, +}; +use super::*; +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 +} + +fn parse(schema: Value) -> DocumentType { + parse_dispatched(schema, PlatformVersion::latest(), 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) { + assert!( + parse_dispatched(schema.clone(), PlatformVersion::latest(), true).is_err(), + "a validating parse refuses it ({needle})" + ); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + needle, + ); +} + +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: Some(RandomRange { + minimum: 1, + maximum: 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), + Some(RandomRange { + minimum: -5, + maximum: 5 + }) + ); +} + +#[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 = PlatformVersion::latest(); + + // 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); +} 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 55f4b66f1ee..ec519724f61 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 @@ -2113,6 +2113,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..abd5dafd6dc 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,90 @@ 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 admit a + /// `sys.random` function). + 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 +514,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 +667,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 +783,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 +797,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 +817,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 +832,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/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..bc4c1ec3222 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/random.rs @@ -0,0 +1,135 @@ +//! `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, requires +/// `minimum <= maximum`, and requires both to fit the property's integer type, at most 64 bits +/// wide, so the range never holds more than 2^64 values. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RandomRange { + /// The smallest value drawn, and the placeholder the document holds until the draw. + pub minimum: i128, + /// The largest value drawn. + pub maximum: i128, +} + +impl RandomRange { + /// 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. + pub fn size(&self) -> u128 { + self.maximum.abs_diff(self.minimum) + 1 + } +} + +/// `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 { + minimum: -2, + maximum: 2, + }; + 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 widest = RandomRange { + minimum: 0, + maximum: u64::MAX.into(), + }; + assert_eq!(widest.size(), 1u128 << 64); + assert_eq!(widest.value_at((1u128 << 64) - 1), Some(u64::MAX.into())); + } + + #[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 e7b17533de8..87e79d49cfd 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 @@ -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::{ 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..eb3900933d6 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}; @@ -138,6 +138,8 @@ pub(super) enum StoredEncryptionScheme { pub(super) struct StoredGeneratedFrom { function: StoredSystemFunction, params: Vec, + /// [`GeneratedFrom::range`]: `(minimum, maximum)`. + range: Option<(i128, i128)>, } /// [`SystemFunction`]. @@ -146,6 +148,7 @@ pub(super) enum StoredSystemFunction { StringTransformation(StoredStringTransformation), Hash(HashFunction), Token(StoredTokenFunction), + Random(StoredRandomFunction), } /// [`TokenFunction`]. @@ -154,6 +157,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 { @@ -519,7 +528,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 +551,9 @@ impl From<&GeneratedFrom> for StoredGeneratedFrom { SystemFunction::Token(token) => StoredSystemFunction::Token(match token { TokenFunction::Decimals => StoredTokenFunction::Decimals, }), + SystemFunction::Random(random) => StoredSystemFunction::Random(match random { + RandomFunction::Uniform => StoredRandomFunction::Uniform, + }), }, params: params .iter() @@ -545,13 +561,18 @@ impl From<&GeneratedFrom> for StoredGeneratedFrom { GenerationParam::Property(path) => path.clone(), }) .collect(), + range: range.map(|range| (range.minimum, range.maximum)), } } } impl From for GeneratedFrom { fn from(stored: StoredGeneratedFrom) -> Self { - let StoredGeneratedFrom { function, params } = stored; + let StoredGeneratedFrom { + function, + params, + range, + } = stored; GeneratedFrom { function: match function { StoredSystemFunction::StringTransformation(transformation) => { @@ -570,8 +591,12 @@ impl From for GeneratedFrom { StoredSystemFunction::Token(token) => SystemFunction::Token(match token { StoredTokenFunction::Decimals => TokenFunction::Decimals, }), + StoredSystemFunction::Random(random) => SystemFunction::Random(match random { + StoredRandomFunction::Uniform => RandomFunction::Uniform, + }), }, params: params.into_iter().map(GenerationParam::Property).collect(), + range: range.map(|(minimum, maximum)| RandomRange { minimum, maximum }), } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from_random.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from_random.rs new file mode 100644 index 00000000000..d75905dfdfb --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from_random.rs @@ -0,0 +1,419 @@ +//! End-to-end coverage for `generatedFrom` with `sys.random.uniform` (protocol version 15): an +//! integer property the platform writes with a placeholder, its range's minimum, when the +//! document is created, and draws from the block's random beacon once the block's transitions +//! are accepted (`Drive::draw_random_document_properties`). A client leaves the property out +//! and takes the drawn value from the proof of its own transition; one that sends a value is +//! refused, and a replace keeps the drawn value. + +use super::*; + +mod generated_from_random_tests { + use super::*; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::consensus::basic::BasicError; + use dpp::data_contract::schema::DataContractSchemaMethodsV0; + use dpp::document::Document; + use dpp::identity::{Identity, IdentityPublicKey, SecurityLevel}; + use dpp::platform_value::platform_value; + use dpp::prelude::{DataContract, Identifier, IdentityNonce}; + use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; + use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionMutRef; + use dpp::state_transition::proof_result::StateTransitionProofResult; + use dpp::state_transition::StateTransition; + use dpp::tests::fixtures::get_data_contract_fixture; + use drive::drive::document::draw_random_document_properties::RandomDocumentRequest; + use drive::drive::Drive; + use drive::util::storage_flags::StorageFlags; + use simple_signer::signer::SimpleSigner; + use std::collections::BTreeMap; + use std::sync::Arc; + + const BEACON: [u8; 96] = [7; 96]; + + /// A mutable `card` type: its `name`, a `rarity` drawn from 1 to 100 and indexed, and a + /// free `note`. + fn card_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "indices": [{ "name": "byRarity", "properties": [{ "rarity": "asc" }] }], + "properties": { + "name": { "type": "string", "maxLength": 32, "position": 0 }, + "rarity": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "generatedFrom": { "function": "sys.random.uniform" }, + "position": 1 + }, + "note": { "type": "string", "maxLength": 32, "position": 2 } + }, + "required": ["name", "rarity"], + "additionalProperties": false + }) + } + + /// One identity holding a `card` contract. + struct CardFixture { + platform: TempPlatform, + signer: SimpleSigner, + key: IdentityPublicKey, + identity: Identity, + contract: DataContract, + next_nonce: IdentityNonce, + } + + impl CardFixture { + fn new() -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.5)); + + let mut contract = get_data_contract_fixture( + Some(identity.id()), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + contract + .set_document_schema( + "card", + card_schema(), + true, + &mut Vec::new(), + platform_version, + ) + .expect("expected to add the card document type"); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + + Self { + platform, + signer, + key, + identity, + contract, + next_nonce: 1, + } + } + + fn card_data(rarity: Option, note: Option<&str>) -> BTreeMap { + let mut data = BTreeMap::from([("name".to_string(), Value::Text("Ace".to_string()))]); + if let Some(rarity) = rarity { + data.insert("rarity".to_string(), Value::U8(rarity)); + } + if let Some(note) = note { + data.insert("note".to_string(), Value::Text(note.to_string())); + } + data + } + + /// `transition` with its document's data set to exactly `data`, signed again. + async fn send_as_given( + &self, + mut transition: StateTransition, + data: BTreeMap, + ) -> StateTransition { + { + let StateTransition::Batch(batch) = &mut transition else { + panic!("expected a batch transition"); + }; + let Some(BatchedTransitionMutRef::Document(document_transition)) = + batch.first_transition_mut() + else { + panic!("expected a document transition"); + }; + *document_transition + .data_mut() + .expect("the document transition carries data") = data; + } + transition + .sign_external( + &self.key, + &self.signer, + Some(|_, _| Ok(SecurityLevel::HIGH)), + ) + .await + .expect("expected to sign the transition again"); + transition + } + + /// A create transition for a card holding exactly `data`. + async fn create_transition( + &mut self, + data: BTreeMap, + seed: u64, + ) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let document_type = self + .contract + .document_type_for_name("card") + .expect("expected the card document type"); + let mut rng = StdRng::seed_from_u64(seed); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + self.identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + document + .set_id_for_creation(document_type, &entropy.0, self.next_nonce, platform_version) + .expect("expected to set the document id"); + *document.properties_mut() = data.clone(); + + let transition = BatchTransition::new_document_creation_transition_from_document( + document, + document_type, + entropy.0, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + self.next_nonce += 1; + self.send_as_given(transition, data).await + } + + /// A replace transition for `stored`, its revision bumped, holding exactly `data`. + async fn replace_transition( + &mut self, + stored: &Document, + data: BTreeMap, + ) -> StateTransition { + let platform_version = PlatformVersion::latest(); + let mut replacement = stored.clone(); + replacement + .increment_revision() + .expect("expected the revision to increment"); + let document_type = self + .contract + .document_type_for_name("card") + .expect("expected the card document type"); + let transition = BatchTransition::new_document_replacement_transition_from_document( + replacement, + document_type, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + self.next_nonce += 1; + self.send_as_given(transition, data).await + } + + fn process(&self, transition: &StateTransition) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let platform_state = self.platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// Draws the rarity of `card` from [`BEACON`], as the block that created it does once + /// its transitions are accepted. + fn draw(&self, card: &Document) { + let drawn = self + .platform + .drive + .draw_random_document_properties( + &BEACON, + &[RandomDocumentRequest { + contract_id: self.contract.id(), + document_type_name: "card".to_string(), + document_id: card.id(), + }], + &BlockInfo::default(), + None, + PlatformVersion::latest(), + ) + .expect("expected the draw to run"); + assert_eq!(drawn, 1); + } + + /// Proves the committed state `transition` wrote and verifies the proof as a client + /// holding the card contract does: it cannot know the drawn value. + fn verify_proof(&self, transition: &StateTransition) -> StateTransitionProofResult { + let platform_version = PlatformVersion::latest(); + let proof = self + .platform + .drive + .prove_state_transition(transition, None, platform_version) + .expect("expected to prove the state transition") + .into_data() + .expect("expected proof bytes"); + let known_contracts: BTreeMap = + BTreeMap::from([(self.contract.id(), self.contract.clone())]); + let (_, outcome) = Drive::verify_state_transition_was_executed_with_proof( + transition, + &BlockInfo::default(), + &proof, + &|id| Ok(known_contracts.get(id).cloned().map(Arc::new)), + platform_version, + ) + .expect("expected the proof to verify"); + outcome.into_result() + } + + fn stored_cards(&self) -> Vec { + let platform_version = PlatformVersion::latest(); + let query = DriveDocumentQuery::from_sql_expr( + "select * from card", + &self.contract, + Some(&self.platform.config.drive), + platform_version, + ) + .expect("expected a document query"); + self.platform + .drive + .query_documents(query, None, false, None, None) + .expect("expected a query result") + .documents() + .to_vec() + } + } + + fn rarity_of(document: &Document) -> Option { + document + .properties() + .get("rarity") + .and_then(|value| value.to_integer::().ok()) + } + + fn assert_success(result: &StateTransitionExecutionResult) { + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. }, + "{result:?}" + ); + } + + fn assert_not_generated(result: StateTransitionExecutionResult) { + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + assert_matches!( + error, + ConsensusError::BasicError(BasicError::DocumentPropertyNotGeneratedError(e)) + if e.property() == "rarity" && e.function() == "sys.random.uniform" + ); + } + + #[tokio::test] + async fn should_write_the_placeholder_then_prove_the_drawn_value() { + let mut fixture = CardFixture::new(); + + let transition = fixture + .create_transition(CardFixture::card_data(None, None), 1) + .await; + assert_success(&fixture.process(&transition)); + let stored = fixture.stored_cards(); + assert_eq!(stored.len(), 1); + assert_eq!(rarity_of(&stored[0]), Some(1), "the placeholder"); + + fixture.draw(&stored[0]); + let drawn = rarity_of(&fixture.stored_cards()[0]).expect("a drawn rarity"); + assert!((1..=100).contains(&drawn)); + + // The client that sent the create, which left the rarity out, reads the drawn value + // from the proof of its own transition + assert_matches!( + fixture.verify_proof(&transition), + StateTransitionProofResult::VerifiedDocuments(documents) + if documents.values().flatten().all(|document| rarity_of(document) == Some(drawn)) + ); + } + + #[tokio::test] + async fn should_refuse_a_create_that_picks_the_value() { + let mut fixture = CardFixture::new(); + + let transition = fixture + .create_transition(CardFixture::card_data(Some(100), None), 2) + .await; + assert_not_generated(fixture.process(&transition)); + assert!(fixture.stored_cards().is_empty()); + } + + #[tokio::test] + async fn should_keep_the_drawn_value_on_a_replace() { + let mut fixture = CardFixture::new(); + + let transition = fixture + .create_transition(CardFixture::card_data(None, None), 3) + .await; + assert_success(&fixture.process(&transition)); + fixture.draw(&fixture.stored_cards()[0]); + let drawn = fixture.stored_cards().remove(0); + let rarity = rarity_of(&drawn).expect("a drawn rarity"); + + // Left out, the drawn value is kept + let transition = fixture + .replace_transition(&drawn, CardFixture::card_data(None, Some("kept"))) + .await; + assert_success(&fixture.process(&transition)); + let replaced = fixture.stored_cards().remove(0); + assert_eq!(replaced.get("note"), Some(&Value::Text("kept".to_string()))); + assert_eq!(rarity_of(&replaced), Some(rarity)); + assert_matches!( + fixture.verify_proof(&transition), + StateTransitionProofResult::VerifiedDocuments(_) + ); + + // Another value cannot be picked + let other = if rarity == 100 { 99 } else { rarity + 1 }; + let transition = fixture + .replace_transition(&replaced, CardFixture::card_data(Some(other), None)) + .await; + assert_not_generated(fixture.process(&transition)); + assert_eq!(rarity_of(&fixture.stored_cards()[0]), Some(rarity)); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index a3750ecbcdb..7132ef7101e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -15,6 +15,7 @@ mod duplicate_keys; mod encrypted_for; mod gas_sponsorship; mod generated_from; +mod generated_from_random; mod generated_from_token; mod id_reuse; mod immutable; 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..92e7101955a --- /dev/null +++ b/packages/rs-drive/src/drive/document/draw_random_document_properties/mod.rs @@ -0,0 +1,75 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::block::block_info::BlockInfo; +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 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], + })), + } + } +} 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..477a0e66db1 --- /dev/null +++ b/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs @@ -0,0 +1,482 @@ +use crate::drive::document::draw_random_document_properties::RandomDocumentRequest; +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::util::grove_operations::DirectQueryType; +use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; +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::{ + integer_value_for_type, DocumentPropertyType, RandomRange, SystemFunction, +}; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::platform_value::btreemap_extensions::BTreeValueMapInsertionPathHelper; +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) + } + + /// 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::object_size_info::DocumentInfo::DocumentRefInfo; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::data_contract::DataContractFactory; + use dpp::document::DocumentV0; + use dpp::platform_value::{platform_value, Identifier, Value}; + use dpp::prelude::DataContract; + use std::collections::BTreeMap; + + const OWNER: [u8; 32] = [42; 32]; + const BEACON: [u8; 96] = [7; 96]; + + /// A contract with an immutable `card` type, its `rarity` drawn from 1 to 100 and indexed, + /// and a `plain` type drawing nothing. + fn contract() -> DataContract { + DataContractFactory::new(PlatformVersion::latest().protocol_version) + .expect("factory") + .create_with_value_config( + Identifier::from(OWNER), + 0, + platform_value!({ + "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 } + }, + "required": ["name"], + "additionalProperties": false + } + }), + None, + None, + ) + .expect("contract") + .data_contract_owned() + } + + fn setup() -> (Drive, DataContract) { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = contract(); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("contract applies"); + (drive, contract) + } + + /// Inserts a `card` holding the placeholder, the way a create transition writes it. + fn insert_card(drive: &Drive, contract: &DataContract, marker: u8) -> Identifier { + let id = Identifier::from([marker; 32]); + let document = Document::V0(DocumentV0 { + id, + owner_id: Identifier::from(OWNER), + properties: BTreeMap::from([ + ("name".to_string(), Value::Text("Ace".to_string())), + ("rarity".to_string(), Value::U8(1)), + ]), + ..Default::default() + }); + 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, + PlatformVersion::latest(), + None, + ) + .expect("the card inserts"); + 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) -> Vec { + let platform_version = PlatformVersion::latest(); + 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() + } + + const RARITY: RandomRange = RandomRange { + minimum: 1, + maximum: 100, + }; + + #[test] + fn should_draw_into_an_immutable_document_and_move_its_index_entry() { + let (drive, contract) = setup(); + let id = insert_card(&drive, &contract, 1); + let request = request(contract.id(), "card", id); + let expected = draw_v0(&BEACON, &request, "rarity", RARITY).expect("draws"); + assert!((1..=100).contains(&expected)); + + let drawn = drive + .draw_random_document_properties( + &BEACON, + &[request], + &BlockInfo::default(), + None, + PlatformVersion::latest(), + ) + .expect("the draw runs"); + assert_eq!(drawn, 1); + + let found = cards_with_rarity(&drive, &contract, expected); + 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).is_empty()); + } + } + + #[test] + fn should_draw_each_document_and_beacon_independently_and_reproducibly() { + let widest = RandomRange { + minimum: 0, + maximum: u64::MAX.into(), + }; + 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), + 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 (drive, contract) = setup(); + 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, + PlatformVersion::latest(), + ) + .expect("the draw runs"); + assert_eq!(drawn, 0); + } + + #[test] + fn should_not_draw_before_protocol_version_15() { + let (drive, _contract) = setup(); + 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 { .. })) + )); + } +} diff --git a/packages/rs-drive/src/drive/document/mod.rs b/packages/rs-drive/src/drive/document/mod.rs index 2e38d05666e..04e242dc00e 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..e96e06ab781 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( 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/drive_versions/drive_document_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs index c4fbf1f7354..42d69ae1683 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 @@ -37,6 +37,11 @@ pub struct DriveDocumentMethodVersions { /// `verify_private_document_entries` checks. `Some(0)` from /// `DRIVE_DOCUMENT_METHOD_VERSIONS_V5` (protocol version 15), `None` before. pub prove_private_document_entries: 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. `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 8193461a1f4..5fa1f26d4f9 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 @@ -93,6 +93,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V1: DriveDocumentMethodVersions = insert_empty_private_document_store_operation: None, // no private document type before protocol version 15 fetch_private_document_entries: None, // no private document type before protocol version 15 prove_private_document_entries: None, + 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 d5e51cc9f31..22133a53812 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 @@ -127,6 +127,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V2: DriveDocumentMethodVersions = insert_empty_private_document_store_operation: None, // no private document type before protocol version 15 fetch_private_document_entries: None, // no private document type before protocol version 15 prove_private_document_entries: None, + 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 ef1dba68dec..0d378da2f55 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 @@ -124,6 +124,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V3: DriveDocumentMethodVersions = insert_empty_private_document_store_operation: None, // no private document type before protocol version 15 fetch_private_document_entries: None, // no private document type before protocol version 15 prove_private_document_entries: None, + 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 5109e826e6e..330ff57348e 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 @@ -194,6 +194,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = insert_empty_private_document_store_operation: None, // no private document type before protocol version 15 fetch_private_document_entries: None, // no private document type before protocol version 15 prove_private_document_entries: None, + 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 7758114f0e1..2b3ee72cb26 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 @@ -12,6 +12,9 @@ use crate::version::drive_versions::drive_document_method_versions::{ /// limits of `SYSTEM_LIMITS_V5`. `fetch_private_document_entries` and /// `prove_private_document_entries` are `Some(0)` too (`None` before): a page of a store's /// entries can be read and proved. +/// * `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. @@ -22,6 +25,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V5: DriveDocumentMethodVersions = insert_empty_private_document_store_operation: Some(0), // changed: a private document type's store is created with it 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 + 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 From 4928a7f79bc0114f532afd37214233f9d7148db8 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 03:48:18 +0700 Subject: [PATCH 02/12] fix(platform): keep sys.random.uniform inert until drawn, prepay its index entries, refuse unsafe readers Review follow-ups: - `random_generated_from` (new, None in every table, protocol version 15 included) keeps `apply_generated_from` 1 refusing `sys.random.uniform` until the release that wires the draw in turns it on; the end-to-end tests move with that release. - A create whose type indexes a random property prepays Drive's estimated (average-layer) cost of the index entries the draw may create, through `Drive::prepay_random_document_index_entries`, so later refunds of those entries are funded. The draw stays unbilled. - Registration refuses a random property as a sum or average source, as a derived-index source, read by any contract rule (propertyConstraints, deleteConstraints, immutable when, retractedWhen, referenced reads, reference where/findBy reads, countOf/sumOf filter keys), and on a type with a contested index. - `RandomRange` is checked at construction and decoding. - Changelog note 18 in protocol version 15 and the generatedFrom chapter. Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords/generated-from.md | 56 ++- .../try_from_schema/common/mod.rs | 43 +- .../class_methods/try_from_schema/mod.rs | 162 ++++++- .../v3/generated_from_random_tests.rs | 345 +++++++++++++- .../class_methods/try_from_schema/v3/mod.rs | 14 +- .../document_type/methods/mod.rs | 9 +- .../src/data_contract/document_type/mod.rs | 72 +++ .../generated_from/system_function/random.rs | 125 +++++- .../document_type/property/mod.rs | 27 +- .../property/reference_lookup.rs | 22 +- .../serialized_version/v0/property.rs | 71 ++- .../tests/document/generated_from_random.rs | 419 ------------------ .../batch/tests/document/mod.rs | 1 - .../v0/mod.rs | 76 +++- .../draw_random_document_properties/mod.rs | 69 ++- .../draw_random_document_properties/v0/mod.rs | 235 +++++++--- .../v0/transformer.rs | 198 ++++++++- .../dpp_versions/dpp_contract_versions/mod.rs | 7 + .../dpp_versions/dpp_contract_versions/v1.rs | 1 + .../dpp_versions/dpp_contract_versions/v2.rs | 1 + .../dpp_versions/dpp_contract_versions/v3.rs | 1 + .../dpp_versions/dpp_contract_versions/v4.rs | 1 + .../dpp_versions/dpp_contract_versions/v5.rs | 1 + .../dpp_versions/dpp_contract_versions/v6.rs | 1 + .../dpp_versions/dpp_contract_versions/v7.rs | 6 +- .../drive_document_method_versions/mod.rs | 6 +- .../rs-platform-version/src/version/v15.rs | 30 +- 27 files changed, 1405 insertions(+), 594 deletions(-) delete mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from_random.rs diff --git a/book/src/contract-keywords/generated-from.md b/book/src/contract-keywords/generated-from.md index 9f2dc2f5a4f..b5bd4fa6a7c 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,49 @@ 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; +- 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 +159,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 4d78b8d6f1d..70055379958 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 @@ -3346,17 +3346,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 { @@ -3364,7 +3375,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; @@ -3374,26 +3385,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 @@ -3401,8 +3407,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 aafd2014ccb..6c5884c8372 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,19 +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::{ - integer_type_bounds, 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, + 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}; @@ -54,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; @@ -2975,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" ))), @@ -3118,10 +3120,12 @@ fn apply_generated_from_v0( /// /// 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`]). +/// `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) @@ -3218,7 +3222,31 @@ fn apply_generated_from_v1( } } SystemFunction::Random(RandomFunction::Uniform) => { - range = Some(uniform_range(inner_properties, property_type, function)?); + 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!( @@ -3334,7 +3362,12 @@ fn uniform_range( in between" ))); } - Ok(RandomRange { minimum, maximum }) + // 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` @@ -3460,7 +3493,16 @@ fn refuse_state_generated_schema_path(schema: &Value, path: &str) -> Result<(), /// 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. +/// 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. 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( @@ -3556,6 +3598,64 @@ pub(super) fn validate_generated_from_declarations( 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 + ))); + } + // 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| { @@ -4010,8 +4110,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, @@ -4282,6 +4383,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 } @@ -5430,6 +5542,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/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 index 81a92f58285..e0263ad58ec 100644 --- 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 @@ -1,11 +1,18 @@ //! `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`. 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`). +//! 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, }; @@ -62,23 +69,92 @@ fn card_schema_with(key: &str, value: Value) -> Value { 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, PlatformVersion::latest(), true).expect("the schema parses") + 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(), PlatformVersion::latest(), true).is_err(), + 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, PlatformVersion::latest(), false), + parse_dispatched(schema, &drawing_platform_version(), true), needle, ); } +/// 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() @@ -91,10 +167,7 @@ fn should_parse_sys_random_uniform_onto_a_bounded_integer_property_from_protocol let expected = Some(GeneratedFrom { function: SystemFunction::Random(RandomFunction::Uniform), params: vec![], - range: Some(RandomRange { - minimum: 1, - maximum: 100, - }), + range: RandomRange::new(1, 100), }); let rarity = |document_type: DocumentType| { document_type @@ -134,10 +207,22 @@ fn should_parse_sys_random_uniform_onto_a_bounded_integer_property_from_protocol .generated_from .as_ref() .and_then(|generated_from| generated_from.range), - Some(RandomRange { - minimum: -5, - maximum: 5 - }) + 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", ); } @@ -301,7 +386,7 @@ fn should_keep_the_stored_value_on_a_replace() { #[test] fn should_leave_the_value_to_the_platform_on_a_client() { let document_type = parse(card_schema()); - let platform_version = PlatformVersion::latest(); + let platform_version = &drawing_platform_version(); // The transition builders remove it, whatever the document holds let mut properties = data(vec![ @@ -345,3 +430,231 @@ fn should_take_the_proved_value_into_a_rebuilt_document() { .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", + ); + } +} + +#[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/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index ec519724f61..0d95c90a2d0 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, @@ -1374,6 +1374,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 \ 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 abd5dafd6dc..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 @@ -437,8 +437,9 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { /// (`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 admit a - /// `sys.random` function). + /// 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, @@ -464,7 +465,7 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { } let Some(placeholder) = self.flattened_properties().get(path).and_then(|property| { - integer_value_for_type(&property.property_type, range.minimum) + integer_value_for_type(&property.property_type, range.minimum()) }) else { // Registration admits the function only on an integer property whose @@ -833,7 +834,7 @@ pub(crate) fn with_state_generated_stand_ins( let stand_in = match generated_from.function { SystemFunction::Token(TokenFunction::Decimals) => Value::U8(0), SystemFunction::Random(_) => { - let Some(minimum) = generated_from.range.map(|range| range.minimum) else { + let Some(minimum) = generated_from.range.map(|range| range.minimum()) else { continue; }; // At most 64 bits wide, so one of the two holds it 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 2669afc319a..39c0d6ab62e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -44,6 +44,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; @@ -450,6 +452,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/system_function/random.rs b/packages/rs-dpp/src/data_contract/document_type/property/generated_from/system_function/random.rs index bc4c1ec3222..e2a00ae0a4c 100644 --- 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 @@ -33,19 +33,67 @@ impl RandomFunction { } /// 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, requires -/// `minimum <= maximum`, and requires both to fit the property's integer type, at most 64 bits -/// wide, so the range never holds more than 2^64 values. +/// `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", deny_unknown_fields)] +#[serde(rename_all = "camelCase", try_from = "UncheckedRandomRange")] pub struct RandomRange { /// The smallest value drawn, and the placeholder the document holds until the draw. - pub minimum: i128, + minimum: i128, /// The largest value drawn. - pub maximum: i128, + 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()?; @@ -53,9 +101,20 @@ impl RandomRange { (value <= self.maximum).then_some(value) } - /// How many values the range holds; at least one. + /// 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 { - self.maximum.abs_diff(self.minimum) + 1 + 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) } } @@ -97,21 +156,53 @@ mod tests { #[test] fn should_count_and_index_every_value_of_the_range() { - let range = RandomRange { - minimum: -2, - maximum: 2, - }; + 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 widest = RandomRange { - minimum: 0, - maximum: u64::MAX.into(), - }; - assert_eq!(widest.size(), 1u128 << 64); + 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] 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 87e79d49cfd..da191db8434 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; @@ -1595,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 eb3900933d6..8aac95eb579 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 @@ -254,7 +254,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, }) } @@ -561,19 +561,33 @@ impl From<&GeneratedFrom> for StoredGeneratedFrom { GenerationParam::Property(path) => path.clone(), }) .collect(), - range: range.map(|range| (range.minimum, range.maximum)), + range: range.map(|range| (range.minimum(), range.maximum())), } } } -impl From for GeneratedFrom { - fn from(stored: StoredGeneratedFrom) -> Self { +impl TryFrom for GeneratedFrom { + type Error = ProtocolError; + + /// Refuses a stored range [`RandomRange::new`] does not admit, which registration never + /// stores, rather than construct a range whose draw and size it does not hold. + fn try_from(stored: StoredGeneratedFrom) -> Result { let StoredGeneratedFrom { function, params, range, } = stored; - GeneratedFrom { + let range = range + .map(|(minimum, maximum)| { + 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" + )) + }) + }) + .transpose()?; + Ok(GeneratedFrom { function: match function { StoredSystemFunction::StringTransformation(transformation) => { SystemFunction::StringTransformation(match transformation { @@ -596,7 +610,52 @@ impl From for GeneratedFrom { }), }, params: params.into_iter().map(GenerationParam::Property).collect(), - range: range.map(|(minimum, maximum)| RandomRange { minimum, maximum }), + range, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stored_uniform(range: Option<(i128, i128)>) -> StoredGeneratedFrom { + StoredGeneratedFrom { + function: StoredSystemFunction::Random(StoredRandomFunction::Uniform), + params: vec![], + range, + } + } + + #[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_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/tests/document/generated_from_random.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from_random.rs deleted file mode 100644 index d75905dfdfb..00000000000 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/generated_from_random.rs +++ /dev/null @@ -1,419 +0,0 @@ -//! End-to-end coverage for `generatedFrom` with `sys.random.uniform` (protocol version 15): an -//! integer property the platform writes with a placeholder, its range's minimum, when the -//! document is created, and draws from the block's random beacon once the block's transitions -//! are accepted (`Drive::draw_random_document_properties`). A client leaves the property out -//! and takes the drawn value from the proof of its own transition; one that sends a value is -//! refused, and a replace keeps the drawn value. - -use super::*; - -mod generated_from_random_tests { - use super::*; - use crate::rpc::core::MockCoreRPCLike; - use crate::test::helpers::setup::TempPlatform; - use dpp::consensus::basic::BasicError; - use dpp::data_contract::schema::DataContractSchemaMethodsV0; - use dpp::document::Document; - use dpp::identity::{Identity, IdentityPublicKey, SecurityLevel}; - use dpp::platform_value::platform_value; - use dpp::prelude::{DataContract, Identifier, IdentityNonce}; - use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; - use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; - use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionMutRef; - use dpp::state_transition::proof_result::StateTransitionProofResult; - use dpp::state_transition::StateTransition; - use dpp::tests::fixtures::get_data_contract_fixture; - use drive::drive::document::draw_random_document_properties::RandomDocumentRequest; - use drive::drive::Drive; - use drive::util::storage_flags::StorageFlags; - use simple_signer::signer::SimpleSigner; - use std::collections::BTreeMap; - use std::sync::Arc; - - const BEACON: [u8; 96] = [7; 96]; - - /// A mutable `card` type: its `name`, a `rarity` drawn from 1 to 100 and indexed, and a - /// free `note`. - fn card_schema() -> Value { - platform_value!({ - "type": "object", - "documentsMutable": true, - "indices": [{ "name": "byRarity", "properties": [{ "rarity": "asc" }] }], - "properties": { - "name": { "type": "string", "maxLength": 32, "position": 0 }, - "rarity": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "generatedFrom": { "function": "sys.random.uniform" }, - "position": 1 - }, - "note": { "type": "string", "maxLength": 32, "position": 2 } - }, - "required": ["name", "rarity"], - "additionalProperties": false - }) - } - - /// One identity holding a `card` contract. - struct CardFixture { - platform: TempPlatform, - signer: SimpleSigner, - key: IdentityPublicKey, - identity: Identity, - contract: DataContract, - next_nonce: IdentityNonce, - } - - impl CardFixture { - fn new() -> Self { - let platform_version = PlatformVersion::latest(); - let mut platform = TestPlatformBuilder::new() - .build_with_mock_rpc() - .set_initial_state_structure(); - let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.5)); - - let mut contract = get_data_contract_fixture( - Some(identity.id()), - 0, - platform_version.protocol_version, - ) - .data_contract_owned(); - contract - .set_document_schema( - "card", - card_schema(), - true, - &mut Vec::new(), - platform_version, - ) - .expect("expected to add the card document type"); - platform - .drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("expected to apply the contract"); - - Self { - platform, - signer, - key, - identity, - contract, - next_nonce: 1, - } - } - - fn card_data(rarity: Option, note: Option<&str>) -> BTreeMap { - let mut data = BTreeMap::from([("name".to_string(), Value::Text("Ace".to_string()))]); - if let Some(rarity) = rarity { - data.insert("rarity".to_string(), Value::U8(rarity)); - } - if let Some(note) = note { - data.insert("note".to_string(), Value::Text(note.to_string())); - } - data - } - - /// `transition` with its document's data set to exactly `data`, signed again. - async fn send_as_given( - &self, - mut transition: StateTransition, - data: BTreeMap, - ) -> StateTransition { - { - let StateTransition::Batch(batch) = &mut transition else { - panic!("expected a batch transition"); - }; - let Some(BatchedTransitionMutRef::Document(document_transition)) = - batch.first_transition_mut() - else { - panic!("expected a document transition"); - }; - *document_transition - .data_mut() - .expect("the document transition carries data") = data; - } - transition - .sign_external( - &self.key, - &self.signer, - Some(|_, _| Ok(SecurityLevel::HIGH)), - ) - .await - .expect("expected to sign the transition again"); - transition - } - - /// A create transition for a card holding exactly `data`. - async fn create_transition( - &mut self, - data: BTreeMap, - seed: u64, - ) -> StateTransition { - let platform_version = PlatformVersion::latest(); - let document_type = self - .contract - .document_type_for_name("card") - .expect("expected the card document type"); - let mut rng = StdRng::seed_from_u64(seed); - let entropy = Bytes32::random_with_rng(&mut rng); - let mut document = document_type - .random_document_with_identifier_and_entropy( - &mut rng, - self.identity.id(), - entropy, - DocumentFieldFillType::DoNotFillIfNotRequired, - DocumentFieldFillSize::AnyDocumentFillSize, - platform_version, - ) - .expect("expected a random document"); - document - .set_id_for_creation(document_type, &entropy.0, self.next_nonce, platform_version) - .expect("expected to set the document id"); - *document.properties_mut() = data.clone(); - - let transition = BatchTransition::new_document_creation_transition_from_document( - document, - document_type, - entropy.0, - &self.key, - self.next_nonce, - 0, - None, - &self.signer, - platform_version, - None, - ) - .await - .expect("expected the create transition"); - self.next_nonce += 1; - self.send_as_given(transition, data).await - } - - /// A replace transition for `stored`, its revision bumped, holding exactly `data`. - async fn replace_transition( - &mut self, - stored: &Document, - data: BTreeMap, - ) -> StateTransition { - let platform_version = PlatformVersion::latest(); - let mut replacement = stored.clone(); - replacement - .increment_revision() - .expect("expected the revision to increment"); - let document_type = self - .contract - .document_type_for_name("card") - .expect("expected the card document type"); - let transition = BatchTransition::new_document_replacement_transition_from_document( - replacement, - document_type, - &self.key, - self.next_nonce, - 0, - None, - &self.signer, - platform_version, - None, - ) - .await - .expect("expected the replace transition"); - self.next_nonce += 1; - self.send_as_given(transition, data).await - } - - fn process(&self, transition: &StateTransition) -> StateTransitionExecutionResult { - let platform_version = PlatformVersion::latest(); - let platform_state = self.platform.state.load(); - let serialized = transition - .serialize_to_bytes() - .expect("expected the transition to serialize"); - let transaction = self.platform.drive.grove.start_transaction(); - let processing_result = self - .platform - .platform - .process_raw_state_transitions( - &[serialized], - &platform_state, - &BlockInfo::default(), - &transaction, - platform_version, - false, - None, - ) - .expect("expected to process the state transition"); - self.platform - .drive - .grove - .commit_transaction(transaction) - .unwrap() - .expect("expected to commit the transaction"); - processing_result.into_execution_results().remove(0) - } - - /// Draws the rarity of `card` from [`BEACON`], as the block that created it does once - /// its transitions are accepted. - fn draw(&self, card: &Document) { - let drawn = self - .platform - .drive - .draw_random_document_properties( - &BEACON, - &[RandomDocumentRequest { - contract_id: self.contract.id(), - document_type_name: "card".to_string(), - document_id: card.id(), - }], - &BlockInfo::default(), - None, - PlatformVersion::latest(), - ) - .expect("expected the draw to run"); - assert_eq!(drawn, 1); - } - - /// Proves the committed state `transition` wrote and verifies the proof as a client - /// holding the card contract does: it cannot know the drawn value. - fn verify_proof(&self, transition: &StateTransition) -> StateTransitionProofResult { - let platform_version = PlatformVersion::latest(); - let proof = self - .platform - .drive - .prove_state_transition(transition, None, platform_version) - .expect("expected to prove the state transition") - .into_data() - .expect("expected proof bytes"); - let known_contracts: BTreeMap = - BTreeMap::from([(self.contract.id(), self.contract.clone())]); - let (_, outcome) = Drive::verify_state_transition_was_executed_with_proof( - transition, - &BlockInfo::default(), - &proof, - &|id| Ok(known_contracts.get(id).cloned().map(Arc::new)), - platform_version, - ) - .expect("expected the proof to verify"); - outcome.into_result() - } - - fn stored_cards(&self) -> Vec { - let platform_version = PlatformVersion::latest(); - let query = DriveDocumentQuery::from_sql_expr( - "select * from card", - &self.contract, - Some(&self.platform.config.drive), - platform_version, - ) - .expect("expected a document query"); - self.platform - .drive - .query_documents(query, None, false, None, None) - .expect("expected a query result") - .documents() - .to_vec() - } - } - - fn rarity_of(document: &Document) -> Option { - document - .properties() - .get("rarity") - .and_then(|value| value.to_integer::().ok()) - } - - fn assert_success(result: &StateTransitionExecutionResult) { - assert_matches!( - result, - StateTransitionExecutionResult::SuccessfulExecution { .. }, - "{result:?}" - ); - } - - fn assert_not_generated(result: StateTransitionExecutionResult) { - let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { - panic!("expected a paid consensus error, got {result:?}"); - }; - assert_matches!( - error, - ConsensusError::BasicError(BasicError::DocumentPropertyNotGeneratedError(e)) - if e.property() == "rarity" && e.function() == "sys.random.uniform" - ); - } - - #[tokio::test] - async fn should_write_the_placeholder_then_prove_the_drawn_value() { - let mut fixture = CardFixture::new(); - - let transition = fixture - .create_transition(CardFixture::card_data(None, None), 1) - .await; - assert_success(&fixture.process(&transition)); - let stored = fixture.stored_cards(); - assert_eq!(stored.len(), 1); - assert_eq!(rarity_of(&stored[0]), Some(1), "the placeholder"); - - fixture.draw(&stored[0]); - let drawn = rarity_of(&fixture.stored_cards()[0]).expect("a drawn rarity"); - assert!((1..=100).contains(&drawn)); - - // The client that sent the create, which left the rarity out, reads the drawn value - // from the proof of its own transition - assert_matches!( - fixture.verify_proof(&transition), - StateTransitionProofResult::VerifiedDocuments(documents) - if documents.values().flatten().all(|document| rarity_of(document) == Some(drawn)) - ); - } - - #[tokio::test] - async fn should_refuse_a_create_that_picks_the_value() { - let mut fixture = CardFixture::new(); - - let transition = fixture - .create_transition(CardFixture::card_data(Some(100), None), 2) - .await; - assert_not_generated(fixture.process(&transition)); - assert!(fixture.stored_cards().is_empty()); - } - - #[tokio::test] - async fn should_keep_the_drawn_value_on_a_replace() { - let mut fixture = CardFixture::new(); - - let transition = fixture - .create_transition(CardFixture::card_data(None, None), 3) - .await; - assert_success(&fixture.process(&transition)); - fixture.draw(&fixture.stored_cards()[0]); - let drawn = fixture.stored_cards().remove(0); - let rarity = rarity_of(&drawn).expect("a drawn rarity"); - - // Left out, the drawn value is kept - let transition = fixture - .replace_transition(&drawn, CardFixture::card_data(None, Some("kept"))) - .await; - assert_success(&fixture.process(&transition)); - let replaced = fixture.stored_cards().remove(0); - assert_eq!(replaced.get("note"), Some(&Value::Text("kept".to_string()))); - assert_eq!(rarity_of(&replaced), Some(rarity)); - assert_matches!( - fixture.verify_proof(&transition), - StateTransitionProofResult::VerifiedDocuments(_) - ); - - // Another value cannot be picked - let other = if rarity == 100 { 99 } else { rarity + 1 }; - let transition = fixture - .replace_transition(&replaced, CardFixture::card_data(Some(other), None)) - .await; - assert_not_generated(fixture.process(&transition)); - assert_eq!(rarity_of(&fixture.stored_cards()[0]), Some(rarity)); - } -} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index 7132ef7101e..a3750ecbcdb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -15,7 +15,6 @@ mod duplicate_keys; mod encrypted_for; mod gas_sponsorship; mod generated_from; -mod generated_from_random; mod generated_from_token; mod id_reuse; mod immutable; 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/draw_random_document_properties/mod.rs b/packages/rs-drive/src/drive/document/draw_random_document_properties/mod.rs index 92e7101955a..930620fdbcb 100644 --- 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 @@ -4,6 +4,10 @@ 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; @@ -28,7 +32,8 @@ impl Drive { /// 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 the draw is free. + /// 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. @@ -72,4 +77,66 @@ impl Drive { })), } } + + /// 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 index 477a0e66db1..f2b45da40f4 100644 --- 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 @@ -3,19 +3,25 @@ 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; +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::{ - integer_value_for_type, DocumentPropertyType, RandomRange, SystemFunction, + 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}; @@ -52,6 +58,77 @@ impl Drive { 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. + #[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![]; + 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, + )?; + 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, + )?; + Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, + ) + } + /// 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. @@ -231,26 +308,38 @@ mod tests { use super::*; use crate::config::DriveConfig; use crate::query::DriveDocumentQuery; - use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; - use dpp::data_contract::DataContractFactory; + use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; use dpp::document::DocumentV0; - use dpp::platform_value::{platform_value, Identifier, Value}; - use dpp::prelude::DataContract; + 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. - fn contract() -> DataContract { - DataContractFactory::new(PlatformVersion::latest().protocol_version) - .expect("factory") - .create_with_value_config( - Identifier::from(OWNER), - 0, - platform_value!({ + /// 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, @@ -273,21 +362,21 @@ mod tests { "properties": { "name": { "type": "string", "maxLength": 32, "position": 0 } }, + "indices": [{ "name": "byName", "properties": [{ "name": "asc" }] }], "required": ["name"], "additionalProperties": false } - }), - None, - None, - ) - .expect("contract") - .data_contract_owned() + } + }), + true, + platform_version, + ) + .expect("contract") } - fn setup() -> (Drive, DataContract) { - let platform_version = PlatformVersion::latest(); + fn setup(platform_version: &PlatformVersion) -> (Drive, DataContract) { let drive = setup_drive_with_initial_state_structure(Some(platform_version)); - let contract = contract(); + let contract = contract(platform_version); drive .apply_contract( &contract, @@ -301,18 +390,27 @@ mod tests { (drive, contract) } - /// Inserts a `card` holding the placeholder, the way a create transition writes it. - fn insert_card(drive: &Drive, contract: &DataContract, marker: u8) -> Identifier { - let id = Identifier::from([marker; 32]); - let document = Document::V0(DocumentV0 { - id, + /// 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 { @@ -330,11 +428,11 @@ mod tests { BlockInfo::default(), true, None, - PlatformVersion::latest(), + platform_version, None, ) .expect("the card inserts"); - id + document.id() } fn request( @@ -350,8 +448,12 @@ mod tests { } /// The cards whose rarity is `rarity`, through the `byRarity` index. - fn cards_with_rarity(drive: &Drive, contract: &DataContract, rarity: i128) -> Vec { - let platform_version = PlatformVersion::latest(); + 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, @@ -369,17 +471,17 @@ mod tests { .collect() } - const RARITY: RandomRange = RandomRange { - minimum: 1, - maximum: 100, - }; + 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 (drive, contract) = setup(); - let id = insert_card(&drive, &contract, 1); + 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).expect("draws"); + let expected = draw_v0(&BEACON, &request, "rarity", rarity_range()).expect("draws"); assert!((1..=100).contains(&expected)); let drawn = drive @@ -388,12 +490,12 @@ mod tests { &[request], &BlockInfo::default(), None, - PlatformVersion::latest(), + &platform_version, ) .expect("the draw runs"); assert_eq!(drawn, 1); - let found = cards_with_rarity(&drive, &contract, expected); + let found = cards_with_rarity(&drive, &contract, expected, &platform_version); assert_eq!(found.len(), 1); assert_eq!(found[0].id(), id); assert_eq!( @@ -403,16 +505,13 @@ mod tests { Some(expected) ); if expected != 1 { - assert!(cards_with_rarity(&drive, &contract, 1).is_empty()); + 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 { - minimum: 0, - maximum: u64::MAX.into(), - }; + 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])); @@ -438,7 +537,7 @@ mod tests { // 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), + draw_v0(&BEACON, &first, "rarity", rarity_range()), Some(RARITY_VECTOR) ); } @@ -448,7 +547,8 @@ mod tests { #[test] fn should_skip_a_deleted_document_and_a_type_drawing_nothing() { - let (drive, contract) = setup(); + let platform_version = drawing_platform_version(); + let (drive, contract) = setup(&platform_version); let drawn = drive .draw_random_document_properties( &BEACON, @@ -458,7 +558,7 @@ mod tests { ], &BlockInfo::default(), None, - PlatformVersion::latest(), + &platform_version, ) .expect("the draw runs"); assert_eq!(drawn, 0); @@ -466,7 +566,7 @@ mod tests { #[test] fn should_not_draw_before_protocol_version_15() { - let (drive, _contract) = setup(); + let (drive, _contract) = setup(&drawing_platform_version()); let result = drive.draw_random_document_properties( &BEACON, &[], @@ -479,4 +579,37 @@ mod tests { Err(Error::Drive(DriveError::VersionNotActive { .. })) )); } + + #[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/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 e96e06ab781..e43995b7bec 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 @@ -1,6 +1,8 @@ use dpp::block::block_info::BlockInfo; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV1Getters}; +use dpp::data_contract::document_type::indexes_a_random_property; use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; +use dpp::document::Document; use dpp::fee::fee_result::FeeResult; use dpp::platform_value::Identifier; use grovedb::TransactionArg; @@ -19,7 +21,7 @@ use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::batch::batched_transition::BatchedTransitionAction; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionAccessorsV0}; -use crate::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{DecisionProposalCreate, DocumentCreateTransitionActionV0}; +use crate::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{DecisionProposalCreate, DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0, DocumentCreateTransitionActionV0, DocumentFromCreateTransitionAction}; use crate::state_transition_action::batch::batched_transition::document_transition::state_generated_properties::generate_state_read_properties; use crate::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; @@ -128,6 +130,14 @@ impl DocumentCreateTransitionActionV0 { let document_type_indexes = document_type.indexes(); + // Added in place at protocol version 15, inert before it, where no type declares a + // `sys.random` function: whether the draw moves the document's entry in one of its + // ordinary indexes once the block is accepted (registration refuses such a property in a + // unique index, and on a type with a contested index), which the create prepays below + let prepays_draw = document_type_indexes + .values() + .any(|index| indexes_a_random_property(document_type, index)); + // A decision proposal (protocol version 15; its contract does not exist before it) // states the decision proposal fund, which names its own type rather than a contested // index: it is kept as stated for validation to judge, and opens no contest. @@ -203,25 +213,179 @@ impl DocumentCreateTransitionActionV0 { (None, None) }; + let action: DocumentCreateTransitionAction = DocumentCreateTransitionActionV0 { + base, + block_info: *block_info, + data, + prefunded_voting_balance: prefunded_voting_balances_by_vote_poll, + current_store_contest_info, + should_store_contest_info: should_store_contest_info.map(Box::new), + property_constraint_reads: Default::default(), + moderated: false, + consumed_documents: Vec::new(), + derived_index_values: None, + decision_proposal, + } + .into(); + + // The draw writes the document's entries in those indexes again, under the drawn + // values, and may create the branches they hang from, whose elements carry the owner's + // refundable storage flags; the draw itself bills nobody. So the create prepays those + // entries at the average case: what Drive's fee estimation gives for inserting the + // document's entries into those indexes without reading state. + if prepays_draw { + let document = + Document::try_from_create_transition_action(&action, owner_id, platform_version)?; + let base = action.base(); + fee_result.checked_add_assign(drive.prepay_random_document_index_entries( + &document, + owner_id, + &base.data_contract_fetch_info_ref().contract, + base.document_type()?, + block_info, + transaction, + platform_version, + )?)?; + } + Ok(( - BatchedTransitionAction::DocumentAction(DocumentTransitionAction::CreateAction( - DocumentCreateTransitionActionV0 { - base, - block_info: *block_info, - data, - prefunded_voting_balance: prefunded_voting_balances_by_vote_poll, - current_store_contest_info, - should_store_contest_info: should_store_contest_info.map(Box::new), - property_constraint_reads: Default::default(), - moderated: false, - consumed_documents: Vec::new(), - derived_index_values: None, - decision_proposal, - } + BatchedTransitionAction::DocumentAction(DocumentTransitionAction::CreateAction(action)) .into(), - )) - .into(), fee_result, )) } } + +#[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 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 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 { + 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"); + Arc::new(DataContractFetchInfo { + contract, + storage_flags: None, + cost: OperationCost::default(), + fee: None, + }) + } + + /// What building the action of a valid create of a `document_type_name` document bills. + fn create_fee(document_type_name: &str) -> FeeResult { + 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: BTreeMap::from([("name".to_string(), Value::Text("Ace".to_string()))]), + prefunded_voting_balance: None, + }; + let (result, fee_result) = + 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 + ); + fee_result + } + + #[test] + fn should_prepay_the_draw_of_an_indexed_random_property_on_create() { + let indexed = create_fee("card"); + let unindexed = create_fee("looseCard"); + assert!(indexed.storage_fee > unindexed.storage_fee); + assert!(indexed.processing_fee > unindexed.processing_fee); + } + + #[test] + fn should_prepay_nothing_for_a_random_property_in_no_index() { + // Nothing else the action reads is billed for this type either + assert_eq!(create_fee("looseCard"), FeeResult::default()); + } +} 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 5da69b458a7..8ad1605ec8e 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs @@ -210,6 +210,13 @@ pub struct DocumentTypeSchemaVersions { /// 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 42d69ae1683..4e22bdfda92 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 @@ -39,8 +39,10 @@ pub struct DriveDocumentMethodVersions { pub prove_private_document_entries: 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. `Some(0)` from `DRIVE_DOCUMENT_METHOD_VERSIONS_V5` (protocol - /// version 15), `None` in every earlier table, which admit no `sys.random` property. + /// 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, } diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index a7919273a43..e891c3b362b 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -392,9 +392,35 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// stored without storage flags by `transition_to_version_15`. Users cannot write them: /// `creationRestrictionMode` 2 refuses a create, and the data trigger `bindings` 3 /// (`DRIVE_ABCI_VALIDATION_VERSIONS_V11`) refuse a replace or a delete. +/// +/// 18. **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: 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) + drive: DRIVE_VERSION_V10, // changed: 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 18); 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) 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) @@ -410,7 +436,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 18) 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) From 70eff440c6bb8699bb37bed2ec236647140d19ea Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 04:19:15 +0700 Subject: [PATCH 03/12] test(drive): pin the create prepay of an indexed random property Co-Authored-By: Claude Opus 5.5 --- .../v0/transformer.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 e43995b7bec..10fc0e68c60 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 @@ -381,6 +381,16 @@ mod tests { let unindexed = create_fee("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] From e579bc5cd378afdb67f1c76a9852c4983ce5da56 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 05:40:49 +0700 Subject: [PATCH 04/12] fix(platform): keep compiled generatedFrom bytes, complete derived values before the random prepay - The stored random range moves into the stored Random function variant, so every declaration of another function compiles to the bytes it did before (the latest-version root pins hold again); a byte pin test guards it. - The create's random-index prepayment completes the document's derived index values, estimated, as the insert does, instead of keying an entry on a value the create's document does not carry yet. - Registration refuses index shapes the prepayment cannot cover: an index holding a random property that sums or averages another property, an integerRange index on it, and on a mutable type an index skipping documents on a stored property a same-block replace could add. Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords/generated-from.md | 3 +- .../class_methods/try_from_schema/mod.rs | 61 +++++++++- .../v3/generated_from_random_tests.rs | 80 +++++++++++++ .../serialized_version/v0/property.rs | 81 +++++++++----- .../draw_random_document_properties/v0/mod.rs | 12 +- .../v0/transformer.rs | 105 +++++++++++++++++- 6 files changed, 306 insertions(+), 36 deletions(-) diff --git a/book/src/contract-keywords/generated-from.md b/book/src/contract-keywords/generated-from.md index b5bd4fa6a7c..0447be97a23 100644 --- a/book/src/contract-keywords/generated-from.md +++ b/book/src/contract-keywords/generated-from.md @@ -141,7 +141,8 @@ Protocol version 15 parses the function, but it is not active at any protocol ve 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; +- 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, and, on a type whose documents are mutable, an index skipping documents on a stored property (`skipIfAbsent`, or a typed array), which a replace in the same block could add before the draw; - 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. 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 6c5884c8372..f8561b432b6 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 @@ -3497,8 +3497,16 @@ fn refuse_state_generated_schema_path(schema: &Value, path: &str) -> Result<(), /// 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. And nothing a write is judged by -/// may read it: not a reference declared on the type (the property holding it, a `where` +/// 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; and on a type +/// whose documents are mutable it may not skip documents on a stored property (`skipIfAbsent`, +/// or a typed array), which a replace in the block of the create could add before the draw. +/// 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 @@ -3631,6 +3639,55 @@ pub(super) fn validate_generated_from_declarations( 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 derived property is fixed once written, so only a stored one can be added + // by a replace + if document_type.documents_mutable { + 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()) + }) + { + 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 \ + in the block of the create could add before the draw, creating \ + entries the create did not prepay: skip on it only on a type whose \ + documents are immutable", + 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()) { 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 index e0263ad58ec..7be370ff931 100644 --- 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 @@ -468,6 +468,86 @@ fn should_refuse_summing_or_averaging_a_property_drawn_at_random() { } } +/// [`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_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 + let mut immutable = schema; + immutable + .insert("documentsMutable".to_string(), Value::Bool(false)) + .expect("the schema is a map"); + let document_type = parse(immutable); + assert!(document_type.as_ref().indexes().contains_key("byRarityTag")); +} + #[test] fn should_refuse_a_reference_of_the_type_reading_a_property_drawn_at_random() { let with_deck = |where_clause: Value| { 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 8aac95eb579..3dbdd1306f6 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 @@ -138,17 +138,21 @@ pub(super) enum StoredEncryptionScheme { pub(super) struct StoredGeneratedFrom { function: StoredSystemFunction, params: Vec, - /// [`GeneratedFrom::range`]: `(minimum, maximum)`. - range: Option<(i128, i128)>, } -/// [`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(StoredRandomFunction), + Random { + function: StoredRandomFunction, + /// [`GeneratedFrom::range`]: `(minimum, maximum)`. + range: Option<(i128, i128)>, + }, } /// [`TokenFunction`]. @@ -551,9 +555,12 @@ impl From<&GeneratedFrom> for StoredGeneratedFrom { SystemFunction::Token(token) => StoredSystemFunction::Token(match token { TokenFunction::Decimals => StoredTokenFunction::Decimals, }), - SystemFunction::Random(random) => StoredSystemFunction::Random(match random { - RandomFunction::Uniform => StoredRandomFunction::Uniform, - }), + SystemFunction::Random(random) => StoredSystemFunction::Random { + function: match random { + RandomFunction::Uniform => StoredRandomFunction::Uniform, + }, + range: range.map(|range| (range.minimum(), range.maximum())), + }, }, params: params .iter() @@ -561,7 +568,6 @@ impl From<&GeneratedFrom> for StoredGeneratedFrom { GenerationParam::Property(path) => path.clone(), }) .collect(), - range: range.map(|range| (range.minimum(), range.maximum())), } } } @@ -572,21 +578,20 @@ impl TryFrom for GeneratedFrom { /// Refuses a stored range [`RandomRange::new`] does not admit, which registration never /// stores, rather than construct a range whose draw and size it does not hold. fn try_from(stored: StoredGeneratedFrom) -> Result { - let StoredGeneratedFrom { - function, - params, - range, - } = stored; - let range = range - .map(|(minimum, maximum)| { - 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" - )) + let StoredGeneratedFrom { function, params } = stored; + let range = match function { + StoredSystemFunction::Random { range, .. } => range + .map(|(minimum, maximum)| { + 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" + )) + }) }) - }) - .transpose()?; + .transpose()?, + _ => None, + }; Ok(GeneratedFrom { function: match function { StoredSystemFunction::StringTransformation(transformation) => { @@ -605,9 +610,11 @@ impl TryFrom for GeneratedFrom { StoredSystemFunction::Token(token) => SystemFunction::Token(match token { StoredTokenFunction::Decimals => TokenFunction::Decimals, }), - StoredSystemFunction::Random(random) => SystemFunction::Random(match random { - StoredRandomFunction::Uniform => RandomFunction::Uniform, - }), + StoredSystemFunction::Random { function, .. } => { + SystemFunction::Random(match function { + StoredRandomFunction::Uniform => RandomFunction::Uniform, + }) + } }, params: params.into_iter().map(GenerationParam::Property).collect(), range, @@ -618,12 +625,15 @@ impl TryFrom for GeneratedFrom { #[cfg(test)] mod tests { use super::*; + use platform_serialization::PLATFORM_BINCODE_CONFIG; fn stored_uniform(range: Option<(i128, i128)>) -> StoredGeneratedFrom { StoredGeneratedFrom { - function: StoredSystemFunction::Random(StoredRandomFunction::Uniform), + function: StoredSystemFunction::Random { + function: StoredRandomFunction::Uniform, + range, + }, params: vec![], - range, } } @@ -642,6 +652,23 @@ mod tests { ); } + #[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 + let generated_from = GeneratedFrom { + function: SystemFunction::StringTransformation(StringTransformation::Lowercase), + params: vec![GenerationParam::Property("label".to_string())], + range: None, + }; + let bytes = bincode::encode_to_vec( + StoredGeneratedFrom::from(&generated_from), + PLATFORM_BINCODE_CONFIG, + ) + .expect("encodes"); + assert_eq!(bytes, [0, 3, 1, 5, b'l', b'a', b'b', b'e', b'l']); + } + #[test] fn should_refuse_a_stored_random_range_it_could_not_draw_from() { for range in [ 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 index f2b45da40f4..f0bf38a92b6 100644 --- 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 @@ -63,7 +63,10 @@ impl Drive { /// (`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. + /// 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( @@ -102,6 +105,13 @@ impl 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, 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 10fc0e68c60..3cd47ae4e76 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 @@ -313,19 +313,90 @@ mod tests { 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": { - "card": card_schema(true), - "looseCard": card_schema(false) - } + "documentSchemas": document_schemas }), true, platform_version, @@ -341,6 +412,17 @@ mod tests { /// What building the action of a valid create of a `document_type_name` document bills. fn create_fee(document_type_name: &str) -> FeeResult { + create_fee_with(contract, document_type_name, BTreeMap::new()) + } + + /// [`create_fee`] of a document of a type of the contract `contract` builds, holding + /// `data` besides its name. + fn create_fee_with( + contract: fn(&PlatformVersion) -> Arc, + document_type_name: &str, + mut data: BTreeMap, + ) -> FeeResult { + data.insert("name".to_string(), 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); @@ -352,7 +434,7 @@ mod tests { data_contract_id: Identifier::from(CONTRACT_ID), }), entropy: [2; 32], - data: BTreeMap::from([("name".to_string(), Value::Text("Ace".to_string()))]), + data, prefunded_voting_balance: None, }; let (result, fee_result) = @@ -393,6 +475,19 @@ mod tests { ); } + #[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 yet: the prepayment completes it + // as the insert does, estimated, rather than key an entry on a value it lacks + let fee = create_fee_with( + set_contract, + "setCard", + BTreeMap::from([("setId".to_string(), Value::Identifier([3; 32]))]), + ); + assert!(fee.storage_fee > 0); + assert!(fee.processing_fee > 0); + } + #[test] fn should_prepay_nothing_for_a_random_property_in_no_index() { // Nothing else the action reads is billed for this type either From 0ead2b47da539ac482c54c0b3120a2572685f84c Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 06:09:21 +0700 Subject: [PATCH 05/12] test(platform): pin the stored bytes of every non-random generatedFrom and the derived-index prepay Co-Authored-By: Claude Opus 5.5 --- .../serialized_version/v0/property.rs | 44 ++++++++++++++----- .../v0/transformer.rs | 13 +++++- 2 files changed, 43 insertions(+), 14 deletions(-) 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 3dbdd1306f6..ec8c933b2fa 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 @@ -655,18 +655,38 @@ mod tests { #[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 - let generated_from = GeneratedFrom { - function: SystemFunction::StringTransformation(StringTransformation::Lowercase), - params: vec![GenerationParam::Property("label".to_string())], - range: None, - }; - let bytes = bincode::encode_to_vec( - StoredGeneratedFrom::from(&generated_from), - PLATFORM_BINCODE_CONFIG, - ) - .expect("encodes"); - assert_eq!(bytes, [0, 3, 1, 5, b'l', b'a', b'b', b'e', b'l']); + // 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] 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 3cd47ae4e76..0240b499a81 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 @@ -484,8 +484,17 @@ mod tests { "setCard", BTreeMap::from([("setId".to_string(), Value::Identifier([3; 32]))]), ); - assert!(fee.storage_fee > 0); - assert!(fee.processing_fee > 0); + // 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] From 3f7d292d258f86b515d76d0a6edb807024f7c9d4 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 07:50:20 +0700 Subject: [PATCH 06/12] fix(platform): charge the random-index prepayment once the create's schema holds The prepayment keys the create's values into the indexes the draw moves, so it ran on values no schema had checked yet: a value of the wrong type made building the action fail with an internal error instead of the paid schema refusal that bumps the nonce. It now runs at the end of the create's state validation, after the structure validation checked the values and the reference validation set the derived ones, and is charged there. Registration also refuses, on a type whose documents are mutable, an integerRange index holding a random property: a replace in the block of the create could move its source into more windows before the draw. Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords/generated-from.md | 2 +- .../class_methods/try_from_schema/mod.rs | 15 +- .../v3/generated_from_random_tests.rs | 31 +++ .../state_v2/mod.rs | 30 +++ .../v0/transformer.rs | 208 ++++++++++++------ 5 files changed, 220 insertions(+), 66 deletions(-) diff --git a/book/src/contract-keywords/generated-from.md b/book/src/contract-keywords/generated-from.md index 0447be97a23..d8bf702861a 100644 --- a/book/src/contract-keywords/generated-from.md +++ b/book/src/contract-keywords/generated-from.md @@ -142,7 +142,7 @@ A value nothing can refuse once it is drawn may not be read by anything that jud - 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, and, on a type whose documents are mutable, an index skipping documents on a stored property (`skipIfAbsent`, or a typed array), which a replace in the same block could add before the draw; +- 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, and, on a type whose documents are mutable, an index skipping documents on a stored property (`skipIfAbsent`, or a typed array), which a replace in the same block could add before the draw, or any `integerRange` index, whose source a replace could move into more windows; - 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. 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 f8561b432b6..94b6aeb5e38 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 @@ -3505,7 +3505,8 @@ fn refuse_state_generated_schema_path(schema: &Value, path: &str) -> Result<(), /// 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; and on a type /// whose documents are mutable it may not skip documents on a stored property (`skipIfAbsent`, -/// or a typed array), which a replace in the block of the create could add before the draw. +/// or a typed array), which a replace in the block of the create could add before the draw, +/// nor be an `integerRange` index at all, whose source a replace could move into more windows. /// 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 @@ -3667,6 +3668,18 @@ pub(super) fn validate_generated_from_declarations( generated_from.function, index.name ))); } + // A replace in the block of the create may move the window source before the + // draw, into more windows than the create's + if document_type.documents_mutable && index.integer_range.is_some() { + 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 falls in, \ + which a replace in the block of the create could change before the \ + draw, creating entries the create did not prepay: hold the property in \ + such an index only on a type whose documents are immutable", + generated_from.function, index.name + ))); + } // A derived property is fixed once written, so only a stored one can be added // by a replace if document_type.documents_mutable { 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 index 7be370ff931..18269898328 100644 --- 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 @@ -522,6 +522,37 @@ fn should_refuse_an_index_holding_a_property_drawn_at_random_that_sums_or_window ); } +#[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 falls in", + ); + + // An immutable card keeps its price + let mut immutable = schema; + immutable + .insert("documentsMutable".to_string(), Value::Bool(false)) + .expect("the schema is a map"); + let document_type = parse(immutable); + 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( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs index e783d9dc53c..ef56bb69792 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs @@ -6,6 +6,8 @@ use dpp::consensus::state::document::document_contest_not_paid_for_error::Docume use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::indexes_a_random_property; +use dpp::document::Document; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use dpp::identifier::Identifier; @@ -16,6 +18,7 @@ use drive::query::TransactionArg; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{ DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0, + DocumentFromCreateTransitionAction, }; use crate::error::Error; @@ -221,6 +224,33 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio } } + // A create of a type indexing a `sys.random` property (protocol version 15) prepays the + // index entries the draw from the block's random beacon may create + // (`Drive::prepay_random_document_index_entries`), charged here, once the schema and + // the references hold: the prepayment keys the document's values, which only the + // structure validation checks. Added in place to this shipped generation: before 15 no + // type declares such a property, so no index holds one and nothing is charged. + let document_type = self.base().document_type()?; + if document_type + .indexes() + .values() + .any(|index| indexes_a_random_property(document_type, index)) + { + let document = + Document::try_from_create_transition_action(self, owner_id, platform_version)?; + let fee_result = platform.drive.prepay_random_document_index_entries( + &document, + owner_id, + &self.base().data_contract_fetch_info_ref().contract, + document_type, + block_info, + transaction, + platform_version, + )?; + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + } + Ok(SimpleConsensusValidationResult::new()) } } 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 0240b499a81..597741f70f6 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 @@ -1,8 +1,6 @@ use dpp::block::block_info::BlockInfo; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV1Getters}; -use dpp::data_contract::document_type::indexes_a_random_property; use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; -use dpp::document::Document; use dpp::fee::fee_result::FeeResult; use dpp::platform_value::Identifier; use grovedb::TransactionArg; @@ -21,7 +19,7 @@ use crate::error::drive::DriveError; use crate::error::Error; use crate::state_transition_action::batch::batched_transition::BatchedTransitionAction; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionAccessorsV0}; -use crate::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{DecisionProposalCreate, DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0, DocumentCreateTransitionActionV0, DocumentFromCreateTransitionAction}; +use crate::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{DecisionProposalCreate, DocumentCreateTransitionActionV0}; use crate::state_transition_action::batch::batched_transition::document_transition::state_generated_properties::generate_state_read_properties; use crate::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; use crate::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; @@ -130,14 +128,6 @@ impl DocumentCreateTransitionActionV0 { let document_type_indexes = document_type.indexes(); - // Added in place at protocol version 15, inert before it, where no type declares a - // `sys.random` function: whether the draw moves the document's entry in one of its - // ordinary indexes once the block is accepted (registration refuses such a property in a - // unique index, and on a type with a contested index), which the create prepays below - let prepays_draw = document_type_indexes - .values() - .any(|index| indexes_a_random_property(document_type, index)); - // A decision proposal (protocol version 15; its contract does not exist before it) // states the decision proposal fund, which names its own type rather than a contested // index: it is kept as stated for validation to judge, and opens no contest. @@ -213,44 +203,24 @@ impl DocumentCreateTransitionActionV0 { (None, None) }; - let action: DocumentCreateTransitionAction = DocumentCreateTransitionActionV0 { - base, - block_info: *block_info, - data, - prefunded_voting_balance: prefunded_voting_balances_by_vote_poll, - current_store_contest_info, - should_store_contest_info: should_store_contest_info.map(Box::new), - property_constraint_reads: Default::default(), - moderated: false, - consumed_documents: Vec::new(), - derived_index_values: None, - decision_proposal, - } - .into(); - - // The draw writes the document's entries in those indexes again, under the drawn - // values, and may create the branches they hang from, whose elements carry the owner's - // refundable storage flags; the draw itself bills nobody. So the create prepays those - // entries at the average case: what Drive's fee estimation gives for inserting the - // document's entries into those indexes without reading state. - if prepays_draw { - let document = - Document::try_from_create_transition_action(&action, owner_id, platform_version)?; - let base = action.base(); - fee_result.checked_add_assign(drive.prepay_random_document_index_entries( - &document, - owner_id, - &base.data_contract_fetch_info_ref().contract, - base.document_type()?, - block_info, - transaction, - platform_version, - )?)?; - } - Ok(( - BatchedTransitionAction::DocumentAction(DocumentTransitionAction::CreateAction(action)) + BatchedTransitionAction::DocumentAction(DocumentTransitionAction::CreateAction( + DocumentCreateTransitionActionV0 { + base, + block_info: *block_info, + data, + prefunded_voting_balance: prefunded_voting_balances_by_vote_poll, + current_store_contest_info, + should_store_contest_info: should_store_contest_info.map(Box::new), + property_constraint_reads: Default::default(), + moderated: false, + consumed_documents: Vec::new(), + derived_index_values: None, + decision_proposal, + } .into(), + )) + .into(), fee_result, )) } @@ -265,6 +235,9 @@ mod tests { 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; @@ -410,19 +383,33 @@ mod tests { }) } - /// What building the action of a valid create of a `document_type_name` document bills. - fn create_fee(document_type_name: &str) -> FeeResult { - create_fee_with(contract, document_type_name, BTreeMap::new()) + /// 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) } - /// [`create_fee`] of a document of a type of the contract `contract` builds, holding - /// `data` besides its name. - fn create_fee_with( + /// 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 state validation + /// 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 { - data.insert("name".to_string(), Value::Text("Ace".to_string())); + ) -> (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); @@ -437,7 +424,7 @@ mod tests { data, prefunded_voting_balance: None, }; - let (result, fee_result) = + let (result, build_fee) = DocumentCreateTransitionActionV0::try_from_borrowed_document_create_transition_with_contract_lookup( &drive, Identifier::from(OWNER), @@ -454,13 +441,47 @@ mod tests { "the create is valid: {:?}", result.errors ); - fee_result + 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: state validation charges it, once the + // schema holds + assert_eq!(build_fee, FeeResult::default()); + prepayment } #[test] fn should_prepay_the_draw_of_an_indexed_random_property_on_create() { - let indexed = create_fee("card"); - let unindexed = create_fee("looseCard"); + 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 @@ -477,13 +498,15 @@ mod tests { #[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 yet: the prepayment completes it - // as the insert does, estimated, rather than key an entry on a value it lacks - let fee = create_fee_with( + // 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. @@ -497,9 +520,66 @@ mod tests { ); } + #[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() { - // Nothing else the action reads is billed for this type either - assert_eq!(create_fee("looseCard"), FeeResult::default()); + assert_eq!(prepayment("looseCard"), FeeResult::default()); } } From d7e97669999fb2deac377eb6c6007538b4ff1919 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 08:11:17 +0700 Subject: [PATCH 07/12] fix(platform): admit window and skip sources fixed once written beside a random property The refusals of an integerRange source and a stored skipIfAbsent property in an index holding a random property now apply only to a property a replace can change (schema_property_is_fixed_once_written): a mutable type listing it immutable, with no moderator able to change it, keeps it as written. A state validation test charges the draw prepayment: a create of a type indexing a random property is charged what an unindexed one is, then the prepayment. Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords/generated-from.md | 2 +- .../class_methods/try_from_schema/mod.rs | 73 ++++--- .../v3/generated_from_random_tests.rs | 37 ++-- .../state_v2/mod.rs | 202 ++++++++++++++++++ 4 files changed, 271 insertions(+), 43 deletions(-) diff --git a/book/src/contract-keywords/generated-from.md b/book/src/contract-keywords/generated-from.md index d8bf702861a..061137b6341 100644 --- a/book/src/contract-keywords/generated-from.md +++ b/book/src/contract-keywords/generated-from.md @@ -142,7 +142,7 @@ A value nothing can refuse once it is drawn may not be read by anything that jud - 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, and, on a type whose documents are mutable, an index skipping documents on a stored property (`skipIfAbsent`, or a typed array), which a replace in the same block could add before the draw, or any `integerRange` index, whose source a replace could move into more windows; +- 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 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 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. 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 94b6aeb5e38..1b7ee315f12 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 @@ -3503,10 +3503,12 @@ fn refuse_state_generated_schema_path(schema: &Value, path: &str) -> Result<(), /// 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; and on a type -/// whose documents are mutable it may not skip documents on a stored property (`skipIfAbsent`, -/// or a typed array), which a replace in the block of the create could add before the draw, -/// nor be an `integerRange` index at all, whose source a replace could move into more windows. +/// in every window holding it, more for a drawn value than for the placeholder; nor may it +/// skip documents on a stored property a replace could add before the draw (`skipIfAbsent`, +/// or a typed array), nor be an `integerRange` index whose source a replace 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 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 @@ -3668,37 +3670,48 @@ pub(super) fn validate_generated_from_declarations( generated_from.function, index.name ))); } - // A replace in the block of the create may move the window source before the - // draw, into more windows than the create's - if document_type.documents_mutable && index.integer_range.is_some() { + // A replace 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| { + !schema_property_is_fixed_once_written( + DocumentTypeRef::V2(document_type), + 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 falls in, \ - which a replace in the block of the create could change before the \ - draw, creating entries the create did not prepay: hold the property in \ - such an index only on a type whose documents are immutable", + index \"{}\" files a document in every window its source \"{source}\" \ + falls in, which a replace 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 can be added - // by a replace - if document_type.documents_mutable { - 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()) - }) - { - 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 \ - in the block of the create could add before the draw, creating \ - entries the create did not prepay: skip on it only on a type whose \ - documents are immutable", - generated_from.function, index.name - ))); - } + // A derived property is fixed once written, so only a stored one a replace 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()) + && !schema_property_is_fixed_once_written( + DocumentTypeRef::V2(document_type), + 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 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 + ))); } } // A reference is judged when the document is written, before the draw 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 index 18269898328..4c41524e337 100644 --- 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 @@ -538,19 +538,25 @@ fn should_refuse_a_window_index_holding_a_property_drawn_at_random_on_a_mutable_ expect_refused( schema.clone(), "integerRange index \"byPriceWindowAndRarity\" files a document in every window its \ - source falls in", + source \"price\" falls in", ); - // An immutable card keeps its price - let mut immutable = schema; + // 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 document_type = parse(immutable); - assert!(document_type - .as_ref() - .indexes() - .contains_key("byPriceWindowAndRarity")); + 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] @@ -570,13 +576,20 @@ fn should_refuse_skipping_on_a_stored_property_beside_one_drawn_at_random_on_a_m "index \"byRarityTag\" skips a document without \"tag\"", ); - // A replace can not add the tag to an immutable card - let mut immutable = schema; + // 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 document_type = parse(immutable); - assert!(document_type.as_ref().indexes().contains_key("byRarityTag")); + 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] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs index ef56bb69792..234166c9b15 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs @@ -254,3 +254,205 @@ impl DocumentCreateTransitionActionStateValidationV2 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 drive::drive::contract::DataContractFetchInfo; + 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::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_v2( + &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_charge_the_draw_prepayment_once_the_create_is_valid() { + let (indexed, prepayment) = state_validation_charges("card"); + let (unindexed, nothing) = state_validation_charges("looseCard"); + assert_ne!(prepayment, FeeResult::default()); + assert_eq!(nothing, FeeResult::default()); + // What a create the draw moves no index entry for is charged, then the prepayment + let mut expected = unindexed; + expected.push(prepayment); + assert_eq!(indexed, expected); + } +} From 86bbb70a1a2ebf69b76a1160d05ba4c89d346842 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 08:24:10 +0700 Subject: [PATCH 08/12] fix(platform): prepay random draws with the insert, judge moderator writes and restores - The random-index prepayment is charged with the document's insert (add_document_for_contract_operations v1, a precalculated fee beside the ttl's cleanup prepayment) instead of in the create's state validation: a create refused at any later stage, such as its shielded token payment, inserts nothing and prepays nothing. - The checks of what may move an index entry before the draw now run after the moderators' abilities and the derived index properties are applied (validate_random_declarations_after_moderation), so a field only moderators set cannot be skipped on beside a random property. - A random property is refused on a type whose moderators delete keeping a record: a restore would bring back a placeholder no draw reaches. Co-Authored-By: Claude Opus 5.5 --- book/src/contract-keywords/generated-from.md | 3 +- .../class_methods/try_from_schema/mod.rs | 148 ++++++++++++------ .../v3/generated_from_random_tests.rs | 94 +++++++++++ .../class_methods/try_from_schema/v3/mod.rs | 8 +- .../state_v2/mod.rs | 42 +---- .../draw_random_document_properties/v0/mod.rs | 62 ++++++++ .../v1/mod.rs | 41 +++++ .../v0/transformer.rs | 8 +- 8 files changed, 315 insertions(+), 91 deletions(-) diff --git a/book/src/contract-keywords/generated-from.md b/book/src/contract-keywords/generated-from.md index 061137b6341..3e5c22d9986 100644 --- a/book/src/contract-keywords/generated-from.md +++ b/book/src/contract-keywords/generated-from.md @@ -142,7 +142,8 @@ A value nothing can refuse once it is drawn may not be read by anything that jud - 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 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; +- 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. 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 1b7ee315f12..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 @@ -3503,12 +3503,9 @@ fn refuse_state_generated_schema_path(schema: &Value, path: &str) -> Result<(), /// 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; nor may it -/// skip documents on a stored property a replace could add before the draw (`skipIfAbsent`, -/// or a typed array), nor be an `integerRange` index whose source a replace 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). +/// 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 @@ -3670,49 +3667,6 @@ pub(super) fn validate_generated_from_declarations( generated_from.function, index.name ))); } - // A replace 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| { - !schema_property_is_fixed_once_written( - DocumentTypeRef::V2(document_type), - 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 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 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()) - && !schema_property_is_fixed_once_written( - DocumentTypeRef::V2(document_type), - 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 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 - ))); - } } // 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()) @@ -3836,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 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 index 4c41524e337..18ae2d9e8d0 100644 --- 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 @@ -17,6 +17,7 @@ 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, @@ -104,6 +105,33 @@ fn expect_refused_at_registration(schema: Value, needle: &str) { ); } +/// `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)>, @@ -592,6 +620,72 @@ fn should_refuse_skipping_on_a_stored_property_beside_one_drawn_at_random_on_a_m } } +#[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| { 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 0d95c90a2d0..97289cf8d3e 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 @@ -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 diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs index 234166c9b15..a0cf5f88574 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs @@ -6,8 +6,6 @@ use dpp::consensus::state::document::document_contest_not_paid_for_error::Docume use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::indexes_a_random_property; -use dpp::document::Document; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use dpp::identifier::Identifier; @@ -18,7 +16,6 @@ use drive::query::TransactionArg; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::{ DocumentCreateTransitionAction, DocumentCreateTransitionActionAccessorsV0, - DocumentFromCreateTransitionAction, }; use crate::error::Error; @@ -224,33 +221,6 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio } } - // A create of a type indexing a `sys.random` property (protocol version 15) prepays the - // index entries the draw from the block's random beacon may create - // (`Drive::prepay_random_document_index_entries`), charged here, once the schema and - // the references hold: the prepayment keys the document's values, which only the - // structure validation checks. Added in place to this shipped generation: before 15 no - // type declares such a property, so no index holds one and nothing is charged. - let document_type = self.base().document_type()?; - if document_type - .indexes() - .values() - .any(|index| indexes_a_random_property(document_type, index)) - { - let document = - Document::try_from_create_transition_action(self, owner_id, platform_version)?; - let fee_result = platform.drive.prepay_random_document_index_entries( - &document, - owner_id, - &self.base().data_contract_fetch_info_ref().contract, - document_type, - block_info, - transaction, - platform_version, - )?; - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - } - Ok(SimpleConsensusValidationResult::new()) } } @@ -269,7 +239,9 @@ mod tests { 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; @@ -445,14 +417,14 @@ mod tests { } #[test] - fn should_charge_the_draw_prepayment_once_the_create_is_valid() { + 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()); - // What a create the draw moves no index entry for is charged, then the prepayment - let mut expected = unindexed; - expected.push(prepayment); - assert_eq!(indexed, expected); + assert_eq!(indexed, unindexed); } } 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 index f0bf38a92b6..878f3434462 100644 --- 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 @@ -590,6 +590,68 @@ mod tests { )); } + #[test] + fn should_prepay_the_draw_once_with_the_insert_of_the_document() { + // The insert of a card 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 insert_fee = |platform_version: &PlatformVersion| { + let (drive, contract) = setup(platform_version); + let document = card(1); + 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("card").expect("card"), + }, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("the card inserts") + }; + let (drive, contract) = setup(&platform_version); + let prepayment = drive + .prepay_random_document_index_entries( + &card(1), + Identifier::from(OWNER), + &contract, + contract.document_type_for_name("card").expect("card"), + &BlockInfo::default(), + None, + &platform_version, + ) + .expect("the prepayment is priced"); + assert_ne!(prepayment, FeeResult::default()); + + let with = insert_fee(&platform_version); + let without = insert_fee(&without_draw); + assert_eq!( + with.storage_fee, + without.storage_fee + prepayment.storage_fee + ); + assert_eq!( + with.processing_fee, + without.processing_fee + prepayment.processing_fee + ); + } + #[test] fn should_prepay_the_entries_of_the_indexes_holding_a_random_property_only() { let platform_version = drawing_platform_version(); 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/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 597741f70f6..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 @@ -400,9 +400,8 @@ mod tests { /// 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 state validation - /// prepays for the draw: `Drive::prepay_random_document_index_entries` of the document - /// the action writes. + /// 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, @@ -472,8 +471,7 @@ mod tests { /// 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: state validation charges it, once the - // schema holds + // Building the action bills no prepayment: the insert of its document charges it assert_eq!(build_fee, FeeResult::default()); prepayment } From 03d0d74cf91b7627f5055fb68610a4dbcd7bd6af Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 09:15:03 +0700 Subject: [PATCH 09/12] fix(drive): price an expiring document's draw prepayment by its lifetime - The random-index prepayment retags its entries with the remaining-lifetime pricing of a type whose documents expire, as the document's insert does, instead of pricing them as perpetual storage. - The verify-only create cost (document_create_cost, behind wasm-sdk's documentCreateCost) refuses a type indexing a random property, whose create also prepays the draw's entries, rather than understate it. - CHANGELOG: the dpp Rust API changes (GeneratedFrom::range, the SystemFunction::Random variant). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++ .../rs-drive/src/drive/document/cost/mod.rs | 18 ++++- .../rs-drive/src/drive/document/cost/tests.rs | 55 +++++++++++++ .../draw_random_document_properties/v0/mod.rs | 78 ++++++++++++++++++- 4 files changed, 153 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 608d4d49125..548de9ed6eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ ### 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 (Rust API):** Drive's cost estimators write their layer descriptions into `drive::util::estimated_layers::EstimatedLayers` instead of `HashMap`, in `DriveLowLevelOperationConverter` 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 1e1c558f7cb..aa38316993d 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; @@ -407,6 +408,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/v0/mod.rs b/packages/rs-drive/src/drive/document/draw_random_document_properties/v0/mod.rs index 878f3434462..232e4437ca5 100644 --- 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 @@ -1,4 +1,7 @@ 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; @@ -121,6 +124,25 @@ impl Drive { &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, @@ -320,7 +342,7 @@ mod tests { 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; + use dpp::document::{DocumentV0, DocumentV0Setters}; use dpp::platform_value::{platform_value, Value}; use std::collections::BTreeMap; @@ -375,6 +397,24 @@ mod tests { "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 } } }), @@ -652,6 +692,42 @@ mod tests { ); } + #[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(); From 343dfd0bba5e3de0de7f522e045be3c754398a9c Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 09:24:56 +0700 Subject: [PATCH 10/12] test(drive): insert an expiring random-indexed document and check its prepayment Co-Authored-By: Claude Opus 5.5 --- .../draw_random_document_properties/v0/mod.rs | 109 ++++++++++-------- 1 file changed, 61 insertions(+), 48 deletions(-) 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 index 232e4437ca5..c1a95b98980 100644 --- 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 @@ -632,8 +632,9 @@ mod tests { #[test] fn should_prepay_the_draw_once_with_the_insert_of_the_document() { - // The insert of a card is charged the prepayment on top of what it costs at a version - // whose draw method version is unset, which prepays nothing + // 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 @@ -641,55 +642,67 @@ mod tests { .methods .document .draw_random_document_properties = None; - let insert_fee = |platform_version: &PlatformVersion| { - let (drive, contract) = setup(platform_version); - let document = card(1); - 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), + 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"), }, - contract: &contract, - document_type: contract.document_type_for_name("card").expect("card"), - }, - false, - BlockInfo::default(), - true, - None, - platform_version, + false, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .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 card inserts") - }; - let (drive, contract) = setup(&platform_version); - let prepayment = drive - .prepay_random_document_index_entries( - &card(1), - Identifier::from(OWNER), - &contract, - contract.document_type_for_name("card").expect("card"), - &BlockInfo::default(), - None, - &platform_version, - ) - .expect("the prepayment is priced"); - assert_ne!(prepayment, FeeResult::default()); - - let with = insert_fee(&platform_version); - let without = insert_fee(&without_draw); - assert_eq!( - with.storage_fee, - without.storage_fee + prepayment.storage_fee - ); - assert_eq!( - with.processing_fee, - without.processing_fee + prepayment.processing_fee - ); + .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] From 294c15f3c2df2b2098057b36dae8ee6943bde99c Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 11:02:13 +0700 Subject: [PATCH 11/12] fix(dpp): refuse a stored random declaration without its range Co-Authored-By: Claude Opus 5.5 --- .../serialized_version/v0/property.rs | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) 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 ec8c933b2fa..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 @@ -575,21 +575,28 @@ impl From<&GeneratedFrom> for StoredGeneratedFrom { impl TryFrom for GeneratedFrom { type Error = ProtocolError; - /// Refuses a stored range [`RandomRange::new`] does not admit, which registration never - /// stores, rather than construct a range whose draw and size it does not hold. + /// 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; let range = match function { - StoredSystemFunction::Random { range, .. } => range - .map(|(minimum, maximum)| { - 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" - )) - }) - }) - .transpose()?, + // 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 { @@ -689,6 +696,14 @@ mod tests { } } + #[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 [ From f2f2747cb989af14be03915c1f1a66ac24a2b97e Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Wed, 4 Nov 2026 11:16:52 +0700 Subject: [PATCH 12/12] test(drive): skip a stored card deleted before its draw Co-Authored-By: Claude Opus 5.5 --- .../draw_random_document_properties/v0/mod.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) 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 index c1a95b98980..5fad83c620c 100644 --- 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 @@ -614,6 +614,51 @@ mod tests { 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, + None, + ) + .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());