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 c47d016c863..a212e619dcc 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 @@ -1543,7 +1543,16 @@ mod tests { mod token_tests { use super::*; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::UnpaidConsensusError; + use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; + use dpp::balances::credits::TokenAmount; + use dpp::block::epoch::Epoch; use dpp::data_contract::accessors::v1::DataContractV1Setters; + use dpp::data_contract::associated_token::token_distribution_key::TokenDistributionType; + 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::state_transition::batch_transition::methods::v1::DocumentsBatchTransitionMethodsV1; + use dpp::state_transition::batch_transition::BatchTransition; + use dpp::util::deserializer::ProtocolVersion; use dpp::data_contract::associated_token::token_configuration::accessors::v0::{TokenConfigurationV0Getters, TokenConfigurationV0Setters}; use dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; @@ -2688,6 +2697,277 @@ mod tests { .unwrap() .expect("expected to commit transaction"); } + + /// Registers a contract without tokens, adds a token at position 0 + /// through a data contract update, then has the contract owner claim + /// from that token at block height 41 / time 200, all under + /// `protocol_version`. Returns the claim's processing result and the + /// owner's resulting token balance. + async fn claim_from_token_added_by_update( + protocol_version: ProtocolVersion, + distribution_type: TokenDistributionType, + configure_distribution: impl FnOnce(&mut TokenConfiguration, Identifier), + ) -> (StateTransitionsProcessingResult, Option) { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected a known protocol version"); + // Genesis state: a claim writes a token history document, so the + // token history system contract has to be registered. + 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 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()); + // A perpetual distribution pays from the contract's creation moment. + data_contract.set_created_at(Some(0)); + data_contract.set_created_at_block_height(Some(0)); + data_contract.set_created_at_epoch(Some(0)); + + 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); + + let mut token_configuration = + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()); + token_configuration.set_conventions(TokenConfigurationConvention::V0( + TokenConfigurationConventionV0 { + localizations: BTreeMap::from([( + "en".to_string(), + TokenConfigurationLocalization::V0(TokenConfigurationLocalizationV0 { + should_capitalize: true, + singular_form: "credit".to_string(), + plural_form: "credits".to_string(), + }), + )]), + decimals: 8, + }, + )); + configure_distribution(&mut token_configuration, identity.id()); + updated_data_contract.add_token(0, token_configuration); + + let token_id = updated_data_contract + .token_id(0) + .expect("expected the token added at position 0"); + + let data_contract_update_transition = + DataContractUpdateTransition::new_from_data_contract( + updated_data_contract.clone(), + &identity.clone().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(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[update_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "the update adding the token must succeed on every protocol version" + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let claim_block_info = BlockInfo { + time_ms: 200, + height: 41, + core_height: 42, + epoch: Epoch::new(0).unwrap(), + }; + + let claim_transition = BatchTransition::new_token_claim_transition( + token_id, + identity.id(), + data_contract.id(), + 0, + distribution_type, + None, + &key, + 3, + 0, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create the claim transition"); + + let claim_bytes = claim_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( + &[claim_bytes], + &platform_state, + &claim_block_info, + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let token_balance = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch token balance"); + + (processing_result, token_balance) + } + + /// Pays the claimant 50 tokens every 10 blocks. + fn set_block_based_perpetual_distribution( + token_configuration: &mut TokenConfiguration, + recipient: Identifier, + ) { + token_configuration + .distribution_rules_mut() + .set_perpetual_distribution(Some(TokenPerpetualDistribution::V0( + TokenPerpetualDistributionV0 { + distribution_type: RewardDistributionType::BlockBasedDistribution { + interval: 10, + function: DistributionFunction::FixedAmount { amount: 50 }, + }, + distribution_recipient: TokenDistributionRecipient::Identity(recipient), + }, + ))); + } + + /// Pays the claimant 445 tokens at time 100. + fn set_pre_programmed_distribution( + token_configuration: &mut TokenConfiguration, + recipient: Identifier, + ) { + token_configuration + .distribution_rules_mut() + .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0( + TokenPreProgrammedDistributionV0 { + distributions: BTreeMap::from([(100, BTreeMap::from([(recipient, 445)]))]), + }, + ))); + } + + #[tokio::test] + async fn should_claim_perpetual_distribution_of_token_added_by_update() { + let (processing_result, token_balance) = claim_from_token_added_by_update( + PlatformVersion::latest().protocol_version, + TokenDistributionType::Perpetual, + set_block_based_perpetual_distribution, + ) + .await; + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + // Four full 10-block cycles have passed at height 41. + assert_eq!(token_balance, Some(200)); + } + + #[tokio::test] + async fn should_claim_pre_programmed_distribution_of_token_added_by_update() { + let (processing_result, token_balance) = claim_from_token_added_by_update( + PlatformVersion::latest().protocol_version, + TokenDistributionType::PreProgrammed, + set_pre_programmed_distribution, + ) + .await; + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + assert_eq!(token_balance, Some(445)); + } + + /// The frozen side of the gate. Protocol version 13 creates no + /// distribution storage for a token added by update, so the claim has + /// nowhere to record itself and fails as an internal error: never a + /// consensus error, never paid for, and stripped from every proposal. + #[tokio::test] + async fn should_fail_to_claim_distributions_of_token_added_by_update_on_protocol_version_13( + ) { + for (distribution_type, configure_distribution) in [ + ( + TokenDistributionType::Perpetual, + set_block_based_perpetual_distribution + as fn(&mut TokenConfiguration, Identifier), + ), + ( + TokenDistributionType::PreProgrammed, + set_pre_programmed_distribution, + ), + ] { + let (processing_result, token_balance) = + claim_from_token_added_by_update(13, distribution_type, configure_distribution) + .await; + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::InternalError(_)] + ); + assert_eq!(token_balance, None); + } + } } mod keyword_updates { diff --git a/packages/rs-drive/src/drive/contract/update/update_contract/mod.rs b/packages/rs-drive/src/drive/contract/update/update_contract/mod.rs index d0d8a744b51..5b838566042 100644 --- a/packages/rs-drive/src/drive/contract/update/update_contract/mod.rs +++ b/packages/rs-drive/src/drive/contract/update/update_contract/mod.rs @@ -1,5 +1,6 @@ mod v0; mod v1; +mod v2; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -73,9 +74,17 @@ impl Drive { platform_version, previous_fee_versions, ), + 2 => self.update_contract_v2( + contract, + block_info, + apply, + transaction, + platform_version, + previous_fee_versions, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "update_contract".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } @@ -144,9 +153,18 @@ impl Drive { drive_operations, platform_version, ), + 2 => self.update_contract_element_v2( + contract_element, + contract, + original_contract, + block_info, + transaction, + drive_operations, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "update_contract_element".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } @@ -222,9 +240,19 @@ impl Drive { drive_operations, platform_version, ), + 2 => self.update_contract_add_operations_v2( + contract_element, + contract, + original_contract, + block_info, + estimated_costs_only_with_layer_info, + transaction, + drive_operations, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "update_contract_add_operations".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive/src/drive/contract/update/update_contract/v1/mod.rs b/packages/rs-drive/src/drive/contract/update/update_contract/v1/mod.rs index ed3070a6529..c19c1d5ce3f 100644 --- a/packages/rs-drive/src/drive/contract/update/update_contract/v1/mod.rs +++ b/packages/rs-drive/src/drive/contract/update/update_contract/v1/mod.rs @@ -14,7 +14,6 @@ use dpp::serialization::PlatformSerializableWithPlatformVersion; use crate::error::contract::DataContractError; use dpp::data_contract::accessors::v1::DataContractV1Getters; use dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; -use dpp::data_contract::associated_token::token_distribution_rules::accessors::v1::TokenDistributionRulesV1Getters; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; @@ -200,7 +199,7 @@ impl Drive { /// operations for updating a contract. #[allow(clippy::too_many_arguments)] - fn update_contract_operations_v1( + pub(in crate::drive::contract::update::update_contract) fn update_contract_operations_v1( &self, contract_element: Element, contract: &DataContract, @@ -242,25 +241,6 @@ impl Drive { transaction, platform_version, )?); - - // A token added by this update gets its once-per-identity claims subtree here, as - // `insert_contract` does for the tokens of a new contract; without it every claim - // would insert under a path that does not exist. Tokens of the original contract - // can not be reconfigured, so theirs already exists. - if !original_contract.tokens().contains_key(token_pos) - && configuration - .distribution_rules() - .once_per_identity_distribution() - .is_some() - { - self.add_once_per_identity_distribution( - token_id.to_buffer(), - estimated_costs_only_with_layer_info, - &mut batch_operations, - transaction, - platform_version, - )?; - } } if !contract.groups().is_empty() { @@ -323,13 +303,9 @@ mod tests { use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; 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::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_distribution_rules::accessors::v1::TokenDistributionRulesV1Setters; - use dpp::data_contract::associated_token::token_once_per_identity_distribution::v0::TokenOncePerIdentityDistributionV0; - use dpp::data_contract::associated_token::token_once_per_identity_distribution::TokenOncePerIdentityDistribution; use dpp::data_contract::config::v0::DataContractConfigSettersV0; use dpp::data_contract::group::v0::GroupV0; use dpp::data_contract::group::Group; @@ -385,100 +361,6 @@ mod tests { .expect("update adding tokens should succeed"); } - /// A token added by an update whose rules carry a once-per-identity distribution gets its - /// claims subtree, so a claim can be recorded under it; a later update that adds nothing - /// leaves the existing subtree alone. - #[test] - fn test_update_contract_v1_adds_token_with_once_per_identity_distribution() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - - let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) - .data_contract_owned(); - contract.config_mut().set_readonly(false); - - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("insert initial contract without tokens"); - - let mut token_config = TokenConfiguration::V0( - TokenConfigurationV0::default_most_restrictive().with_base_supply(0), - ); - token_config - .distribution_rules_mut() - .set_once_per_identity_distribution(Some(TokenOncePerIdentityDistribution::V0( - TokenOncePerIdentityDistributionV0 { amount: 100 }, - ))); - contract.set_tokens(BTreeMap::from([(0, token_config)])); - contract.increment_version(); - - drive - .update_contract( - &contract, - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("update adding the token should succeed"); - - let token_id = contract.token_id(0).expect("expected the token id"); - let claimant = Identifier::random(); - - let operations = drive - .mark_once_per_identity_release_as_distributed_operations( - token_id.to_buffer(), - claimant.to_buffer(), - 1_000, - &BlockInfo::default(), - &mut None, - platform_version, - ) - .expect("expected the claim operations"); - drive - .apply_batch_low_level_drive_operations( - None, - None, - operations, - &mut vec![], - &platform_version.drive, - ) - .expect("the claim must insert under the token's claims subtree"); - - assert_eq!( - drive - .fetch_once_per_identity_distribution_claim( - token_id.to_buffer(), - claimant, - None, - platform_version, - ) - .expect("expected to fetch the claim"), - Some(1_000) - ); - - // The token now belongs to the original contract, so its subtree is not added again. - contract.increment_version(); - drive - .update_contract( - &contract, - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("a later update should leave the claims subtree alone"); - } - /// Exercises `update_contract_operations_v1` where the updated contract /// gains groups that weren't in the original. This covers the /// `if !contract.groups().is_empty()` true branch inside diff --git a/packages/rs-drive/src/drive/contract/update/update_contract/v2/mod.rs b/packages/rs-drive/src/drive/contract/update/update_contract/v2/mod.rs new file mode 100644 index 00000000000..310539769b7 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/update/update_contract/v2/mod.rs @@ -0,0 +1,679 @@ +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::v0::DataContractConfigGettersV0; +use dpp::data_contract::DataContract; +use dpp::fee::fee_result::FeeResult; + +use dpp::serialization::PlatformSerializableWithPlatformVersion; + +use crate::error::contract::DataContractError; +use dpp::data_contract::accessors::v1::DataContractV1Getters; +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::data_contract::associated_token::token_distribution_rules::accessors::v1::TokenDistributionRulesV1Getters; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Updates a data contract. + /// + /// This function updates a given data contract in the storage. The fee for updating + /// the contract is also calculated and returned. + /// + /// # Arguments + /// + /// * `contract` - A reference to the `DataContract` to be updated. + /// * `block_info` - A `BlockInfo` object containing information about the block where + /// the contract is being updated. + /// * `apply` - A boolean indicating whether the contract update should be applied (`true`) or not (`false`). Passing `false` would only tell the fees but won't interact with the state. + /// * `transaction` - A `TransactionArg` object representing the transaction to be used + /// for updating the contract. + /// + /// # Returns + /// + /// * `Result` - If successful, returns a `FeeResult` representing the fee + /// for updating the contract. If an error occurs during the contract update or fee calculation, + /// returns an `Error`. + /// + /// # Errors + /// + /// This function returns an error if the contract update or fee calculation fails. + #[inline(always)] + pub(super) fn update_contract_v2( + &self, + contract: &DataContract, + block_info: BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + previous_fee_versions: Option<&CachedEpochIndexFeeVersions>, + ) -> Result { + if !apply { + return self.insert_contract( + contract, + block_info, + false, + transaction, + platform_version, + ); + } + + let mut drive_operations: Vec = vec![]; + + let contract_bytes = contract.serialize_to_bytes_with_platform_version(platform_version)?; + + // Since we can update the contract by definition it already has storage flags + let storage_flags = Some(StorageFlags::new_single_epoch( + block_info.epoch.index, + Some(contract.owner_id().to_buffer()), + )); + + let contract_element = Element::Item( + contract_bytes, + StorageFlags::map_to_some_element_flags(storage_flags.as_ref()), + ); + + let original_contract_fetch_info = self + .get_contract_with_fetch_info_and_add_to_operations( + contract.id().to_buffer(), + Some(&block_info.epoch), + true, + transaction, + &mut drive_operations, + platform_version, + )? + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "contract should exist", + )))?; + + if original_contract_fetch_info.contract.config().readonly() { + return Err(Error::Drive(DriveError::UpdatingReadOnlyImmutableContract( + "original contract is readonly", + ))); + } + + self.update_contract_element_v2( + contract_element, + contract, + &original_contract_fetch_info.contract, + &block_info, + transaction, + &mut drive_operations, + platform_version, + )?; + + // Update DataContracts cache with the new contract + let updated_contract_fetch_info = self + .fetch_contract_and_add_operations( + contract.id().to_buffer(), + Some(&block_info.epoch), + transaction, + &mut drive_operations, + platform_version, + )? + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "contract should exist", + )))?; + + self.cache + .data_contracts + .insert_rewritten(updated_contract_fetch_info, transaction.is_some()); + + Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + previous_fee_versions, + ) + } + + /// Updates a contract. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn update_contract_element_v2( + &self, + contract_element: Element, + contract: &DataContract, + original_contract: &DataContract, + block_info: &BlockInfo, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let mut estimated_costs_only_with_layer_info = + None::>; + let batch_operations = self.update_contract_operations_v2( + contract_element, + contract, + original_contract, + block_info, + &mut estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + drive_operations, + &platform_version.drive, + ) + } + + /// Updates a contract. + #[allow(clippy::too_many_arguments)] + #[inline(always)] + pub(super) fn update_contract_add_operations_v2( + &self, + contract_element: Element, + contract: &DataContract, + original_contract: &DataContract, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let batch_operations = self.update_contract_operations_v2( + contract_element, + contract, + original_contract, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + drive_operations.extend(batch_operations); + Ok(()) + } + + /// operations for updating a contract. + /// + /// The v1 operations, plus the perpetual, pre-programmed and + /// once-per-identity distribution storage of every token the update adds: + /// the same storage `insert_contract` creates for a token present at + /// registration. v1 + /// created none of it, so the first claim on such a token wrote its claim + /// record under a tree that did not exist and failed as an internal error, + /// leaving the distribution unclaimable. + #[allow(clippy::too_many_arguments)] + fn update_contract_operations_v2( + &self, + contract_element: Element, + contract: &DataContract, + original_contract: &DataContract, + block_info: &BlockInfo, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut batch_operations: Vec = self + .update_contract_operations_v1( + contract_element, + contract, + original_contract, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + + for (token_pos, configuration) in contract.tokens() { + // Only a token absent from the original contract is new to state. + // A token the contract already had keeps the distribution storage + // it has, and both helpers error when the token's tree already + // exists. That covers a token config update too, which reaches + // this method with its token present in the original contract. + if original_contract.tokens().contains_key(token_pos) { + continue; + } + + let token_id = contract.token_id(*token_pos).ok_or(Error::DataContract( + DataContractError::CorruptedDataContract(format!( + "data contract has a token at position {}, but it can not be found", + token_pos + )), + ))?; + + if let Some(perpetual_distribution) = + configuration.distribution_rules().perpetual_distribution() + { + self.add_perpetual_distribution( + token_id.to_buffer(), + perpetual_distribution, + estimated_costs_only_with_layer_info, + &mut batch_operations, + transaction, + platform_version, + )?; + } + + if let Some(pre_programmed_distribution) = configuration + .distribution_rules() + .pre_programmed_distribution() + { + self.add_pre_programmed_distributions( + token_id.to_buffer(), + contract.owner_id().to_buffer(), + pre_programmed_distribution, + block_info, + estimated_costs_only_with_layer_info, + &mut batch_operations, + transaction, + platform_version, + )?; + } + + // The once-per-identity claims subtree, as `insert_contract` creates it for the + // tokens of a new contract; without it every claim would insert under a path + // that does not exist. + if configuration + .distribution_rules() + .once_per_identity_distribution() + .is_some() + { + self.add_once_per_identity_distribution( + token_id.to_buffer(), + estimated_costs_only_with_layer_info, + &mut batch_operations, + transaction, + platform_version, + )?; + } + } + + Ok(batch_operations) + } +} + +#[cfg(test)] +mod tests { + use crate::drive::Drive; + use crate::error::Error; + use crate::util::storage_flags::StorageFlags; + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + 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_distribution_rules::accessors::v1::TokenDistributionRulesV1Setters; + use dpp::data_contract::associated_token::token_once_per_identity_distribution::v0::TokenOncePerIdentityDistributionV0; + use dpp::data_contract::associated_token::token_once_per_identity_distribution::TokenOncePerIdentityDistribution; + use dpp::data_contract::associated_token::token_perpetual_distribution::distribution_function::DistributionFunction; + use dpp::data_contract::associated_token::token_perpetual_distribution::distribution_recipient::TokenDistributionRecipient; + use dpp::data_contract::associated_token::token_perpetual_distribution::reward_distribution_moment::RewardDistributionMoment; + use dpp::data_contract::associated_token::token_perpetual_distribution::reward_distribution_type::RewardDistributionType; + use dpp::data_contract::associated_token::token_perpetual_distribution::v0::TokenPerpetualDistributionV0; + use dpp::data_contract::associated_token::token_perpetual_distribution::TokenPerpetualDistribution; + 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::prelude::{DataContract, Identifier}; + use dpp::tests::fixtures::get_dashpay_contract_fixture; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + const DISTRIBUTION_RECIPIENT: [u8; 32] = [7; 32]; + + fn block_based_distribution_type() -> RewardDistributionType { + RewardDistributionType::BlockBasedDistribution { + interval: 10, + function: DistributionFunction::FixedAmount { amount: 50 }, + } + } + + /// A token paying `DISTRIBUTION_RECIPIENT` 50 tokens every 10 blocks and, + /// once, 445 tokens at time 100. + fn token_with_both_distributions() -> TokenConfiguration { + let mut configuration = TokenConfiguration::V0( + TokenConfigurationV0::default_most_restrictive().with_base_supply(0), + ); + let recipient = Identifier::from(DISTRIBUTION_RECIPIENT); + configuration + .distribution_rules_mut() + .set_perpetual_distribution(Some(TokenPerpetualDistribution::V0( + TokenPerpetualDistributionV0 { + distribution_type: block_based_distribution_type(), + distribution_recipient: TokenDistributionRecipient::Identity(recipient), + }, + ))); + configuration + .distribution_rules_mut() + .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0( + TokenPreProgrammedDistributionV0 { + distributions: BTreeMap::from([(100, BTreeMap::from([(recipient, 445)]))]), + }, + ))); + configuration + } + + /// Registers a contract without tokens, then adds + /// `token_with_both_distributions` at position 0 through `update_contract`. + /// Returns the updated contract and the id of the added token. + fn add_token_with_distributions_by_update( + drive: &Drive, + platform_version: &PlatformVersion, + ) -> (DataContract, [u8; 32]) { + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + contract.config_mut().set_readonly(false); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("insert initial contract without tokens"); + + contract.set_tokens(BTreeMap::from([(0, token_with_both_distributions())])); + contract.increment_version(); + + drive + .update_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("update adding the token should succeed"); + + let token_id = contract + .token_id(0) + .expect("expected the token added at position 0") + .to_buffer(); + + (contract, token_id) + } + + /// Writes what a perpetual claim at block 40 writes. + fn record_perpetual_claim( + drive: &Drive, + token_id: [u8; 32], + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let operations = drive.mark_perpetual_release_as_distributed_operations( + token_id, + DISTRIBUTION_RECIPIENT, + RewardDistributionMoment::BlockBasedMoment(40), + &mut None, + platform_version, + )?; + drive.apply_batch_low_level_drive_operations( + None, + None, + operations, + &mut vec![], + &platform_version.drive, + ) + } + + /// Writes what a claim of the pre-programmed release at time 100 writes. + fn record_pre_programmed_claim( + drive: &Drive, + token_id: [u8; 32], + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let operations = drive.mark_pre_programmed_release_as_distributed_operations( + token_id, + DISTRIBUTION_RECIPIENT, + 100, + &BlockInfo::default(), + &mut None, + None, + platform_version, + )?; + drive.apply_batch_low_level_drive_operations( + None, + None, + operations, + &mut vec![], + &platform_version.drive, + ) + } + + /// A token added by an update whose rules carry a once-per-identity distribution gets its + /// claims subtree, so a claim can be recorded under it; a later update that adds nothing + /// leaves the existing subtree alone. + #[test] + fn should_create_once_per_identity_distribution_storage_for_token_added_by_update() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + contract.config_mut().set_readonly(false); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("insert initial contract without tokens"); + + let mut token_config = TokenConfiguration::V0( + TokenConfigurationV0::default_most_restrictive().with_base_supply(0), + ); + token_config + .distribution_rules_mut() + .set_once_per_identity_distribution(Some(TokenOncePerIdentityDistribution::V0( + TokenOncePerIdentityDistributionV0 { amount: 100 }, + ))); + contract.set_tokens(BTreeMap::from([(0, token_config)])); + contract.increment_version(); + + drive + .update_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("update adding the token should succeed"); + + let token_id = contract.token_id(0).expect("expected the token id"); + let claimant = Identifier::random(); + + let operations = drive + .mark_once_per_identity_release_as_distributed_operations( + token_id.to_buffer(), + claimant.to_buffer(), + 1_000, + &BlockInfo::default(), + &mut None, + platform_version, + ) + .expect("expected the claim operations"); + drive + .apply_batch_low_level_drive_operations( + None, + None, + operations, + &mut vec![], + &platform_version.drive, + ) + .expect("the claim must insert under the token's claims subtree"); + + assert_eq!( + drive + .fetch_once_per_identity_distribution_claim( + token_id.to_buffer(), + claimant, + None, + platform_version, + ) + .expect("expected to fetch the claim"), + Some(1_000) + ); + + // The token now belongs to the original contract, so its subtree is not added again. + contract.increment_version(); + drive + .update_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("a later update should leave the claims subtree alone"); + } + + #[test] + fn should_create_perpetual_distribution_storage_for_token_added_by_update() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let (_, token_id) = add_token_with_distributions_by_update(&drive, platform_version); + + record_perpetual_claim(&drive, token_id, platform_version) + .expect("a perpetual claim on the added token should be recordable"); + + let last_paid_moment = drive + .fetch_perpetual_distribution_last_paid_moment( + token_id, + Identifier::from(DISTRIBUTION_RECIPIENT), + &block_based_distribution_type(), + None, + platform_version, + ) + .expect("expected to fetch the last paid moment"); + assert_eq!( + last_paid_moment, + Some(RewardDistributionMoment::BlockBasedMoment(40)) + ); + } + + #[test] + fn should_create_pre_programmed_distribution_storage_for_token_added_by_update() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let (_, token_id) = add_token_with_distributions_by_update(&drive, platform_version); + + let distributions = drive + .fetch_token_pre_programmed_distributions(token_id, None, None, None, platform_version) + .expect("expected to fetch the pre-programmed distributions"); + assert_eq!( + distributions, + BTreeMap::from([( + 100, + BTreeMap::from([(Identifier::from(DISTRIBUTION_RECIPIENT), 445)]) + )]) + ); + + record_pre_programmed_claim(&drive, token_id, platform_version) + .expect("a pre-programmed claim on the added token should be recordable"); + + let last_paid_time = drive + .fetch_pre_programmed_distribution_last_paid_time_ms( + token_id, + Identifier::from(DISTRIBUTION_RECIPIENT), + None, + platform_version, + ) + .expect("expected to fetch the last paid time"); + assert_eq!(last_paid_time, Some(100)); + } + + /// The frozen side of the gate, through the same dispatcher: protocol + /// version 13 selects v1, which never creates the distribution storage, so + /// neither claim can be recorded there. + #[test] + fn should_leave_token_added_by_update_without_distribution_storage_on_protocol_version_13() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + + let (_, token_id) = add_token_with_distributions_by_update(&drive, platform_version); + + record_perpetual_claim(&drive, token_id, platform_version) + .expect_err("v1 creates no perpetual distribution tree to record the claim under"); + record_pre_programmed_claim(&drive, token_id, platform_version) + .expect_err("v1 creates no pre-programmed distribution tree to record the claim under"); + } + + /// The distribution storage helpers error when a token's tree already + /// exists, so an update must leave the tokens it did not add alone, whether + /// they came from the registration or from an earlier update. + #[test] + fn should_not_recreate_distribution_storage_of_tokens_the_contract_already_had() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + contract.config_mut().set_readonly(false); + contract.set_tokens(BTreeMap::from([(0, token_with_both_distributions())])); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("insert initial contract with a token"); + + // The first update adds a second token next to the registered one, the + // second update changes nothing about either of them. + let mut tokens = contract.tokens().clone(); + tokens.insert(1, token_with_both_distributions()); + contract.set_tokens(tokens); + + for _ in 0..2 { + contract.increment_version(); + drive + .update_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("update keeping existing tokens should succeed"); + } + + for position in [0, 1] { + let token_id = contract + .token_id(position) + .expect("expected both tokens") + .to_buffer(); + record_perpetual_claim(&drive, token_id, platform_version) + .expect("a perpetual claim should be recordable on both tokens"); + record_pre_programmed_claim(&drive, token_id, platform_version) + .expect("a pre-programmed claim should be recordable on both tokens"); + } + } +} diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v4.rs index cb41f12e04c..eea832412b4 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_contract_method_versions/v4.rs @@ -1,12 +1,14 @@ use crate::version::drive_versions::drive_contract_method_versions::v3::DRIVE_CONTRACT_METHOD_VERSIONS_V3; use crate::version::drive_versions::drive_contract_method_versions::{ DriveContractInsertMethodVersions, DriveContractMethodVersions, + DriveContractUpdateMethodVersions, }; /// Drive contract methods for protocol v14+. /// /// Identical to [`super::v3::DRIVE_CONTRACT_METHOD_VERSIONS_V3`] except -/// `insert.add_contract_to_storage` is bumped to `1`. +/// `insert.add_contract_to_storage` is bumped to `1` and +/// `update.update_contract` is bumped to `2`. /// /// The v1 storage writer stores, beside the contract, a four-byte item holding /// the contract's version number (`[64, id] / 2`) on every contract create and @@ -14,11 +16,23 @@ use crate::version::drive_versions::drive_contract_method_versions::{ /// prove a contract's version without the contract bytes. Contracts stored /// before this version get their item on the first block of protocol version /// 14 (`Drive::add_version_items_to_all_contracts`). +/// +/// The v2 contract update creates the perpetual and pre-programmed distribution +/// storage of a token the update adds, as the contract insert always has for a +/// token present at registration. v1 created none of it, so a claim on such a +/// token failed as an internal error and the distribution was unclaimable. +/// There is no backfill for tokens added by an update before this version: +/// mainnet has none (checked at block 436796, where no contract update ever +/// carried a token and every token's contract is still at version 1). pub const DRIVE_CONTRACT_METHOD_VERSIONS_V4: DriveContractMethodVersions = DriveContractMethodVersions { insert: DriveContractInsertMethodVersions { add_contract_to_storage: 1, ..DRIVE_CONTRACT_METHOD_VERSIONS_V3.insert }, + update: DriveContractUpdateMethodVersions { + update_contract: 2, + ..DRIVE_CONTRACT_METHOD_VERSIONS_V3.update + }, ..DRIVE_CONTRACT_METHOD_VERSIONS_V3 }; 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 15a7d139194..71fc54c2719 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v9.rs @@ -81,7 +81,7 @@ pub const DRIVE_VERSION_V9: DriveVersion = DriveVersion { }, document: DRIVE_DOCUMENT_METHOD_VERSIONS_V4, // changed in v9: v2 index walkers + v1 update walker (shared-prefix aggregate indexes become insertable) and the detect_ranked_mode slot vote: DRIVE_VOTE_METHOD_VERSIONS_V2, - contract: DRIVE_CONTRACT_METHOD_VERSIONS_V4, // changed in v9: add_contract_to_storage v1 writes the contract version item beside the contract + contract: DRIVE_CONTRACT_METHOD_VERSIONS_V4, // changed in v9: add_contract_to_storage v1 writes the contract version item beside the contract; update_contract v2 creates the distribution storage of tokens added by an update fees: DriveFeesMethodVersions { calculate_fee: 0 }, estimated_costs: DriveEstimatedCostsMethodVersions { add_estimation_costs_for_levels_up_to_contract: 0,