From 78c9678704c86f2973c8554feebb75eab581d6c3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 06:41:58 +0700 Subject: [PATCH 1/5] fix(drive)!: create distribution trees for tokens added by contract update A token added through a data contract update never got its perpetual or pre-programmed distribution storage: update_contract v1 only creates the token's balance, identity info, status, contract info and supply entries, while insert_contract v1 also calls add_perpetual_distribution and add_pre_programmed_distributions. A claim on such a token read the missing last-claim path as None and then failed while applying (the last-claim insert for perpetual, the scheduled-reference delete for pre-programmed), so it became an InternalError, was stripped from every proposal, and the distribution was unclaimable. - update_contract v2, selected by protocol version 14 only (DRIVE_CONTRACT_METHOD_VERSIONS_V4 amended in place), creates the storage for tokens absent from the original contract. v1 is unchanged outside its tests, which are now pinned to protocol version 13. - transition_to_version_14 backfills the storage of tokens added by update before the upgrade (Drive::add_missing_token_distribution_storage_to_all_contracts). It is idempotent, runs inside the block transaction, and skips a pre-programmed distribution whose amounts no sum tree can hold instead of failing the upgrade block. Co-Authored-By: Claude Fable 5.1 --- .../v0/mod.rs | 12 + .../data_contract_update/mod.rs | 350 +++++++ ...n_distribution_storage_to_all_contracts.rs | 527 ++++++++++ .../src/drive/contract/migration/mod.rs | 1 + .../contract/update/update_contract/mod.rs | 34 +- .../contract/update/update_contract/v1/mod.rs | 15 +- .../contract/update/update_contract/v2/mod.rs | 961 ++++++++++++++++++ .../drive_contract_method_versions/v4.rs | 16 +- .../src/version/drive_versions/v9.rs | 2 +- 9 files changed, 1908 insertions(+), 10 deletions(-) create mode 100644 packages/rs-drive/src/drive/contract/migration/add_missing_token_distribution_storage_to_all_contracts.rs create mode 100644 packages/rs-drive/src/drive/contract/update/update_contract/v2/mod.rs diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index b4ffd3e2839..1863d75eb6a 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -766,6 +766,18 @@ impl Platform { self.drive .insert_contract_groups_structure(Some(transaction), platform_version)?; + // Token distribution storage: before this version a contract update created none of + // the perpetual or pre-programmed distribution storage of a token it added, so every + // claim on such a token failed as an internal error. `update_contract` v2 creates it + // from this version on, but only for the tokens an update adds, so the tokens added + // before it get theirs here. + self.drive + .add_missing_token_distribution_storage_to_all_contracts( + block_info, + transaction, + platform_version, + )?; + Ok(()) } } 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..cf0e53b464c 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,17 @@ mod tests { mod token_tests { use super::*; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::UnpaidConsensusError; + use crate::platform_types::platform_state::PlatformState; + 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 +2698,346 @@ mod tests { .unwrap() .expect("expected to commit transaction"); } + + /// Registers a contract without tokens and adds a token at position 0 + /// through a data contract update, both under `update_protocol_version`. + /// Then has the contract owner claim from that token at block height + /// 41 / time 200 under `claim_protocol_version`, running the first-block + /// protocol change events in between when the two differ. Returns the + /// claim's processing result and the owner's resulting token balance. + async fn claim_from_token_added_by_update( + update_protocol_version: ProtocolVersion, + claim_protocol_version: ProtocolVersion, + distribution_type: TokenDistributionType, + configure_distribution: impl FnOnce(&mut TokenConfiguration, Identifier), + ) -> (StateTransitionsProcessingResult, Option) { + let platform_version = PlatformVersion::get(update_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(update_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 mut platform_state = PlatformState::clone(&platform_state); + if claim_protocol_version != update_protocol_version { + let upgraded_platform_version = PlatformVersion::get(claim_protocol_version) + .expect("expected a known protocol version"); + let transaction = platform.drive.grove.start_transaction(); + platform + .perform_events_on_first_block_of_protocol_change( + &platform_state, + &claim_block_info, + &transaction, + update_protocol_version, + upgraded_platform_version, + ) + .expect("expected the protocol change events to succeed"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the upgrade"); + platform_state.set_current_protocol_version_in_consensus(claim_protocol_version); + } + let platform_version = PlatformVersion::get(claim_protocol_version) + .expect("expected a known protocol version"); + + 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 latest = PlatformVersion::latest().protocol_version; + let (processing_result, token_balance) = claim_from_token_added_by_update( + latest, + latest, + 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 latest = PlatformVersion::latest().protocol_version; + let (processing_result, token_balance) = claim_from_token_added_by_update( + latest, + latest, + 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, + 13, + distribution_type, + configure_distribution, + ) + .await; + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::InternalError(_)] + ); + assert_eq!(token_balance, None); + } + } + + /// A token added by update before protocol version 14 gets its + /// distribution storage on the first block of version 14, so it is + /// claimable from then on. + #[tokio::test] + async fn should_claim_distributions_of_token_added_by_update_before_the_upgrade() { + for (distribution_type, configure_distribution, expected_balance) in [ + ( + TokenDistributionType::Perpetual, + set_block_based_perpetual_distribution + as fn(&mut TokenConfiguration, Identifier), + 200, + ), + ( + TokenDistributionType::PreProgrammed, + set_pre_programmed_distribution, + 445, + ), + ] { + let (processing_result, token_balance) = claim_from_token_added_by_update( + 13, + 14, + distribution_type, + configure_distribution, + ) + .await; + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + assert_eq!(token_balance, Some(expected_balance)); + } + } } mod keyword_updates { diff --git a/packages/rs-drive/src/drive/contract/migration/add_missing_token_distribution_storage_to_all_contracts.rs b/packages/rs-drive/src/drive/contract/migration/add_missing_token_distribution_storage_to_all_contracts.rs new file mode 100644 index 00000000000..c05207a81b7 --- /dev/null +++ b/packages/rs-drive/src/drive/contract/migration/add_missing_token_distribution_storage_to_all_contracts.rs @@ -0,0 +1,527 @@ +use crate::drive::tokens::paths::{ + token_root_perpetual_distributions_path, token_root_pre_programmed_distributions_path, +}; +use crate::drive::Drive; +use crate::error::contract::DataContractError; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::util::grove_operations::DirectQueryType; +use dpp::balances::credits::TokenAmount; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +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_configuration::TokenConfiguration; +use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; +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::data_contract::DataContract; +use dpp::version::PlatformVersion; +use grovedb::Transaction; + +impl Drive { + /// Creates the perpetual and pre-programmed distribution storage of every token in state + /// that is configured with such a distribution but has no storage for it. + /// + /// Runs once, on the first block of protocol version 14. Before that version a contract + /// update created only the balance, identity info, status, contract info and supply entries + /// of a token it added, never the distribution storage the contract insert creates, so every + /// claim on such a token failed as an internal error. From version 14 the update creates the + /// storage itself (`update_contract` v2), but only for the tokens it adds, so the tokens + /// added before it need theirs created here. + /// + /// A token whose storage exists is left untouched, so the walk is safe to repeat. A + /// pre-programmed distribution that can not be stored at all (see + /// [`pre_programmed_distribution_is_storable`]) is skipped: an error here would halt the + /// chain on the upgrade block over a distribution nobody could ever have claimed. + /// + /// Returns the number of tokens that received storage. + pub fn add_missing_token_distribution_storage_to_all_contracts( + &self, + block_info: &BlockInfo, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result { + let mut start_at = None; + let mut repaired_token_count = 0usize; + + loop { + let page = + self.fetch_contract_ids(start_at, u16::MAX, Some(transaction), platform_version)?; + + for contract_id in &page { + repaired_token_count += self.add_missing_token_distribution_storage_to_contract( + *contract_id, + block_info, + transaction, + platform_version, + )?; + } + + match page.last() { + Some(last_id) if page.len() == u16::MAX as usize => { + start_at = Some((*last_id, false)); + } + _ => break, + } + } + + tracing::info!( + repaired_token_count, + "created the missing distribution storage of tokens added by a contract update" + ); + + Ok(repaired_token_count) + } + + fn add_missing_token_distribution_storage_to_contract( + &self, + contract_id: [u8; 32], + block_info: &BlockInfo, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result { + let fetch_info = self + .fetch_contract_and_add_operations( + contract_id, + None, + Some(transaction), + &mut vec![], + platform_version, + )? + .ok_or_else(|| { + Error::Drive(DriveError::CorruptedDriveState(format!( + "contract {} is listed under the contracts root but can not be fetched", + hex::encode(contract_id) + ))) + })?; + let contract = &fetch_info.contract; + + let mut repaired_token_count = 0usize; + + for (token_pos, configuration) in contract.tokens() { + let token_id = contract + .token_id(*token_pos) + .ok_or_else(|| { + Error::DataContract(DataContractError::CorruptedDataContract(format!( + "data contract has a token at position {}, but it can not be found", + token_pos + ))) + })? + .to_buffer(); + + let added_perpetual = self.add_missing_perpetual_distribution_storage( + token_id, + configuration, + transaction, + platform_version, + )?; + + let added_pre_programmed = self.add_missing_pre_programmed_distribution_storage( + contract, + token_id, + configuration, + block_info, + transaction, + platform_version, + )?; + + if added_perpetual || added_pre_programmed { + repaired_token_count += 1; + } + } + + Ok(repaired_token_count) + } + + fn add_missing_perpetual_distribution_storage( + &self, + token_id: [u8; 32], + configuration: &TokenConfiguration, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result { + let Some(perpetual_distribution) = + configuration.distribution_rules().perpetual_distribution() + else { + return Ok(false); + }; + + let has_storage = self.grove_has_raw( + (&token_root_perpetual_distributions_path()).into(), + &token_id, + DirectQueryType::StatefulDirectQuery, + Some(transaction), + &mut vec![], + &platform_version.drive, + )?; + if has_storage { + return Ok(false); + } + + // One batch per token and kind: the storage helpers look for an existing tree in + // state only, never among the operations gathered so far. + let mut batch_operations = vec![]; + self.add_perpetual_distribution( + token_id, + perpetual_distribution, + &mut None, + &mut batch_operations, + Some(transaction), + platform_version, + )?; + self.apply_batch_low_level_drive_operations( + None, + Some(transaction), + batch_operations, + &mut vec![], + &platform_version.drive, + )?; + + Ok(true) + } + + fn add_missing_pre_programmed_distribution_storage( + &self, + contract: &DataContract, + token_id: [u8; 32], + configuration: &TokenConfiguration, + block_info: &BlockInfo, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result { + let Some(pre_programmed_distribution) = configuration + .distribution_rules() + .pre_programmed_distribution() + else { + return Ok(false); + }; + + let has_storage = self.grove_has_raw( + (&token_root_pre_programmed_distributions_path()).into(), + &token_id, + DirectQueryType::StatefulDirectQuery, + Some(transaction), + &mut vec![], + &platform_version.drive, + )?; + if has_storage { + return Ok(false); + } + + if !pre_programmed_distribution_is_storable(pre_programmed_distribution) { + tracing::warn!( + contract_id = %contract.id(), + token_id = hex::encode(token_id), + "skipped a pre-programmed distribution whose amounts do not fit a sum tree" + ); + return Ok(false); + } + + let mut batch_operations = vec![]; + self.add_pre_programmed_distributions( + token_id, + contract.owner_id().to_buffer(), + pre_programmed_distribution, + block_info, + &mut None, + &mut batch_operations, + Some(transaction), + platform_version, + )?; + self.apply_batch_low_level_drive_operations( + None, + Some(transaction), + batch_operations, + &mut vec![], + &platform_version.drive, + )?; + + Ok(true) + } +} + +/// Whether the storage of `distribution` can be written: every release is a sum tree of its +/// recipients' amounts, so each amount and each release's total has to fit an `i64`. +/// +/// No validation bounds these amounts. The contract insert rejects a distribution that fails +/// this as an internal error, but before protocol version 14 a contract update never wrote the +/// storage and so admitted it. +fn pre_programmed_distribution_is_storable(distribution: &TokenPreProgrammedDistribution) -> bool { + distribution.distributions().values().all(|release| { + release + .values() + .try_fold(0 as TokenAmount, |total, amount| total.checked_add(*amount)) + .is_some_and(|total| total <= i64::MAX as TokenAmount) + }) +} + +#[cfg(test)] +mod tests { + use crate::drive::tokens::paths::token_root_pre_programmed_distributions_path; + use crate::drive::Drive; + use crate::error::Error; + use crate::util::grove_operations::DirectQueryType; + use crate::util::storage_flags::StorageFlags; + use crate::util::test_helpers::setup::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_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::Identifier; + use dpp::tests::fixtures::get_dashpay_contract_fixture; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + const RECIPIENT: [u8; 32] = [7; 32]; + + fn upgrade_block_info() -> BlockInfo { + BlockInfo { + time_ms: 5000, + height: 500, + core_height: 50, + epoch: Default::default(), + } + } + + /// A token paying `RECIPIENT` 50 tokens every 10 blocks and, once, `amount` tokens at + /// time 100. + fn token_with_both_distributions(amount: TokenAmount) -> TokenConfiguration { + let mut configuration = TokenConfiguration::V0( + TokenConfigurationV0::default_most_restrictive().with_base_supply(0), + ); + let recipient = Identifier::from(RECIPIENT); + 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), + }, + ))); + configuration + .distribution_rules_mut() + .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0( + TokenPreProgrammedDistributionV0 { + distributions: BTreeMap::from([(100, BTreeMap::from([(recipient, amount)]))]), + }, + ))); + configuration + } + + /// Under protocol version 13, registers a contract without tokens and adds `token` at + /// position 0 through a contract update, which leaves it without distribution storage. + /// Returns the token id. + fn add_token_by_update_before_the_upgrade( + drive: &Drive, + contract_seed: u8, + token: TokenConfiguration, + ) -> [u8; 32] { + let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + contract.set_id([contract_seed; 32].into()); + contract.config_mut().set_readonly(false); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to insert the contract without tokens"); + + contract.set_tokens(BTreeMap::from([(0, token)])); + contract.increment_version(); + + drive + .update_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("expected the update adding the token to succeed"); + + contract + .token_id(0) + .expect("expected the token added at position 0") + .to_buffer() + } + + fn run_backfill(drive: &Drive) -> usize { + let transaction = drive.grove.start_transaction(); + let repaired_token_count = drive + .add_missing_token_distribution_storage_to_all_contracts( + &upgrade_block_info(), + &transaction, + PlatformVersion::latest(), + ) + .expect("expected the backfill to succeed"); + drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + repaired_token_count + } + + fn root_hash(drive: &Drive) -> [u8; 32] { + drive + .grove + .root_hash(None, &PlatformVersion::latest().drive.grove_version) + .unwrap() + .expect("expected a root hash") + } + + /// Writes what a perpetual claim at block 40 and a claim of the release at time 100 write. + fn record_both_claims(drive: &Drive, token_id: [u8; 32]) -> Result<(), Error> { + let platform_version = PlatformVersion::latest(); + let mut operations = drive.mark_perpetual_release_as_distributed_operations( + token_id, + RECIPIENT, + RewardDistributionMoment::BlockBasedMoment(40), + &mut None, + platform_version, + )?; + operations.extend(drive.mark_pre_programmed_release_as_distributed_operations( + token_id, + RECIPIENT, + 100, + &BlockInfo::default(), + &mut None, + None, + platform_version, + )?); + drive.apply_batch_low_level_drive_operations( + None, + None, + operations, + &mut vec![], + &platform_version.drive, + ) + } + + #[test] + fn should_create_the_distribution_storage_of_tokens_added_by_update_before_the_upgrade() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // Two contracts releasing at the same time share that time's timed distribution tree. + let token_ids = [1u8, 2].map(|contract_seed| { + add_token_by_update_before_the_upgrade( + &drive, + contract_seed, + token_with_both_distributions(445), + ) + }); + for token_id in token_ids { + record_both_claims(&drive, token_id) + .expect_err("no claim can be recorded before the upgrade"); + } + + assert_eq!(run_backfill(&drive), 2); + + for token_id in token_ids { + 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(RECIPIENT), 445)]))]) + ); + + record_both_claims(&drive, token_id) + .expect("both claims should be recordable after the upgrade"); + } + } + + #[test] + fn should_leave_tokens_that_already_have_their_storage_unchanged() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // Registered with its token: the contract insert created the storage. + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + contract.set_id([3; 32].into()); + contract.set_tokens(BTreeMap::from([(0, token_with_both_distributions(445))])); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to insert the contract with its token"); + + add_token_by_update_before_the_upgrade(&drive, 4, token_with_both_distributions(445)); + + assert_eq!(run_backfill(&drive), 1, "only the token added by update"); + + // A retried upgrade block finds nothing left to do. + let root_hash_after_backfill = root_hash(&drive); + assert_eq!(run_backfill(&drive), 0); + assert_eq!(root_hash(&drive), root_hash_after_backfill); + } + + /// No validation bounds a pre-programmed amount, and before protocol version 14 an update + /// never wrote the release, so state can hold a release no sum tree can. The upgrade block + /// must not fail over it. + #[test] + fn should_skip_a_pre_programmed_distribution_that_can_not_be_stored() { + let drive = setup_drive_with_initial_state_structure(None); + + let token_id = add_token_by_update_before_the_upgrade( + &drive, + 5, + token_with_both_distributions(u64::MAX), + ); + + assert_eq!(run_backfill(&drive), 1, "the perpetual storage is created"); + + let has_pre_programmed_storage = drive + .grove_has_raw( + (&token_root_pre_programmed_distributions_path()).into(), + &token_id, + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &PlatformVersion::latest().drive, + ) + .expect("expected to look for the pre-programmed storage"); + assert!( + !has_pre_programmed_storage, + "the pre-programmed storage is not" + ); + } +} diff --git a/packages/rs-drive/src/drive/contract/migration/mod.rs b/packages/rs-drive/src/drive/contract/migration/mod.rs index 4bf094f6ad0..7952bc01660 100644 --- a/packages/rs-drive/src/drive/contract/migration/mod.rs +++ b/packages/rs-drive/src/drive/contract/migration/mod.rs @@ -1,2 +1,3 @@ +mod add_missing_token_distribution_storage_to_all_contracts; mod add_version_items_to_all_contracts; mod strip_unknown_document_schema_properties; 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 fa3176c78dd..64bbacf442c 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 @@ -315,6 +315,11 @@ mod tests { use dpp::version::PlatformVersion; use std::collections::BTreeMap; + /// v1 is frozen: protocol version 13 is the last one that selects it. + fn frozen_platform_version() -> &'static PlatformVersion { + PlatformVersion::get(13).expect("expected protocol version 13") + } + /// Exercises `update_contract_operations_v1` when the updated contract /// gains tokens that weren't in the original. This covers the loop that /// calls `create_token_trees_operations` for each token. @@ -323,7 +328,7 @@ mod tests { #[test] fn test_update_contract_v1_adds_tokens_creates_token_trees() { let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); + let platform_version = frozen_platform_version(); // Original: no tokens. let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) @@ -368,7 +373,7 @@ mod tests { #[test] fn test_update_contract_v1_adds_groups() { let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); + let platform_version = frozen_platform_version(); let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) .data_contract_owned(); @@ -414,7 +419,7 @@ mod tests { #[test] fn test_update_contract_v1_keyword_delta_via_update_contract() { let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); + let platform_version = frozen_platform_version(); // Insert the keyword_search system contract first (required because // update_contract_v1 calls update_contract_keywords_operations). @@ -531,7 +536,7 @@ mod tests { #[test] fn clearing_a_contracts_keywords_leaves_the_old_ones_indexed() { let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); + let platform_version = frozen_platform_version(); let keyword_search = load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) @@ -596,7 +601,7 @@ mod tests { #[test] fn test_update_contract_v1_description_via_update_contract() { let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); + let platform_version = frozen_platform_version(); let keyword_search = load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) 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..bab753ea25b --- /dev/null +++ b/packages/rs-drive/src/drive/contract/update/update_contract/v2/mod.rs @@ -0,0 +1,961 @@ +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::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. + /// + /// Differs from v1 in one way: a token the update adds also gets its + /// perpetual and pre-programmed distribution storage, the same storage + /// `insert_contract` v1 creates for a token present at registration. v1 + /// created only the token's balance, identity info, status, contract info + /// and supply entries, so the first claim on such a token wrote its + /// last-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_v0( + contract_element, + contract, + original_contract, + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + + for (token_pos, configuration) in contract.tokens() { + 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 + )), + ))?; + + batch_operations.extend(self.create_token_trees_operations( + contract.id(), + *token_pos, + token_id.to_buffer(), + configuration.start_as_paused(), + true, + &mut None, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + + // 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; + } + + 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, + )?; + } + } + + if !contract.groups().is_empty() { + batch_operations.extend(self.add_new_groups_operations( + contract.id(), + contract.groups(), + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + } + + // Skipping an empty keyword set is load-bearing, but it is a shield + // rather than a fix, and both halves matter to anyone changing it. + // + // What it prevents: the keyword update emits its deletes blind to each + // other in one batch, so several of them jointly emptying the shared + // `byContractId/` group would leave that group tree behind + // with nothing in it — and emptying the group without refilling it + // requires exactly this empty-set case. + // + // What it costs: the previous keyword documents are not deleted either, + // so a contract that clears its keywords advertises none while keyword + // search still returns it under the old ones. Removing this guard to fix + // that trades a stale index for a stranded group tree; the deletes have + // to become sibling-aware first. Both halves are pinned — + // `clearing_a_contracts_keywords_leaves_the_old_ones_indexed` and + // `clearing_every_keyword_leaves_an_empty_by_contract_id_group_behind`. + if !contract.keywords().is_empty() { + batch_operations.extend(self.update_contract_keywords_operations( + contract.id(), + contract.owner_id(), + contract.keywords(), + block_info, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?); + } + + if let Some(description) = contract.description() { + batch_operations.extend(self.update_contract_description_operations( + contract.id(), + contract.owner_id(), + description, + block_info, + estimated_costs_only_with_layer_info, + 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_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::data_contract::group::v0::GroupV0; + use dpp::data_contract::group::Group; + use dpp::prelude::{DataContract, Identifier}; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::tests::fixtures::get_dashpay_contract_fixture; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + /// Exercises `update_contract_operations_v2` when the updated contract + /// gains tokens that weren't in the original. This covers the loop that + /// calls `create_token_trees_operations` for each token. + /// PR #3516 inserts contracts with tokens but does not exercise an + /// UPDATE that adds tokens. + #[test] + fn test_update_contract_v2_adds_tokens_creates_token_trees() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // Original: no tokens. + 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"); + + // Updated: add a token configuration. The update path exercises the + // `create_token_trees_operations` call in update_contract_operations_v2. + let token_config = TokenConfiguration::V0( + TokenConfigurationV0::default_most_restrictive().with_base_supply(0), + ); + 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 tokens should succeed"); + } + + /// Exercises `update_contract_operations_v2` where the updated contract + /// gains groups that weren't in the original. This covers the + /// `if !contract.groups().is_empty()` true branch inside + /// `update_contract_operations_v2`, invoking `add_new_groups_operations`. + #[test] + fn test_update_contract_v2_adds_groups() { + 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(); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("insert"); + + // Add a group. + let member = Identifier::random(); + let group = Group::V0(GroupV0 { + members: BTreeMap::from([(member, 1)]), + required_power: 1, + }); + contract.set_groups(BTreeMap::from([(0, group)])); + contract.increment_version(); + + drive + .update_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("update adding groups should succeed"); + } + + /// Exercises `update_contract_operations_v2`'s keyword-update branch: + /// update a contract that starts with some keywords to a new set of + /// keywords (different set), routed through the full `update_contract_v2` + /// path rather than the dedicated `update_contract_keywords` API. + /// PR #3516 covers the dedicated API but not the embedded path invoked + /// via `update_contract`. + #[test] + fn test_update_contract_v2_keyword_delta_via_update_contract() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + // Insert the keyword_search system contract first (required because + // update_contract_v2 calls update_contract_keywords_operations). + let keyword_search = + load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) + .expect("load keyword_search"); + drive + .apply_contract( + &keyword_search, + BlockInfo::default(), + true, + None, + None, + platform_version, + ) + .expect("apply keyword_search"); + + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + contract.set_keywords(vec!["initial_a".to_string(), "initial_b".to_string()]); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("initial insert with keywords"); + + // Now change keywords entirely. + contract.set_keywords(vec!["new_x".to_string(), "new_y".to_string()]); + contract.increment_version(); + + drive + .update_contract( + &contract, + BlockInfo { + time_ms: 2000, + height: 10, + core_height: 5, + epoch: Default::default(), + }, + true, + None, + platform_version, + None, + ) + .expect("update keyword delta via update_contract should succeed"); + } + + /// The keywords the keyword search index currently returns for `contract_id`. + fn indexed_keywords( + drive: &crate::drive::Drive, + keyword_search: &dpp::prelude::DataContract, + contract_id: Identifier, + platform_version: &PlatformVersion, + ) -> Vec { + use crate::drive::document::query::QueryDocumentsOutcomeV0Methods; + use crate::query::{DriveDocumentQuery, WhereClause, WhereOperator}; + use dpp::document::DocumentV0Getters; + use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; + use dpp::platform_value::Value; + + let document_type = keyword_search + .document_type_for_name("contractKeywords") + .expect("contractKeywords doctype"); + let mut query = DriveDocumentQuery::all_items_query(keyword_search, document_type, None); + query.internal_clauses.equal_clauses.insert( + "contractId".to_string(), + WhereClause { + field: "contractId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(contract_id.to_buffer()), + }, + ); + let mut keywords: Vec = drive + .query_documents( + query, + None, + false, + None, + Some(platform_version.protocol_version), + ) + .expect("the byContractId query must succeed") + .documents_owned() + .into_iter() + .map(|document| { + document + .properties() + .get_string("keyword") + .expect("every keyword document carries a keyword") + }) + .collect(); + keywords.sort(); + keywords + } + + /// **This test asserts a defect, not the desired behaviour**, and it is the + /// other half of the empty-keyword-set skip above. + /// + /// Clearing a contract's keywords does not delete its keyword documents: an + /// empty set skips the keyword update entirely, so the previous documents + /// survive and stay indexed. The contract then advertises no keywords while + /// keyword search still returns it under the old ones, permanently. + /// + /// The skip is a shield, not a fix. It is what keeps the deletes from + /// jointly emptying the shared `byContractId` group and stranding it — see + /// `clearing_every_keyword_leaves_an_empty_by_contract_id_group_behind` — + /// so removing it to make this test go green trades a stale index for an + /// empty group tree. Making the deletes sibling-aware has to come first. + #[test] + fn clearing_a_contracts_keywords_leaves_the_old_ones_indexed() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let keyword_search = + load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) + .expect("load keyword_search"); + drive + .apply_contract( + &keyword_search, + BlockInfo::default(), + true, + None, + None, + platform_version, + ) + .expect("apply keyword_search"); + + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + contract.set_keywords(vec!["alpha".to_string(), "bravo".to_string()]); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("initial insert with keywords"); + + assert_eq!( + indexed_keywords(&drive, &keyword_search, contract.id(), platform_version), + vec!["alpha".to_string(), "bravo".to_string()], + "baseline: both keywords are indexed" + ); + + contract.set_keywords(vec![]); + contract.increment_version(); + drive + .update_contract( + &contract, + BlockInfo::default(), + true, + None, + platform_version, + None, + ) + .expect("clearing keywords via update_contract should succeed"); + + assert_eq!( + indexed_keywords(&drive, &keyword_search, contract.id(), platform_version), + vec!["alpha".to_string(), "bravo".to_string()], + "the old keyword documents are expected to survive: an empty keyword set skips \ + the keyword update rather than performing it" + ); + } + + /// Exercises `update_contract_operations_v2`'s description-update branch: + /// changing contract description routes through + /// `update_contract_description_operations`. Covers the `if let Some(description)` + /// true branch specifically from the v2 update path (not the dedicated update + /// description API). + #[test] + fn test_update_contract_v2_description_via_update_contract() { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + + let keyword_search = + load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) + .expect("load keyword_search"); + drive + .apply_contract( + &keyword_search, + BlockInfo::default(), + true, + None, + None, + platform_version, + ) + .expect("apply keyword_search"); + + let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) + .data_contract_owned(); + contract.set_description(Some("initial description".to_string())); + + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("initial insert with description"); + + contract.set_description(Some("updated description text".to_string())); + contract.increment_version(); + + drive + .update_contract( + &contract, + BlockInfo { + time_ms: 3000, + height: 20, + core_height: 7, + epoch: Default::default(), + }, + true, + None, + platform_version, + None, + ) + .expect("update description via update_contract should succeed"); + } + + 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, + ) + } + + #[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..ce00a477192 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. +/// Tokens added by an update before this version get their storage on the first +/// block of protocol version 14 +/// (`Drive::add_missing_token_distribution_storage_to_all_contracts`). 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, From 6bfdc5f2de1c33a7d4897dcca295a9880f156543 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 19:03:57 +0700 Subject: [PATCH 2/5] fix(drive): drop the distribution storage backfill, mainnet has nothing to repair The first block of protocol version 14 walked every contract to create the distribution storage of tokens that an update had added before the upgrade. Mainnet has no such token, checked at block 436796: - all 20 data contract updates ever broadcast (12 succeeded) decode to a contract without tokens, and an update that adds a token has to carry it - the 5 tokens on mainnet all belong to contracts still at version 1, so none was ever touched by a contract update or a token config update, and none has a perpetual or pre-programmed distribution So the walk could only ever be a no-op there, and it was the riskiest part of the change: state rewritten on the upgrade block, where an error halts the chain. transition_to_version_14 and the migration module are back to what v4.2-dev has. update_contract v2 is unchanged. Co-Authored-By: Claude Fable 5.1 --- .../v0/mod.rs | 12 - .../data_contract_update/mod.rs | 98 +--- ...n_distribution_storage_to_all_contracts.rs | 527 ------------------ .../src/drive/contract/migration/mod.rs | 1 - .../drive_contract_method_versions/v4.rs | 6 +- 5 files changed, 17 insertions(+), 627 deletions(-) delete mode 100644 packages/rs-drive/src/drive/contract/migration/add_missing_token_distribution_storage_to_all_contracts.rs diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 1e247b2f699..d31d3b93f64 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -789,18 +789,6 @@ impl Platform { platform_version, )?; - // Token distribution storage: before this version a contract update created none of - // the perpetual or pre-programmed distribution storage of a token it added, so every - // claim on such a token failed as an internal error. `update_contract` v2 creates it - // from this version on, but only for the tokens an update adds, so the tokens added - // before it get theirs here. - self.drive - .add_missing_token_distribution_storage_to_all_contracts( - block_info, - transaction, - platform_version, - )?; - Ok(()) } } 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 cf0e53b464c..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,6 @@ mod tests { mod token_tests { use super::*; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::UnpaidConsensusError; - use crate::platform_types::platform_state::PlatformState; use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; use dpp::balances::credits::TokenAmount; use dpp::block::epoch::Epoch; @@ -2699,24 +2698,22 @@ mod tests { .expect("expected to commit transaction"); } - /// Registers a contract without tokens and adds a token at position 0 - /// through a data contract update, both under `update_protocol_version`. - /// Then has the contract owner claim from that token at block height - /// 41 / time 200 under `claim_protocol_version`, running the first-block - /// protocol change events in between when the two differ. Returns the - /// claim's processing result and the owner's resulting token balance. + /// 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( - update_protocol_version: ProtocolVersion, - claim_protocol_version: ProtocolVersion, + protocol_version: ProtocolVersion, distribution_type: TokenDistributionType, configure_distribution: impl FnOnce(&mut TokenConfiguration, Identifier), ) -> (StateTransitionsProcessingResult, Option) { - let platform_version = PlatformVersion::get(update_protocol_version) - .expect("expected a known protocol version"); + 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(update_protocol_version) + .with_initial_protocol_version(protocol_version) .build_with_mock_rpc() .set_genesis_state(); @@ -2822,31 +2819,6 @@ mod tests { epoch: Epoch::new(0).unwrap(), }; - let mut platform_state = PlatformState::clone(&platform_state); - if claim_protocol_version != update_protocol_version { - let upgraded_platform_version = PlatformVersion::get(claim_protocol_version) - .expect("expected a known protocol version"); - let transaction = platform.drive.grove.start_transaction(); - platform - .perform_events_on_first_block_of_protocol_change( - &platform_state, - &claim_block_info, - &transaction, - update_protocol_version, - upgraded_platform_version, - ) - .expect("expected the protocol change events to succeed"); - platform - .drive - .grove - .commit_transaction(transaction) - .unwrap() - .expect("expected to commit the upgrade"); - platform_state.set_current_protocol_version_in_consensus(claim_protocol_version); - } - let platform_version = PlatformVersion::get(claim_protocol_version) - .expect("expected a known protocol version"); - let claim_transition = BatchTransition::new_token_claim_transition( token_id, identity.id(), @@ -2936,10 +2908,8 @@ mod tests { #[tokio::test] async fn should_claim_perpetual_distribution_of_token_added_by_update() { - let latest = PlatformVersion::latest().protocol_version; let (processing_result, token_balance) = claim_from_token_added_by_update( - latest, - latest, + PlatformVersion::latest().protocol_version, TokenDistributionType::Perpetual, set_block_based_perpetual_distribution, ) @@ -2955,10 +2925,8 @@ mod tests { #[tokio::test] async fn should_claim_pre_programmed_distribution_of_token_added_by_update() { - let latest = PlatformVersion::latest().protocol_version; let (processing_result, token_balance) = claim_from_token_added_by_update( - latest, - latest, + PlatformVersion::latest().protocol_version, TokenDistributionType::PreProgrammed, set_pre_programmed_distribution, ) @@ -2989,13 +2957,9 @@ mod tests { set_pre_programmed_distribution, ), ] { - let (processing_result, token_balance) = claim_from_token_added_by_update( - 13, - 13, - distribution_type, - configure_distribution, - ) - .await; + 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(), @@ -3004,40 +2968,6 @@ mod tests { assert_eq!(token_balance, None); } } - - /// A token added by update before protocol version 14 gets its - /// distribution storage on the first block of version 14, so it is - /// claimable from then on. - #[tokio::test] - async fn should_claim_distributions_of_token_added_by_update_before_the_upgrade() { - for (distribution_type, configure_distribution, expected_balance) in [ - ( - TokenDistributionType::Perpetual, - set_block_based_perpetual_distribution - as fn(&mut TokenConfiguration, Identifier), - 200, - ), - ( - TokenDistributionType::PreProgrammed, - set_pre_programmed_distribution, - 445, - ), - ] { - let (processing_result, token_balance) = claim_from_token_added_by_update( - 13, - 14, - distribution_type, - configure_distribution, - ) - .await; - - assert_matches!( - processing_result.execution_results().as_slice(), - [StateTransitionExecutionResult::SuccessfulExecution { .. }] - ); - assert_eq!(token_balance, Some(expected_balance)); - } - } } mod keyword_updates { diff --git a/packages/rs-drive/src/drive/contract/migration/add_missing_token_distribution_storage_to_all_contracts.rs b/packages/rs-drive/src/drive/contract/migration/add_missing_token_distribution_storage_to_all_contracts.rs deleted file mode 100644 index c05207a81b7..00000000000 --- a/packages/rs-drive/src/drive/contract/migration/add_missing_token_distribution_storage_to_all_contracts.rs +++ /dev/null @@ -1,527 +0,0 @@ -use crate::drive::tokens::paths::{ - token_root_perpetual_distributions_path, token_root_pre_programmed_distributions_path, -}; -use crate::drive::Drive; -use crate::error::contract::DataContractError; -use crate::error::drive::DriveError; -use crate::error::Error; -use crate::util::grove_operations::DirectQueryType; -use dpp::balances::credits::TokenAmount; -use dpp::block::block_info::BlockInfo; -use dpp::data_contract::accessors::v0::DataContractV0Getters; -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_configuration::TokenConfiguration; -use dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; -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::data_contract::DataContract; -use dpp::version::PlatformVersion; -use grovedb::Transaction; - -impl Drive { - /// Creates the perpetual and pre-programmed distribution storage of every token in state - /// that is configured with such a distribution but has no storage for it. - /// - /// Runs once, on the first block of protocol version 14. Before that version a contract - /// update created only the balance, identity info, status, contract info and supply entries - /// of a token it added, never the distribution storage the contract insert creates, so every - /// claim on such a token failed as an internal error. From version 14 the update creates the - /// storage itself (`update_contract` v2), but only for the tokens it adds, so the tokens - /// added before it need theirs created here. - /// - /// A token whose storage exists is left untouched, so the walk is safe to repeat. A - /// pre-programmed distribution that can not be stored at all (see - /// [`pre_programmed_distribution_is_storable`]) is skipped: an error here would halt the - /// chain on the upgrade block over a distribution nobody could ever have claimed. - /// - /// Returns the number of tokens that received storage. - pub fn add_missing_token_distribution_storage_to_all_contracts( - &self, - block_info: &BlockInfo, - transaction: &Transaction, - platform_version: &PlatformVersion, - ) -> Result { - let mut start_at = None; - let mut repaired_token_count = 0usize; - - loop { - let page = - self.fetch_contract_ids(start_at, u16::MAX, Some(transaction), platform_version)?; - - for contract_id in &page { - repaired_token_count += self.add_missing_token_distribution_storage_to_contract( - *contract_id, - block_info, - transaction, - platform_version, - )?; - } - - match page.last() { - Some(last_id) if page.len() == u16::MAX as usize => { - start_at = Some((*last_id, false)); - } - _ => break, - } - } - - tracing::info!( - repaired_token_count, - "created the missing distribution storage of tokens added by a contract update" - ); - - Ok(repaired_token_count) - } - - fn add_missing_token_distribution_storage_to_contract( - &self, - contract_id: [u8; 32], - block_info: &BlockInfo, - transaction: &Transaction, - platform_version: &PlatformVersion, - ) -> Result { - let fetch_info = self - .fetch_contract_and_add_operations( - contract_id, - None, - Some(transaction), - &mut vec![], - platform_version, - )? - .ok_or_else(|| { - Error::Drive(DriveError::CorruptedDriveState(format!( - "contract {} is listed under the contracts root but can not be fetched", - hex::encode(contract_id) - ))) - })?; - let contract = &fetch_info.contract; - - let mut repaired_token_count = 0usize; - - for (token_pos, configuration) in contract.tokens() { - let token_id = contract - .token_id(*token_pos) - .ok_or_else(|| { - Error::DataContract(DataContractError::CorruptedDataContract(format!( - "data contract has a token at position {}, but it can not be found", - token_pos - ))) - })? - .to_buffer(); - - let added_perpetual = self.add_missing_perpetual_distribution_storage( - token_id, - configuration, - transaction, - platform_version, - )?; - - let added_pre_programmed = self.add_missing_pre_programmed_distribution_storage( - contract, - token_id, - configuration, - block_info, - transaction, - platform_version, - )?; - - if added_perpetual || added_pre_programmed { - repaired_token_count += 1; - } - } - - Ok(repaired_token_count) - } - - fn add_missing_perpetual_distribution_storage( - &self, - token_id: [u8; 32], - configuration: &TokenConfiguration, - transaction: &Transaction, - platform_version: &PlatformVersion, - ) -> Result { - let Some(perpetual_distribution) = - configuration.distribution_rules().perpetual_distribution() - else { - return Ok(false); - }; - - let has_storage = self.grove_has_raw( - (&token_root_perpetual_distributions_path()).into(), - &token_id, - DirectQueryType::StatefulDirectQuery, - Some(transaction), - &mut vec![], - &platform_version.drive, - )?; - if has_storage { - return Ok(false); - } - - // One batch per token and kind: the storage helpers look for an existing tree in - // state only, never among the operations gathered so far. - let mut batch_operations = vec![]; - self.add_perpetual_distribution( - token_id, - perpetual_distribution, - &mut None, - &mut batch_operations, - Some(transaction), - platform_version, - )?; - self.apply_batch_low_level_drive_operations( - None, - Some(transaction), - batch_operations, - &mut vec![], - &platform_version.drive, - )?; - - Ok(true) - } - - fn add_missing_pre_programmed_distribution_storage( - &self, - contract: &DataContract, - token_id: [u8; 32], - configuration: &TokenConfiguration, - block_info: &BlockInfo, - transaction: &Transaction, - platform_version: &PlatformVersion, - ) -> Result { - let Some(pre_programmed_distribution) = configuration - .distribution_rules() - .pre_programmed_distribution() - else { - return Ok(false); - }; - - let has_storage = self.grove_has_raw( - (&token_root_pre_programmed_distributions_path()).into(), - &token_id, - DirectQueryType::StatefulDirectQuery, - Some(transaction), - &mut vec![], - &platform_version.drive, - )?; - if has_storage { - return Ok(false); - } - - if !pre_programmed_distribution_is_storable(pre_programmed_distribution) { - tracing::warn!( - contract_id = %contract.id(), - token_id = hex::encode(token_id), - "skipped a pre-programmed distribution whose amounts do not fit a sum tree" - ); - return Ok(false); - } - - let mut batch_operations = vec![]; - self.add_pre_programmed_distributions( - token_id, - contract.owner_id().to_buffer(), - pre_programmed_distribution, - block_info, - &mut None, - &mut batch_operations, - Some(transaction), - platform_version, - )?; - self.apply_batch_low_level_drive_operations( - None, - Some(transaction), - batch_operations, - &mut vec![], - &platform_version.drive, - )?; - - Ok(true) - } -} - -/// Whether the storage of `distribution` can be written: every release is a sum tree of its -/// recipients' amounts, so each amount and each release's total has to fit an `i64`. -/// -/// No validation bounds these amounts. The contract insert rejects a distribution that fails -/// this as an internal error, but before protocol version 14 a contract update never wrote the -/// storage and so admitted it. -fn pre_programmed_distribution_is_storable(distribution: &TokenPreProgrammedDistribution) -> bool { - distribution.distributions().values().all(|release| { - release - .values() - .try_fold(0 as TokenAmount, |total, amount| total.checked_add(*amount)) - .is_some_and(|total| total <= i64::MAX as TokenAmount) - }) -} - -#[cfg(test)] -mod tests { - use crate::drive::tokens::paths::token_root_pre_programmed_distributions_path; - use crate::drive::Drive; - use crate::error::Error; - use crate::util::grove_operations::DirectQueryType; - use crate::util::storage_flags::StorageFlags; - use crate::util::test_helpers::setup::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_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::Identifier; - use dpp::tests::fixtures::get_dashpay_contract_fixture; - use dpp::version::PlatformVersion; - use std::collections::BTreeMap; - - const RECIPIENT: [u8; 32] = [7; 32]; - - fn upgrade_block_info() -> BlockInfo { - BlockInfo { - time_ms: 5000, - height: 500, - core_height: 50, - epoch: Default::default(), - } - } - - /// A token paying `RECIPIENT` 50 tokens every 10 blocks and, once, `amount` tokens at - /// time 100. - fn token_with_both_distributions(amount: TokenAmount) -> TokenConfiguration { - let mut configuration = TokenConfiguration::V0( - TokenConfigurationV0::default_most_restrictive().with_base_supply(0), - ); - let recipient = Identifier::from(RECIPIENT); - 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), - }, - ))); - configuration - .distribution_rules_mut() - .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0( - TokenPreProgrammedDistributionV0 { - distributions: BTreeMap::from([(100, BTreeMap::from([(recipient, amount)]))]), - }, - ))); - configuration - } - - /// Under protocol version 13, registers a contract without tokens and adds `token` at - /// position 0 through a contract update, which leaves it without distribution storage. - /// Returns the token id. - fn add_token_by_update_before_the_upgrade( - drive: &Drive, - contract_seed: u8, - token: TokenConfiguration, - ) -> [u8; 32] { - let platform_version = PlatformVersion::get(13).expect("expected protocol version 13"); - let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) - .data_contract_owned(); - contract.set_id([contract_seed; 32].into()); - contract.config_mut().set_readonly(false); - - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("expected to insert the contract without tokens"); - - contract.set_tokens(BTreeMap::from([(0, token)])); - contract.increment_version(); - - drive - .update_contract( - &contract, - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("expected the update adding the token to succeed"); - - contract - .token_id(0) - .expect("expected the token added at position 0") - .to_buffer() - } - - fn run_backfill(drive: &Drive) -> usize { - let transaction = drive.grove.start_transaction(); - let repaired_token_count = drive - .add_missing_token_distribution_storage_to_all_contracts( - &upgrade_block_info(), - &transaction, - PlatformVersion::latest(), - ) - .expect("expected the backfill to succeed"); - drive - .grove - .commit_transaction(transaction) - .unwrap() - .expect("expected to commit"); - repaired_token_count - } - - fn root_hash(drive: &Drive) -> [u8; 32] { - drive - .grove - .root_hash(None, &PlatformVersion::latest().drive.grove_version) - .unwrap() - .expect("expected a root hash") - } - - /// Writes what a perpetual claim at block 40 and a claim of the release at time 100 write. - fn record_both_claims(drive: &Drive, token_id: [u8; 32]) -> Result<(), Error> { - let platform_version = PlatformVersion::latest(); - let mut operations = drive.mark_perpetual_release_as_distributed_operations( - token_id, - RECIPIENT, - RewardDistributionMoment::BlockBasedMoment(40), - &mut None, - platform_version, - )?; - operations.extend(drive.mark_pre_programmed_release_as_distributed_operations( - token_id, - RECIPIENT, - 100, - &BlockInfo::default(), - &mut None, - None, - platform_version, - )?); - drive.apply_batch_low_level_drive_operations( - None, - None, - operations, - &mut vec![], - &platform_version.drive, - ) - } - - #[test] - fn should_create_the_distribution_storage_of_tokens_added_by_update_before_the_upgrade() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - - // Two contracts releasing at the same time share that time's timed distribution tree. - let token_ids = [1u8, 2].map(|contract_seed| { - add_token_by_update_before_the_upgrade( - &drive, - contract_seed, - token_with_both_distributions(445), - ) - }); - for token_id in token_ids { - record_both_claims(&drive, token_id) - .expect_err("no claim can be recorded before the upgrade"); - } - - assert_eq!(run_backfill(&drive), 2); - - for token_id in token_ids { - 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(RECIPIENT), 445)]))]) - ); - - record_both_claims(&drive, token_id) - .expect("both claims should be recordable after the upgrade"); - } - } - - #[test] - fn should_leave_tokens_that_already_have_their_storage_unchanged() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - - // Registered with its token: the contract insert created the storage. - let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) - .data_contract_owned(); - contract.set_id([3; 32].into()); - contract.set_tokens(BTreeMap::from([(0, token_with_both_distributions(445))])); - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("expected to insert the contract with its token"); - - add_token_by_update_before_the_upgrade(&drive, 4, token_with_both_distributions(445)); - - assert_eq!(run_backfill(&drive), 1, "only the token added by update"); - - // A retried upgrade block finds nothing left to do. - let root_hash_after_backfill = root_hash(&drive); - assert_eq!(run_backfill(&drive), 0); - assert_eq!(root_hash(&drive), root_hash_after_backfill); - } - - /// No validation bounds a pre-programmed amount, and before protocol version 14 an update - /// never wrote the release, so state can hold a release no sum tree can. The upgrade block - /// must not fail over it. - #[test] - fn should_skip_a_pre_programmed_distribution_that_can_not_be_stored() { - let drive = setup_drive_with_initial_state_structure(None); - - let token_id = add_token_by_update_before_the_upgrade( - &drive, - 5, - token_with_both_distributions(u64::MAX), - ); - - assert_eq!(run_backfill(&drive), 1, "the perpetual storage is created"); - - let has_pre_programmed_storage = drive - .grove_has_raw( - (&token_root_pre_programmed_distributions_path()).into(), - &token_id, - DirectQueryType::StatefulDirectQuery, - None, - &mut vec![], - &PlatformVersion::latest().drive, - ) - .expect("expected to look for the pre-programmed storage"); - assert!( - !has_pre_programmed_storage, - "the pre-programmed storage is not" - ); - } -} diff --git a/packages/rs-drive/src/drive/contract/migration/mod.rs b/packages/rs-drive/src/drive/contract/migration/mod.rs index 7952bc01660..4bf094f6ad0 100644 --- a/packages/rs-drive/src/drive/contract/migration/mod.rs +++ b/packages/rs-drive/src/drive/contract/migration/mod.rs @@ -1,3 +1,2 @@ -mod add_missing_token_distribution_storage_to_all_contracts; mod add_version_items_to_all_contracts; mod strip_unknown_document_schema_properties; 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 ce00a477192..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 @@ -21,9 +21,9 @@ use crate::version::drive_versions::drive_contract_method_versions::{ /// 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. -/// Tokens added by an update before this version get their storage on the first -/// block of protocol version 14 -/// (`Drive::add_missing_token_distribution_storage_to_all_contracts`). +/// 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 { From e4e8b7265ca942d24beeb4633113951d0cc9ecf6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 19:38:14 +0700 Subject: [PATCH 3/5] refactor(drive): build update_contract v2 on top of v1 v2 was a full copy of v1 with the distribution storage added. It now calls update_contract_operations_v1 and appends the perpetual and pre-programmed storage of the tokens the update adds, the way v1 builds on update_contract_operations_v0. v1 is back to what v4.2-dev has, apart from the visibility of that one function. That includes the once-per-identity claims subtree #4827 put there, which v2 now gets by delegation instead of carrying its own copy, and v1's tests, which run through v2 at the latest protocol version and so need neither pinning nor duplicating. Co-Authored-By: Claude Fable 5.1 --- .../contract/update/update_contract/v1/mod.rs | 137 ++++- .../contract/update/update_contract/v2/mod.rs | 541 +----------------- 2 files changed, 139 insertions(+), 539 deletions(-) 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 64bbacf442c..4969c5c6202 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,6 +14,7 @@ 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; @@ -199,7 +200,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, @@ -241,6 +242,25 @@ 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() { @@ -303,9 +323,13 @@ 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::DataContractV1Setters; + 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::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; @@ -315,11 +339,6 @@ mod tests { use dpp::version::PlatformVersion; use std::collections::BTreeMap; - /// v1 is frozen: protocol version 13 is the last one that selects it. - fn frozen_platform_version() -> &'static PlatformVersion { - PlatformVersion::get(13).expect("expected protocol version 13") - } - /// Exercises `update_contract_operations_v1` when the updated contract /// gains tokens that weren't in the original. This covers the loop that /// calls `create_token_trees_operations` for each token. @@ -328,7 +347,7 @@ mod tests { #[test] fn test_update_contract_v1_adds_tokens_creates_token_trees() { let drive = setup_drive_with_initial_state_structure(None); - let platform_version = frozen_platform_version(); + let platform_version = PlatformVersion::latest(); // Original: no tokens. let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) @@ -366,6 +385,100 @@ 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 @@ -373,7 +486,7 @@ mod tests { #[test] fn test_update_contract_v1_adds_groups() { let drive = setup_drive_with_initial_state_structure(None); - let platform_version = frozen_platform_version(); + let platform_version = PlatformVersion::latest(); let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) .data_contract_owned(); @@ -419,7 +532,7 @@ mod tests { #[test] fn test_update_contract_v1_keyword_delta_via_update_contract() { let drive = setup_drive_with_initial_state_structure(None); - let platform_version = frozen_platform_version(); + let platform_version = PlatformVersion::latest(); // Insert the keyword_search system contract first (required because // update_contract_v1 calls update_contract_keywords_operations). @@ -536,7 +649,7 @@ mod tests { #[test] fn clearing_a_contracts_keywords_leaves_the_old_ones_indexed() { let drive = setup_drive_with_initial_state_structure(None); - let platform_version = frozen_platform_version(); + let platform_version = PlatformVersion::latest(); let keyword_search = load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) @@ -601,7 +714,7 @@ mod tests { #[test] fn test_update_contract_v1_description_via_update_contract() { let drive = setup_drive_with_initial_state_structure(None); - let platform_version = frozen_platform_version(); + let platform_version = PlatformVersion::latest(); let keyword_search = load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) 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 index eee844d1316..12da33665f4 100644 --- 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 @@ -15,7 +15,6 @@ 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; @@ -201,13 +200,12 @@ impl Drive { /// operations for updating a contract. /// - /// Differs from v1 in one way: a token the update adds also gets its - /// perpetual, pre-programmed and once-per-identity distribution storage, - /// the same storage `insert_contract` creates for a token present at - /// registration. v1 created only the token's balance, identity info, - /// status, contract info and supply entries, 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. + /// The v1 operations, plus the perpetual and pre-programmed 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, @@ -222,7 +220,7 @@ impl Drive { platform_version: &PlatformVersion, ) -> Result, Error> { let mut batch_operations: Vec = self - .update_contract_operations_v0( + .update_contract_operations_v1( contract_element, contract, original_contract, @@ -233,25 +231,6 @@ impl Drive { )?; for (token_pos, configuration) in contract.tokens() { - 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 - )), - ))?; - - batch_operations.extend(self.create_token_trees_operations( - contract.id(), - *token_pos, - token_id.to_buffer(), - configuration.start_as_paused(), - true, - &mut None, - estimated_costs_only_with_layer_info, - transaction, - platform_version, - )?); - // 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 @@ -261,6 +240,13 @@ impl Drive { 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() { @@ -289,73 +275,6 @@ impl Drive { 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, - )?; - } - } - - if !contract.groups().is_empty() { - batch_operations.extend(self.add_new_groups_operations( - contract.id(), - contract.groups(), - estimated_costs_only_with_layer_info, - transaction, - platform_version, - )?); - } - - // Skipping an empty keyword set is load-bearing, but it is a shield - // rather than a fix, and both halves matter to anyone changing it. - // - // What it prevents: the keyword update emits its deletes blind to each - // other in one batch, so several of them jointly emptying the shared - // `byContractId/` group would leave that group tree behind - // with nothing in it — and emptying the group without refilling it - // requires exactly this empty-set case. - // - // What it costs: the previous keyword documents are not deleted either, - // so a contract that clears its keywords advertises none while keyword - // search still returns it under the old ones. Removing this guard to fix - // that trades a stale index for a stranded group tree; the deletes have - // to become sibling-aware first. Both halves are pinned — - // `clearing_a_contracts_keywords_leaves_the_old_ones_indexed` and - // `clearing_every_keyword_leaves_an_empty_by_contract_id_group_behind`. - if !contract.keywords().is_empty() { - batch_operations.extend(self.update_contract_keywords_operations( - contract.id(), - contract.owner_id(), - contract.keywords(), - block_info, - estimated_costs_only_with_layer_info, - transaction, - platform_version, - )?); - } - - if let Some(description) = contract.description() { - batch_operations.extend(self.update_contract_description_operations( - contract.id(), - contract.owner_id(), - description, - block_info, - estimated_costs_only_with_layer_info, - transaction, - platform_version, - )?); } Ok(batch_operations) @@ -375,9 +294,6 @@ mod tests { 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; @@ -387,440 +303,11 @@ mod tests { 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::group::v0::GroupV0; - use dpp::data_contract::group::Group; use dpp::prelude::{DataContract, Identifier}; - use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use dpp::tests::fixtures::get_dashpay_contract_fixture; use dpp::version::PlatformVersion; use std::collections::BTreeMap; - /// Exercises `update_contract_operations_v2` when the updated contract - /// gains tokens that weren't in the original. This covers the loop that - /// calls `create_token_trees_operations` for each token. - /// PR #3516 inserts contracts with tokens but does not exercise an - /// UPDATE that adds tokens. - #[test] - fn test_update_contract_v2_adds_tokens_creates_token_trees() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - - // Original: no tokens. - 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"); - - // Updated: add a token configuration. The update path exercises the - // `create_token_trees_operations` call in update_contract_operations_v2. - let token_config = TokenConfiguration::V0( - TokenConfigurationV0::default_most_restrictive().with_base_supply(0), - ); - 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 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_v2_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_v2` where the updated contract - /// gains groups that weren't in the original. This covers the - /// `if !contract.groups().is_empty()` true branch inside - /// `update_contract_operations_v2`, invoking `add_new_groups_operations`. - #[test] - fn test_update_contract_v2_adds_groups() { - 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(); - - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("insert"); - - // Add a group. - let member = Identifier::random(); - let group = Group::V0(GroupV0 { - members: BTreeMap::from([(member, 1)]), - required_power: 1, - }); - contract.set_groups(BTreeMap::from([(0, group)])); - contract.increment_version(); - - drive - .update_contract( - &contract, - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("update adding groups should succeed"); - } - - /// Exercises `update_contract_operations_v2`'s keyword-update branch: - /// update a contract that starts with some keywords to a new set of - /// keywords (different set), routed through the full `update_contract_v2` - /// path rather than the dedicated `update_contract_keywords` API. - /// PR #3516 covers the dedicated API but not the embedded path invoked - /// via `update_contract`. - #[test] - fn test_update_contract_v2_keyword_delta_via_update_contract() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - - // Insert the keyword_search system contract first (required because - // update_contract_v2 calls update_contract_keywords_operations). - let keyword_search = - load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) - .expect("load keyword_search"); - drive - .apply_contract( - &keyword_search, - BlockInfo::default(), - true, - None, - None, - platform_version, - ) - .expect("apply keyword_search"); - - let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) - .data_contract_owned(); - contract.set_keywords(vec!["initial_a".to_string(), "initial_b".to_string()]); - - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("initial insert with keywords"); - - // Now change keywords entirely. - contract.set_keywords(vec!["new_x".to_string(), "new_y".to_string()]); - contract.increment_version(); - - drive - .update_contract( - &contract, - BlockInfo { - time_ms: 2000, - height: 10, - core_height: 5, - epoch: Default::default(), - }, - true, - None, - platform_version, - None, - ) - .expect("update keyword delta via update_contract should succeed"); - } - - /// The keywords the keyword search index currently returns for `contract_id`. - fn indexed_keywords( - drive: &crate::drive::Drive, - keyword_search: &dpp::prelude::DataContract, - contract_id: Identifier, - platform_version: &PlatformVersion, - ) -> Vec { - use crate::drive::document::query::QueryDocumentsOutcomeV0Methods; - use crate::query::{DriveDocumentQuery, WhereClause, WhereOperator}; - use dpp::document::DocumentV0Getters; - use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; - use dpp::platform_value::Value; - - let document_type = keyword_search - .document_type_for_name("contractKeywords") - .expect("contractKeywords doctype"); - let mut query = DriveDocumentQuery::all_items_query(keyword_search, document_type, None); - query.internal_clauses.equal_clauses.insert( - "contractId".to_string(), - WhereClause { - field: "contractId".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(contract_id.to_buffer()), - }, - ); - let mut keywords: Vec = drive - .query_documents( - query, - None, - false, - None, - Some(platform_version.protocol_version), - ) - .expect("the byContractId query must succeed") - .documents_owned() - .into_iter() - .map(|document| { - document - .properties() - .get_string("keyword") - .expect("every keyword document carries a keyword") - }) - .collect(); - keywords.sort(); - keywords - } - - /// **This test asserts a defect, not the desired behaviour**, and it is the - /// other half of the empty-keyword-set skip above. - /// - /// Clearing a contract's keywords does not delete its keyword documents: an - /// empty set skips the keyword update entirely, so the previous documents - /// survive and stay indexed. The contract then advertises no keywords while - /// keyword search still returns it under the old ones, permanently. - /// - /// The skip is a shield, not a fix. It is what keeps the deletes from - /// jointly emptying the shared `byContractId` group and stranding it — see - /// `clearing_every_keyword_leaves_an_empty_by_contract_id_group_behind` — - /// so removing it to make this test go green trades a stale index for an - /// empty group tree. Making the deletes sibling-aware has to come first. - #[test] - fn clearing_a_contracts_keywords_leaves_the_old_ones_indexed() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - - let keyword_search = - load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) - .expect("load keyword_search"); - drive - .apply_contract( - &keyword_search, - BlockInfo::default(), - true, - None, - None, - platform_version, - ) - .expect("apply keyword_search"); - - let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) - .data_contract_owned(); - contract.set_keywords(vec!["alpha".to_string(), "bravo".to_string()]); - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("initial insert with keywords"); - - assert_eq!( - indexed_keywords(&drive, &keyword_search, contract.id(), platform_version), - vec!["alpha".to_string(), "bravo".to_string()], - "baseline: both keywords are indexed" - ); - - contract.set_keywords(vec![]); - contract.increment_version(); - drive - .update_contract( - &contract, - BlockInfo::default(), - true, - None, - platform_version, - None, - ) - .expect("clearing keywords via update_contract should succeed"); - - assert_eq!( - indexed_keywords(&drive, &keyword_search, contract.id(), platform_version), - vec!["alpha".to_string(), "bravo".to_string()], - "the old keyword documents are expected to survive: an empty keyword set skips \ - the keyword update rather than performing it" - ); - } - - /// Exercises `update_contract_operations_v2`'s description-update branch: - /// changing contract description routes through - /// `update_contract_description_operations`. Covers the `if let Some(description)` - /// true branch specifically from the v2 update path (not the dedicated update - /// description API). - #[test] - fn test_update_contract_v2_description_via_update_contract() { - let drive = setup_drive_with_initial_state_structure(None); - let platform_version = PlatformVersion::latest(); - - let keyword_search = - load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) - .expect("load keyword_search"); - drive - .apply_contract( - &keyword_search, - BlockInfo::default(), - true, - None, - None, - platform_version, - ) - .expect("apply keyword_search"); - - let mut contract = get_dashpay_contract_fixture(None, 0, platform_version.protocol_version) - .data_contract_owned(); - contract.set_description(Some("initial description".to_string())); - - drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("initial insert with description"); - - contract.set_description(Some("updated description text".to_string())); - contract.increment_version(); - - drive - .update_contract( - &contract, - BlockInfo { - time_ms: 3000, - height: 20, - core_height: 7, - epoch: Default::default(), - }, - true, - None, - platform_version, - None, - ) - .expect("update description via update_contract should succeed"); - } - const DISTRIBUTION_RECIPIENT: [u8; 32] = [7; 32]; fn block_based_distribution_type() -> RewardDistributionType { From dc3c53de8b12aadff3a77f3303037003b9d930e2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 19:44:41 +0700 Subject: [PATCH 4/5] refactor(drive): move the once-per-identity claims subtree into update_contract v2 #4827 created the claims subtree of a token added by update inside update_contract v1, the generation protocol version 14 selected at the time. The once-per-identity kind is new in protocol version 14 and that version selects v2 now, so the block and its test move there, next to the perpetual and pre-programmed storage and inside the same absent-from-the-original-contract gate. v1 is back to what it was before #4827, apart from the visibility of update_contract_operations_v1. Co-Authored-By: Claude Fable 5.1 --- .../contract/update/update_contract/v1/mod.rs | 120 +---------------- .../contract/update/update_contract/v2/mod.rs | 122 +++++++++++++++++- 2 files changed, 120 insertions(+), 122 deletions(-) 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 4969c5c6202..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; @@ -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 index 12da33665f4..310539769b7 100644 --- 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 @@ -15,6 +15,7 @@ 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; @@ -200,9 +201,10 @@ impl Drive { /// operations for updating a contract. /// - /// The v1 operations, plus the perpetual and pre-programmed distribution - /// storage of every token the update adds: the same storage - /// `insert_contract` creates for a token present at registration. v1 + /// 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. @@ -275,6 +277,23 @@ impl Drive { 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) @@ -294,6 +313,9 @@ mod tests { 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; @@ -432,6 +454,100 @@ mod tests { ) } + /// 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); From d1aff2ad72d1446ada5b97ad5dd84285be06a5a6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 07:49:03 +0700 Subject: [PATCH 5/5] fix(drive)!: bound pre-programmed distribution amounts and queue a shared release-time tree once Two pre-programmed token distribution inputs passed every validation and then failed inside Drive as internal errors, which are never paid for and are stripped from every proposal. Both fixes are gated to protocol version 14. Amounts. A release is stored as a sum tree of its recipients' amounts, so an amount above i64::MAX, or amounts totalling more, can not be written. The new TokenPreProgrammedDistribution::validate_amounts (rs-dpp) rejects such a release with PreProgrammedDistributionAmountOverLimitError (basic, 10277): - on contract create, in basic_structure v2 (unpaid, caught at check_tx); - on contract update, in DataContract::validate_update v1, for the tokens the update adds only (paid). What protocol version 13 did, verified by test: a single amount over the limit was an internal error on create AND on update, because the fee estimation of an update takes the contract insert path. Only a release whose amounts each fit but total more was accepted by an update, since the estimation does not sum and the v1 update wrote no distribution storage. A network may hold such a token, so tokens a contract already has are not judged and the contract stays updatable. Shared release time. add_pre_programmed_distributions v0 looked for the release-time tree all tokens share in state only, so two tokens of one contract releasing at the same time queued it twice in one batch. That is refused ("insertion order error") only by a Drive with batching_consistency_verification on, which rs-drive tests force and shipped nodes leave off; with it off GroveDB folds the identical inserts and the contract is stored. v1 also looks among the gathered operations. It stores the same state (equal root hash, pinned by test) and lowers the processing fee by the existence read the later tokens no longer make, hence a new version (DRIVE_TOKEN_METHOD_VERSIONS_V2, selected by DRIVE_VERSION_V9 only) rather than an edit of v0. The new BasicError variant goes after #4827's at the enum tail and is pinned at discriminant 188 in basic_error_tail_discriminants_are_frozen. Stacked on #4834 (update_contract v2). Co-Authored-By: Claude Fable 5.1 --- book/src/error-handling/error-codes.md | 2 +- .../methods/mod.rs | 1 + .../methods/validate_amounts/mod.rs | 37 ++ .../methods/validate_amounts/v0/mod.rs | 143 +++++ .../token_pre_programmed_distribution/mod.rs | 1 + .../methods/validate_update/v1/mod.rs | 184 +++++- .../src/errors/consensus/basic/basic_error.rs | 26 +- .../consensus/basic/data_contract/mod.rs | 2 + ...ed_distribution_amount_over_limit_error.rs | 68 +++ packages/rs-dpp/src/errors/consensus/codes.rs | 1 + .../basic_structure/v2/mod.rs | 144 +++++ .../data_contract_create/mod.rs | 188 ++++++ .../data_contract_update/mod.rs | 189 ++++++ .../add_pre_programmed_distribution/mod.rs | 13 +- .../add_pre_programmed_distribution/v1/mod.rs | 551 ++++++++++++++++++ .../dpp_versions/dpp_contract_versions/mod.rs | 3 + .../dpp_versions/dpp_contract_versions/v1.rs | 1 + .../dpp_versions/dpp_contract_versions/v2.rs | 1 + .../dpp_versions/dpp_contract_versions/v3.rs | 1 + .../dpp_versions/dpp_contract_versions/v4.rs | 1 + .../dpp_versions/dpp_contract_versions/v5.rs | 1 + .../dpp_versions/dpp_contract_versions/v6.rs | 1 + .../drive_token_method_versions/mod.rs | 1 + .../drive_token_method_versions/v2.rs | 27 + .../src/version/drive_versions/v9.rs | 9 +- .../rs-platform-version/src/version/v14.rs | 24 + .../src/errors/consensus/consensus_error.rs | 5 +- 27 files changed, 1610 insertions(+), 15 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/methods/mod.rs create mode 100644 packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/methods/validate_amounts/mod.rs create mode 100644 packages/rs-dpp/src/data_contract/associated_token/token_pre_programmed_distribution/methods/validate_amounts/v0/mod.rs create mode 100644 packages/rs-dpp/src/errors/consensus/basic/data_contract/pre_programmed_distribution_amount_over_limit_error.rs create mode 100644 packages/rs-drive/src/drive/tokens/distribution/add_pre_programmed_distribution/v1/mod.rs create mode 100644 packages/rs-platform-version/src/version/drive_versions/drive_token_method_versions/v2.rs 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 14f95134ad5..44142e089c0 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 837d9cc1a7a..27b1f97b79a 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 @@ -1558,6 +1558,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; @@ -1733,6 +1737,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 de7ab4efe95..8b5550258d0 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -254,6 +254,30 @@ 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. **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. +/// 12. **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 945479ca2d6..033a2cede8a 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}; @@ -1120,6 +1120,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() + } } }