diff --git a/book/src/fees/overview.md b/book/src/fees/overview.md index a771a14e7e3..fa8c3c79d43 100644 --- a/book/src/fees/overview.md +++ b/book/src/fees/overview.md @@ -535,6 +535,11 @@ Fee versions are stored in the `FEE_VERSIONS` array and looked up by number. The `uses_version_fee_multiplier_permille` field allows a global scaling factor (permille = divide by 1000; a value of 1000 means no change). +The platform state keeps a fee history keyed by epoch: the genesis generation +is recorded when the chain is initialised, and each later generation at the +epoch change that activates it, so refunds can find the schedule that priced +the bytes being removed. + ## Key Source Files | File | Contents | diff --git a/book/src/testing/strategy-tests.md b/book/src/testing/strategy-tests.md index 9faf4905688..9808d9589c0 100644 --- a/book/src/testing/strategy-tests.md +++ b/book/src/testing/strategy-tests.md @@ -291,6 +291,36 @@ let continued = continue_chain_for_strategy( This is invaluable for testing restart scenarios and verifying that state persists correctly across platform restarts. +A continuation is not automatically the same workload as an uninterrupted run: +`continue_chain_for_strategy` reseeds its random generator from the +`StrategyRandomness` it is given, and `state_transitions_for_block` redeploys +any `start_contracts` still in the strategy at the continuation's first block. +To compare a continued run with an uninterrupted one, split both at the same +block, hand the second segment the mutated `strategy` the first segment +returned (its operations are remapped to the deployed contract id; clear its +`start_contracts`, which the harness puts back after deploying them), the +`identities`, the `signer` and the nonce counters, and reseed both +continuations from the same entropy. `state_transitions_per_block` +on the outcome lists what the strategy submitted per block, so a test can prove +the two runs executed one workload before comparing their results. + +### Crossing a Fee-Version Boundary + +`test_cases/fee_version_boundary_tests.rs` upgrades a chain from the latest +protocol version to the `TEST_PLATFORM_V4` mock, whose only difference is a fee +generation with a doubled storage rate (see the versioning chapter's mock +versions section). With `upgrading_info` voting for the mock from block 1 and +60 blocks per epoch, epoch 0 collects the votes, the first block of epoch 1 +locks the mock in, and the first block of epoch 2 activates it and records the +new generation in the fee history. Each simulation runs the same workload +twice, continuously and reopened from the persisted state at the same block +(`store_platform_state: true`, then `TempPlatform::open_with_tempdir`), with +`independent_process_proposal_verification` on so every block is also +validated as a non-proposer. The two runs must agree on the root hash, the +per-block transition results, the identity balances and the fee history, and +at least one document written before the boundary must be deleted after it so +the history-driven refund path runs against persisted bytes. + ## How Strategy Tests Differ from Unit Tests | Aspect | Unit Tests | Strategy Tests | diff --git a/book/src/versioning/platform-version.md b/book/src/versioning/platform-version.md index e7317d3b924..6d1ce5395af 100644 --- a/book/src/versioning/platform-version.md +++ b/book/src/versioning/platform-version.md @@ -371,6 +371,20 @@ This is a clever design: tests can exercise version upgrade logic (like "what happens when we transition from test version 2 to test version 3?") without needing to create real protocol versions. +`TEST_PLATFORM_V4` (`version/mocks/v4_test.rs`) is the latest shipped tables +with one substitution: its fee schedule is `TEST_FEE_VERSION_DOUBLED_STORAGE` +(`version/mocks/fee_doubled_storage_test.rs`), the latest schedule with a new +`fee_version_number` and a doubled storage disk usage rate. No shipped schedule +carries a number other than 1, so this is the only fee generation that +exercises the registry lookup, the epoch-change hook, the saved-state round +trip and the history-driven refund path. Its number sits in the same shifted +range as the mock protocol versions (`(1 << TEST_PROTOCOL_VERSION_SHIFT_BYTES) ++ 1`), and `FeeVersion::get` resolves that range through the test registry only +under `mock-versions`: a production node that finds such a number in its saved +state rejects it instead of falling back to a real schedule. When a new +protocol version ships, move the mock's base to its table so it keeps tracking +the latest behaviour. + ## Why Immutable Snapshots? You might wonder: why not use a mutable configuration object? Why not a diff --git a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/tests.rs b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/tests.rs index 4ec54f0d698..742f1f9ec9c 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/tests.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/block_processing_end_events/tests.rs @@ -1572,3 +1572,765 @@ mod storage_refund_clawback_tests { ); } } + +/// Storage refunds across a fee-generation boundary, driven through the real +/// block loop (`process_raw_state_transitions` with the state's fee history). +/// +/// The doubled-storage test generation (`TEST_PLATFORM_V4`) differs from the +/// latest shipped schedule only in its storage disk usage rate, so every +/// control run below is the same workload under `PlatformVersion::latest()`: +/// storage fees must double, processing fees must not move, and a refund +/// priced at the wrong generation is off by exactly a factor of two. +#[cfg(test)] +mod fee_generation_boundary { + use crate::execution::validation::state_transition::tests::{ + fetch_expected_identity_balance, process_state_transitions, + setup_identity_with_system_credits_with_platform_version, + }; + use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::fast_forward_to_block::fast_forward_to_block; + use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use dpp::block::block_info::BlockInfo; + use dpp::block::epoch::{Epoch, EpochIndex}; + use dpp::dash_to_credits; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, + }; + use dpp::data_contract::document_type::DocumentTypeRef; + use dpp::data_contract::DataContract; + use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use dpp::fee::epoch::distribution::calculate_storage_fee_refund_amount_and_leftovers; + use dpp::fee::epoch::{CreditsPerEpoch, DEFAULT_EPOCHS_PER_ERA, GENESIS_EPOCH_INDEX}; + use dpp::fee::fee_result::FeeResult; + use dpp::fee::Credits; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::platform_value::Bytes32; + use dpp::prelude::IdentityNonce; + use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; + use dpp::state_transition::batch_transition::BatchTransition; + use dpp::util::deserializer::ProtocolVersion; + use drive::drive::document::query::QueryDocumentsWithFlagsOutcomeV0Methods; + use drive::drive::Drive; + use drive::error::drive::DriveError; + use drive::error::Error as DriveCrateError; + use drive::fees::op::LowLevelDriveOperation; + use drive::grovedb_costs::storage_cost::removal::{ + StorageRemovalPerEpochByIdentifier, StorageRemovedBytes, + }; + use drive::grovedb_costs::storage_cost::StorageCost; + use drive::grovedb_costs::OperationCost; + use drive::query::DriveDocumentQuery; + use drive::util::storage_flags::StorageFlags; + use drive::util::test_helpers::setup_contract; + use platform_version::version::mocks::fee_doubled_storage_test::TEST_FEE_VERSION_DOUBLED_STORAGE; + use platform_version::version::mocks::v4_test::{TEST_PLATFORM_V4, TEST_PROTOCOL_VERSION_4}; + use platform_version::version::PlatformVersion; + use rand::prelude::StdRng; + use rand::SeedableRng; + use simple_signer::signer::SimpleSigner; + + const CONTRACT_PATH: &str = + "tests/supporting_files/contract/dashpay/dashpay-contract-no-indexes.json"; + const IDENTITY_SEED: u64 = 958; + const DOCUMENT_SEED: u64 = 433; + + /// Everything a boundary vector needs to compare one run against another. + struct Workload { + identity_balance_before: Credits, + /// The first document of the identity on the contract, which also pays + /// for joining the contract (the identity contract nonce). + join: FeeResult, + /// The document whose deletion is measured. + insertion: FeeResult, + deletion: FeeResult, + /// The deletion's refund to the identity, per storage epoch. + refunds: CreditsPerEpoch, + epochs_per_era: u16, + } + + fn assert_credits_balanced( + platform: &TempPlatform, + platform_version: &PlatformVersion, + ) { + let credits_verified = platform + .drive + .calculate_total_credits_balance(None, &platform_version.drive) + .expect("expected to check sum trees"); + + let balanced = credits_verified + .ok() + .expect("expected the credit sums to be checkable"); + + assert!(balanced, "platform should be balanced {}", credits_verified); + } + + /// The storage flags the stored document carries, read back from the tree + /// so a write that silently landed in the wrong epoch fails on the flags + /// rather than on the arithmetic. + fn stored_document_flags( + platform: &TempPlatform, + contract: &DataContract, + document_type: DocumentTypeRef<'_>, + document: &Document, + platform_version: &PlatformVersion, + ) -> StorageFlags { + let query = DriveDocumentQuery::new_primary_key_single_item_query( + contract, + document_type, + document.id(), + ); + let mut documents = platform + .drive + .query_documents_with_flags( + query, + None, + false, + None, + Some(platform_version.protocol_version), + ) + .expect("expected to query the stored document") + .documents_owned(); + let (_, storage_flags) = documents.pop().expect("expected the document to be stored"); + storage_flags.expect("expected the stored document to carry storage flags") + } + + fn assert_stored_in_epoch( + platform: &TempPlatform, + contract: &DataContract, + document_type: DocumentTypeRef<'_>, + document: &Document, + epoch_index: EpochIndex, + owner: &Identity, + platform_version: &PlatformVersion, + ) { + let storage_flags = stored_document_flags( + platform, + contract, + document_type, + document, + platform_version, + ); + assert_eq!( + *storage_flags.base_epoch(), + epoch_index, + "the document must be stored in epoch {epoch_index}, got {storage_flags:?}" + ); + assert_eq!( + storage_flags.owner_id(), + Some(&owner.id().to_buffer()), + "the document's bytes must be owned by the identity that pays for them" + ); + } + + #[allow(clippy::too_many_arguments)] + async fn store_document( + platform: &TempPlatform, + document_type: DocumentTypeRef<'_>, + rng: &mut StdRng, + identity: &Identity, + key: &IdentityPublicKey, + signer: &SimpleSigner, + identity_contract_nonce: IdentityNonce, + block_info: BlockInfo, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> (Document, FeeResult) { + let entropy = Bytes32::random_with_rng(rng); + + let mut document = document_type + .random_document_with_identifier_and_entropy( + rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + + // The id consensus assigns commits to the identity contract nonce, so + // the document the test keeps for its lookup and delete must carry it. + document + .set_id_for_creation( + document_type, + &entropy.0, + identity_contract_nonce, + platform_version, + ) + .expect("expected to set the document id"); + + document.set("avatarUrl", "http://test.com/bob.jpg".into()); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + key, + identity_contract_nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + // The block helper reads the version from `platform_state`, which every + // boundary vector sets to the version it processes under. + let (mut fee_results, _) = process_state_transitions( + platform, + &[documents_batch_create_transition], + block_info, + platform_state, + ); + + assert_credits_balanced(platform, platform_version); + + (document, fee_results.remove(0)) + } + + #[allow(clippy::too_many_arguments)] + async fn delete_document( + platform: &TempPlatform, + document: Document, + document_type: DocumentTypeRef<'_>, + key: &IdentityPublicKey, + signer: &SimpleSigner, + identity_contract_nonce: IdentityNonce, + block_info: BlockInfo, + platform_state: &PlatformState, + platform_version: &PlatformVersion, + ) -> FeeResult { + let documents_batch_delete_transition = + BatchTransition::new_document_deletion_transition_from_document( + document, + document_type, + key, + identity_contract_nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + // The block helper reads the version from `platform_state`, which every + // boundary vector sets to the version it processes under. + let (mut fee_results, _) = process_state_transitions( + platform, + &[documents_batch_delete_transition], + block_info, + platform_state, + ); + + assert_credits_balanced(platform, platform_version); + + fee_results.remove(0) + } + + /// The block state the hook would leave behind after the doubled-storage + /// generation activated at `activation_epoch`: the mock protocol version + /// is current, and the fee history carries the activation entry. + fn state_after_upgrade_to_doubled_storage( + platform: &TempPlatform, + activation_epoch: EpochIndex, + ) -> PlatformState { + let mut platform_state = platform.state.load().as_ref().clone(); + platform_state.set_current_protocol_version_in_consensus(TEST_PROTOCOL_VERSION_4); + platform_state.set_next_epoch_protocol_version(TEST_PROTOCOL_VERSION_4); + platform_state + .previous_fee_versions_mut() + .insert(activation_epoch, &TEST_FEE_VERSION_DOUBLED_STORAGE); + platform_state + } + + /// Recovers the number of bytes a refund paid for. + /// + /// GroveDB derives the removed byte count from the element it deletes and + /// the fee result only carries the priced refund, so the count is taken + /// from a refund known to be priced at `rate`: the refund grows by almost + /// a full rate per byte while the rounding drift of the era distribution + /// is bounded by the number of epochs, so exactly one count reproduces it. + fn removed_bytes_priced_at( + refund: Credits, + rate: Credits, + storage_epoch: EpochIndex, + current_epoch: EpochIndex, + epochs_per_era: u16, + max_bytes: u64, + ) -> u64 { + (1..=max_bytes) + .find(|bytes| { + expected_refund(bytes * rate, storage_epoch, current_epoch, epochs_per_era) + == refund + }) + .unwrap_or_else(|| { + panic!("no byte count up to {max_bytes} priced at {rate} refunds {refund} credits") + }) + } + + fn expected_refund( + storage_fee: Credits, + storage_epoch: EpochIndex, + current_epoch: EpochIndex, + epochs_per_era: u16, + ) -> Credits { + calculate_storage_fee_refund_amount_and_leftovers( + storage_fee, + storage_epoch, + current_epoch, + epochs_per_era, + ) + .expect("expected to compute the refund") + .0 + } + + fn refunds_for(fee_result: &FeeResult, identity: &Identity) -> CreditsPerEpoch { + fee_result + .fee_refunds + .get(&identity.id().to_buffer()) + .expect("expected refunds for the identity") + .clone() + } + + fn refund_amount(refunds: &CreditsPerEpoch, storage_epoch: EpochIndex) -> Credits { + assert_eq!( + refunds.len(), + 1, + "the refund must be sectioned into the single storage epoch {storage_epoch}, got {refunds:?}" + ); + *refunds + .get(&storage_epoch) + .expect("expected the refund to be keyed by the storage epoch") + } + + /// One run on either side of a boundary compared to the same run at the + /// latest schedule: storage doubles, processing does not move, and the + /// refund is exactly the refund of the same bytes at twice the rate. + fn assert_doubled_storage_generation( + doubled: &Workload, + control: &Workload, + storage_epoch: EpochIndex, + current_epoch: EpochIndex, + ) { + let control_rate = PlatformVersion::latest() + .fee_version + .storage + .storage_disk_usage_credit_per_byte; + let doubled_rate = TEST_FEE_VERSION_DOUBLED_STORAGE + .storage + .storage_disk_usage_credit_per_byte; + assert_eq!(doubled_rate, 2 * control_rate); + + for (doubled_fee, control_fee) in [ + (&doubled.join, &control.join), + (&doubled.insertion, &control.insertion), + ] { + assert_eq!( + doubled_fee.storage_fee, + 2 * control_fee.storage_fee, + "bytes stored under the doubled generation cost twice the latest rate" + ); + assert_eq!( + doubled_fee.processing_fee, control_fee.processing_fee, + "only the storage rate differs between the generations" + ); + } + assert_eq!(doubled.deletion.storage_fee, 0); + assert_eq!(control.deletion.storage_fee, 0); + assert_eq!(doubled.epochs_per_era, control.epochs_per_era); + + let control_refund = refund_amount(&control.refunds, storage_epoch); + let doubled_refund = refund_amount(&doubled.refunds, storage_epoch); + + let removed_bytes = removed_bytes_priced_at( + control_refund, + control_rate, + storage_epoch, + current_epoch, + control.epochs_per_era, + control.insertion.storage_fee / control_rate, + ); + assert_eq!( + doubled_refund, + expected_refund( + removed_bytes * doubled_rate, + storage_epoch, + current_epoch, + doubled.epochs_per_era + ), + "the refund of {removed_bytes} bytes stored in epoch {storage_epoch} and removed in \ + epoch {current_epoch} must be priced at the doubled generation" + ); + } + + /// Bytes stored in epoch 2 and removed in epoch 3, optionally after the + /// doubled-storage generation activated in epoch 2. + async fn store_after_boundary_delete_next_epoch(activation: Option) -> Workload { + const STORAGE_EPOCH: EpochIndex = 2; + const REMOVAL_EPOCH: EpochIndex = 3; + + let latest = PlatformVersion::latest(); + let processing_version = if activation.is_some() { + PlatformVersion::get(TEST_PROTOCOL_VERSION_4).expect("expected the mock version") + } else { + latest + }; + + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let contract = setup_contract( + &platform.drive, + CONTRACT_PATH, + None, + None, + None::, + None, + None, + ); + let profile = contract + .document_type_for_name("profile") + .expect("expected a profile document type"); + + let mut rng = StdRng::seed_from_u64(DOCUMENT_SEED); + + let (identity, signer, key) = setup_identity_with_system_credits_with_platform_version( + &mut platform, + IDENTITY_SEED, + dash_to_credits!(1), + latest, + ); + let identity_balance_before = dash_to_credits!(1); + + fast_forward_to_block(&platform, 1_200_000_000, 900, 42, STORAGE_EPOCH, false); + let block_state = |platform: &TempPlatform| match activation { + Some(activation_epoch) => { + state_after_upgrade_to_doubled_storage(platform, activation_epoch) + } + None => platform.state.load().as_ref().clone(), + }; + let platform_state = block_state(&platform); + let block_info = *platform_state.last_block_info(); + assert_eq!(block_info.epoch.index, STORAGE_EPOCH); + + let (join_document, join) = store_document( + &platform, + profile, + &mut rng, + &identity, + &key, + &signer, + 1, + block_info, + &platform_state, + processing_version, + ) + .await; + let (document, insertion) = store_document( + &platform, + profile, + &mut rng, + &identity, + &key, + &signer, + 2, + block_info, + &platform_state, + processing_version, + ) + .await; + for stored in [&join_document, &document] { + assert_stored_in_epoch( + &platform, + &contract, + profile, + stored, + STORAGE_EPOCH, + &identity, + processing_version, + ); + } + + fast_forward_to_block(&platform, 1_800_000_000, 1_300, 42, REMOVAL_EPOCH, false); + let platform_state = block_state(&platform); + let block_info = *platform_state.last_block_info(); + assert_eq!(block_info.epoch.index, REMOVAL_EPOCH); + + let deletion = delete_document( + &platform, + document, + profile, + &key, + &signer, + 3, + block_info, + &platform_state, + processing_version, + ) + .await; + let refunds = refunds_for(&deletion, &identity); + + fetch_expected_identity_balance( + &platform, + identity.id(), + processing_version, + identity_balance_before + - join.total_base_fee() + - insertion.total_base_fee() + - deletion.total_base_fee() + + refunds.values().sum::(), + ); + + Workload { + identity_balance_before, + join, + insertion, + deletion, + refunds, + epochs_per_era: platform.config.drive.epochs_per_era, + } + } + + /// Bytes stored in the genesis epoch of a chain started at + /// `protocol_version` and removed in epoch 1, with the fee history the + /// initial state records. + async fn store_at_genesis_delete_in_epoch_one(protocol_version: ProtocolVersion) -> Workload { + const REMOVAL_EPOCH: EpochIndex = 1; + + let platform_version = + PlatformVersion::get(protocol_version).expect("expected the protocol version"); + + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + + let contract = setup_contract( + &platform.drive, + CONTRACT_PATH, + None, + None, + None::, + None, + Some(platform_version), + ); + let profile = contract + .document_type_for_name("profile") + .expect("expected a profile document type"); + + let mut rng = StdRng::seed_from_u64(DOCUMENT_SEED); + + let (identity, signer, key) = setup_identity_with_system_credits_with_platform_version( + &mut platform, + IDENTITY_SEED, + dash_to_credits!(1), + platform_version, + ); + let identity_balance_before = dash_to_credits!(1); + + let platform_state = platform.state.load().as_ref().clone(); + assert_eq!( + platform_state.previous_fee_versions(), + &CachedEpochIndexFeeVersions::from([( + GENESIS_EPOCH_INDEX, + platform_version.fee_version.as_static() + )]), + "the initial state records the genesis fee generation" + ); + let block_info = BlockInfo::default(); + assert_eq!(block_info.epoch.index, GENESIS_EPOCH_INDEX); + + let (join_document, join) = store_document( + &platform, + profile, + &mut rng, + &identity, + &key, + &signer, + 1, + block_info, + &platform_state, + platform_version, + ) + .await; + let (document, insertion) = store_document( + &platform, + profile, + &mut rng, + &identity, + &key, + &signer, + 2, + block_info, + &platform_state, + platform_version, + ) + .await; + for stored in [&join_document, &document] { + assert_stored_in_epoch( + &platform, + &contract, + profile, + stored, + GENESIS_EPOCH_INDEX, + &identity, + platform_version, + ); + } + + fast_forward_to_block(&platform, 1_200_000_000, 900, 42, REMOVAL_EPOCH, false); + let platform_state = platform.state.load().as_ref().clone(); + let block_info = *platform_state.last_block_info(); + assert_eq!(block_info.epoch.index, REMOVAL_EPOCH); + + let deletion = delete_document( + &platform, + document, + profile, + &key, + &signer, + 3, + block_info, + &platform_state, + platform_version, + ) + .await; + let refunds = refunds_for(&deletion, &identity); + + fetch_expected_identity_balance( + &platform, + identity.id(), + platform_version, + identity_balance_before + - join.total_base_fee() + - insertion.total_base_fee() + - deletion.total_base_fee() + + refunds.values().sum::(), + ); + + Workload { + identity_balance_before, + join, + insertion, + deletion, + refunds, + epochs_per_era: platform.config.drive.epochs_per_era, + } + } + + #[tokio::test] + async fn should_refund_bytes_stored_after_a_fee_generation_boundary_at_the_new_rate() { + let control = store_after_boundary_delete_next_epoch(None).await; + let doubled = store_after_boundary_delete_next_epoch(Some(2)).await; + + assert_eq!( + doubled.identity_balance_before, + control.identity_balance_before + ); + assert_doubled_storage_generation(&doubled, &control, 2, 3); + } + + #[tokio::test] + async fn should_refund_genesis_epoch_bytes_at_the_genesis_fee_generation_rate() { + // Without the genesis entry in the fee history the lookup would fall + // back to the first registered generation and refund at half the rate + // the bytes were paid for. + let control = + store_at_genesis_delete_in_epoch_one(PlatformVersion::latest().protocol_version).await; + let doubled = store_at_genesis_delete_in_epoch_one(TEST_PROTOCOL_VERSION_4).await; + + assert_eq!( + doubled.identity_balance_before, + control.identity_balance_before + ); + assert_doubled_storage_generation(&doubled, &control, GENESIS_EPOCH_INDEX, 1); + } + + #[test] + fn should_reject_a_non_first_fee_generation_removal_without_fee_history() { + const REMOVED_BYTES: u32 = 1_000; + let owner = [7u8; 32]; + let epoch = Epoch::new(3).expect("expected an epoch"); + + let removal = || { + let mut removal_per_epoch_by_identifier = StorageRemovalPerEpochByIdentifier::new(); + removal_per_epoch_by_identifier + .entry(owner) + .or_default() + .insert(GENESIS_EPOCH_INDEX, REMOVED_BYTES); + LowLevelDriveOperation::CalculatedCostOperation(OperationCost { + storage_cost: StorageCost { + added_bytes: 0, + replaced_bytes: 0, + removed_bytes: StorageRemovedBytes::SectionedStorageRemoval( + removal_per_epoch_by_identifier, + ), + }, + ..Default::default() + }) + }; + + // A generation other than the first cannot price a sectioned removal + // without the fee history: the lifecycle callers that pass `None` may + // only remove unflagged bytes. + let without_history = Drive::calculate_fee( + None, + Some(vec![removal()]), + &epoch, + DEFAULT_EPOCHS_PER_ERA, + &TEST_PLATFORM_V4, + None, + ); + assert!( + matches!( + without_history, + Err(DriveCrateError::Drive(DriveError::CorruptedCodeExecution( + _ + ))) + ), + "expected a corrupted code execution error, got {without_history:?}" + ); + + // With the history the same removal is refunded at the generation + // active for the storage epoch's lookup. + let history = CachedEpochIndexFeeVersions::from([( + GENESIS_EPOCH_INDEX, + &TEST_FEE_VERSION_DOUBLED_STORAGE, + )]); + let fee_result = Drive::calculate_fee( + None, + Some(vec![removal()]), + &epoch, + DEFAULT_EPOCHS_PER_ERA, + &TEST_PLATFORM_V4, + Some(&history), + ) + .expect("expected the removal to be priced with the fee history"); + assert_eq!( + fee_result + .fee_refunds + .get(&owner) + .and_then(|refunds| refunds.get(&GENESIS_EPOCH_INDEX)) + .copied(), + Some(expected_refund( + Credits::from(REMOVED_BYTES) + * TEST_FEE_VERSION_DOUBLED_STORAGE + .storage + .storage_disk_usage_credit_per_byte, + GENESIS_EPOCH_INDEX, + epoch.index, + DEFAULT_EPOCHS_PER_ERA, + )) + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/upgrade_protocol_version/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/upgrade_protocol_version/v0/mod.rs index f87f67705b5..c9ca11aa2c6 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/upgrade_protocol_version/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/upgrade_protocol_version/v0/mod.rs @@ -107,7 +107,9 @@ impl Platform { &platform_version.fee_version, ); } - // In case of empty cached_fee_version, insert the new (epoch_index, fee_version) + // In case of empty cached_fee_version, insert the new (epoch_index, fee_version). + // A fresh state records its genesis generation at init chain, so this branch + // remains for saved states created before the genesis entry was recorded. } else { previous_fee_versions_map.insert( epoch_info.current_epoch_index(), @@ -177,6 +179,7 @@ mod tests { use crate::test::helpers::setup::TestPlatformBuilder; use dpp::block::block_info::BlockInfo; use dpp::block::epoch::Epoch; + use dpp::fee::epoch::GENESIS_EPOCH_INDEX; use dpp::version::PlatformVersion; #[test] @@ -365,6 +368,60 @@ mod tests { ); } + #[test] + fn should_not_insert_a_second_entry_on_epoch_change_when_the_generation_is_unchanged() { + let platform_version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let transaction = platform.drive.grove.start_transaction(); + + let epoch_info = EpochInfo::V0(EpochInfoV0 { + current_epoch_index: 1, + previous_epoch_index: Some(0), + is_epoch_change: true, + }); + + let block_info = BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height: 100, + epoch: Epoch::new(1).expect("expected epoch"), + }; + + let last_committed_state = platform.state.load(); + let mut block_platform_state = last_committed_state.as_ref().clone(); + + // The genesis generation is recorded at init chain, so the map is not empty. + let seeded = block_platform_state.previous_fee_versions().clone(); + assert_eq!(seeded.len(), 1, "a fresh state carries the genesis entry"); + assert_eq!( + seeded + .get(&GENESIS_EPOCH_INDEX) + .expect("genesis entry") + .fee_version_number, + platform_version.fee_version.fee_version_number + ); + + platform + .upgrade_protocol_version_on_epoch_change_v0( + &block_info, + &epoch_info, + &last_committed_state, + &mut block_platform_state, + &transaction, + platform_version, + ) + .expect("epoch change with the same protocol version succeeds"); + + assert_eq!( + block_platform_state.previous_fee_versions(), + &seeded, + "an epoch change within the same fee generation leaves the history alone" + ); + } + #[test] fn test_epoch_change_with_upgrade_vote_sets_next_version() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index c3ae91ed840..34955e30c57 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -214,12 +214,26 @@ pub(in crate::execution) mod tests { seed: u64, credits: Credits, ) -> (Identity, SimpleSigner, IdentityPublicKey) { - let platform_version = PlatformVersion::latest(); + setup_identity_with_system_credits_with_platform_version( + platform, + seed, + credits, + PlatformVersion::latest(), + ) + } + + /// Same as `setup_identity_with_system_credits`, under a chosen platform version + pub(in crate::execution) fn setup_identity_with_system_credits_with_platform_version( + platform: &mut TempPlatform, + seed: u64, + credits: Credits, + platform_version: &PlatformVersion, + ) -> (Identity, SimpleSigner, IdentityPublicKey) { platform .drive .add_to_system_credits(credits, None, platform_version) .expect("expected to add to system credits"); - setup_identity(platform, seed, credits) + setup_identity_with_platform_version(platform, seed, credits, platform_version) } pub(in crate::execution) fn setup_identity( @@ -227,7 +241,15 @@ pub(in crate::execution) mod tests { seed: u64, credits: Credits, ) -> (Identity, SimpleSigner, IdentityPublicKey) { - let platform_version = PlatformVersion::latest(); + setup_identity_with_platform_version(platform, seed, credits, PlatformVersion::latest()) + } + + pub(in crate::execution) fn setup_identity_with_platform_version( + platform: &mut TempPlatform, + seed: u64, + credits: Credits, + platform_version: &PlatformVersion, + ) -> (Identity, SimpleSigner, IdentityPublicKey) { let mut signer = SimpleSigner::default(); let mut rng = StdRng::seed_from_u64(seed); diff --git a/packages/rs-drive-abci/src/platform_types/platform_state/mod.rs b/packages/rs-drive-abci/src/platform_types/platform_state/mod.rs index f4467c9cce9..7dfa78b7c46 100644 --- a/packages/rs-drive-abci/src/platform_types/platform_state/mod.rs +++ b/packages/rs-drive-abci/src/platform_types/platform_state/mod.rs @@ -33,7 +33,9 @@ use dpp::block::block_info::BlockInfo; use dpp::dashcore::hashes::Hash; use dpp::dashcore_rpc::json::MasternodeListItem; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::fee::epoch::GENESIS_EPOCH_INDEX; use dpp::util::hash::hash_double; +use dpp::version::fee::FeeVersion; use std::collections::BTreeMap; use std::fmt::{Debug, Formatter}; @@ -192,6 +194,20 @@ impl PlatformState { ) -> Result { let platform_version = PlatformVersion::get(current_protocol_version_in_consensus)?; + // Record the genesis fee generation. The epoch-change hook only records a + // generation on the first non-genesis epoch change, so without this entry + // bytes stored in epoch 0 of a network whose genesis schedule is not + // generation 1 would be refunded at generation 1 rates forever (the lookup + // falls back to the first registered generation below the earliest entry). + // Unobservable on every existing network: their genesis generation is 1, + // which is exactly the fallback, and the map lives in the saved state, not + // in the app hash. Saved states created before this entry was recorded + // keep working through the same fallback. The value is the registry entry + // rather than the schedule reference so the in-memory map equals the map + // after a saved-state round trip, which resolves numbers through the + // registry. + let genesis_fee_version = FeeVersion::get(platform_version.fee_version.fee_version_number)?; + let state = PlatformState { last_committed_block_info: None, current_protocol_version_in_consensus, @@ -210,7 +226,10 @@ impl PlatformState { full_masternode_list: Default::default(), hpmn_masternode_list: Default::default(), genesis_block_info: None, - previous_fee_versions: Default::default(), + previous_fee_versions: CachedEpochIndexFeeVersions::from([( + GENESIS_EPOCH_INDEX, + genesis_fee_version, + )]), heavy_fields_dirty: true, masternode_changes: EntryChanges::all(), validator_set_changes: EntryChanges::all(), @@ -365,6 +384,162 @@ impl TryFromPlatformVersioned for PlatformState { mod tests { use super::*; + mod fee_history { + use super::*; + use crate::config::PlatformConfig; + use dpp::block::epoch::{Epoch, EpochIndex}; + use dpp::fee::default_costs::{EpochCosts, KnownCostItem}; + use dpp::version::fee::FeeVersionNumber; + use platform_version::version::mocks::fee_doubled_storage_test::{ + TEST_FEE_VERSION_DOUBLED_STORAGE, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE, + }; + use platform_version::version::mocks::v4_test::{ + TEST_PLATFORM_V4, TEST_PROTOCOL_VERSION_4, + }; + + const COST_ITEMS: [KnownCostItem; 5] = [ + KnownCostItem::StorageDiskUsageCreditPerByte, + KnownCostItem::StorageProcessingCreditPerByte, + KnownCostItem::StorageLoadCreditPerByte, + KnownCostItem::NonStorageLoadCreditPerByte, + KnownCostItem::StorageSeekCost, + ]; + + fn fresh_state(protocol_version: ProtocolVersion) -> PlatformState { + PlatformState::default_with_protocol_versions( + protocol_version, + protocol_version, + &PlatformConfig::default(), + ) + .expect("expected a default platform state") + } + + #[test] + fn should_record_the_genesis_fee_generation_in_a_fresh_state() { + let platform_version = PlatformVersion::latest(); + let state = fresh_state(platform_version.protocol_version); + + let expected = FeeVersion::get(platform_version.fee_version.fee_version_number) + .expect("the genesis schedule's number is registered"); + assert_eq!( + state.previous_fee_versions, + CachedEpochIndexFeeVersions::from([(GENESIS_EPOCH_INDEX, expected)]) + ); + + for epoch_index in [GENESIS_EPOCH_INDEX, 7] { + let epoch = Epoch::new(epoch_index).expect("epoch"); + assert_eq!( + epoch.active_fee_version(&state.previous_fee_versions), + expected, + "epoch {epoch_index} must resolve to the genesis generation" + ); + } + } + + #[test] + fn should_record_the_genesis_fee_generation_of_a_mock_version() { + let state = fresh_state(TEST_PROTOCOL_VERSION_4); + + assert_eq!( + state.previous_fee_versions, + CachedEpochIndexFeeVersions::from([( + GENESIS_EPOCH_INDEX, + &TEST_FEE_VERSION_DOUBLED_STORAGE + )]) + ); + let epoch = Epoch::new(GENESIS_EPOCH_INDEX).expect("epoch"); + assert_eq!( + epoch.cost_for_known_cost_item( + &state.previous_fee_versions, + KnownCostItem::StorageDiskUsageCreditPerByte + ), + 2 * PlatformVersion::latest() + .fee_version + .storage + .storage_disk_usage_credit_per_byte, + "genesis-epoch bytes of a chain started at the mock are priced at the doubled rate" + ); + } + + /// A state at the mock version whose history holds the genesis + /// generation and the test generation activated at epoch 3. + fn state_with_a_test_generation_boundary() -> PlatformState { + let mut state = fresh_state(TEST_PROTOCOL_VERSION_4); + state.previous_fee_versions = CachedEpochIndexFeeVersions::from([ + ( + GENESIS_EPOCH_INDEX, + PlatformVersion::latest().fee_version.as_static(), + ), + (3, &TEST_FEE_VERSION_DOUBLED_STORAGE), + ]); + state + } + + fn fee_history_numbers(state: &PlatformState) -> Vec<(EpochIndex, FeeVersionNumber)> { + state + .previous_fee_versions + .iter() + .map(|(epoch_index, fee_version)| (*epoch_index, fee_version.fee_version_number)) + .collect() + } + + fn assert_fee_history_survived(restored: &PlatformState, original: &PlatformState) { + assert_eq!(fee_history_numbers(restored), fee_history_numbers(original)); + assert_eq!( + fee_history_numbers(restored), + vec![ + ( + GENESIS_EPOCH_INDEX, + PlatformVersion::latest().fee_version.fee_version_number + ), + (3, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE) + ] + ); + + for epoch_index in 0..=5 { + let epoch = Epoch::new(epoch_index).expect("epoch"); + for cost_item in COST_ITEMS { + assert_eq!( + epoch.cost_for_known_cost_item(&restored.previous_fee_versions, cost_item), + epoch.cost_for_known_cost_item(&original.previous_fee_versions, cost_item), + "epoch {epoch_index} costs must survive the saved-state round trip" + ); + } + } + } + + /// The standalone record (saved-state structure 0, also written beside + /// every checkpoint) resolves a test generation number through the + /// registry when it is read back. + #[test] + fn should_round_trip_the_test_fee_generation_number_through_saved_state() { + let state = state_with_a_test_generation_boundary(); + + let bytes = state + .serialize_standalone_to_bytes() + .expect("state serializes"); + let restored = PlatformState::versioned_deserialize_trusted(&bytes, &TEST_PLATFORM_V4) + .expect("state with a test fee generation number deserializes"); + + assert_fee_history_survived(&restored, &state); + } + + /// The per-block record of saved-state structure 1, the one the latest + /// version writes, carries the same numbers and resolves them the same + /// way when the state is rebuilt from the record and its entries. + #[test] + fn should_round_trip_the_test_fee_generation_number_through_the_structure_1_record() { + let state = state_with_a_test_generation_boundary(); + + let record = PlatformStateForSavingV2::from(&state); + let restored = record + .into_platform_state(Vec::new(), Vec::new()) + .expect("state with a test fee generation number rebuilds from its record"); + + assert_fee_history_survived(&restored, &state); + } + } + mod versioned_deserialize { use super::*; use crate::test::fixture::platform_state::{ diff --git a/packages/rs-drive-abci/tests/strategy_tests/execution.rs b/packages/rs-drive-abci/tests/strategy_tests/execution.rs index 6d25d111b30..d359cafada5 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/execution.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/execution.rs @@ -1249,6 +1249,7 @@ pub(crate) async fn continue_chain_for_strategy<'a>( withdrawals: total_withdrawals, validator_set_updates, state_transition_results_per_block, + state_transitions_per_block, instant_lock_quorums, signer, } diff --git a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs index 96820a70270..8035d72c3f3 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs @@ -3118,6 +3118,9 @@ pub struct ChainExecutionOutcome<'a> { /// height to the validator set update at that height pub validator_set_updates: BTreeMap, pub state_transition_results_per_block: BTreeMap>, + /// Every state transition the strategy submitted, per block, whether or not + /// the proposer kept it; lets a test prove two runs executed one workload. + pub state_transitions_per_block: BTreeMap>, pub signer: SimpleSigner, } diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs new file mode 100644 index 00000000000..81716fbb3b8 --- /dev/null +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/fee_version_boundary_tests.rs @@ -0,0 +1,631 @@ +//! Replay coverage across a fee-generation boundary. +//! +//! The doubled-storage mock protocol version (`TEST_PLATFORM_V4`) is the +//! latest shipped tables with a fee generation that exists only under +//! `mock-versions`, so upgrading to it changes nothing but the storage rate +//! and the fee history entry the epoch-change hook records. Each simulation +//! runs one workload twice: continuously, and stopped at the same block, +//! reopened from the persisted state and continued. The two runs must agree +//! on every root hash, every transition result, every identity balance and +//! the fee history; every block also re-runs `process_proposal` as an +//! independent validator, which is the proposer versus validator parity. + +#[cfg(test)] +mod tests { + use crate::addresses_with_balance::AddressesWithBalance; + use crate::execution::{continue_chain_for_strategy, run_chain_for_strategy}; + use crate::strategy::{ + ChainExecutionOutcome, ChainExecutionParameters, NetworkStrategy, StrategyRandomness, + UpgradingInfo, + }; + use dash_platform_macros::stack_size; + use dpp::block::epoch::EpochIndex; + use dpp::dash_to_duffs; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::document_type::random_document::{ + DocumentFieldFillSize, DocumentFieldFillType, + }; + use dpp::fee::Credits; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::platform_value::Identifier; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + use dpp::state_transition::batch_transition::batched_transition::document_transition::{ + DocumentTransition, DocumentTransitionV0Methods, + }; + use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; + use dpp::state_transition::StateTransition; + use dpp::tests::json_document::json_document_to_created_contract; + use dpp::version::fee::FeeVersionNumber; + use dpp::version::PlatformVersion; + use drive_abci::abci::app::FullAbciApplication; + use drive_abci::config::{ + ChainLockConfig, ExecutionConfig, InstantLockConfig, PlatformConfig, PlatformTestConfig, + ValidatorSetConfig, + }; + use drive_abci::platform_types::platform_state::PlatformStateV0Methods; + use drive_abci::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use platform_version::version::mocks::fee_doubled_storage_test::TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE; + use platform_version::version::mocks::v4_test::TEST_PROTOCOL_VERSION_4; + use std::collections::{BTreeMap, BTreeSet}; + use strategy_tests::frequency::Frequency; + use strategy_tests::operations::{DocumentAction, DocumentOp, Operation, OperationType}; + use strategy_tests::{IdentityInsertInfo, StartAddresses, StartIdentities, Strategy}; + use tenderdash_abci::proto::abci::ExecTxResult; + + /// Sixty blocks per epoch: `epoch_time_length_s` of 60 at one block per second. + const BLOCKS_PER_EPOCH: u64 = 60; + const SEED: u64 = 41; + + /// What a run leaves behind, in the fields the two runs must agree on. + #[derive(Debug, PartialEq, Eq)] + struct Snapshot { + root_hash: [u8; 32], + height: u64, + protocol_version: u32, + /// Per block: the code and gas of every transition the proposer kept. + results_per_block: BTreeMap>, + /// Per block of the second segment: the bytes of every transition the + /// strategy submitted, which proves the two runs executed one workload. + submitted_per_block: BTreeMap>>, + balances: BTreeMap<[u8; 32], Credits>, + fee_history: Vec<(EpochIndex, FeeVersionNumber)>, + } + + /// Document ids created or deleted by the kept transitions of a run, + /// keyed by block. + #[derive(Default)] + struct DocumentActivity { + created: BTreeMap>, + deleted: BTreeMap>, + } + + fn document_activity( + results_per_block: &BTreeMap>, + ) -> DocumentActivity { + let mut activity = DocumentActivity::default(); + for (height, results) in results_per_block { + for (state_transition, result) in results { + let StateTransition::Batch(batch) = state_transition else { + continue; + }; + if result.code != 0 { + continue; + } + for transition in batch.transitions_iter() { + let BatchedTransitionRef::Document(document_transition) = transition else { + continue; + }; + let bucket = match document_transition { + DocumentTransition::Create(_) => &mut activity.created, + DocumentTransition::Delete(_) => &mut activity.deleted, + _ => continue, + }; + bucket + .entry(*height) + .or_default() + .insert(document_transition.get_id()); + } + } + } + activity + } + + fn platform_config() -> PlatformConfig { + PlatformConfig { + validator_set: ValidatorSetConfig { + quorum_size: 30, + ..Default::default() + }, + // Chain locks need their own quorum type: the harness only signs them + // (and the independent validator path only accepts them) when the + // chain-lock quorums are distinct from the validator set. + chain_lock: ChainLockConfig::default(), + instant_lock: InstantLockConfig::default_100_67(), + execution: ExecutionConfig { + verify_sum_trees: true, + epoch_time_length_s: BLOCKS_PER_EPOCH, + ..Default::default() + }, + block_spacing_ms: 1_000, + testing_configs: PlatformTestConfig { + store_platform_state: true, + ..PlatformTestConfig::default_minimal_verifications() + }, + ..Default::default() + } + } + + /// One identity per block, one to two random inserts and one delete per + /// block on the all-mutable DashPay contract, so documents are written and + /// removed on both sides of every boundary the tests cross. + fn network_strategy(upgrading_info: Option) -> NetworkStrategy { + let platform_version = PlatformVersion::latest(); + let created_contract = json_document_to_created_contract( + "tests/supporting_files/contract/dashpay/dashpay-contract-all-mutable.json", + 1, + true, + platform_version, + ) + .expect("expected to get contract from a json document"); + let contract = created_contract.data_contract(); + let document_type = contract + .document_type_for_name("contactRequest") + .expect("expected a contactRequest document type") + .to_owned_document_type(); + + NetworkStrategy { + strategy: Strategy { + start_contracts: vec![(created_contract.clone(), None)], + operations: vec![ + Operation { + op_type: OperationType::Document(DocumentOp { + contract: contract.clone(), + action: DocumentAction::DocumentActionInsertRandom( + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + ), + document_type: document_type.clone(), + }), + frequency: Frequency { + times_per_block_range: 1..3, + chance_per_block: None, + }, + }, + Operation { + op_type: OperationType::Document(DocumentOp { + contract: contract.clone(), + action: DocumentAction::DocumentActionDelete, + document_type, + }), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + }, + ], + start_identities: StartIdentities::default(), + start_addresses: StartAddresses::default(), + identity_inserts: IdentityInsertInfo { + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + start_balance_range: dash_to_duffs!(1)..=dash_to_duffs!(1), + ..Default::default() + }, + identity_contract_nonce_gaps: None, + signer: None, + }, + total_hpmns: 50, + extra_normal_mns: 0, + validator_quorum_count: 24, + chain_lock_quorum_count: 4, + upgrading_info, + proposer_strategy: Default::default(), + rotate_quorums: false, + failure_testing: None, + query_testing: None, + verify_state_transition_results: true, + // The validator path verifies the chain lock of every proposal it did + // not build, so the locks must carry a real quorum signature. + independent_process_proposal_verification: true, + sign_chain_locks: true, + ..Default::default() + } + } + + fn snapshot( + abci_app: &FullAbciApplication<'_, drive_abci::rpc::core::MockCoreRPCLike>, + identities: &[dpp::identity::Identity], + results_per_block: &BTreeMap>, + submitted_per_block: &BTreeMap>, + second_segment_start: u64, + ) -> Snapshot { + let state = abci_app.platform.state.load(); + let platform_version = state + .current_platform_version() + .expect("expected the state's platform version"); + let balances = abci_app + .platform + .drive + .fetch_identities_balances( + &identities + .iter() + .map(|identity| identity.id().to_buffer()) + .collect(), + None, + platform_version, + ) + .expect("expected to fetch identity balances"); + assert_eq!( + balances.len(), + identities.len(), + "every identity the strategy created must have a balance" + ); + Snapshot { + root_hash: state + .last_committed_block_app_hash() + .expect("expected a committed block"), + height: state.last_committed_block_height(), + protocol_version: state.current_protocol_version_in_consensus(), + results_per_block: results_per_block + .iter() + .map(|(height, results)| { + ( + *height, + results + .iter() + .map(|(_, result)| (result.code, result.gas_used)) + .collect(), + ) + }) + .collect(), + submitted_per_block: submitted_per_block + .iter() + .filter(|(height, _)| **height >= second_segment_start) + .map(|(height, transitions)| { + ( + *height, + transitions + .iter() + .map(|transition| { + transition + .serialize_to_bytes() + .expect("expected to serialize the transition") + }) + .collect(), + ) + }) + .collect(), + balances, + fee_history: state + .previous_fee_versions() + .iter() + .map(|(epoch_index, fee_version)| (*epoch_index, fee_version.fee_version_number)) + .collect(), + } + } + + struct SplitRun { + snapshot: Snapshot, + activity: DocumentActivity, + /// Fee history as the first segment left it. + fee_history_at_split: Vec<(EpochIndex, FeeVersionNumber)>, + protocol_version_at_split: u32, + } + + /// Runs `first_blocks` blocks, then `second_blocks` more with the same + /// workload. With `reopen` the platform is dropped between the segments + /// and rebuilt from what it persisted, so the second segment runs on the + /// saved state instead of the in-memory one. + /// + /// The continuation is workload-preserving: the mutated strategy of the + /// first segment (its operations remapped to the deployed contract, its + /// start contracts cleared so they are not deployed again), its + /// identities, its signer and its nonce counters are handed to the second + /// segment, and both runs reseed the second segment from the same entropy. + async fn run_split( + mut platform: TempPlatform, + config: PlatformConfig, + strategy: NetworkStrategy, + first_blocks: u64, + second_blocks: u64, + reopen: bool, + ) -> SplitRun { + let ChainExecutionOutcome { + abci_app, + proposers, + validator_quorums, + current_validator_quorum_hash, + current_proposer_versions, + end_time_ms, + identity_nonce_counter, + identity_contract_nonce_counter, + instant_lock_quorums, + identities, + signer, + strategy: mut continued_strategy, + state_transition_results_per_block: first_results, + state_transitions_per_block: first_submitted, + .. + } = run_chain_for_strategy( + &mut platform, + first_blocks, + strategy.clone(), + config.clone(), + SEED, + &mut None, + &mut None, + ) + .await; + + let state = abci_app.platform.state.load(); + assert_eq!(state.last_committed_block_height(), first_blocks); + let fee_history_at_split = state + .previous_fee_versions() + .iter() + .map(|(epoch_index, fee_version)| (*epoch_index, fee_version.fee_version_number)) + .collect::>(); + let protocol_version_at_split = state.current_protocol_version_in_consensus(); + drop(state); + drop(abci_app); + + // The first segment deployed the start contracts and remapped the + // operations to the deployed ids, but the harness puts the (remapped) + // contracts back into the strategy, and `state_transitions_for_block` + // would deploy them again at the continuation's first block. Take them + // out so the second segment keeps working on the documents the first + // one wrote. + continued_strategy.strategy.start_contracts.clear(); + continued_strategy.strategy.signer = Some(signer); + + let platform = if reopen { + let TempPlatform { + platform: mut platform_before_restart, + tempdir, + } = platform; + let core_rpc = std::mem::take(&mut platform_before_restart.core_rpc); + drop(platform_before_restart); + let mut reopened = TempPlatform::open_with_tempdir(tempdir, config.clone()); + reopened.platform.core_rpc = core_rpc; + let state = reopened.state.load(); + assert_eq!( + state.last_committed_block_height(), + first_blocks, + "the reopened platform must resume from the persisted state" + ); + assert_eq!( + state + .previous_fee_versions() + .iter() + .map(|(epoch_index, fee_version)| ( + *epoch_index, + fee_version.fee_version_number + )) + .collect::>(), + fee_history_at_split, + "the fee history must survive the saved-state round trip" + ); + assert_eq!( + state.current_protocol_version_in_consensus(), + protocol_version_at_split + ); + drop(state); + reopened + } else { + platform + }; + let abci_app = FullAbciApplication::new(&platform.platform); + + let ChainExecutionOutcome { + abci_app, + identities, + state_transition_results_per_block: second_results, + state_transitions_per_block: second_submitted, + .. + } = continue_chain_for_strategy( + abci_app, + ChainExecutionParameters { + block_start: first_blocks + 1, + core_height_start: 1, + block_count: second_blocks, + proposers, + validator_quorums, + current_validator_quorum_hash, + current_proposer_versions: Some(current_proposer_versions), + current_identity_nonce_counter: identity_nonce_counter, + current_identity_contract_nonce_counter: identity_contract_nonce_counter, + current_votes: BTreeMap::default(), + start_time_ms: strategy.start_time_ms, + current_time_ms: end_time_ms, + instant_lock_quorums, + current_identities: identities, + current_addresses_with_balance: AddressesWithBalance::default(), + }, + continued_strategy, + config, + StrategyRandomness::SeedEntropy(SEED + 1), + ) + .await; + + let mut results_per_block = first_results; + results_per_block.extend(second_results); + let mut submitted_per_block = first_submitted; + submitted_per_block.extend(second_submitted); + + let snapshot = snapshot( + &abci_app, + &identities, + &results_per_block, + &submitted_per_block, + first_blocks + 1, + ); + assert_eq!(snapshot.height, first_blocks + second_blocks); + let activity = document_activity(&results_per_block); + + SplitRun { + snapshot, + activity, + fee_history_at_split, + protocol_version_at_split, + } + } + + fn assert_no_internal_errors(snapshot: &Snapshot) { + const INTERNAL_ERROR_CODE: u32 = 13; + for (height, results) in &snapshot.results_per_block { + assert!( + results.iter().all(|(code, _)| *code != INTERNAL_ERROR_CODE), + "block {height} produced an internal error: {results:?}" + ); + } + } + + /// At least one document written at or before `created_by` was deleted at + /// or after `deleted_from`, so the history-driven refund path priced bytes + /// that were persisted before that point (the restart, or the activation + /// of a new fee generation). + fn assert_deleted_earlier_document( + activity: &DocumentActivity, + created_by: u64, + deleted_from: u64, + ) { + let created_before = activity + .created + .range(..=created_by) + .flat_map(|(_, ids)| ids.iter().copied()) + .collect::>(); + let deleted_after = activity + .deleted + .range(deleted_from..) + .flat_map(|(_, ids)| ids.iter().copied()) + .collect::>(); + assert!( + !created_before.is_empty(), + "expected documents to be created by block {created_by}" + ); + assert!( + deleted_after.iter().any(|id| created_before.contains(id)), + "expected a document created by block {created_by} to be deleted from block \ + {deleted_from}; deleted {deleted_after:?}" + ); + } + + fn assert_runs_agree(continuous: &SplitRun, reopened: &SplitRun) { + assert_eq!( + continuous.snapshot.submitted_per_block, reopened.snapshot.submitted_per_block, + "both runs must submit the same transitions after the split" + ); + assert_eq!( + continuous.snapshot.results_per_block, reopened.snapshot.results_per_block, + "both runs must accept the same transitions with the same fees" + ); + assert_eq!(continuous.snapshot.balances, reopened.snapshot.balances); + assert_eq!( + continuous.snapshot.fee_history, + reopened.snapshot.fee_history + ); + assert_eq!( + continuous.snapshot.root_hash, reopened.snapshot.root_hash, + "the continuous run and the run reopened from saved state must end on one root hash" + ); + assert_eq!(continuous.snapshot, reopened.snapshot); + assert_no_internal_errors(&continuous.snapshot); + } + + /// Every proposer votes for the mock from block 1, so epoch 0 collects + /// the votes, the first block of epoch 1 locks the mock in as the next + /// version, and the first block of epoch 2 activates it and records the + /// doubled-storage generation at epoch 2. The split at block 130 sits + /// nine blocks after activation, inside the new generation. + #[stack_size(4 * 1024 * 1024)] + #[test] + async fn run_chain_upgrade_across_fee_version_boundary_keeps_replay_and_saved_state_in_agreement( + ) { + let latest = PlatformVersion::latest(); + let genesis_number = latest.fee_version.fee_version_number; + let activation_block = 2 * BLOCKS_PER_EPOCH + 1; + let first_blocks = 130; + let second_blocks = 30; + assert!(first_blocks > activation_block); + + let strategy = network_strategy(Some(UpgradingInfo { + current_protocol_version: latest.protocol_version, + proposed_protocol_versions_with_weight: vec![(TEST_PROTOCOL_VERSION_4, 1)], + upgrade_three_quarters_life: 0.0, + })); + let config = platform_config(); + + let mut runs = Vec::with_capacity(2); + for reopen in [false, true] { + let platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .with_initial_protocol_version(latest.protocol_version) + .build_with_mock_rpc(); + let run = run_split( + platform, + config.clone(), + strategy.clone(), + first_blocks, + second_blocks, + reopen, + ) + .await; + + assert_eq!(run.protocol_version_at_split, TEST_PROTOCOL_VERSION_4); + assert_eq!(run.snapshot.protocol_version, TEST_PROTOCOL_VERSION_4); + assert_eq!( + run.fee_history_at_split, + vec![ + (0, genesis_number), + (2, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE) + ], + "the genesis generation is recorded at init chain and the doubled generation at \ + the epoch that activated it" + ); + assert_eq!(run.snapshot.fee_history, run.fee_history_at_split); + // A delete that crosses the boundary: written under the genesis + // generation, removed under the doubled one. + assert_deleted_earlier_document(&run.activity, activation_block - 1, activation_block); + // A delete that crosses the split: written before the restart point, + // removed after it. + assert_deleted_earlier_document(&run.activity, first_blocks, first_blocks + 1); + runs.push(run); + } + + let reopened = runs.pop().expect("reopened run"); + let continuous = runs.pop().expect("continuous run"); + assert_runs_agree(&continuous, &reopened); + } + + /// A chain started at the mock: the initial state records the doubled + /// generation for the genesis epoch, and the epoch changes at blocks 61 + /// and 121 (the second one after the restart) leave the history alone + /// because the generation does not change. + #[stack_size(4 * 1024 * 1024)] + #[test] + async fn run_chain_started_at_a_new_fee_generation_records_the_genesis_generation_and_survives_a_restart( + ) { + let first_blocks = 70; + let second_blocks = 60; + + let strategy = network_strategy(None); + let config = platform_config(); + + let mut runs = Vec::with_capacity(2); + for reopen in [false, true] { + let platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .with_initial_protocol_version(TEST_PROTOCOL_VERSION_4) + .build_with_mock_rpc(); + let run = run_split( + platform, + config.clone(), + strategy.clone(), + first_blocks, + second_blocks, + reopen, + ) + .await; + + assert_eq!(run.protocol_version_at_split, TEST_PROTOCOL_VERSION_4); + assert_eq!(run.snapshot.protocol_version, TEST_PROTOCOL_VERSION_4); + assert_eq!( + run.fee_history_at_split, + vec![(0, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE)] + ); + assert_eq!( + run.snapshot.fee_history, + vec![(0, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE)], + "an epoch change within one generation must not add a history entry" + ); + assert_deleted_earlier_document(&run.activity, first_blocks, first_blocks + 1); + runs.push(run); + } + + let reopened = runs.pop().expect("reopened run"); + let continuous = runs.pop().expect("continuous run"); + assert_runs_agree(&continuous, &reopened); + } +} diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs index 1e963cb1cbd..c562545fdce 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs @@ -5,6 +5,7 @@ mod comprehensive_tests; mod core_height_increase; mod core_update_tests; mod data_contract_history_tests; +mod fee_version_boundary_tests; mod identity_and_document_tests; mod identity_transfer_tests; mod process_proposal_collision_tests; diff --git a/packages/rs-platform-version/src/version/fee/mod.rs b/packages/rs-platform-version/src/version/fee/mod.rs index 29e02b56012..9f941e70574 100644 --- a/packages/rs-platform-version/src/version/fee/mod.rs +++ b/packages/rs-platform-version/src/version/fee/mod.rs @@ -16,6 +16,10 @@ use crate::version::fee::v1::FEE_VERSION1; use crate::version::fee::vote_resolution_fund_fees::{ VoteResolutionFundFees, VoteResolutionFundFeesFieldsBeforeVersion4, }; +#[cfg(feature = "mock-versions")] +use crate::version::mocks::fee_doubled_storage_test::TEST_FEE_VERSIONS; +#[cfg(feature = "mock-versions")] +use crate::version::mocks::TEST_PROTOCOL_VERSION_SHIFT_BYTES; use bincode::{Decode, Encode}; pub mod data_contract_registration; @@ -55,6 +59,22 @@ impl FeeVersion { } pub fn get<'a>(version: FeeVersionNumber) -> Result<&'a Self, PlatformVersionError> { if version > 0 { + #[cfg(feature = "mock-versions")] + { + // Test fee generations share the mock protocol versions' shifted + // number range, so a number with the test bit set is resolved by + // number in the test registry and never reaches the production one. + if version >> TEST_PROTOCOL_VERSION_SHIFT_BYTES > 0 { + return TEST_FEE_VERSIONS + .iter() + .find(|fee_version| fee_version.fee_version_number == version) + .ok_or_else(|| { + PlatformVersionError::UnknownVersionError(format!( + "no test fee version {version}" + )) + }); + } + } FEE_VERSIONS.get(version as usize - 1).ok_or_else(|| { PlatformVersionError::UnknownVersionError(format!("no fee version {version}")) }) @@ -67,6 +87,14 @@ impl FeeVersion { pub fn get_optional<'a>(version: FeeVersionNumber) -> Option<&'a Self> { if version > 0 { + #[cfg(feature = "mock-versions")] + { + if version >> TEST_PROTOCOL_VERSION_SHIFT_BYTES > 0 { + return TEST_FEE_VERSIONS + .iter() + .find(|fee_version| fee_version.fee_version_number == version); + } + } FEE_VERSIONS.get(version as usize - 1) } else { None @@ -149,3 +177,66 @@ impl From for FeeVersion { } } } + +#[cfg(all(test, feature = "mock-versions"))] +mod mock_fee_generation_tests { + use super::{FeeStorageVersion, FeeVersion, FEE_VERSIONS}; + use crate::version::mocks::fee_doubled_storage_test::{ + TEST_FEE_VERSION_DOUBLED_STORAGE, TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE, + }; + use crate::version::mocks::TEST_PROTOCOL_VERSION_SHIFT_BYTES; + use crate::version::PlatformVersion; + + #[test] + fn should_resolve_the_test_fee_generation_only_through_the_shifted_number_range() { + let resolved = FeeVersion::get(TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE) + .expect("the test fee generation resolves by its number"); + assert_eq!(resolved, &TEST_FEE_VERSION_DOUBLED_STORAGE); + assert_eq!( + FeeVersion::get_optional(TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE), + Some(&TEST_FEE_VERSION_DOUBLED_STORAGE) + ); + + let unregistered = (1 << TEST_PROTOCOL_VERSION_SHIFT_BYTES) + 2; + assert!( + FeeVersion::get(unregistered).is_err(), + "a shifted number with no test generation is an error, not a fallback" + ); + assert!(FeeVersion::get_optional(unregistered).is_none()); + + assert!( + FEE_VERSIONS + .iter() + .all(|fee_version| fee_version.fee_version_number + >> TEST_PROTOCOL_VERSION_SHIFT_BYTES + == 0), + "the production registry must never carry a shifted number" + ); + } + + #[test] + fn should_keep_the_test_fee_generation_aligned_with_the_latest_schedule_except_storage_disk_usage( + ) { + let latest = &PlatformVersion::latest().fee_version; + let doubled = &TEST_FEE_VERSION_DOUBLED_STORAGE; + + assert_eq!( + doubled.storage.storage_disk_usage_credit_per_byte, + 2 * latest.storage.storage_disk_usage_credit_per_byte + ); + + // Everything but the generation number and the disk usage rate is the + // latest schedule, so a fee difference across the boundary is storage. + let aligned = FeeVersion { + fee_version_number: latest.fee_version_number, + storage: FeeStorageVersion { + storage_disk_usage_credit_per_byte: latest + .storage + .storage_disk_usage_credit_per_byte, + ..doubled.storage.clone() + }, + ..doubled.clone() + }; + assert_eq!(&aligned, latest); + } +} diff --git a/packages/rs-platform-version/src/version/mocks/fee_doubled_storage_test.rs b/packages/rs-platform-version/src/version/mocks/fee_doubled_storage_test.rs new file mode 100644 index 00000000000..c0ba5cafc37 --- /dev/null +++ b/packages/rs-platform-version/src/version/mocks/fee_doubled_storage_test.rs @@ -0,0 +1,47 @@ +use crate::version::fee::storage::v1::FEE_STORAGE_VERSION1; +use crate::version::fee::storage::FeeStorageVersion; +use crate::version::fee::v3::FEE_VERSION3; +use crate::version::fee::{FeeVersion, FeeVersionNumber}; +use crate::version::mocks::TEST_PROTOCOL_VERSION_SHIFT_BYTES; + +/// The fee generation number of the doubled-storage test schedule. +/// +/// Test fee generations live in the same shifted range as the mock protocol +/// versions (a set high bit that no production number can carry), so a +/// persisted fee history that names this number is rejected by a node built +/// without `mock-versions`, exactly as a mock protocol version would be. +pub const TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE: FeeVersionNumber = + (1 << TEST_PROTOCOL_VERSION_SHIFT_BYTES) + 1; + +/// Storage rates of the doubled-storage test schedule: the disk usage rate is +/// exactly twice `FEE_STORAGE_VERSION1`, every other storage rate (including +/// the TTL ephemeral rate) is unchanged. +pub const TEST_FEE_STORAGE_VERSION_DOUBLED: FeeStorageVersion = FeeStorageVersion { + storage_disk_usage_credit_per_byte: 2 * FEE_STORAGE_VERSION1.storage_disk_usage_credit_per_byte, + ..FEE_STORAGE_VERSION1 +}; + +/// A fee generation that exists only under `mock-versions`. +/// +/// No shipped schedule carries a `fee_version_number` other than 1, so the +/// registry lookup, the epoch-change hook, the saved-state round trip and the +/// history-driven refund path have no production input that exercises a +/// second generation. This schedule is `FEE_VERSION3`, the latest shipped +/// schedule, with a new generation number and a doubled storage disk usage +/// rate: processing, hashing, signature and vote resolution rates are +/// identical, so any fee difference across a boundary into this generation is +/// storage, and a refund priced at the wrong generation is off by a factor of +/// two. +/// +/// It is unreachable in production builds: the constant is compiled only +/// with `mock-versions`, and `FeeVersion::get` resolves the shifted number +/// range only under that feature. +pub const TEST_FEE_VERSION_DOUBLED_STORAGE: FeeVersion = FeeVersion { + fee_version_number: TEST_FEE_VERSION_NUMBER_DOUBLED_STORAGE, + storage: TEST_FEE_STORAGE_VERSION_DOUBLED, + ..FEE_VERSION3 +}; + +/// The test fee generations, resolved by number through `FeeVersion::get` +/// when the number is in the shifted test range. +pub const TEST_FEE_VERSIONS: &[FeeVersion] = &[TEST_FEE_VERSION_DOUBLED_STORAGE]; diff --git a/packages/rs-platform-version/src/version/mocks/mod.rs b/packages/rs-platform-version/src/version/mocks/mod.rs index aef47dea3d5..3c0a292247f 100644 --- a/packages/rs-platform-version/src/version/mocks/mod.rs +++ b/packages/rs-platform-version/src/version/mocks/mod.rs @@ -1,4 +1,6 @@ +pub mod fee_doubled_storage_test; pub mod v2_test; pub mod v3_test; +pub mod v4_test; pub const TEST_PROTOCOL_VERSION_SHIFT_BYTES: u32 = 28; diff --git a/packages/rs-platform-version/src/version/mocks/v4_test.rs b/packages/rs-platform-version/src/version/mocks/v4_test.rs new file mode 100644 index 00000000000..2eecbd000b4 --- /dev/null +++ b/packages/rs-platform-version/src/version/mocks/v4_test.rs @@ -0,0 +1,24 @@ +use crate::version::mocks::fee_doubled_storage_test::TEST_FEE_VERSION_DOUBLED_STORAGE; +use crate::version::mocks::TEST_PROTOCOL_VERSION_SHIFT_BYTES; +use crate::version::protocol_version::PlatformVersion; +use crate::version::v14::PLATFORM_V14; + +pub const TEST_PROTOCOL_VERSION_4: u32 = (1 << TEST_PROTOCOL_VERSION_SHIFT_BYTES) + 4; + +/// A mock protocol version that is the latest shipped protocol version with +/// the doubled-storage test fee generation. +/// +/// Every dispatch table is the shipped one, so an upgrade from the latest +/// protocol version to this mock changes nothing but the fee generation: +/// `perform_events_on_first_block_of_protocol_change` fires no migration +/// (its gates compare against shipped numbers, all below the mock), and the +/// only observable difference after activation is the storage rate and the +/// fee history entry the epoch-change hook records. +/// +/// When a new protocol version is introduced, move the base to its table so +/// the mock keeps tracking the latest shipped behaviour. +pub const TEST_PLATFORM_V4: PlatformVersion = PlatformVersion { + protocol_version: TEST_PROTOCOL_VERSION_4, + fee_version: TEST_FEE_VERSION_DOUBLED_STORAGE, + ..PLATFORM_V14 +}; diff --git a/packages/rs-platform-version/src/version/protocol_version.rs b/packages/rs-platform-version/src/version/protocol_version.rs index 00cc470bbc7..c61c4c16aec 100644 --- a/packages/rs-platform-version/src/version/protocol_version.rs +++ b/packages/rs-platform-version/src/version/protocol_version.rs @@ -8,6 +8,8 @@ use crate::version::mocks::v2_test::TEST_PLATFORM_V2; #[cfg(feature = "mock-versions")] use crate::version::mocks::v3_test::TEST_PLATFORM_V3; #[cfg(feature = "mock-versions")] +use crate::version::mocks::v4_test::TEST_PLATFORM_V4; +#[cfg(feature = "mock-versions")] use crate::version::mocks::TEST_PROTOCOL_VERSION_SHIFT_BYTES; use crate::version::system_data_contract_versions::SystemDataContractVersions; #[cfg(feature = "mock-versions")] @@ -67,7 +69,8 @@ pub const PLATFORM_VERSIONS: &[PlatformVersion] = &[ // We use OnceLock to be able to modify the version mocks pub static PLATFORM_TEST_VERSIONS: OnceLock> = OnceLock::new(); #[cfg(feature = "mock-versions")] -const DEFAULT_PLATFORM_TEST_VERSIONS: &[PlatformVersion] = &[TEST_PLATFORM_V2, TEST_PLATFORM_V3]; +pub const DEFAULT_PLATFORM_TEST_VERSIONS: &[PlatformVersion] = + &[TEST_PLATFORM_V2, TEST_PLATFORM_V3, TEST_PLATFORM_V4]; pub const LATEST_PLATFORM_VERSION: &PlatformVersion = &PLATFORM_V14; @@ -83,7 +86,7 @@ impl PlatformVersion { // Init default set of test versions let versions = PLATFORM_TEST_VERSIONS - .get_or_init(|| vec![TEST_PLATFORM_V2, TEST_PLATFORM_V3]); + .get_or_init(|| Vec::from(DEFAULT_PLATFORM_TEST_VERSIONS)); return versions.get(test_version as usize - 2).ok_or( PlatformVersionError::UnknownVersionError(format!( @@ -111,7 +114,7 @@ impl PlatformVersion { // Init default set of test versions let versions = PLATFORM_TEST_VERSIONS - .get_or_init(|| vec![TEST_PLATFORM_V2, TEST_PLATFORM_V3]); + .get_or_init(|| Vec::from(DEFAULT_PLATFORM_TEST_VERSIONS)); return versions.get(test_version as usize - 2); } diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 0af3e8e62bd..a1e779be3b0 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -313,12 +313,12 @@ mod tests { #[cfg(feature = "mock-versions")] #[test] fn mock_platform_versions_carry_the_same_documents_batch_cap() { - use crate::version::mocks::v2_test::TEST_PLATFORM_V2; - use crate::version::mocks::v3_test::TEST_PLATFORM_V3; - use crate::version::protocol_version::PLATFORM_TEST_VERSIONS; + use crate::version::protocol_version::{ + DEFAULT_PLATFORM_TEST_VERSIONS, PLATFORM_TEST_VERSIONS, + }; let versions = - PLATFORM_TEST_VERSIONS.get_or_init(|| vec![TEST_PLATFORM_V2, TEST_PLATFORM_V3]); + PLATFORM_TEST_VERSIONS.get_or_init(|| Vec::from(DEFAULT_PLATFORM_TEST_VERSIONS)); assert!( !versions.is_empty(), "the mock version registry is empty; this test would assert nothing"