From 78c9678704c86f2973c8554feebb75eab581d6c3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 19 Sep 2026 06:41:58 +0700 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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);