diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index d2defe0fa04..2043d729894 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -50,7 +50,7 @@ Error codes are organized into ranges that correspond to error categories and su |-------|----------|----------| | 10000-10099 | Versioning | `UnsupportedVersionError` (10000), `ProtocolVersionParsingError` (10001), `IncompatibleProtocolVersionError` (10004) | | 10100-10199 | Structure | `JsonSchemaCompilationError` (10100), `InvalidIdentifierError` (10102), `ValueError` (10103) | -| 10200-10276 | Data Contract | `DataContractMaxDepthExceedError` (10200), `DuplicateIndexError` (10201), `InvalidDataContractIdError` (10204), `DataContractInvalidRequiredFieldsUpdateError` (10276) | +| 10200-10277 | Data Contract | `DataContractMaxDepthExceedError` (10200), `DuplicateIndexError` (10201), `InvalidDataContractIdError` (10204), `DataContractInvalidRequiredFieldsUpdateError` (10276), `PreProgrammedDistributionAmountOverLimitError` (10277) | | 10350-10359 | Groups | `GroupPositionDoesNotExistError` (10350), `GroupExceedsMaxMembersError` (10354) | | 10360-10367 | Contract Groups | `ContractGroupMembershipsOverLimitError` (10360), `InvalidContractGroupAdminsError` (10364), `InvalidContractGroupDescriptionLengthError` (10367); 10365 unassigned | | 10400-10418 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401) | diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/methods/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/methods/mod.rs new file mode 100644 index 00000000000..3885229354a --- /dev/null +++ b/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/methods/mod.rs @@ -0,0 +1 @@ +mod validate_amounts; diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/methods/validate_amounts/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/methods/validate_amounts/mod.rs new file mode 100644 index 00000000000..fea501099e4 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/methods/validate_amounts/mod.rs @@ -0,0 +1,37 @@ +use crate::data_contract::associated_token::token_pre_programmed_distribution::TokenPreProgrammedDistribution; +use crate::data_contract::TokenContractPosition; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +mod v0; + +impl TokenPreProgrammedDistribution { + /// Validates that the amounts of every release can be stored. + /// + /// Drive stores each release as a sum tree of its recipients' amounts, so an amount above + /// `i64::MAX`, or a release whose amounts total more than that, can not be written. Without + /// this check such a distribution passes validation and then fails inside Drive as an + /// internal error, which is never paid for and only makes the transition disappear. + /// + /// `token_position` is the position of the token in its contract, reported in the error. + pub fn validate_amounts( + &self, + token_position: TokenContractPosition, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .contract_versions + .token_versions + .validate_pre_programmed_distribution_amounts + { + 0 => Ok(self.validate_amounts_v0(token_position)), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "TokenPreProgrammedDistribution::validate_amounts".to_string(), + known_versions: vec![0], + received: version, + }), + } + } +} diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/methods/validate_amounts/v0/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/methods/validate_amounts/v0/mod.rs new file mode 100644 index 00000000000..75e1f3a2864 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/methods/validate_amounts/v0/mod.rs @@ -0,0 +1,143 @@ +use crate::balances::credits::TokenAmount; +use crate::consensus::basic::data_contract::PreProgrammedDistributionAmountOverLimitError; +use crate::data_contract::associated_token::token_pre_programmed_distribution::accessors::v0::TokenPreProgrammedDistributionV0Methods; +use crate::data_contract::associated_token::token_pre_programmed_distribution::TokenPreProgrammedDistribution; +use crate::data_contract::TokenContractPosition; +use crate::validation::SimpleConsensusValidationResult; + +impl TokenPreProgrammedDistribution { + #[inline(always)] + pub(super) fn validate_amounts_v0( + &self, + token_position: TokenContractPosition, + ) -> SimpleConsensusValidationResult { + for (timestamp, release) in self.distributions() { + // A single amount over the limit puts the total over it too, so the total is the + // only thing to check. A total that does not even fit a `u64` is over the limit. + let total_fits = release + .values() + .try_fold(0 as TokenAmount, |total, amount| total.checked_add(*amount)) + .is_some_and(|total| total <= i64::MAX as TokenAmount); + + if !total_fits { + return SimpleConsensusValidationResult::new_with_error( + PreProgrammedDistributionAmountOverLimitError::new(token_position, *timestamp) + .into(), + ); + } + } + + SimpleConsensusValidationResult::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consensus::basic::BasicError; + use crate::consensus::codes::ErrorWithCode; + use crate::consensus::ConsensusError; + use crate::data_contract::associated_token::token_pre_programmed_distribution::v0::TokenPreProgrammedDistributionV0; + use crate::prelude::TimestampMillis; + use assert_matches::assert_matches; + use platform_value::Identifier; + use platform_version::version::PlatformVersion; + use std::collections::BTreeMap; + + const MAX_AMOUNT: TokenAmount = i64::MAX as TokenAmount; + + fn distribution( + releases: impl IntoIterator)>, + ) -> TokenPreProgrammedDistribution { + let distributions = releases + .into_iter() + .map(|(timestamp, amounts)| { + let release = amounts + .into_iter() + .enumerate() + .map(|(recipient, amount)| { + (Identifier::from([recipient as u8 + 1; 32]), amount) + }) + .collect::>(); + (timestamp, release) + }) + .collect(); + TokenPreProgrammedDistribution::V0(TokenPreProgrammedDistributionV0 { distributions }) + } + + #[test] + fn should_accept_releases_whose_totals_fit() { + let platform_version = PlatformVersion::latest(); + + let result = distribution([ + (100, vec![MAX_AMOUNT]), + (200, vec![MAX_AMOUNT - 1, 1]), + (300, vec![]), + ]) + .validate_amounts(0, platform_version) + .expect("expected to validate"); + + assert!(result.is_valid(), "unexpected errors: {:?}", result.errors); + } + + #[test] + fn should_reject_an_amount_over_the_limit() { + let platform_version = PlatformVersion::latest(); + + let result = distribution([(100, vec![5]), (200, vec![MAX_AMOUNT + 1])]) + .validate_amounts(3, platform_version) + .expect("expected to validate"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::PreProgrammedDistributionAmountOverLimitError(e) + )] if e.token_position() == 3 && e.timestamp() == 200 + ); + assert_eq!(result.errors[0].code(), 10277); + } + + #[test] + fn should_reject_a_release_whose_amounts_total_over_the_limit() { + let platform_version = PlatformVersion::latest(); + + let result = distribution([(100, vec![MAX_AMOUNT, 1])]) + .validate_amounts(0, platform_version) + .expect("expected to validate"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::PreProgrammedDistributionAmountOverLimitError(e) + )] if e.token_position() == 0 && e.timestamp() == 100 + ); + } + + #[test] + fn should_reject_a_release_whose_total_does_not_fit_a_u64() { + let platform_version = PlatformVersion::latest(); + + let result = distribution([(100, vec![u64::MAX, u64::MAX])]) + .validate_amounts(0, platform_version) + .expect("expected to validate"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::PreProgrammedDistributionAmountOverLimitError(_) + )] + ); + } + + #[test] + fn should_judge_every_release_on_its_own_total() { + let platform_version = PlatformVersion::latest(); + + // Releases are separate sum trees, so two full releases do not add up. + let result = distribution([(100, vec![MAX_AMOUNT]), (200, vec![MAX_AMOUNT])]) + .validate_amounts(0, platform_version) + .expect("expected to validate"); + + assert!(result.is_valid(), "unexpected errors: {:?}", result.errors); + } +} diff --git a/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/mod.rs b/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/mod.rs index 4d620d7b98a..26e1b31b27d 100644 --- a/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/mod.rs +++ b/packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/mod.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use std::fmt; pub mod accessors; +mod methods; pub mod v0; diff --git a/packages/rs-dpp/src/data_contract/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/methods/validate_update/v1/mod.rs index 24eec3afaf5..45fa85c3fab 100644 --- a/packages/rs-dpp/src/data_contract/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/validate_update/v1/mod.rs @@ -1,6 +1,9 @@ use crate::block::block_info::BlockInfo; use crate::consensus::basic::data_contract::DataContractInvalidRequiredFieldsUpdateError; use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::accessors::v1::DataContractV1Getters; +use crate::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use crate::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::DataContract; use crate::validation::SimpleConsensusValidationResult; @@ -16,9 +19,14 @@ impl DataContract { /// resolves its own generation from the platform version, so this /// method needs no logic of its own for them.) /// + /// It also bounds the pre-programmed distribution amounts of the tokens + /// the update adds, which from the same protocol version get their + /// distribution storage written by the update (Drive `update_contract` + /// v2) and so have to be storable. + /// /// Delegating to generation 0 is safe because that generation is /// shipped and therefore frozen. The checks are independent and - /// short-circuiting, so appending the extra one changes only which + /// short-circuiting, so appending the extra ones changes only which /// error is reported when an update violates several rules at once — /// never whether it is rejected. #[inline(always)] @@ -33,7 +41,46 @@ impl DataContract { return Ok(result); } - Ok(self.validate_update_new_document_types_required_since(new_data_contract)) + let result = self.validate_update_new_document_types_required_since(new_data_contract); + if !result.is_valid() { + return Ok(result); + } + + self.validate_update_new_tokens_pre_programmed_amounts(new_data_contract, platform_version) + } + + /// A token introduced by this update has its pre-programmed releases + /// written to Drive as sum trees, so each release must total at most + /// `i64::MAX`. + /// + /// Only the added tokens are judged. Before protocol version 14 an + /// update wrote no distribution storage and therefore admitted a token + /// with a release whose amounts each fit but total more; its contract + /// carries that token in every later update and has to remain updatable. + fn validate_update_new_tokens_pre_programmed_amounts( + &self, + new_data_contract: &DataContract, + platform_version: &PlatformVersion, + ) -> Result { + for (token_contract_position, token_configuration) in new_data_contract.tokens() { + if self.tokens().contains_key(token_contract_position) { + continue; + } + let Some(distribution) = token_configuration + .distribution_rules() + .pre_programmed_distribution() + else { + continue; + }; + + let result = + distribution.validate_amounts(*token_contract_position, platform_version)?; + if !result.is_valid() { + return Ok(result); + } + } + + Ok(SimpleConsensusValidationResult::new()) } /// Document types introduced by this update have no old counterpart, @@ -77,15 +124,24 @@ impl DataContract { #[cfg(test)] mod tests { use super::*; + use crate::balances::credits::TokenAmount; use crate::consensus::basic::basic_error::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::accessors::v0::DataContractV0Setters; + use crate::data_contract::accessors::v1::DataContractV1Setters; + use crate::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; + use crate::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use crate::data_contract::associated_token::token_configuration::TokenConfiguration; + use crate::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Setters; + use crate::data_contract::associated_token::token_pre_programmed_distribution::v0::TokenPreProgrammedDistributionV0; + use crate::data_contract::associated_token::token_pre_programmed_distribution::TokenPreProgrammedDistribution; use crate::data_contract::methods::validate_update::DataContractUpdateValidationMethodsV0; use crate::data_contract::schema::DataContractSchemaMethodsV0; use crate::prelude::IdentityNonce; use crate::tests::fixtures::get_data_contract_fixture; use assert_matches::assert_matches; - use platform_value::platform_value; + use platform_value::{platform_value, Identifier}; + use std::collections::BTreeMap; #[test] fn should_validate_required_since_on_document_types_added_by_the_update() { @@ -165,4 +221,126 @@ mod tests { result.errors ); } + + /// A token releasing `amounts` at time 100, one recipient per amount. + fn token_releasing(amounts: &[TokenAmount]) -> TokenConfiguration { + let mut configuration = TokenConfiguration::V0( + TokenConfigurationV0::default_most_restrictive().with_base_supply(0), + ); + let release = amounts + .iter() + .enumerate() + .map(|(recipient, amount)| (Identifier::from([recipient as u8 + 1; 32]), *amount)) + .collect::>(); + configuration + .distribution_rules_mut() + .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0( + TokenPreProgrammedDistributionV0 { + distributions: BTreeMap::from([(100, release)]), + }, + ))); + configuration + } + + /// The fixture contract holding `old_tokens`, and the update of it that holds `new_tokens`. + fn contract_and_its_update( + old_tokens: BTreeMap, + new_tokens: BTreeMap, + protocol_version: u32, + ) -> (DataContract, DataContract) { + let mut old_data_contract = + get_data_contract_fixture(None, IdentityNonce::default(), protocol_version) + .data_contract_owned(); + old_data_contract.set_tokens(old_tokens); + + let mut new_data_contract = old_data_contract.clone(); + new_data_contract.set_tokens(new_tokens); + new_data_contract.set_version(old_data_contract.version() + 1); + + (old_data_contract, new_data_contract) + } + + #[test] + fn should_reject_a_token_added_by_the_update_whose_release_totals_over_the_limit() { + let platform_version = PlatformVersion::latest(); + let over_limit_releases: [&[TokenAmount]; 2] = [ + &[i64::MAX as TokenAmount + 1], + &[i64::MAX as TokenAmount, 1], + ]; + + for amounts in over_limit_releases { + let (old_data_contract, new_data_contract) = contract_and_its_update( + BTreeMap::new(), + BTreeMap::from([(0, token_releasing(amounts))]), + platform_version.protocol_version, + ); + + let result = old_data_contract + .validate_update(&new_data_contract, &BlockInfo::default(), platform_version) + .expect("failed validate update"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::PreProgrammedDistributionAmountOverLimitError(e) + )] if e.token_position() == 0 && e.timestamp() == 100 + ); + } + } + + #[test] + fn should_accept_a_token_added_by_the_update_whose_release_totals_the_limit() { + let platform_version = PlatformVersion::latest(); + + let (old_data_contract, new_data_contract) = contract_and_its_update( + BTreeMap::new(), + BTreeMap::from([(0, token_releasing(&[i64::MAX as TokenAmount - 1, 1]))]), + platform_version.protocol_version, + ); + + let result = old_data_contract + .validate_update(&new_data_contract, &BlockInfo::default(), platform_version) + .expect("failed validate update"); + + assert!(result.is_valid(), "unexpected errors: {:?}", result.errors); + } + + /// Before protocol version 14 a contract update wrote no distribution storage, so it + /// admitted a token with a release whose amounts each fit but total over the limit. Such + /// a contract has to stay updatable: the check covers the tokens an update adds, never + /// the ones it carries over. + #[test] + fn should_not_judge_the_release_totals_of_tokens_the_contract_already_had() { + let platform_version = PlatformVersion::latest(); + + let legacy_token = token_releasing(&[i64::MAX as TokenAmount, 1]); + let (old_data_contract, new_data_contract) = contract_and_its_update( + BTreeMap::from([(0, legacy_token.clone())]), + BTreeMap::from([(0, legacy_token), (1, token_releasing(&[445]))]), + platform_version.protocol_version, + ); + + let result = old_data_contract + .validate_update(&new_data_contract, &BlockInfo::default(), platform_version) + .expect("failed validate update"); + + assert!(result.is_valid(), "unexpected errors: {:?}", result.errors); + } + + #[test] + fn should_still_accept_a_release_total_over_the_limit_on_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + + let (old_data_contract, new_data_contract) = contract_and_its_update( + BTreeMap::new(), + BTreeMap::from([(0, token_releasing(&[i64::MAX as TokenAmount, 1]))]), + platform_version.protocol_version, + ); + + let result = old_data_contract + .validate_update(&new_data_contract, &BlockInfo::default(), platform_version) + .expect("failed validate update"); + + assert!(result.is_valid(), "unexpected errors: {:?}", result.errors); + } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index 28de4219034..2a04daea04b 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -32,11 +32,12 @@ use crate::consensus::basic::data_contract::{ InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, - NonContiguousContractTokenPositionsError, RedundantDocumentPaidForByTokenWithContractId, - SystemPropertyIndexAlreadyPresentError, UndefinedIndexPropertyError, - UniqueIndicesLimitReachedError, UnknownDocumentCreationRestrictionModeError, - UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, - UnknownTradeModeError, UnknownTransferableTypeError, + NonContiguousContractTokenPositionsError, PreProgrammedDistributionAmountOverLimitError, + RedundantDocumentPaidForByTokenWithContractId, SystemPropertyIndexAlreadyPresentError, + UndefinedIndexPropertyError, UniqueIndicesLimitReachedError, + UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, + UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, + UnknownTransferableTypeError, }; use crate::consensus::basic::data_contract::{ InvalidJsonSchemaRefError, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, @@ -777,6 +778,10 @@ pub enum BasicError { InvalidTokenOncePerIdentityDistributionAmountError( InvalidTokenOncePerIdentityDistributionAmountError, ), + + // Pre-programmed distribution amounts (protocol version 14). + #[error(transparent)] + PreProgrammedDistributionAmountOverLimitError(PreProgrammedDistributionAmountOverLimitError), } impl From for ConsensusError { @@ -791,7 +796,7 @@ mod tests { /// `BasicError` is bincode-encoded positionally, so a variant inserted anywhere but the tail /// shifts the wire discriminant of every variant after it. These are the frozen - /// discriminants of the last two variants: a new variant goes after them, and gets its own + /// discriminants of the last variants: a new variant goes after them, and gets its own /// line here. fn discriminant_of(error: BasicError) -> u32 { let bytes = bincode::encode_to_vec(error, bincode::config::standard()) @@ -811,7 +816,7 @@ mod tests { )), 186 ); - // Once-per-identity token distribution (protocol version 14): the tail of the enum. + // Once-per-identity token distribution (protocol version 14). assert_eq!( discriminant_of( BasicError::InvalidTokenOncePerIdentityDistributionAmountError( @@ -820,5 +825,12 @@ mod tests { ), 187 ); + // Pre-programmed distribution amounts (protocol version 14): the tail of the enum. + assert_eq!( + discriminant_of(BasicError::PreProgrammedDistributionAmountOverLimitError( + PreProgrammedDistributionAmountOverLimitError::new(0, 100) + )), + 188 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs index d15ccaca661..d30276402c7 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs @@ -46,6 +46,7 @@ mod main_group_is_not_defined; mod new_tokens_destination_identity_option_required_error; mod non_contiguous_contract_group_positions_error; mod non_contiguous_contract_token_positions_error; +mod pre_programmed_distribution_amount_over_limit_error; mod redundant_document_paid_for_by_token_with_contract_id; mod system_property_index_already_present_error; mod token_decimals_over_limit_error; @@ -112,6 +113,7 @@ pub use main_group_is_not_defined::*; pub use new_tokens_destination_identity_option_required_error::*; pub use non_contiguous_contract_group_positions_error::*; pub use non_contiguous_contract_token_positions_error::*; +pub use pre_programmed_distribution_amount_over_limit_error::*; pub use redundant_document_paid_for_by_token_with_contract_id::*; pub use token_decimals_over_limit_error::*; pub use token_payment_by_burning_only_allowed_on_internal_token_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/basic/data_contract/pre_programmed_distribution_amount_over_limit_error.rs b/packages/rs-dpp/src/errors/consensus/basic/data_contract/pre_programmed_distribution_amount_over_limit_error.rs new file mode 100644 index 00000000000..a82999bee58 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/data_contract/pre_programmed_distribution_amount_over_limit_error.rs @@ -0,0 +1,68 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::TokenContractPosition; +use crate::errors::ProtocolError; +use crate::prelude::TimestampMillis; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// The amounts a token's pre-programmed distribution releases at one time total more than +/// `i64::MAX`. Each release is stored as a sum tree of its recipients' amounts, so neither an +/// amount nor the total of a release can exceed what the sum tree holds. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Token at position {} has a pre-programmed distribution at {} whose amounts total more than the maximum of {}", + token_position, + timestamp, + i64::MAX +)] +#[platform_serialize(unversioned)] +pub struct PreProgrammedDistributionAmountOverLimitError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + token_position: TokenContractPosition, + timestamp: TimestampMillis, +} + +impl PreProgrammedDistributionAmountOverLimitError { + pub fn new(token_position: TokenContractPosition, timestamp: TimestampMillis) -> Self { + Self { + token_position, + timestamp, + } + } + + pub fn token_position(&self) -> TokenContractPosition { + self.token_position + } + + pub fn timestamp(&self) -> TimestampMillis { + self.timestamp + } +} + +impl From for ConsensusError { + fn from(err: PreProgrammedDistributionAmountOverLimitError) -> Self { + Self::BasicError(BasicError::PreProgrammedDistributionAmountOverLimitError( + err, + )) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 8d97f793740..0a4a2284865 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -121,6 +121,7 @@ impl ErrorWithCode for BasicError { Self::InvalidTokenDistributionTimeIntervalNotMinuteAlignedError(_) => 10274, Self::RedundantDocumentPaidForByTokenWithContractId(_) => 10275, Self::DataContractInvalidRequiredFieldsUpdateError { .. } => 10276, + Self::PreProgrammedDistributionAmountOverLimitError(_) => 10277, // Group Errors: 10350-10399 Self::GroupPositionDoesNotExistError(_) => 10350, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs index 06361e19801..bfa657ea42c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs @@ -9,6 +9,8 @@ use dpp::consensus::basic::data_contract::DataContractInvalidRequiredFieldsUpdat use dpp::consensus::ConsensusError; use dpp::contract_group::ContractGroupMember; use dpp::dashcore::Network; +use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dpp::identifier::Identifier; use dpp::state_transition::data_contract_create_transition::accessors::{ DataContractCreateTransitionAccessorsV0, DataContractCreateTransitionAccessorsV1, @@ -103,6 +105,25 @@ impl DataContractCreateStateTransitionBasicStructureValidationV2 for DataContrac return Ok(SimpleConsensusValidationResult::new_with_error(error)); } + // Drive stores every pre-programmed release as a sum tree of its recipients' amounts. + // A release totalling more than `i64::MAX` passed every check and then failed inside + // Drive as an internal error, which nobody pays for and which only makes the + // transition disappear from every proposal. + for (token_contract_position, token_configuration) in self.data_contract().tokens() { + let Some(distribution) = token_configuration + .distribution_rules() + .pre_programmed_distribution() + else { + continue; + }; + + let validation_result = + distribution.validate_amounts(*token_contract_position, platform_version)?; + if !validation_result.is_valid() { + return Ok(validation_result); + } + } + Ok(SimpleConsensusValidationResult::new()) } } @@ -233,15 +254,27 @@ fn contract_group_basic_structure_error( #[cfg(test)] mod tests { use super::*; + use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; use assert_matches::assert_matches; + use dpp::balances::credits::TokenAmount; use dpp::consensus::basic::BasicError; use dpp::consensus::ConsensusError; + use dpp::data_contract::accessors::v1::DataContractV1Setters; + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; + use dpp::data_contract::associated_token::token_configuration_localization::v0::TokenConfigurationLocalizationV0; + use dpp::data_contract::associated_token::token_configuration_localization::TokenConfigurationLocalization; + use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Setters; + use dpp::data_contract::associated_token::token_pre_programmed_distribution::v0::TokenPreProgrammedDistributionV0; + use dpp::data_contract::associated_token::token_pre_programmed_distribution::TokenPreProgrammedDistribution; use dpp::platform_value::platform_value; use dpp::prelude::IdentityNonce; use dpp::state_transition::data_contract_create_transition::DataContractCreateTransitionV0; use dpp::tests::fixtures::get_data_contract_fixture; use platform_version::version::PlatformVersion; use platform_version::TryIntoPlatformVersioned; + use std::collections::BTreeMap; fn create_transition_with_required_since( required_since: u32, @@ -319,4 +352,115 @@ mod tests { )] if e.details().contains("cannot carry requiredSince 2") ); } + + /// A create transition whose contract has one token per entry of `releases`, each + /// releasing its amounts at time 100, one recipient per amount. + fn create_transition_with_pre_programmed_releases( + releases: &[&[TokenAmount]], + platform_version: &PlatformVersion, + ) -> DataContractCreateTransition { + let identity_nonce = IdentityNonce::default(); + + let mut data_contract = + get_data_contract_fixture(None, identity_nonce, platform_version.protocol_version) + .data_contract_owned(); + + for (position, amounts) in releases.iter().enumerate() { + let mut configuration = TokenConfiguration::V0( + TokenConfigurationV0::default_most_restrictive().with_base_supply(0), + ); + configuration.conventions_mut().localizations_mut().insert( + "en".to_string(), + TokenConfigurationLocalization::V0(TokenConfigurationLocalizationV0 { + should_capitalize: true, + singular_form: "test".to_string(), + plural_form: "tests".to_string(), + }), + ); + let release = amounts + .iter() + .enumerate() + .map(|(recipient, amount)| (Identifier::from([recipient as u8 + 1; 32]), *amount)) + .collect::>(); + configuration + .distribution_rules_mut() + .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0( + TokenPreProgrammedDistributionV0 { + distributions: BTreeMap::from([(100, release)]), + }, + ))); + data_contract.add_token(position as u16, configuration); + } + + DataContractCreateTransitionV0 { + data_contract: data_contract + .try_into_platform_versioned(platform_version) + .expect("failed to convert data contract"), + identity_nonce, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + .into() + } + + #[test] + fn should_reject_a_pre_programmed_release_totalling_over_the_limit() { + let platform_version = PlatformVersion::latest(); + let over_limit_releases: [&[TokenAmount]; 2] = [ + &[i64::MAX as TokenAmount + 1], + &[i64::MAX as TokenAmount, 1], + ]; + + for over_limit_release in over_limit_releases { + // The second token is the offending one + let transition = create_transition_with_pre_programmed_releases( + &[&[445], over_limit_release], + platform_version, + ); + + let result = transition + .validate_basic_structure(Network::Testnet, platform_version) + .expect("failed to validate basic structure"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::PreProgrammedDistributionAmountOverLimitError(e) + )] if e.token_position() == 1 && e.timestamp() == 100 + ); + } + } + + #[test] + fn should_accept_a_pre_programmed_release_totalling_the_limit() { + let platform_version = PlatformVersion::latest(); + + let transition = create_transition_with_pre_programmed_releases( + &[&[i64::MAX as TokenAmount - 1, 1]], + platform_version, + ); + + let result = transition + .validate_basic_structure(Network::Testnet, platform_version) + .expect("failed to validate basic structure"); + + assert!(result.is_valid(), "unexpected errors: {:?}", result.errors); + } + + /// Protocol version 13 runs basic structure v1, which is shipped and keeps admitting the + /// release. The create then fails inside Drive as an internal error, as it always has. + #[test] + fn should_still_accept_a_release_total_over_the_limit_on_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + + let transition = + create_transition_with_pre_programmed_releases(&[&[u64::MAX]], platform_version); + + let result = transition + .validate_basic_structure(Network::Testnet, platform_version) + .expect("failed to validate basic structure"); + + assert!(result.is_valid(), "unexpected errors: {:?}", result.errors); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs index 80447ddc7b4..93c6246d5b5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs @@ -1561,6 +1561,10 @@ mod tests { mod pre_programmed_distribution { use super::*; + use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::data_contract::accessors::v1::DataContractV1Setters; use dpp::data_contract::associated_token::token_pre_programmed_distribution::v0::TokenPreProgrammedDistributionV0; use dpp::data_contract::associated_token::token_pre_programmed_distribution::TokenPreProgrammedDistribution; use drive::drive::Drive; @@ -1736,6 +1740,190 @@ mod tests { assert_eq!(verified_pre_programmed_distributions, distributions); } + + const RELEASE_TIME: TimestampMillis = 1700000000000; + + /// Processes the creation of the basic token contract given one token per entry + /// of `releases`. Each token releases its amounts at `RELEASE_TIME`, the first to + /// the contract owner and the others to further identities. Returns the + /// processing result, committed, and the token ids. + async fn process_create_with_tokens_releasing( + releases: &[&[TokenAmount]], + platform: &mut TempPlatform, + platform_version: &PlatformVersion, + ) -> (StateTransitionsProcessingResult, Vec<[u8; 32]>) { + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(platform, 958, dash_to_credits!(1.0)); + + let recipient_count = releases.iter().map(|amounts| amounts.len()).max(); + let mut recipients = vec![identity.id()]; + for seed in 1..recipient_count.unwrap_or_default() { + let (recipient, _, _) = + setup_identity(platform, 958 + seed as u64, dash_to_credits!(0.1)); + recipients.push(recipient.id()); + } + + let mut data_contract = json_document_to_contract_with_ids( + "tests/supporting_files/contract/basic-token/basic-token.json", + None, + None, + false, //no need to validate the data contracts in tests for drive + platform_version, + ) + .expect("expected to get json based contract"); + + let base_token_configuration = data_contract + .tokens() + .get(&0) + .expect("expected first token") + .clone(); + + for (position, amounts) in releases.iter().enumerate() { + let mut token_configuration = base_token_configuration.clone(); + token_configuration.set_base_supply(0); + token_configuration + .distribution_rules_mut() + .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0( + TokenPreProgrammedDistributionV0 { + distributions: BTreeMap::from([( + RELEASE_TIME, + recipients + .iter() + .copied() + .zip(amounts.iter().copied()) + .collect(), + )]), + }, + ))); + data_contract.add_token(position as u16, token_configuration); + } + + let data_contract_id = DataContract::generate_data_contract_id_v0(identity.id(), 1); + let token_ids = (0..releases.len()) + .map(|position| { + calculate_token_id(data_contract_id.as_bytes(), position as u16) + }) + .collect(); + + let data_contract_create_transition = + DataContractCreateTransition::new_from_data_contract( + data_contract, + 1, + &identity.into_partial_identity_info(), + key.id(), + &signer, + platform_version, + None, + ) + .await + .expect("expect to create data contract create transition"); + + let data_contract_create_serialized_transition = data_contract_create_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[data_contract_create_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + (processing_result, token_ids) + } + + /// Every token releasing at one time keeps its references under one shared + /// release-time tree, which the create has to queue once however many of the + /// contract's tokens release at that time. + #[tokio::test] + async fn should_create_contract_whose_tokens_share_a_pre_programmed_release_time() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let (processing_result, token_ids) = process_create_with_tokens_releasing( + &[&[10], &[20]], + &mut platform, + platform_version, + ) + .await; + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + for (token_id, release_amount) in token_ids.into_iter().zip([10, 20]) { + let fetched_distributions = platform + .drive + .fetch_token_pre_programmed_distributions( + token_id, + None, + None, + None, + platform_version, + ) + .expect("expected to fetch pre-programmed distributions"); + + assert_eq!( + fetched_distributions + .get(&RELEASE_TIME) + .map(|release| release.values().copied().collect::>()), + Some(vec![release_amount]) + ); + } + } + + /// A release is stored as a sum tree, so neither an amount above `i64::MAX` nor + /// amounts totalling more can be written. Nothing validated them, so the create + /// failed inside Drive as an internal error instead of being rejected. + #[tokio::test] + async fn should_reject_contract_with_pre_programmed_release_over_the_limit() { + let platform_version = PlatformVersion::latest(); + let over_limit_releases: [&[TokenAmount]; 2] = [ + &[i64::MAX as TokenAmount + 1], + &[i64::MAX as TokenAmount, 1], + ]; + + for over_limit_release in over_limit_releases { + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let (processing_result, _) = process_create_with_tokens_releasing( + &[&[10], over_limit_release], + &mut platform, + platform_version, + ) + .await; + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError( + BasicError::PreProgrammedDistributionAmountOverLimitError(error) + ) + )] if error.token_position() == 1 && error.timestamp() == RELEASE_TIME + ); + } + } } mod token_errors { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index a212e619dcc..004621fab6c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -2968,6 +2968,195 @@ mod tests { assert_eq!(token_balance, None); } } + + /// Registers a contract without tokens, then processes a data contract + /// update that adds one token per entry of `releases`. Each token + /// releases its amounts at time 100, the first to the contract owner and + /// the others to further identities. + async fn process_update_adding_tokens_releasing( + releases: &[&[TokenAmount]], + protocol_version: ProtocolVersion, + ) -> StateTransitionsProcessingResult { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected a known protocol version"); + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + + let recipient_count = releases.iter().map(|amounts| amounts.len()).max(); + let mut recipients = vec![identity.id()]; + for seed in 1..recipient_count.unwrap_or_default() { + let (recipient, _, _) = + setup_identity(&mut platform, 958 + seed as u64, dash_to_credits!(0.1)); + recipients.push(recipient.id()); + } + + let platform_state = platform.state.load(); + + let mut data_contract = + get_data_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + data_contract.set_owner_id(identity.id()); + + platform + .drive + .apply_contract( + &data_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply contract successfully"); + + let mut updated_data_contract = data_contract.clone(); + updated_data_contract.set_version(2); + + for (position, amounts) in releases.iter().enumerate() { + let mut token_configuration = + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()); + token_configuration + .conventions_mut() + .localizations_mut() + .insert( + "en".to_string(), + TokenConfigurationLocalization::V0(TokenConfigurationLocalizationV0 { + should_capitalize: true, + singular_form: "credit".to_string(), + plural_form: "credits".to_string(), + }), + ); + token_configuration + .distribution_rules_mut() + .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0( + TokenPreProgrammedDistributionV0 { + distributions: BTreeMap::from([( + 100, + recipients + .iter() + .copied() + .zip(amounts.iter().copied()) + .collect(), + )]), + }, + ))); + updated_data_contract.add_token(position as u16, token_configuration); + } + + let data_contract_update_transition = + DataContractUpdateTransition::new_from_data_contract( + updated_data_contract, + &identity.into_partial_identity_info(), + key.id(), + 2, + 0, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create data contract update transition"); + + let update_bytes = data_contract_update_transition + .serialize_to_bytes() + .expect("expected serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + platform + .platform + .process_raw_state_transitions( + &[update_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition") + } + + /// Every token releasing at one time keeps its references under one + /// shared release-time tree, which the update has to queue once however + /// many of the added tokens release at that time. + #[tokio::test] + async fn should_add_tokens_sharing_a_pre_programmed_release_time_by_update() { + let processing_result = process_update_adding_tokens_releasing( + &[&[445], &[445]], + PlatformVersion::latest().protocol_version, + ) + .await; + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + /// A release is stored as a sum tree, so neither an amount above + /// `i64::MAX` nor amounts totalling more can be written. Nothing + /// validated them, so the update failed inside Drive as an internal + /// error instead of being rejected and paid for. + #[tokio::test] + async fn should_reject_update_adding_token_with_pre_programmed_release_over_the_limit() { + let over_limit_releases: [&[TokenAmount]; 2] = [ + &[i64::MAX as TokenAmount + 1], + &[i64::MAX as TokenAmount, 1], + ]; + + for over_limit_release in over_limit_releases { + let processing_result = process_update_adding_tokens_releasing( + &[&[445], over_limit_release], + PlatformVersion::latest().protocol_version, + ) + .await; + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError( + BasicError::PreProgrammedDistributionAmountOverLimitError(error) + ), + .. + }] if error.token_position() == 1 && error.timestamp() == 100 + ); + } + } + + /// Before protocol version 14 an update wrote no distribution storage, + /// so it admitted a release whose amounts each fit but total over the + /// limit. That is shipped behaviour and stays; it is why the check only + /// covers the tokens an update adds. + #[tokio::test] + async fn should_still_add_token_with_pre_programmed_release_total_over_the_limit_on_protocol_version_13( + ) { + let processing_result = + process_update_adding_tokens_releasing(&[&[i64::MAX as TokenAmount, 1]], 13).await; + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + /// A single amount over the limit never got that far, not even before + /// protocol version 14: the fee estimation of an update takes the + /// contract insert path, which refuses the amount as an internal error. + #[tokio::test] + async fn should_still_fail_update_adding_token_with_pre_programmed_amount_over_the_limit_on_protocol_version_13( + ) { + let processing_result = + process_update_adding_tokens_releasing(&[&[i64::MAX as TokenAmount + 1]], 13).await; + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::InternalError(_)] + ); + } } mod keyword_updates { diff --git a/packages/rs-drive/src/drive/tokens/distribution/add_pre_programmed_distribution/mod.rs b/packages/rs-drive/src/drive/tokens/distribution/add_pre_programmed_distribution/mod.rs index 1b58829bf23..7796ced69bd 100644 --- a/packages/rs-drive/src/drive/tokens/distribution/add_pre_programmed_distribution/mod.rs +++ b/packages/rs-drive/src/drive/tokens/distribution/add_pre_programmed_distribution/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -168,9 +169,19 @@ impl Drive { transaction, platform_version, ), + 1 => self.add_pre_programmed_distributions_v1( + token_id, + owner_id, + distribution, + block_info, + estimated_costs_only_with_layer_info, + batch_operations, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_pre_programmed_distributions".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/tokens/distribution/add_pre_programmed_distribution/v1/mod.rs b/packages/rs-drive/src/drive/tokens/distribution/add_pre_programmed_distribution/v1/mod.rs new file mode 100644 index 00000000000..3825c29ea23 --- /dev/null +++ b/packages/rs-drive/src/drive/tokens/distribution/add_pre_programmed_distribution/v1/mod.rs @@ -0,0 +1,551 @@ +use crate::drive::tokens::paths::{ + token_ms_timed_at_time_distributions_path_vec, token_ms_timed_distributions_path_vec, + token_pre_programmed_at_time_distribution_path_vec, token_pre_programmed_distributions_path, + token_root_pre_programmed_distributions_path, + TOKEN_PRE_PROGRAMMED_DISTRIBUTIONS_FOR_IDENTITIES_LAST_CLAIM_KEY, + TOKEN_PRE_PROGRAMMED_DISTRIBUTIONS_KEY, +}; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchInsertTreeApplyType; +use crate::util::object_size_info::{DriveKeyInfo, PathInfo, PathKeyElementInfo}; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::associated_token::token_distribution_key::{ + TokenDistributionKey, TokenDistributionType, +}; +use dpp::data_contract::associated_token::token_perpetual_distribution::distribution_recipient::TokenDistributionRecipient; +use dpp::data_contract::associated_token::token_pre_programmed_distribution::accessors::v0::TokenPreProgrammedDistributionV0Methods; +use dpp::data_contract::associated_token::token_pre_programmed_distribution::TokenPreProgrammedDistribution; +use dpp::serialization::PlatformSerializable; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use grovedb::batch::KeyInfoPath; +use grovedb::element::reference_path::ReferencePathType; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + /// Version 1 of `add_pre_programmed_distributions` (protocol version 14). + /// + /// Version 0 except that the release-time tree under the millisecond timed distributions, + /// which all tokens share, is also looked for among `batch_operations`. A contract whose + /// tokens release at the same time therefore queues that tree once instead of once per + /// token. + /// + /// Queued twice, the batch is refused by a Drive with `batching_consistency_verification` + /// on, as an internal error. The shipped default is off, and there GroveDB folds the two + /// identical inserts into one, so such a contract was stored, and is stored the same here. + /// The later tokens no longer pay for the existence read of the tree, so the processing + /// fee is lower: that, and not the stored state, is what makes this a new version. For a + /// single token the operations are the ones version 0 produces. The layout is documented + /// on [`Drive::add_pre_programmed_distributions`]. + #[allow(clippy::too_many_arguments)] + pub(super) fn add_pre_programmed_distributions_v1( + &self, + token_id: [u8; 32], + owner_id: [u8; 32], + distribution: &TokenPreProgrammedDistribution, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + batch_operations: &mut Vec, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Drive::add_estimation_costs_for_token_pre_programmed_distribution( + token_id, + Some(distribution.distributions().keys()), + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + + Drive::add_estimation_costs_for_root_token_ms_interval_distribution( + distribution.distributions().keys(), + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + let storage_flags = StorageFlags::new_single_epoch(block_info.epoch.index, Some(owner_id)); + + let pre_programmed_distributions_path = token_root_pre_programmed_distributions_path(); + + // Insert the tree for this token's perpetual distribution + let apply_tree_type_no_storage_flags = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertTreeApplyType::StatefulBatchInsertTree + } else { + BatchInsertTreeApplyType::StatelessBatchInsertTree { + in_tree_type: TreeType::NormalTree, + tree_type: TreeType::NormalTree, + flags_len: 0, + } + }; + + let apply_tree_type_with_storage_flags = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertTreeApplyType::StatefulBatchInsertTree + } else { + BatchInsertTreeApplyType::StatelessBatchInsertTree { + in_tree_type: TreeType::NormalTree, + tree_type: TreeType::NormalTree, + flags_len: storage_flags.serialized_size(), + } + }; + + let token_tree_key_info = DriveKeyInfo::Key(token_id.to_vec()); + let pre_programmed_distributions_path_key_info = token_tree_key_info.add_path_info::<3>( + PathInfo::PathFixedSizeArray(pre_programmed_distributions_path), + ); + + let inserted = self.batch_insert_empty_tree_if_not_exists( + pre_programmed_distributions_path_key_info, + TreeType::NormalTree, + None, // we will never clean this part up + apply_tree_type_no_storage_flags, + transaction, + &mut None, + batch_operations, + &platform_version.drive, + )?; + + if !inserted { + return Err(Error::Drive(DriveError::CorruptedCodeExecution("we can not insert the pre programmed distribution as it already existed, this should have been validated before insertion"))); + } + let pre_programmed_distributions_path = token_pre_programmed_distributions_path(&token_id); + + self.batch_insert_empty_tree( + pre_programmed_distributions_path, + DriveKeyInfo::Key(vec![ + TOKEN_PRE_PROGRAMMED_DISTRIBUTIONS_FOR_IDENTITIES_LAST_CLAIM_KEY, + ]), + None, // we will never clean this part up + batch_operations, + &platform_version.drive, + )?; + + for (time, distribution) in distribution.distributions() { + self.batch_insert_empty_sum_tree( + pre_programmed_distributions_path, + DriveKeyInfo::Key(time.to_be_bytes().to_vec()), + None, // we will never clean this part up + batch_operations, + &platform_version.drive, + )?; + + let ms_time_distribution_path = token_ms_timed_distributions_path_vec(); + + let time_tree_key_info = DriveKeyInfo::Key(time.to_be_bytes().to_vec()); + let time_tree_reference_path_key_info = time_tree_key_info + .add_path_info::<0>(PathInfo::PathAsVec(ms_time_distribution_path)); + + // Every token releasing at this time keeps its references under this one tree, so + // an earlier token of the same contract may already have queued it. Version 0 + // looked for the tree in state only and queued it a second time, which a Drive + // verifying the consistency of its batches refuses ("insertion order error"). + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + time_tree_reference_path_key_info, + false, + Some(&storage_flags), + apply_tree_type_with_storage_flags, + transaction, + batch_operations, + &platform_version.drive, + )?; + + let pre_programmed_at_time_distribution_path = + token_pre_programmed_at_time_distribution_path_vec(token_id, *time); + let ms_time_at_time_distribution_path = + token_ms_timed_at_time_distributions_path_vec(*time); + + for (recipient, amount) in distribution { + if *amount > i64::MAX as u64 { + return Err(Error::Protocol(Box::new(ProtocolError::Overflow( + "distribution amount over i64::Max", + )))); + } + // We use a sum tree to be able to ask "at this time how much was distributed" + self.batch_insert( + PathKeyElementInfo::<0>::PathKeyElement(( + pre_programmed_at_time_distribution_path.clone(), + recipient.to_vec(), + Element::new_sum_item(*amount as i64), + )), + batch_operations, + &platform_version.drive, + )?; + + let distribution_key = TokenDistributionKey { + token_id: token_id.into(), + recipient: TokenDistributionRecipient::Identity(*recipient), + distribution_type: TokenDistributionType::PreProgrammed, + }; + + let serialized_key = distribution_key.serialize_consume_to_bytes()?; + + let remaining_reference = vec![ + vec![TOKEN_PRE_PROGRAMMED_DISTRIBUTIONS_KEY], + token_id.to_vec(), + time.to_be_bytes().to_vec(), + recipient.to_vec(), + ]; + + let reference = + ReferencePathType::UpstreamRootHeightReference(2, remaining_reference); + + // Now we create the reference + self.batch_insert( + PathKeyElementInfo::<0>::PathKeyElement(( + ms_time_at_time_distribution_path.clone(), + serialized_key, + Element::new_reference_with_flags( + reference, + storage_flags.to_some_element_flags(), + ), + )), + batch_operations, + &platform_version.drive, + )?; + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::config::DriveConfig; + use crate::drive::Drive; + use crate::error::drive::DriveError; + use crate::error::Error; + use crate::util::storage_flags::StorageFlags; + use crate::util::test_helpers::setup::{setup_drive, setup_drive_with_initial_state_structure}; + use dpp::balances::credits::TokenAmount; + use dpp::block::block_info::BlockInfo; + use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; + use dpp::data_contract::accessors::v1::{DataContractV1Getters, DataContractV1Setters}; + use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; + use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Setters; + use dpp::data_contract::associated_token::token_pre_programmed_distribution::v0::TokenPreProgrammedDistributionV0; + use dpp::data_contract::associated_token::token_pre_programmed_distribution::TokenPreProgrammedDistribution; + use dpp::data_contract::config::v0::DataContractConfigSettersV0; + use dpp::data_contract::TokenContractPosition; + use dpp::prelude::{DataContract, Identifier, TimestampMillis}; + use dpp::tests::fixtures::get_dashpay_contract_fixture; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + const RECIPIENT: [u8; 32] = [7; 32]; + const RELEASE_TIME: TimestampMillis = 100; + const RELEASE_AMOUNT: TokenAmount = 445; + + /// A token releasing `RELEASE_AMOUNT` to `RECIPIENT` at `RELEASE_TIME`. + fn token_releasing_at_the_shared_time() -> TokenConfiguration { + let mut configuration = TokenConfiguration::V0( + TokenConfigurationV0::default_most_restrictive().with_base_supply(0), + ); + configuration + .distribution_rules_mut() + .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0( + TokenPreProgrammedDistributionV0 { + distributions: BTreeMap::from([( + RELEASE_TIME, + BTreeMap::from([(Identifier::from(RECIPIENT), RELEASE_AMOUNT)]), + )]), + }, + ))); + configuration + } + + fn contract_without_tokens(id_seed: u64, platform_version: &PlatformVersion) -> DataContract { + let mut contract = + get_dashpay_contract_fixture(None, id_seed, platform_version.protocol_version) + .data_contract_owned(); + contract.config_mut().set_readonly(false); + contract + } + + /// A contract with two tokens, both releasing at `RELEASE_TIME`. + fn contract_with_two_tokens_sharing_a_release_time( + platform_version: &PlatformVersion, + ) -> DataContract { + let mut contract = contract_without_tokens(0, platform_version); + contract.set_tokens(BTreeMap::from([ + (0, token_releasing_at_the_shared_time()), + (1, token_releasing_at_the_shared_time()), + ])); + contract + } + + fn insert_contract( + drive: &Drive, + contract: &DataContract, + apply: bool, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + drive + .apply_contract( + contract, + BlockInfo::default(), + apply, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .map(|_| ()) + } + + fn token_id(contract: &DataContract, position: TokenContractPosition) -> [u8; 32] { + contract + .token_id(position) + .expect("expected a token at the position") + .to_buffer() + } + + /// Asserts the release of the token is stored and that a claim of it, which consumes the + /// reference kept under the release-time tree all tokens share, can be recorded. + fn assert_release_is_stored_and_claimable( + drive: &Drive, + token_id: [u8; 32], + platform_version: &PlatformVersion, + ) { + let stored = drive + .fetch_token_pre_programmed_distributions(token_id, None, None, None, platform_version) + .expect("expected to fetch the pre-programmed distributions"); + assert_eq!( + stored, + BTreeMap::from([( + RELEASE_TIME, + BTreeMap::from([(Identifier::from(RECIPIENT), RELEASE_AMOUNT)]) + )]) + ); + + let operations = drive + .mark_pre_programmed_release_as_distributed_operations( + token_id, + RECIPIENT, + RELEASE_TIME, + &BlockInfo::default(), + &mut None, + None, + platform_version, + ) + .expect("expected the claim operations"); + drive + .apply_batch_low_level_drive_operations( + None, + None, + operations, + &mut vec![], + &platform_version.drive, + ) + .expect("expected the claim to be recorded"); + } + + #[test] + fn should_insert_contract_whose_tokens_share_a_pre_programmed_release_time() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let contract = contract_with_two_tokens_sharing_a_release_time(platform_version); + + insert_contract(&drive, &contract, true, platform_version) + .expect("expected the contract to be inserted"); + + assert_release_is_stored_and_claimable(&drive, token_id(&contract, 0), platform_version); + assert_release_is_stored_and_claimable(&drive, token_id(&contract, 1), platform_version); + } + + #[test] + fn should_estimate_contract_whose_tokens_share_a_pre_programmed_release_time() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let contract = contract_with_two_tokens_sharing_a_release_time(platform_version); + + insert_contract(&drive, &contract, false, platform_version) + .expect("expected the contract insert to be estimated"); + } + + #[test] + fn should_update_contract_adding_tokens_that_share_a_pre_programmed_release_time() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let mut contract = contract_without_tokens(0, platform_version); + + insert_contract(&drive, &contract, true, platform_version) + .expect("expected the contract without tokens to be inserted"); + + contract.set_tokens(BTreeMap::from([ + (0, token_releasing_at_the_shared_time()), + (1, token_releasing_at_the_shared_time()), + ])); + contract.increment_version(); + + drive + .update_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("expected the update adding both tokens to succeed"); + + assert_release_is_stored_and_claimable(&drive, token_id(&contract, 0), platform_version); + assert_release_is_stored_and_claimable(&drive, token_id(&contract, 1), platform_version); + } + + #[test] + fn should_share_the_release_time_tree_with_a_contract_inserted_earlier() { + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + + let mut first_contract = contract_without_tokens(0, platform_version); + first_contract.set_tokens(BTreeMap::from([(0, token_releasing_at_the_shared_time())])); + let mut second_contract = contract_without_tokens(1, platform_version); + second_contract.set_tokens(BTreeMap::from([(0, token_releasing_at_the_shared_time())])); + assert_ne!(token_id(&first_contract, 0), token_id(&second_contract, 0)); + + insert_contract(&drive, &first_contract, true, platform_version) + .expect("expected the first contract to be inserted"); + insert_contract(&drive, &second_contract, true, platform_version) + .expect("expected the second contract to be inserted"); + + assert_release_is_stored_and_claimable( + &drive, + token_id(&first_contract, 0), + platform_version, + ); + assert_release_is_stored_and_claimable( + &drive, + token_id(&second_contract, 0), + platform_version, + ); + } + + #[test] + fn should_still_queue_the_shared_release_time_tree_twice_on_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + let drive = setup_drive_with_initial_state_structure(Some(platform_version)); + let contract = contract_with_two_tokens_sharing_a_release_time(platform_version); + + // This Drive verifies the consistency of its batches, which a shipped node does not + let error = insert_contract(&drive, &contract, true, platform_version) + .expect_err("version 0 queues the shared release-time tree once per token"); + + assert!( + matches!(&error, Error::Drive(DriveError::GroveDBInsertion(message)) if message.contains("insertion order error")), + "unexpected error: {error:?}" + ); + } + + /// A Drive configured the way a shipped node is. `setup_drive_with_initial_state_structure` + /// turns `batching_consistency_verification` on, the shipped default is off, and only with + /// it on does Drive refuse a batch that queues one tree twice. + fn setup_drive_with_shipped_batching_config(platform_version: &PlatformVersion) -> Drive { + let drive = setup_drive(Some(DriveConfig::default())); + assert!( + !drive.config.batching_consistency_verification, + "this fixture exists to exercise the shipped default" + ); + drive + .create_initial_state_structure(None, platform_version) + .expect("expected to create the initial state structure"); + drive + } + + /// With the shipped batching config version 0 stores such a contract: GroveDB folds the two + /// identical inserts of the shared release-time tree into one. Version 1 has to leave that + /// state untouched. What it does change is the processing fee, by the existence read the + /// second token no longer makes, which is why it is a new version and not an edit. + #[test] + fn should_store_the_state_version_0_stores_with_the_shipped_batching_config() { + let latest_platform_version = PlatformVersion::latest(); + let mut version_0_platform_version = latest_platform_version.clone(); + version_0_platform_version + .drive + .methods + .token + .distribution + .add_pre_programmed_distributions = 0; + + let contract = contract_with_two_tokens_sharing_a_release_time(latest_platform_version); + + let [(version_0_fee, version_0_root_hash), (version_1_fee, version_1_root_hash)] = + [&version_0_platform_version, latest_platform_version].map(|platform_version| { + let drive = setup_drive_with_shipped_batching_config(platform_version); + let fee = drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected the contract to be inserted"); + let root_hash = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected the root hash"); + + for position in [0, 1] { + assert_release_is_stored_and_claimable( + &drive, + token_id(&contract, position), + platform_version, + ); + } + + (fee, root_hash) + }); + + assert_eq!(version_0_root_hash, version_1_root_hash); + assert_eq!(version_0_fee.storage_fee, version_1_fee.storage_fee); + assert!(version_1_fee.processing_fee < version_0_fee.processing_fee); + } + + /// Why validation has to bound the amounts (`TokenPreProgrammedDistribution:: + /// validate_amounts`): a release is a sum tree, so GroveDB refuses one whose amounts each fit + /// an `i64` but total more. The cost estimation does not notice, only the write does. + #[test] + fn should_fail_to_store_a_release_whose_amounts_total_over_i64_max() { + let platform_version = PlatformVersion::latest(); + + let mut configuration = token_releasing_at_the_shared_time(); + configuration + .distribution_rules_mut() + .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0( + TokenPreProgrammedDistributionV0 { + distributions: BTreeMap::from([( + RELEASE_TIME, + BTreeMap::from([ + (Identifier::from([1; 32]), i64::MAX as TokenAmount), + (Identifier::from([2; 32]), 1), + ]), + )]), + }, + ))); + let mut contract = contract_without_tokens(0, platform_version); + contract.set_tokens(BTreeMap::from([(0, configuration)])); + + let drive = setup_drive_with_initial_state_structure(None); + + insert_contract(&drive, &contract, false, platform_version) + .expect("expected the estimation to succeed"); + + let error = insert_contract(&drive, &contract, true, platform_version) + .expect_err("expected the sum tree to overflow"); + + assert!( + matches!(&error, Error::GroveDB(grove_error) if grove_error.to_string().contains("sum is overflowing")), + "unexpected error: {error:?}" + ); + } +} 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 ec80ba92ca8..216aac3fc47 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 @@ -57,6 +57,9 @@ pub struct DocumentTypeVersions { #[derive(Clone, Debug, Default)] pub struct TokenVersions { pub validate_structure_interval: FeatureVersion, + /// `TokenPreProgrammedDistribution::validate_amounts`. Called from protocol version 14 on + /// (data contract create `basic_structure` v2 and `DataContract::validate_update` v1). + pub validate_pre_programmed_distribution_amounts: FeatureVersion, } #[derive(Clone, Debug, Default)] 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 bd6eda1e693..911e3363f20 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 @@ -65,5 +65,6 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { }, token_versions: TokenVersions { validate_structure_interval: 0, + validate_pre_programmed_distribution_amounts: 0, }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs index 1aec3222db0..479f940b4b5 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 @@ -65,5 +65,6 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { }, token_versions: TokenVersions { validate_structure_interval: 0, + validate_pre_programmed_distribution_amounts: 0, }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs index 5d513cc8506..563ef8386bd 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 @@ -67,5 +67,6 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { }, token_versions: TokenVersions { validate_structure_interval: 0, + validate_pre_programmed_distribution_amounts: 0, }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs index 3c9f484e8c4..f4e180f77d9 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 @@ -67,5 +67,6 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { }, token_versions: TokenVersions { validate_structure_interval: 0, + validate_pre_programmed_distribution_amounts: 0, }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs index 674f8593997..bf0e10991b0 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 @@ -69,5 +69,6 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { }, token_versions: TokenVersions { validate_structure_interval: 0, + validate_pre_programmed_distribution_amounts: 0, }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs index 62304b3ceb0..5f35ec7ba2d 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 @@ -111,5 +111,6 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { token_versions: TokenVersions { // 1: an epoch-based perpetual distribution needs an interval of at least one epoch. validate_structure_interval: 1, + validate_pre_programmed_distribution_amounts: 0, }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs index 6e6f851aef5..7e222e183b0 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/mod.rs @@ -1,6 +1,7 @@ use versioned_feature_core::FeatureVersion; pub mod v1; +pub mod v2; #[derive(Clone, Debug, Default)] pub struct DriveTokenMethodVersions { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v2.rs new file mode 100644 index 00000000000..89a564d343d --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v2.rs @@ -0,0 +1,27 @@ +use crate::version::drive_versions::drive_token_method_versions::v1::DRIVE_TOKEN_METHOD_VERSIONS_V1; +use crate::version::drive_versions::drive_token_method_versions::{ + DriveTokenDistributionMethodVersions, DriveTokenMethodVersions, +}; + +/// Drive token methods for protocol v14+. +/// +/// Identical to [`super::v1::DRIVE_TOKEN_METHOD_VERSIONS_V1`] except +/// `distribution.add_pre_programmed_distributions` is bumped to `1`. +/// +/// Every pre-programmed release is indexed under a tree keyed by its release +/// time that all tokens share. v0 looked for that tree in state only, so two +/// tokens of one contract releasing at the same time each queued its creation +/// in the same batch. A node verifying the consistency of its batches +/// (`batching_consistency_verification`, off by default) refuses that batch +/// as an internal error ("insertion order error") while every other node +/// stores the contract. v1 also looks among the operations already gathered, +/// so the tree is queued once. The stored state is the one v0 stores on a +/// default node; the processing fee drops by the existence read the later +/// tokens no longer make. +pub const DRIVE_TOKEN_METHOD_VERSIONS_V2: DriveTokenMethodVersions = DriveTokenMethodVersions { + distribution: DriveTokenDistributionMethodVersions { + add_pre_programmed_distributions: 1, + ..DRIVE_TOKEN_METHOD_VERSIONS_V1.distribution + }, + ..DRIVE_TOKEN_METHOD_VERSIONS_V1 +}; diff --git a/packages/rs-platform-version/src/version/drive_versions/v9.rs b/packages/rs-platform-version/src/version/drive_versions/v9.rs index 71fc54c2719..6936f540861 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v9.rs @@ -9,7 +9,7 @@ use crate::version::drive_versions::drive_grove_method_versions::v1::DRIVE_GROVE use crate::version::drive_versions::drive_identity_method_versions::v2::DRIVE_IDENTITY_METHOD_VERSIONS_V2; use crate::version::drive_versions::drive_state_transition_method_versions::v4::DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4; use crate::version::drive_versions::drive_structure_version::v1::DRIVE_STRUCTURE_V1; -use crate::version::drive_versions::drive_token_method_versions::v1::DRIVE_TOKEN_METHOD_VERSIONS_V1; +use crate::version::drive_versions::drive_token_method_versions::v2::DRIVE_TOKEN_METHOD_VERSIONS_V2; use crate::version::drive_versions::drive_verify_method_versions::v2::DRIVE_VERIFY_METHOD_VERSIONS_V2; use crate::version::drive_versions::drive_vote_method_versions::v2::DRIVE_VOTE_METHOD_VERSIONS_V2; use crate::version::drive_versions::{ @@ -49,6 +49,11 @@ use grovedb_version::version::v4::GROVE_V4; /// and estimation slots; `DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4` moves /// the contract create converter to 1 so a version 1 create transition also /// emits the group registration and membership operations. +/// * **Tokens sharing a pre-programmed release time**: +/// `DRIVE_TOKEN_METHOD_VERSIONS_V2` bumps `add_pre_programmed_distributions` +/// to 1 so a contract whose tokens release at the same time queues the +/// shared release-time tree once. v0 queued it once per token in one batch, +/// which a node verifying batch consistency refuses as an internal error. /// /// Everything else matches `DRIVE_VERSION_V8`. pub const DRIVE_VERSION_V9: DriveVersion = DriveVersion { @@ -96,7 +101,7 @@ pub const DRIVE_VERSION_V9: DriveVersion = DriveVersion { }, verify: DRIVE_VERIFY_METHOD_VERSIONS_V2, // changed in v8: compacted address-balance proof envelope (verify v1) identity: DRIVE_IDENTITY_METHOD_VERSIONS_V2, // changed in v9: v1 withdrawal-by-transaction-index query builder (structural, identical lowering) - token: DRIVE_TOKEN_METHOD_VERSIONS_V1, + token: DRIVE_TOKEN_METHOD_VERSIONS_V2, // changed in v9: add_pre_programmed_distributions v1 queues the release-time tree shared by a contract's tokens once platform_system: DrivePlatformSystemMethodVersions { estimation_costs: DriveSystemEstimationCostsMethodVersions { for_total_system_credits_update: 0, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 97e267545cc..030094e705b 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -254,7 +254,6 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// so such a contract registered and every claim on it failed as an /// internal error, since no cycle can be computed from a zero step. Block /// and time minimums are unchanged. -/// /// 11. **Gas paid by the contract owner**: a token-paid document action's /// `gasFeesPaidBy` (offered by the document type's token cost, asked for by /// the transition's `$tokenPaymentInfo`) is acted on. Both values were @@ -274,7 +273,6 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// validates the state of a sponsored batch whose signer is under the fee /// minimum in full, on the first check and on every recheck (mempool /// policy, not consensus). -/// /// 12. **Optional token costs**: a document type's token cost may declare /// `optional: true` (v3 meta-schema). A transition that leaves /// `$tokenPaymentInfo` out then pays no token and its signer pays the gas @@ -283,6 +281,30 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// payment info present the token is charged exactly as for a required /// cost, and too small a token balance stays a rejection. Contracts up to /// v13 cannot carry the flag, so the waiver is inert before this version. +/// 13. **Pre-programmed distribution amounts are bounded**: +/// `TokenPreProgrammedDistribution::validate_amounts` rejects a release +/// whose amounts total more than `i64::MAX` with the new +/// `PreProgrammedDistributionAmountOverLimitError` (code 10277). It runs +/// on contract create (`DRIVE_ABCI_VALIDATION_VERSIONS_V10`'s create +/// `basic_structure` 2) and, for the tokens an update adds, on contract +/// update (`CONTRACT_VERSIONS_V6`'s `validate_update` 1). A release is +/// stored as a sum tree, so up to v13 such a create passed validation and +/// failed inside Drive as an internal error: never paid for, and stripped +/// from every proposal. An update failed the same way on a single amount +/// over the limit (its fee estimation takes the insert path), but was +/// accepted when only the total overflowed, since it wrote no distribution +/// storage; from v14 it writes it (`update_contract` 2) and would fail. +/// Tokens a contract already has are not judged, so a contract holding +/// such a token stays updatable. +/// 14. **Tokens of one contract sharing a pre-programmed release time**: +/// `DRIVE_TOKEN_METHOD_VERSIONS_V2` bumps +/// `add_pre_programmed_distributions` to 1, which queues the release-time +/// tree the tokens share once instead of once per token. Queued twice, +/// the batch is refused as an internal error by a node with +/// `batching_consistency_verification` on; the default is off, and there +/// GroveDB folds the identical inserts, so the stored state is unchanged +/// and only the processing fee drops, by the existence read the later +/// tokens no longer make. /// /// * `ShieldFromIdentity` (state transition type 21) activates: /// `SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION = 14` gates it in diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 508e5bc1ea9..fb849066d24 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -66,7 +66,7 @@ use dpp::consensus::state::data_trigger::DataTriggerError::{ DataTriggerConditionError, DataTriggerExecutionError, DataTriggerInvalidResultError, }; use wasm_bindgen::{JsError, JsValue}; -use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, DataContractInvalidRequiredFieldsUpdateError, ContestedUniqueIndexWithUniqueIndexError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; +use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, DataContractInvalidRequiredFieldsUpdateError, ContestedUniqueIndexWithUniqueIndexError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, PreProgrammedDistributionAmountOverLimitError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; use dpp::consensus::basic::group::GroupActionNotAllowedOnTransitionError; use dpp::consensus::basic::identity::{DataContractBoundsNotPresentError, DisablingKeyIdAlsoBeingAddedInSameTransitionError, InvalidIdentityCreditWithdrawalTransitionAmountError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidKeyPurposeForContractBoundsError, TooManyMasterPublicKeyError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError}; @@ -1131,6 +1131,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::IdentityKeyLimitsUpdateEmptyError(e) => { generic_consensus_error!(IdentityKeyLimitsUpdateEmptyError, e).into() } + BasicError::PreProgrammedDistributionAmountOverLimitError(e) => { + generic_consensus_error!(PreProgrammedDistributionAmountOverLimitError, e).into() + } } }